diff --git a/.claude/agents/image-blast-export.md b/.claude/agents/image-blast-export.md new file mode 100644 index 0000000..a0f6243 --- /dev/null +++ b/.claude/agents/image-blast-export.md @@ -0,0 +1,17 @@ +--- +name: image-blast-export +description: Runs one Image Blast mesh export in the background. Use when a generated `.glb` needs USDZ, STL, or optional FBX output. +tools: Read, Write, Glob, Bash +model: inherit +background: true +skills: + - image-blast-export +--- + +Run mesh export for generated 3D objects. + +Use the preloaded `image-blast-export` skill as the task contract. The prompt must include a world slug plus one object id/name, or `--all`. Honor optional format arguments when present. + +If the prompt is missing the world, missing the object selection, ambiguous, or asks for FBX without `--via blender`, stop and report the blocker. Do not change existing `.glb` or `.obj` output behavior. + +Run the conversion to completion and report converted files, skipped formats, and provenance files. diff --git a/.claude/scripts/asset-pipeline/convert-mesh.mjs b/.claude/scripts/asset-pipeline/convert-mesh.mjs new file mode 100644 index 0000000..b6c3ddf --- /dev/null +++ b/.claude/scripts/asset-pipeline/convert-mesh.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { ensureDir, one, parseArgs, pathExists, safeFileName, writeJson } from "./fal-queue.mjs"; +import { artifactPath, nextIndex, parseIndexedName } from "./request-metadata.mjs"; + +const execFileAsync = promisify(execFile); +const DEFAULT_FORMATS = ["usdz", "stl"]; +const EXPORTER_VERSION = "three-stdlib@2.36.1"; + +function usage() { + return "Usage: node convert-mesh.mjs --input --output-dir [--asset-name ] [--formats usdz,stl,fbx] [--via blender]"; +} + +function toArrayBuffer(buffer) { + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +} + +function toBuffer(value) { + if (Buffer.isBuffer(value)) return value; + if (value instanceof ArrayBuffer) return Buffer.from(value); + if (ArrayBuffer.isView(value)) return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + throw new Error("Exporter returned an unsupported binary payload."); +} + +function parseFormats(value) { + if (!value) return DEFAULT_FORMATS; + const formats = String(value) + .split(",") + .map((format) => format.trim().toLowerCase()) + .filter(Boolean); + const unsupported = formats.filter((format) => !["usdz", "stl", "fbx"].includes(format)); + if (unsupported.length > 0) throw new Error(`Unsupported format(s): ${unsupported.join(", ")}`); + return [...new Set(formats)]; +} + +function requestedFormats(flags) { + const formats = parseFormats(one(flags, "formats")); + return flags["with-fbx"] && !formats.includes("fbx") ? [...formats, "fbx"] : formats; +} + +async function importFromRootOrApp(packageName, appPath) { + try { + return await import(packageName); + } catch (rootError) { + try { + return await import(pathToFileURL(path.resolve(appPath)).href); + } catch { + throw new Error( + `${packageName} is required. Run \`bun install\` at the repo root, or install app dependencies so ${appPath} exists. Original error: ${rootError.message}` + ); + } + } +} + +async function loadThreeModules() { + const THREE = await importFromRootOrApp("three", "app/node_modules/three/build/three.module.js"); + const stdlib = await importFromRootOrApp("three-stdlib", "app/node_modules/three-stdlib/index.js"); + return { + THREE, + GLTFLoader: stdlib.GLTFLoader, + STLExporter: stdlib.STLExporter, + USDZExporter: stdlib.USDZExporter + }; +} + +function installNodeImageStub() { + if (!globalThis.self) globalThis.self = globalThis; + if (!globalThis.ImageBitmap) { + globalThis.ImageBitmap = class ImageBitmap { + constructor() { + this.width = 1; + this.height = 1; + } + close() {} + }; + } + if (!globalThis.createImageBitmap) { + globalThis.createImageBitmap = async () => new globalThis.ImageBitmap(); + } +} + +function normalizeMaterials(scene, THREE) { + const textureFields = [ + "alphaMap", + "aoMap", + "bumpMap", + "displacementMap", + "emissiveMap", + "envMap", + "lightMap", + "map", + "metalnessMap", + "normalMap", + "roughnessMap" + ]; + + scene.traverse((object) => { + if (!object.isMesh) return; + let material = Array.isArray(object.material) ? object.material.find(Boolean) : object.material; + if (!material?.isMeshStandardMaterial) { + material = new THREE.MeshStandardMaterial({ color: material?.color || 0xffffff }); + } + for (const field of textureFields) material[field] = null; + material.needsUpdate = true; + object.material = material; + }); +} + +async function loadGlbScene(inputPath, modules) { + installNodeImageStub(); + const loader = new modules.GLTFLoader(); + const buffer = await readFile(inputPath); + const resourcePath = `${path.dirname(path.resolve(inputPath))}${path.sep}`; + const gltf = await new Promise((resolve, reject) => { + loader.parse(toArrayBuffer(buffer), resourcePath, resolve, reject); + }); + normalizeMaterials(gltf.scene, modules.THREE); + gltf.scene.updateMatrixWorld(true); + return gltf.scene; +} + +function provenancePath(outputDir, index, slug, format) { + return path.join(outputDir, `.${index}-${safeFileName(slug)}-${format}-request.json`); +} + +async function writeProvenance(options) { + const { format, input, output, provenance, settings } = options; + await writeJson(provenance, { + schema_version: 1, + kind: "mesh-export", + provider: `image-blast-export/${format}`, + endpoint: `image-blast-export/${format}`, + exporter: settings.exporter, + exporter_version: EXPORTER_VERSION, + status: "completed", + input_files: [input], + output_files: [output], + settings, + generated_at: new Date().toISOString() + }); +} + +async function exportUsdz(scene, outputPath, USDZExporter) { + const exporter = new USDZExporter(); + const payload = + typeof exporter.parseAsync === "function" + ? await exporter.parseAsync(scene) + : await exporter.parse(scene); + await writeFile(outputPath, toBuffer(payload)); +} + +async function exportStl(scene, outputPath, STLExporter) { + const payload = new STLExporter().parse(scene, { binary: true }); + await writeFile(outputPath, toBuffer(payload)); +} + +async function blenderOnPath() { + try { + await execFileAsync("which", ["blender"]); + return true; + } catch { + return false; + } +} + +async function exportFbxViaBlender(inputPath, outputPath) { + const script = [ + "import bpy, os", + "bpy.ops.object.select_all(action='SELECT')", + "bpy.ops.object.delete()", + "bpy.ops.import_scene.gltf(filepath=os.environ['IMAGE_BLASTER_INPUT'])", + "bpy.ops.export_scene.fbx(filepath=os.environ['IMAGE_BLASTER_OUTPUT'])" + ].join("; "); + await execFileAsync("blender", ["--background", "--python-expr", script], { + env: { + ...process.env, + IMAGE_BLASTER_INPUT: path.resolve(inputPath), + IMAGE_BLASTER_OUTPUT: path.resolve(outputPath) + }, + maxBuffer: 1024 * 1024 * 10 + }); +} + +export async function convertMesh(options) { + const input = options.input; + const outputDir = options.outputDir; + if (!input) throw new Error("input is required."); + if (!outputDir) throw new Error("outputDir is required."); + if (!(await pathExists(input))) throw new Error(`Input file does not exist: ${input}`); + if (path.extname(input).toLowerCase() !== ".glb") throw new Error("Input must be a .glb file."); + + await ensureDir(outputDir); + const parsed = parseIndexedName(input); + const slug = safeFileName(options.assetName || parsed?.slug || path.basename(input, ".glb").replace(/^\d+-/, "")); + const index = parsed?.index ?? (await nextIndex(outputDir, slug)); + const formats = Array.isArray(options.formats) ? options.formats : parseFormats(options.formats); + const needsScene = formats.some((format) => ["usdz", "stl"].includes(format)); + const modules = needsScene ? await loadThreeModules() : undefined; + const scene = needsScene ? await loadGlbScene(input, modules) : undefined; + const outputs = []; + const skipped = []; + + for (const format of formats) { + if (format === "fbx" && !options.viaBlender) { + console.error("warning: FBX export requires --via blender; skipping FBX."); + skipped.push({ format, reason: "requires --via blender" }); + continue; + } + if (format === "fbx" && !(await blenderOnPath())) { + console.error("warning: blender not found on PATH; skipping FBX export."); + skipped.push({ format, reason: "blender not found on PATH" }); + continue; + } + + const output = artifactPath(outputDir, index, slug, `.${format}`); + const provenance = provenancePath(outputDir, index, slug, format); + if (format === "usdz") await exportUsdz(scene, output, modules.USDZExporter); + if (format === "stl") await exportStl(scene, output, modules.STLExporter); + if (format === "fbx") await exportFbxViaBlender(input, output); + const settings = { + format, + exporter: format === "fbx" ? "blender" : `three-stdlib/${format.toUpperCase()}Exporter`, + via: format === "fbx" ? "blender" : "node", + binary: format === "stl" ? true : undefined, + textures: false + }; + await writeProvenance({ format, input, output, provenance, settings }); + outputs.push({ format, path: output, provenance }); + } + + return { + schema_version: 1, + input, + output_dir: outputDir, + asset_name: slug, + index, + outputs, + skipped + }; +} + +async function main() { + const { flags } = parseArgs(); + const input = one(flags, "input"); + const outputDir = one(flags, "output-dir"); + if (!input || !outputDir) throw new Error(usage()); + const summary = await convertMesh({ + input, + outputDir, + assetName: one(flags, "asset-name"), + formats: requestedFormats(flags), + viaBlender: one(flags, "via") === "blender" || Boolean(flags.blender) + }); + console.log(JSON.stringify(summary, null, 2)); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/.claude/settings.json b/.claude/settings.json index a523655..957a59c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,8 +9,10 @@ "Skill(image-blast-plate)", "Skill(image-blast-image-edit)", "Skill(image-blast-wildcard)", + "Skill(image-blast-export)", "Bash(ls:*)", "Bash(node .claude/scripts/**:*)", + "Bash(node .claude/scripts/asset-pipeline/convert-mesh.mjs:*)", "Bash(node .claude/scripts/project/show-url.mjs:*)", "Bash(bun install:*)", "Bash(bun run dev:*)", diff --git a/.claude/skills/image-blast-export/SKILL.md b/.claude/skills/image-blast-export/SKILL.md new file mode 100644 index 0000000..399db71 --- /dev/null +++ b/.claude/skills/image-blast-export/SKILL.md @@ -0,0 +1,40 @@ +--- +name: image-blast-export +description: Convert generated 3D objects to additional formats (USDZ, STL, optional FBX). Use when an image-blaster object has a `.glb` and the user needs a different format for a game engine, AR target, or 3D printer. +argument-hint: [world-name] [object-id or --all] [--formats usdz,stl,fbx] [--via blender] +allowed-tools: Read Write Glob Bash(ls *) Bash(node .claude/scripts/asset-pipeline/convert-mesh.mjs *) +context: fork +agent: image-blast-export +--- + +convert generated 3D objects for project `$0`. + +## when to use it + +use this after `image-blast-3d` has produced a `.glb` model in `worlds/$0/output//`. + +## defaults + +- USDZ and STL are exported with pure node/three-stdlib. +- FBX is skipped unless the user passes `--via blender`. +- If FBX is requested and `blender` is not on PATH, skip it and report the warning. + +## invocation + +run one conversion per `.glb`: + +```bash +node .claude/scripts/asset-pipeline/convert-mesh.mjs --input "worlds/$0/output//-.glb" --output-dir "worlds/$0/output/" --asset-name "" --formats usdz,stl +``` + +when the user requests FBX, include both `fbx` and `--via blender`: + +```bash +node .claude/scripts/asset-pipeline/convert-mesh.mjs --input "worlds/$0/output//-.glb" --output-dir "worlds/$0/output/" --asset-name "" --formats usdz,stl,fbx --via blender +``` + +## filenames + +preserve the asset index and slug in generated filenames: `N-.usdz`, `N-.stl`, and `.N---request.json`. + +final response: report converted files, skipped formats, and provenance files. diff --git a/README.md b/README.md index 633aa3b..5713eaf 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ IMAGE-BLASTER uses a few generation models: - `--generate-type Normal|LowPoly|Geometry`: `Normal` creates a textured model, `LowPoly` applies polygon reduction, and `Geometry` creates a white geometry-only model. Defaults to `Normal`. - `--polygon-type triangle|quadrilateral`: polygon type for `LowPoly`. Defaults to `triangle`. +### Additional Formats + +USDZ (Apple AR / Vision Pro) and STL (3D printing) are available via the image-blast-export skill. FBX is opt-in behind `--via blender` when a local `blender` binary is on PATH. + ### Examples - Video game level concepts? `IMAGE-BLAST` it. diff --git a/bun.lock b/bun.lock index d1d2890..27e4134 100644 --- a/bun.lock +++ b/bun.lock @@ -2,7 +2,12 @@ "lockfileVersion": 1, "configVersion": 1, "workspaces": { - "": {}, + "": { + "devDependencies": { + "three": "^0.180.0", + "three-stdlib": "^2.36.1", + }, + }, "app": { "name": "image-blaster", "version": "0.0.1", @@ -501,7 +506,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], "file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], @@ -803,6 +808,10 @@ "@react-three/rapier/@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.19.2", "", {}, "sha512-AZHL1jqUF55QJkJyU1yKeh4ImX2J93bVLIezT1+o0FZqTix6O06MOaqpKoJ4MmbDCsoZmwO+qc471/SDMDm2AA=="], + "@sparkjsdev/spark/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "@types/three/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -817,8 +826,6 @@ "stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], - "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], - "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], diff --git a/package.json b/package.json index 22f6921..69bfc43 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,9 @@ "preview": "bun --cwd=app run preview", "test": "bun --cwd=app run test", "typecheck": "bun --cwd=app run typecheck" + }, + "devDependencies": { + "three": "^0.180.0", + "three-stdlib": "^2.36.1" } -} \ No newline at end of file +}