diff --git a/4376bf-img-to-3d-generator/CardListing/3f11fac0-cddf-4662-99ae-2abf3988eb28.json b/4376bf-img-to-3d-generator/CardListing/3f11fac0-cddf-4662-99ae-2abf3988eb28.json new file mode 100644 index 00000000..8c24b1bf --- /dev/null +++ b/4376bf-img-to-3d-generator/CardListing/3f11fac0-cddf-4662-99ae-2abf3988eb28.json @@ -0,0 +1,463 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Img-to-3D Generator", + "summary": "Turn a product photo into an editable 3D model. Drop in a reference image and the studio analyzes the object, plans its parts, and rebuilds it as a procedural Three.js sculpture you can spin, export, and refine — no modeling skills needed. It works best on simple, regular geometry and hard-surface objects — bottles, cans, mugs, furniture, boxes, cylinders and revolved shapes — where a handful of primitives capture the form cleanly. It reproduces real labels and printed graphics from the photo (and tells a stuck-on sticker apart from a print applied straight onto the surface), saves every generation as an editable code file with full version history, and lets you fine-tune the result conversationally through the built-in AI assistant or export it as a .glb. Highly organic or precision-mechanical subjects (faces, figures, firearms) are harder and may only approximate.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "examples.0": { + "links": { + "self": "../ImgTo3dStudio/282993ec-cba6-4130-bc27-582b997caf06" + } + }, + "examples.1": { + "links": { + "self": "../ImgTo3dStudio/182b81b7-103b-47c5-b282-938b20d1740f" + } + }, + "license": { + "links": { + "self": null + } + }, + "categories.0": { + "links": { + "self": "../../Category/content-creation" + } + }, + "tags.0": { + "links": { + "self": "../../Tag/ai" + } + }, + "tags.1": { + "links": { + "self": "../../Tag/app" + } + }, + "specs.0": { + "links": { + "self": "../Spec/29ec246d-0239-474f-80cf-307c8bbf2ed0" + } + }, + "specs.1": { + "links": { + "self": "../Spec/c729ec24-6d02-4917-8f00-cf307c8bbf2e" + } + }, + "specs.2": { + "links": { + "self": "../Spec/d411a892-1fc7-49ec-a46d-0239174f00cf" + } + }, + "specs.3": { + "links": { + "self": "../Spec/541fdc6f-52d4-42c6-aa5b-2a8da1c2b4ac" + } + }, + "specs.4": { + "links": { + "self": "../Spec/2e55541f-dc6f-42d4-82c6-6a5b2a8da1c2" + } + }, + "specs.5": { + "links": { + "self": "../Spec/55541fdc-6f52-44c2-866a-5b2a8da1c2b4" + } + }, + "specs.6": { + "links": { + "self": "../Spec/6350f690-2d3b-4e86-bd4a-4c5602baa7c6" + } + }, + "specs.7": { + "links": { + "self": "../Spec/6d023917-4f00-4f30-bc8b-bf2ed08bb743" + } + }, + "specs.8": { + "links": { + "self": "../Spec/0239174f-00cf-407c-8bbf-2ed08bb74310" + } + }, + "specs.9": { + "links": { + "self": "../Spec/aad411a8-921f-4729-ac24-6d0239174f00" + } + }, + "specs.10": { + "links": { + "self": "../Spec/921fc729-ec24-4d02-b917-4f00cf307c8b" + } + }, + "specs.11": { + "links": { + "self": "../Spec/2f18fbdf-6350-4690-ad3b-1e86bd4a4c56" + } + }, + "specs.12": { + "links": { + "self": "../Spec/b1a52f18-fbdf-4350-b690-2d3b1e86bd4a" + } + }, + "specs.13": { + "links": { + "self": "../Spec/8a50bf6f-70b7-4b52-a46e-5eba44295bbd" + } + }, + "specs.14": { + "links": { + "self": "../Spec/52a46e5e-ba44-495b-bd70-155e047a4dc4" + } + }, + "specs.15": { + "links": { + "self": "../Spec/ba44295b-bd70-455e-847a-4dc40b408c48" + } + }, + "specs.16": { + "links": { + "self": "../Spec/d0c16223-8a50-4f6f-b0b7-3b52a46e5eba" + } + }, + "specs.17": { + "links": { + "self": "../Spec/1fc729ec-246d-4239-974f-00cf307c8bbf" + } + }, + "specs.18": { + "links": { + "self": "../Spec/5270b8aa-d411-4892-9fc7-29ec246d0239" + } + }, + "specs.19": { + "links": { + "self": "../Spec/ec246d02-3917-4f00-8f30-7c8bbf2ed08b" + } + }, + "specs.20": { + "links": { + "self": "../Spec/c162238a-50bf-4f70-b73b-52a46e5eba44" + } + }, + "specs.21": { + "links": { + "self": "../Spec/d08bb743-105a-44af-90c1-62238a50bf6f" + } + }, + "specs.22": { + "links": { + "self": "../Spec/cf307c8b-bf2e-408b-b743-105a34afd0c1" + } + }, + "specs.23": { + "links": { + "self": "../Spec/44295bbd-7015-4e04-ba4d-c40b408c4852" + } + }, + "specs.24": { + "links": { + "self": "../Spec/43105a34-afd0-4162-a38a-50bf6f70b73b" + } + }, + "specs.25": { + "links": { + "self": "../Spec/695270b8-aad4-41a8-921f-c729ec246d02" + } + }, + "specs.26": { + "links": { + "self": "../Spec/62238a50-bf6f-40b7-bb52-a46e5eba4429" + } + }, + "specs.27": { + "links": { + "self": "../Spec/4f00cf30-7c8b-4f2e-908b-b743105a34af" + } + }, + "specs.28": { + "links": { + "self": "../Spec/295bbd70-155e-447a-8dc4-0b408c48521d" + } + }, + "specs.29": { + "links": { + "self": "../Spec/bf2ed08b-b743-405a-b4af-d0c162238a50" + } + }, + "specs.30": { + "links": { + "self": "../Spec/5eba4429-5bbd-4015-9e04-7a4dc40b408c" + } + }, + "specs.31": { + "links": { + "self": "../Spec/307c8bbf-2ed0-4bb7-8310-5a34afd0c162" + } + }, + "specs.32": { + "links": { + "self": "../Spec/6a47deb1-a52f-48fb-9f63-50f6902d3b1e" + } + }, + "specs.33": { + "links": { + "self": "../Spec/deb1a52f-18fb-4f63-90f6-902d3b1e86bd" + } + }, + "specs.34": { + "links": { + "self": "../Spec/5b6a47de-b1a5-4f18-bbdf-6350f6902d3b" + } + }, + "specs.35": { + "links": { + "self": "../Spec/34afd0c1-6223-4a50-bf6f-70b73b52a46e" + } + }, + "specs.36": { + "links": { + "self": "../Spec/2ed08bb7-4310-4a34-afd0-c162238a50bf" + } + }, + "specs.37": { + "links": { + "self": "../Spec/7c8bbf2e-d08b-4743-905a-34afd0c16223" + } + }, + "specs.38": { + "links": { + "self": "../Spec/8bb74310-5a34-4fd0-8162-238a50bf6f70" + } + }, + "specs.39": { + "links": { + "self": "../Spec/50bf6f70-b73b-42a4-ae5e-ba44295bbd70" + } + }, + "specs.40": { + "links": { + "self": "../Spec/6e5eba44-295b-4d70-955e-047a4dc40b40" + } + }, + "specs.41": { + "links": { + "self": "../Spec/68805b6a-47de-41a5-af18-fbdf6350f690" + } + }, + "specs.42": { + "links": { + "self": "../Spec/11a8921f-c729-4c24-ad02-39174f00cf30" + } + }, + "specs.43": { + "links": { + "self": "../Spec/70b8aad4-11a8-421f-8729-ec246d023917" + } + }, + "specs.44": { + "links": { + "self": "../Spec/a8921fc7-29ec-446d-8239-174f00cf307c" + } + }, + "specs.45": { + "links": { + "self": "../Spec/b8aad411-a892-4fc7-a9ec-246d0239174f" + } + }, + "specs.46": { + "links": { + "self": "../Spec/39174f00-cf30-4c8b-bf2e-d08bb743105a" + } + }, + "specs.47": { + "links": { + "self": "../Spec/bd70155e-047a-4dc4-8b40-8c48521d9237" + } + }, + "specs.48": { + "links": { + "self": "../Spec/70b73b52-a46e-4eba-8429-5bbd70155e04" + } + }, + "specs.49": { + "links": { + "self": "../Spec/b743105a-34af-40c1-a223-8a50bf6f70b7" + } + }, + "specs.50": { + "links": { + "self": "../Spec/5bbd7015-5e04-4a4d-840b-408c48521d92" + } + }, + "specs.51": { + "links": { + "self": "../Spec/238a50bf-6f70-473b-92a4-6e5eba44295b" + } + }, + "specs.52": { + "links": { + "self": "../Spec/805b6a47-deb1-452f-98fb-df6350f6902d" + } + }, + "specs.53": { + "links": { + "self": "../Spec/a52f18fb-df63-40f6-902d-3b1e86bd4a4c" + } + }, + "specs.54": { + "links": { + "self": "../Spec/47deb1a5-2f18-4bdf-a350-f6902d3b1e86" + } + }, + "specs.55": { + "links": { + "self": "../Spec/174f00cf-307c-4bbf-aed0-8bb743105a34" + } + }, + "specs.56": { + "links": { + "self": "../Spec/bf6f70b7-3b52-446e-9eba-44295bbd7015" + } + }, + "specs.57": { + "links": { + "self": "../Spec/8bbf2ed0-8bb7-4310-9a34-afd0c162238a" + } + }, + "specs.58": { + "links": { + "self": "../Spec/246d0239-174f-40cf-b07c-8bbf2ed08bb7" + } + }, + "specs.59": { + "links": { + "self": "../Spec/00cf307c-8bbf-4ed0-8bb7-43105a34afd0" + } + }, + "specs.60": { + "links": { + "self": "../Spec/6f70b73b-52a4-4e5e-ba44-295bbd70155e" + } + }, + "specs.61": { + "links": { + "self": "../Spec/105a34af-d0c1-4223-8a50-bf6f70b73b52" + } + }, + "specs.62": { + "links": { + "self": "../Spec/a46e5eba-4429-4bbd-b015-5e047a4dc40b" + } + }, + "specs.63": { + "links": { + "self": "../Spec/5a34afd0-c162-438a-90bf-6f70b73b52a4" + } + }, + "specs.64": { + "links": { + "self": "../Spec/b73b52a4-6e5e-4a44-a95b-bd70155e047a" + } + }, + "specs.65": { + "links": { + "self": "../Spec/3b52a46e-5eba-4429-9bbd-70155e047a4d" + } + }, + "specs.66": { + "links": { + "self": "../Spec/18fbdf63-50f6-402d-bb1e-86bd4a4c5602" + } + }, + "specs.67": { + "links": { + "self": "../Spec/afd0c162-238a-40bf-af70-b73b52a46e5e" + } + }, + "specs.68": { + "links": { + "self": "../Spec/c0ffee00-1234-4abc-8def-0123456789ab" + } + }, + "specs.69": { + "links": { + "self": "../Spec/deadbeef-1234-4abc-8def-0123456789ab" + } + }, + "specs.70": { + "links": { + "self": "../Spec/aa000001-0000-4000-8000-000000000001" + } + }, + "specs.71": { + "links": { + "self": "../Spec/aa000002-0000-4000-8000-000000000002" + } + }, + "specs.72": { + "links": { + "self": "../Spec/aa000003-0000-4000-8000-000000000003" + } + }, + "specs.73": { + "links": { + "self": "../Spec/aa000004-0000-4000-8000-000000000004" + } + }, + "specs.74": { + "links": { + "self": "../Spec/aa000005-0000-4000-8000-000000000005" + } + }, + "images.0": { + "links": { + "self": "../Screenshots/01-hero-final-result.png" + } + }, + "images.1": { + "links": { + "self": "../Screenshots/02-empty-generator.png" + } + }, + "images.2": { + "links": { + "self": "../Screenshots/03-history-rounds.png" + } + }, + "images.3": { + "links": { + "self": "../Screenshots/04-refined-round.png" + } + }, + "skills.0": { + "links": { + "self": "../Skill/refine-sculpt-skill" + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": "../ListingThumbnails/imgto3d-generator-90545c5f.png" + } + }, + "specs.75": { + "links": { + "self": "../Spec/47e95de3-917f-4031-9f6a-851efc2b106b" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/catalog/catalog-app/listing/listing", + "name": "CardListing" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/ImgTo3dStudio/182b81b7-103b-47c5-b282-938b20d1740f.json b/4376bf-img-to-3d-generator/ImgTo3dStudio/182b81b7-103b-47c5-b282-938b20d1740f.json new file mode 100644 index 00000000..770c54cb --- /dev/null +++ b/4376bf-img-to-3d-generator/ImgTo3dStudio/182b81b7-103b-47c5-b282-938b20d1740f.json @@ -0,0 +1,23 @@ +{ + "data": { + "type": "card", + "attributes": { + "references": { + "images": [] + }, + "llmModel": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../img-to-3d-studio", + "name": "ImgTo3dStudio" + } + } + } +} \ No newline at end of file diff --git a/4376bf-img-to-3d-generator/ImgTo3dStudio/282993ec-cba6-4130-bc27-582b997caf06.json b/4376bf-img-to-3d-generator/ImgTo3dStudio/282993ec-cba6-4130-bc27-582b997caf06.json new file mode 100644 index 00000000..67a84f4c --- /dev/null +++ b/4376bf-img-to-3d-generator/ImgTo3dStudio/282993ec-cba6-4130-bc27-582b997caf06.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "references": { + "images": [ + { + "url": "https://static.vecteezy.com/system/resources/thumbnails/027/182/338/small/trailler-truck-isolated-on-a-transparent-background-png.png", + "sourceMode": "url" + } + ] + }, + "llmModel": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../img-to-3d-studio", + "name": "ImgTo3dStudio" + } + }, + "relationships": { + "selectedCreation": { + "links": { + "self": "../img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/feb20557-8e81-4aef-b70f-c842a1584b43" + } + }, + "latestCreation": { + "links": { + "self": "../img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/feb20557-8e81-4aef-b70f-c842a1584b43" + } + } + } + } +} \ No newline at end of file diff --git a/4376bf-img-to-3d-generator/ListingThumbnails/imgto3d-generator-90545c5f.png b/4376bf-img-to-3d-generator/ListingThumbnails/imgto3d-generator-90545c5f.png new file mode 100644 index 00000000..f7b7e14f Binary files /dev/null and b/4376bf-img-to-3d-generator/ListingThumbnails/imgto3d-generator-90545c5f.png differ diff --git a/4376bf-img-to-3d-generator/Screenshots/01-hero-final-result.png b/4376bf-img-to-3d-generator/Screenshots/01-hero-final-result.png new file mode 100644 index 00000000..3cc09667 Binary files /dev/null and b/4376bf-img-to-3d-generator/Screenshots/01-hero-final-result.png differ diff --git a/4376bf-img-to-3d-generator/Screenshots/02-empty-generator.png b/4376bf-img-to-3d-generator/Screenshots/02-empty-generator.png new file mode 100644 index 00000000..0b4d6df6 Binary files /dev/null and b/4376bf-img-to-3d-generator/Screenshots/02-empty-generator.png differ diff --git a/4376bf-img-to-3d-generator/Screenshots/03-history-rounds.png b/4376bf-img-to-3d-generator/Screenshots/03-history-rounds.png new file mode 100644 index 00000000..dc7bb207 Binary files /dev/null and b/4376bf-img-to-3d-generator/Screenshots/03-history-rounds.png differ diff --git a/4376bf-img-to-3d-generator/Screenshots/04-refined-round.png b/4376bf-img-to-3d-generator/Screenshots/04-refined-round.png new file mode 100644 index 00000000..8b788624 Binary files /dev/null and b/4376bf-img-to-3d-generator/Screenshots/04-refined-round.png differ diff --git a/4376bf-img-to-3d-generator/Skill/refine-sculpt-skill.json b/4376bf-img-to-3d-generator/Skill/refine-sculpt-skill.json new file mode 100644 index 00000000..b3dab772 --- /dev/null +++ b/4376bf-img-to-3d-generator/Skill/refine-sculpt-skill.json @@ -0,0 +1,38 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "Skill", + "module": "https://cardstack.com/base/skill" + } + }, + "type": "card", + "attributes": { + "cardTitle": "Sculpt Refiner Skill", + "cardDescription": "Conversational refinement for an Img-to-3D model in the AI Assistant. Compares the model's render against its reference photo, suggests fixes, and applies the ones the user approves via the Refine Model command.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "commands": [ + { + "codeRef": { + "name": "default", + "module": "../commands/refine-sculpt-command" + }, + "requiresApproval": true + } + ], + "instructions": "You are refining a 3D model inside the Boxel AI Assistant. A Sculpted Model card is attached — it shows the REFERENCE photo the model was built from and the model's current RENDER (a screenshot). You can SEE both images; use your own vision to compare them.\n\nHOW YOU WORK:\n- Your FIRST reply is a diagnosis, not a command. LOOK at the reference vs the current render and list, as short bullet rows, the SPECIFIC differences you can see — what is wrong, missing, mis-coloured, the wrong size, or in the wrong place. Then ask ONE question: which of these would you like me to fix (or shall I fix them all, one at a time)?\n- CHECK GLAZING AND OPENINGS DELIBERATELY: windows, a windshield, side/rear glass, a sunroof, a screen, a lens, a grille opening. These are flat and low-contrast, so they are the parts most often left out of the build — if the reference clearly shows a window and the render has none, that is a real 'missing part', call it out.\n- Do NOT dump the spec or JSON into the chat, and do NOT critique endlessly — list only concrete, visible differences (aim for the 2–5 that matter most), positively framed.\n\nAPPLYING A CHANGE:\n- When the user agrees on a change, CALL THE \"Refine Model\" COMMAND — once per change. Give it a single, precise, self-contained instruction naming the part and the exact change, e.g. \"recolor the shorts to bright red #d81f2a\", \"make both ears 30% bigger\", \"move the bill to the front centre of the head\", \"add a windshield as a recessed glass pane across the front of the cab\", \"remove the duplicate nose\".\n- The user reviews and approves each command before it runs, so propose ONE change at a time and wait — do not batch several edits into one call, and do not paste geometry numbers; the command works out the spec edit itself.\n- Each call edits the model IN PLACE (it does not spawn a new copy), and the studio shows the update. After it applies, reply with ONE short sentence saying what you changed, then ask if they want the next fix.\n\nCALLING \"Refine Model\":\n- sculptedModelId: ALWAYS the id of the attached Sculpted Model card — that is the CURRENT round shown in the studio viewport. Each edit is applied in place to that same current round's file; never target a different or older round.\n- instruction: the single plain-language change described above. Recolor / resize / move / reorient / add / remove a part — all fine; describe it clearly enough that someone who cannot see the reference could apply it.\n\nWHAT NOT TO DO:\n- Do not try to rewrite code or emit three.js — you only describe the change; the command edits the model's spec and rebuilds it.\n- Do not invent detail the reference does not show. If the reference and render already match on something, leave it alone." + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/4376bf-img-to-3d-generator/Spec/00cf307c-8bbf-4ed0-8bb7-43105a34afd0.json b/4376bf-img-to-3d-generator/Spec/00cf307c-8bbf-4ed0-8bb7-43105a34afd0.json new file mode 100644 index 00000000..236d40e8 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/00cf307c-8bbf-4ed0-8bb7-43105a34afd0.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# dropHairlineParts\n\n## Summary\n\nA pruning pass in the img-to-3d structure-repair chain. It detects hairline solids — geometry so thin it renders as a wire hanging in space (a mould seam, panel gap, or printed line modelled as a tiny tube). The gate is deliberately name-blind and purely dimensional: tubes with radius under 0.02, tori with tube under 0.008, and boxes/roundedBoxes/cylinders with two extents under 0.015 world units are deleted. Removal cascades: any child parented to a dropped part is dropped too, since a surviving child would inherit root coordinates and fly off on its own.\n\n## Import\n\n```ts\nimport { dropHairlineParts } from '../util/spec-passes/prune';\n```\n\n## Usage\n\nRuns first in `runStructurePasses`, before any other inventory or placement pass. It receives the parsed spec, mutates `parsed.components` in place by filtering out condemned parts, and returns log lines describing each drop. The effect is that surface marks stop rendering as dark lines crossing the model.\n", + "ref": { + "module": "../util/spec-passes/prune", + "name": "dropHairlineParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "dropHairlineParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/0239174f-00cf-407c-8bbf-2ed08bb74310.json b/4376bf-img-to-3d-generator/Spec/0239174f-00cf-407c-8bbf-2ed08bb74310.json new file mode 100644 index 00000000..e45ac4e5 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/0239174f-00cf-407c-8bbf-2ed08bb74310.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# selectRecipes\n\n## Summary\n\nTurns an analysis document into the recipe text for the spec prompt: it maps the names returned by `selectRecipeNames(analysis)` to their blocks in the `RECIPES` table (revolved, organic, character, flatGraphic, wrapDecal, boxy, grip, roundedShell, artwork, architectural, vehicle, machineDetail, finishes) and joins them with blank lines. These are craft rules only some objects need — each costs a couple of thousand tokens of model attention, so an object only receives the ones its own plan triggers; an object triggering none yields an empty string.\n\n## Import\n\n```ts\nimport { selectRecipes } from '../prompts/recipes';\n```\n\n## Usage\n\nCalled by `buildSpecSystemPrompt` in `prompts/spec.gts`: a non-empty result becomes the second system-prompt block (\"Build directives for THIS object\"), while an empty result leaves the invariant contract block alone.", + "ref": { + "module": "../prompts/recipes", + "name": "selectRecipes" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "selectRecipes", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/105a34af-d0c1-4223-8a50-bf6f70b73b52.json b/4376bf-img-to-3d-generator/Spec/105a34af-d0c1-4223-8a50-bf6f70b73b52.json new file mode 100644 index 00000000..3131292f --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/105a34af-d0c1-4223-8a50-bf6f70b73b52.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# flagUnrealizedParts\n\n## Summary\n\nA report-only pass in the img-to-3d structure-repair chain — the mirror of `dropUnplannedParts`. It detects plan entries that NO component realizes: a part the reference plainly has (a bottle's front label, say) that the build simply never authored. It also reports declared materials no component uses, which is often the only trace of a part that was planned, given paint, and never built. Plan parts with approach `revolved` are exempt when a lathe exists to have absorbed them. Geometry cannot be invented from a part name, so nothing is repaired.\n\n## Import\n\n```ts\nimport { flagUnrealizedParts } from '../util/spec-passes/prune';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after the pruning drops; the studio's `generate()` also calls it directly (components/studio-isolated.gts) to decide whether the vision completeness audit is worth its round-trip. Receives (parsed, analysis), mutates nothing, returns log lines.\n", + "ref": { + "module": "../util/spec-passes/prune", + "name": "flagUnrealizedParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "flagUnrealizedParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/11a8921f-c729-4c24-ad02-39174f00cf30.json b/4376bf-img-to-3d-generator/Spec/11a8921f-c729-4c24-ad02-39174f00cf30.json new file mode 100644 index 00000000..7b3d8a0c --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/11a8921f-c729-4c24-ad02-39174f00cf30.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# enforceAttachments\n\n## Summary\n\nAn attachment pass in the img-to-3d structure chain. The analysis writes prose joint lines (\"balcony railing rests-on balcony platform\") that the spec prompt calls hard joints, but nothing checked them numerically — contact at a corner satisfies the interpreter while a roof sits nowhere near centred. Working purely in the spec's own coordinates (no camera model), it REPAIRS the two unambiguous constraints — `centered-above` and `rests-on` seat the subject group on top of its target with a 0.03 overlap, recentring only when it is already roughly centred — and reports `inset-into`, `flush-top`, and `attached-left/right/front/back` violations. It refuses inverted lines (a support never rests on the body it carries; a subject the spec placed below its target is left alone).\n\n## Import\n\n```ts\nimport { enforceAttachments } from '../util/spec-passes/attachments';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after `repairMirroredAttachments`, before `groundSupports`. Receives (parsed, analysis); mutates positions in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/attachments", + "name": "enforceAttachments" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "enforceAttachments", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/174f00cf-307c-4bbf-aed0-8bb743105a34.json b/4376bf-img-to-3d-generator/Spec/174f00cf-307c-4bbf-aed0-8bb743105a34.json new file mode 100644 index 00000000..d2a82cb3 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/174f00cf-307c-4bbf-aed0-8bb743105a34.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# clampToEnvelope\n\n## Summary\n\nA placement pass in the img-to-3d pipeline for revolved objects. The traced silhouette is the object's true outer boundary, and a capsule, ring, or collar wider than the neck — or a part that slid off-axis — pokes past the outline and reads as floating. Given the world-space envelope (half-width per height, `{ y, half }[]` from the traced lathe profile), it lowers a part whose centre floats above the silhouette top onto the top edge, scales an on-axis over-wide solid down to the local half-width (X, and Z too when `revolved`), and slides an off-axis part toward the axis. Lathes, tubes, extrusions and meshes are skipped; decal radii belong to `fitCurvedDecals`.\n\n## Import\n\n```ts\nimport { clampToEnvelope } from '../util/spec-passes/placement';\n```\n\n## Usage\n\nNot in `runStructurePasses` — the studio's `applyParsedSpec` calls `clampToEnvelope(parsed, this.tracedEnvelope, true)` only when silhouette tracing succeeded. Mutates positions/scales in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/placement", + "name": "clampToEnvelope" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "clampToEnvelope", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/18fbdf63-50f6-402d-bb1e-86bd4a4c5602.json b/4376bf-img-to-3d-generator/Spec/18fbdf63-50f6-402d-bb1e-86bd4a4c5602.json new file mode 100644 index 00000000..5368894c --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/18fbdf63-50f6-402d-bb1e-86bd4a4c5602.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# flagSilhouetteNotch\n\n## Summary\n\nA report-only shape pass in the img-to-3d structure chain — the silhouette-level counterpart of the buried-part check: there a part exists but cannot be seen; here the silhouette says a part should exist and none does. It samples the model's height profile in 24 steps along its longest horizontal axis (a vehicle's length), restricted to the central slab so thin flank parts cannot paper over the gap, and looks for a LONG dip — at least 15% of the span, under 65% of the tallest height — with taller structure on BOTH sides. A dip at either end is a nose or tail; a flanked valley is a body part nobody built. Which part is missing is a question about the photograph, so nothing is repaired.\n\n## Import\n\n```ts\nimport { flagSilhouetteNotch } from '../util/spec-passes/shape';\n```\n\n## Usage\n\nRuns late in `runStructurePasses`, among the flag passes. Receives the parsed spec, mutates nothing, returns at most one descriptive log line.\n", + "ref": { + "module": "../util/spec-passes/shape", + "name": "flagSilhouetteNotch" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "flagSilhouetteNotch", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/1fc729ec-246d-4239-974f-00cf307c8bbf.json b/4376bf-img-to-3d-generator/Spec/1fc729ec-246d-4239-974f-00cf307c8bbf.json new file mode 100644 index 00000000..f07455c8 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/1fc729ec-246d-4239-974f-00cf307c8bbf.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# composeComparison\n\n## Summary\n\nPacks the reference photo beside one or more labeled render angles into a single JPEG contact sheet (returned as a data URL), so the refine model compares them in one glance — it reviews exactly one image per round but sees the build from every side, because 3D errors hide from single angles. Each image is drawn at a shared height (384px when there are multiple renders, 512px for one) with a label strip on top: `REFERENCE`, then `RENDER` or the angle labels (`RENDER @ REF ANGLE`, `RENDER FRONT`, `RENDER SIDE`, `RENDER 3/4` when `firstIsReferenceAngle` is set).\n\n## Import\n\n```ts\nimport { composeComparison } from '../util/comparison-sheet';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) composes the sheet from the reference data URL and captured viewport renders before each refine and completeness-audit vision call; the returned data URL goes into the request's image content.", + "ref": { + "module": "../util/comparison-sheet", + "name": "composeComparison" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "composeComparison", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/238a50bf-6f70-473b-92a4-6e5eba44295b.json b/4376bf-img-to-3d-generator/Spec/238a50bf-6f70-473b-92a4-6e5eba44295b.json new file mode 100644 index 00000000..c7f4f304 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/238a50bf-6f70-473b-92a4-6e5eba44295b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# isRecessed\n\n## Summary\n\nA predicate shared by the img-to-3d placement passes. The pipeline's default assumption is that every feature PROTRUDES — `resolveBuriedParts` pushes buried parts back out — so a feature designed to sit INTO its host needs an opt-out. A component counts as recessed when it flags `inset: true` or when its nodeId, partRef, or note matches `RECESS_NAME` (recess, sunken, inset, intake, vent, grille, interior, cavity, well, window, windshield, and variants). It reads the component only; nothing is mutated.\n\n## Import\n\n```ts\nimport { isRecessed } from '../util/spec-passes/placement';\n```\n\n## Usage\n\nCalled inside the placement module itself: `resolveBuriedParts` uses it to exempt recessed features from being ejected out of their host, and `seatRecesses` uses it (plus its `attachTo`/enclosing-host search) to pick which parts get sunk into a surface. Receives a component, returns a boolean.\n", + "ref": { + "module": "../util/spec-passes/placement", + "name": "isRecessed" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "isRecessed", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/246d0239-174f-40cf-b07c-8bbf2ed08bb7.json b/4376bf-img-to-3d-generator/Spec/246d0239-174f-40cf-b07c-8bbf2ed08bb7.json new file mode 100644 index 00000000..6358f362 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/246d0239-174f-40cf-b07c-8bbf2ed08bb7.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# reconcileProportions\n\n## Summary\n\nMeasured proportion reconciliation in the img-to-3d pipeline — the heart of \"LLM names structure, math writes numbers\". The draft build is rendered and measured in the viewer (real world-space boxes), each `partRef` group is compared against its analysis bbox target (normalized to the object's union bounds), and size and placement corrections are written back deterministically — zero vision calls. Widths are skipped when the camera is over 30° off front; vertical MOVES are withheld above 25° elevation and for split groups that share no host and do not touch (sizes still apply). Per-part scale is clamped so the FINAL scale stays within 0.4–2.5×, preventing compounding rounds from driving a part degenerate. Tubes and decal sizes are never touched.\n\n## Import\n\n```ts\nimport { reconcileProportions } from '../util/spec-passes/proportions';\n```\n\n## Usage\n\nCalled by the studio's `reconcileDraftProportions` after `renderDraftAndMeasure`, not in `runStructurePasses`. Receives (parsed, analysis, measured, skipRefs); returns `{ logs, residual }` — the mean deviation the auto-verify loop gates on.\n", + "ref": { + "module": "../util/spec-passes/proportions", + "name": "reconcileProportions" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "reconcileProportions", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/295bbd70-155e-447a-8dc4-0b408c48521d.json b/4376bf-img-to-3d-generator/Spec/295bbd70-155e-447a-8dc4-0b408c48521d.json new file mode 100644 index 00000000..97484785 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/295bbd70-155e-447a-8dc4-0b408c48521d.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# traceLatheProfile\n\n## Summary\n\nTraces a rotationally symmetric part's lathe profile straight from the reference photo's pixels instead of trusting the vision model to invent one. It segments the crop by color threshold against the corner-sampled backdrop, repairs label/reflection gaps in the mask, samples per-row half-widths bottom→top, enforces a unimodal shape prior (never narrowing before the widest point, never widening after, small lip flare excepted), and returns a normalized `[x0,y0, x1,y1, …]` dimensions array starting closed on the axis — or `null` with a reason pushed to `diag` when the crop cannot segment cleanly.\n\n## Import\n\n```ts\nimport { traceLatheProfile } from '../util/silhouette';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) calls it with the loaded reference image and the bbox chosen by `revolvedSilhouetteBbox`, using 16 samples; the traced profile replaces the model-invented lathe dimensions for the revolved body.", + "ref": { + "module": "../util/silhouette", + "name": "traceLatheProfile" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "traceLatheProfile", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/29ec246d-0239-474f-80cf-307c8bbf2ed0.json b/4376bf-img-to-3d-generator/Spec/29ec246d-0239-474f-80cf-307c8bbf2ed0.json new file mode 100644 index 00000000..9b6160ba --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/29ec246d-0239-474f-80cf-307c8bbf2ed0.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# AnalyzeReferenceCommand\n\n## Summary\n\nStage 1 of the image-to-3D pipeline as its own host Command (\"Analyze Reference Images\"). Input: `imageUrls` (resolved reference URLs, primary first, up to 6 used), an optional `model` (enum over `VISION_MODEL_OPTIONS`), and an optional `maxEdge` longest-edge cap that trims vision tokens for this perception-only stage. `run()` fetches each image as a data URL via `fetchAsDataUrl`, sends `ANALYZE_SYSTEM_PROMPT` through `requestSpec` with a seed derived from the URLs (`seedFromStrings`) and `reasoning: { effort: 'low' }` — so one photo set reproduces the same plan — and parses the reply with `parseAnalysisJson`. The output card carries the full `analysisJson` verbatim for the spec stage, plus headline facts: objectType, objectClass, buildBackend and backendReason, partCount, and identityFeatures.\n\n## Import\n\n```ts\nimport { AnalyzeReferenceCommand } from '../commands/analyze-reference';\n```\n\n## Usage\n\n`StudioIsolated` runs it at the start of every generation; splitting the stage out makes the plan an inspectable, reusable artifact — the studio persists it on the `SculptedModel`, a human can correct it before the spec stage, an AI-assistant skill can call it as a tool, and a bad plan can be re-run without re-running the whole generation.", + "ref": { + "module": "../commands/analyze-reference", + "name": "AnalyzeReferenceCommand" + }, + "specType": "command", + "containedExamples": [], + "cardTitle": "AnalyzeReferenceCommand", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/2e55541f-dc6f-42d4-82c6-6a5b2a8da1c2.json b/4376bf-img-to-3d-generator/Spec/2e55541f-dc6f-42d4-82c6-6a5b2a8da1c2.json new file mode 100644 index 00000000..d9a2802f --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/2e55541f-dc6f-42d4-82c6-6a5b2a8da1c2.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ComponentNodeField\n\n## Summary\n\nOne node of a sculpt spec's model tree. Nodes form a flat list linked by `parentId` (FieldDefs cannot nest recursively); the viewer interpreter rebuilds the tree. Fields: `nodeId`, `primitive` (an enum over the `PRIMITIVES` vocabulary), `dimensions`/`position`/`rotation`/`scale` held as JSON number-array strings so the LLM can author them and humans can tweak them in edit view, `materialId`, `text` (textDecal label), `partRef` (the analysis partPlan name this component realizes, used to reconcile measured proportions), `textureRef`/`textureUrl` (reference-crop decal artwork), `repeat` (linear/radial clone system), `assetUrl` (meshAsset .glb), `attachTo` (the structural support the constraint solver pulls it against), and `note`. The computed title reads `nodeId · primitive`.\n\n## Import\n\n```ts\nimport { ComponentNodeField } from '../fields/sculpt-spec';\n```\n\n## Usage\n\nContained as `containsMany` in `SculptSpecField.components`; instances are materialized from parsed LLM output by `specFieldFromParsed` in `util/spec-io.gts` and translated into three.js code by `util/code-export.gts`.", + "ref": { + "module": "../fields/sculpt-spec", + "name": "ComponentNodeField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Sculpt Component", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/2ed08bb7-4310-4a34-afd0-c162238a50bf.json b/4376bf-img-to-3d-generator/Spec/2ed08bb7-4310-4a34-afd0-c162238a50bf.json new file mode 100644 index 00000000..a26c8239 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/2ed08bb7-4310-4a34-afd0-c162238a50bf.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# localHalfExtents\n\n## Summary\n\nReturns a component's unrotated half extents `[hx, hy, hz]` from its `dimensions` (array or JSON string) and scale, using each primitive's own dimension semantics: box/roundedBox halve each dimension, cylinder uses its max radius, a 4-segment cone squares up to face-to-face width, capsule adds its end caps, torus adds tube to radius, extruded shapes measure their outline's half width/height, and so on. Primitives not centred on their origin (lathe, hemisphere, arch, tube) return `undefined` — callers must treat them as unmeasurable rather than guess.\n\n## Import\n\n```ts\nimport { localHalfExtents } from '../util/spec-geometry';\n```\n\n## Usage\n\nCalled by `halfExtents` in the same module, which layers rotation on top; the repair passes consume it through `halfExtents`/`specBox` rather than directly.", + "ref": { + "module": "../util/spec-geometry", + "name": "localHalfExtents" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "localHalfExtents", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/2f18fbdf-6350-4690-ad3b-1e86bd4a4c56.json b/4376bf-img-to-3d-generator/Spec/2f18fbdf-6350-4690-ad3b-1e86bd4a4c56.json new file mode 100644 index 00000000..e8d57ee0 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/2f18fbdf-6350-4690-ad3b-1e86bd4a4c56.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# generateModelJs\n\n## Summary\n\nGenerates the standalone model .js file from a sculpt spec: a `buildSculpture(THREE)` function returning `{ group, meshes }`, with one `addPart()` line per component, emitted materials, and only the helper blocks the spec needs — geometry cases, decal/glow/finish painters, and, emitted verbatim from their own module source via `Function.prototype.toString()`, `expandRepeatInstances` and `seatSurfaceParts`, so exported models behave exactly like the viewport. The file ends with a machine-readable `var SCULPT_SPEC = …` line and a CommonJS export. `meta` adds round/score provenance comments.\n\n## Import\n\n```ts\nimport { generateModelJs } from '../util/code-export';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) generates draft and persisted model files with it, and `RefineSculptCommand` regenerates the file from a merged spec; the output is written to the realm via `WriteTextFileCommand`.", + "ref": { + "module": "../util/code-export", + "name": "generateModelJs" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "generateModelJs", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/307c8bbf-2ed0-4bb7-8310-5a34afd0c162.json b/4376bf-img-to-3d-generator/Spec/307c8bbf-2ed0-4bb7-8310-5a34afd0c162.json new file mode 100644 index 00000000..f2cd60a0 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/307c8bbf-2ed0-4bb7-8310-5a34afd0c162.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# applySpecDiff\n\n## Summary\n\nMerges a model-authored change set into the current spec and returns the merged plain spec. `changed` entries update position/rotation/scale/grounded/materialId on existing nodes (dimensions only with `allowReshape`); a part's `primitive` is frozen on every path. `added` parts are accepted only with `allowAdditions`, a primitive, and a fresh nodeId; `removedNodeIds` are honoured only with `allowRemoval` and cascade to children. Material changes replace by `materialId`. The option gates encode policy: a user-directed edit may resize and add parts, the automatic refine pass may not.\n\n## Import\n\n```ts\nimport { applySpecDiff } from '../util/spec-diff';\n```\n\n## Usage\n\nCalled by the studio (`components/studio-isolated.gts`) after `parseDiffJson` on refine and targeted-edit replies, and by `RefineSculptCommand`, which regenerates the model .js from the merged spec.", + "ref": { + "module": "../util/spec-diff", + "name": "applySpecDiff" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "applySpecDiff", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/34afd0c1-6223-4a50-bf6f-70b73b52a46e.json b/4376bf-img-to-3d-generator/Spec/34afd0c1-6223-4a50-bf6f-70b73b52a46e.json new file mode 100644 index 00000000..0ede3025 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/34afd0c1-6223-4a50-bf6f-70b73b52a46e.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# specBox\n\n## Summary\n\nComputes a component's world-ish axis-aligned bounding box `{ min, max }` from the spec alone: authored position, plus the geometry's own offset from its origin (`localCentreOffset`, rotated through the node's Euler rotation when present), plus rotated half extents (`halfExtents`). Returns `undefined` for primitives whose extents cannot be described. Every pass that reasons about where a part IS should use this rather than position ± halfExtents, so no two passes disagree about a part's box and push it twice in opposite directions.\n\n## Import\n\n```ts\nimport { specBox } from '../util/spec-geometry';\n```\n\n## Usage\n\nCalled by the structure passes in `util/spec-passes/` (`shape.gts`, `overlap.gts`, `placement.gts`) — envelope clamping, collision flagging, and silhouette-notch checks all measure parts through it.", + "ref": { + "module": "../util/spec-geometry", + "name": "specBox" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "specBox", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/39174f00-cf30-4c8b-bf2e-d08bb743105a.json b/4376bf-img-to-3d-generator/Spec/39174f00-cf30-4c8b-bf2e-d08bb743105a.json new file mode 100644 index 00000000..f027fe8a --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/39174f00-cf30-4c8b-bf2e-d08bb743105a.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# alignFaceFeatures\n\n## Summary\n\nA face pass in the img-to-3d structure chain. Every feature can be planned, attached, unburied and inside the silhouette while the whole cluster sits on the side of the head; the canonical frame fixes the front at -Z, so this is enforceable. Three repairs: (1) the feature cluster's mean offset from the head centre gives the authored facing, and a cluster more than 20° off is rotated as one unit onto -Z; (2) measured face landmarks from the analysis set each feature's height and snap its depth onto the head's front surface (y/z only — landmark x is unreliable off-front), or without landmarks a too-deep nose/mouth is seated just proud of the muzzle; (3) a muzzle/face mass whose centre is outside the head is embedded into the skull on the facial midline. Vertical-order violations (nose above eyes) are reported only.\n\n## Import\n\n```ts\nimport { alignFaceFeatures } from '../util/spec-passes/face';\n```\n\n## Usage\n\nRuns in `runStructurePasses` right after `ensureFaceParts`, before `resolveBuriedParts` would eject an embedded muzzle. Mutates positions/rotations; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/face", + "name": "alignFaceFeatures" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "alignFaceFeatures", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/3b52a46e-5eba-4429-9bbd-70155e047a4d.json b/4376bf-img-to-3d-generator/Spec/3b52a46e-5eba-4429-9bbd-70155e047a4d.json new file mode 100644 index 00000000..32b4d037 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/3b52a46e-5eba-4429-9bbd-70155e047a4d.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# repairPrimitiveConventions\n\n## Summary\n\nA shape pass in the img-to-3d structure chain. A few primitives carry their own geometry in `dimensions` rather than being placed by `position`: a tube's dimensions ARE its swept curve and a bone's are its two endpoints, in the node's own frame. A spec that gives such a part both a point list and a non-zero position double-offsets it — a truck's exhaust run was authored along the hull's flank and then offset to y -1.26, ending up as a brown hook floating across the body. The repair is unambiguous because the points already say where the part goes: any tube or bone with a non-negligible position has it zeroed to [0, 0, 0].\n\n## Import\n\n```ts\nimport { repairPrimitiveConventions } from '../util/spec-passes/shape';\n```\n\n## Usage\n\nRuns near the end of `runStructurePasses`, after the flag passes and before `separateCoplanarLayers`. Mutates `position` in place; returns one log line per zeroed part.\n", + "ref": { + "module": "../util/spec-passes/shape", + "name": "repairPrimitiveConventions" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "repairPrimitiveConventions", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/43105a34-afd0-4162-a38a-50bf6f70b73b.json b/4376bf-img-to-3d-generator/Spec/43105a34-afd0-4162-a38a-50bf6f70b73b.json new file mode 100644 index 00000000..9f8f514f --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/43105a34-afd0-4162-a38a-50bf6f70b73b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# writeRealmImage\n\n## Summary\n\nWrites base64 image bytes into the realm as a binary file and returns a file-backed `ImageDef` card whose file URL is the card id — no separate instance JSON is created. It executes `WriteBinaryFileCommand` with `useNonConflictingFilename: true`, so an existing file is never overwritten; if the write returns no file identifier it returns `undefined`. The resulting `ImageDef` carries `id`, `url`, `sourceUrl`, `name` (last path segment), and `contentType`.\n\n## Import\n\n```ts\nimport { writeRealmImage } from '../util/realm-image';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) calls it with the command context plus `{ realm, path, base64, contentType }` to persist cropped decal textures (under `/textures/`) and render screenshots into the realm.", + "ref": { + "module": "../util/realm-image", + "name": "writeRealmImage" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "writeRealmImage", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/44295bbd-7015-4e04-ba4d-c40b408c4852.json b/4376bf-img-to-3d-generator/Spec/44295bbd-7015-4e04-ba4d-c40b408c4852.json new file mode 100644 index 00000000..133e1e3a --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/44295bbd-7015-4e04-ba4d-c40b408c4852.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# slugify\n\n## Summary\n\nTurns a name into a filesystem-safe slug: lowercases, strips one trailing file extension, replaces every non-alphanumeric run with a single hyphen, and trims leading/trailing hyphens. When nothing survives, the `fallback` argument (default `'image'`) is returned instead, so callers always get a usable path segment.\n\n## Import\n\n```ts\nimport { slugify } from '../util/realm-image';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) uses it wherever a realm path is built from user-visible names: the per-studio asset directory (`img-to-3d/`), model .js filenames from the object name, and cropped decal texture filenames from `textureRef` — each with a purpose-specific fallback (`'studio'`, `'model'`, `'artwork'`). Receives a string, returns a slug string.", + "ref": { + "module": "../util/realm-image", + "name": "slugify" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "slugify", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/47deb1a5-2f18-4bdf-a350-f6902d3b1e86.json b/4376bf-img-to-3d-generator/Spec/47deb1a5-2f18-4bdf-a350-f6902d3b1e86.json new file mode 100644 index 00000000..0e4dfaf6 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/47deb1a5-2f18-4bdf-a350-f6902d3b1e86.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# resolveBuriedParts\n\n## Summary\n\nA placement repair in the img-to-3d structure chain. A part can be perfectly assembled and invisible — swallowed whole by its neighbour (a lower hip roof 90% inside a storey box). A solid whose CENTRE lies inside a larger part and whose volume overlaps it by more than 50% is pushed out through the host's nearest face, keeping the 0.03 overlap the assembly rules want. Moves are capped at 15% of the object, one move per part, over up to three settle passes. Decals, glows, shadows, face features, recessed parts, repeat prototypes, concentric revolved shells, parts attached to that very host, and parts comparable in size to their host are all exempt; those cases are reported instead.\n\n## Import\n\n```ts\nimport { resolveBuriedParts } from '../util/spec-passes/placement';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after the face passes and ring seating, before cavity/recess fitting. Mutates positions in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/placement", + "name": "resolveBuriedParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "resolveBuriedParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/47e95de3-917f-4031-9f6a-851efc2b106b.json b/4376bf-img-to-3d-generator/Spec/47e95de3-917f-4031-9f6a-851efc2b106b.json new file mode 100644 index 00000000..cd857678 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/47e95de3-917f-4031-9f6a-851efc2b106b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# RefineSculptCommand\n\n## Summary\n\nApplies one precise, user-approved edit to a SculptedModel round (recolor a part, resize, move, add or remove geometry) and saves the result as a new round in the studio's history, regenerating the model's exported Three.js code. This is the command the Refine Sculpt skill calls for every accepted instruction.\n\n## Import\n\n```ts\nimport RefineSculptCommand from '../commands/refine-sculpt-command';\n```\n\n## Usage\n\nInvoked by the AI assistant through the refine-sculpt skill: pass `sculptedModelId` (the round to refine) and `instruction` (one self-contained edit in plain words, e.g. \"recolor the shorts to bright red #d81f2a\"). The command rewrites the model code, renders it, and links the new round to its parent so History shows the full refine chain.\n", + "ref": { + "module": "../commands/refine-sculpt-command", + "name": "default" + }, + "specType": "command", + "containedExamples": null, + "cardTitle": "RefineSculptCommand", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/4f00cf30-7c8b-4f2e-908b-b743105a34af.json b/4376bf-img-to-3d-generator/Spec/4f00cf30-7c8b-4f2e-908b-b743105a34af.json new file mode 100644 index 00000000..d44f6403 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/4f00cf30-7c8b-4f2e-908b-b743105a34af.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# cropWithBackgroundRemoved\n\n## Summary\n\nCrops `bbox` (normalized coordinates) out of the reference image with the background knocked out to transparent, returning a canvas for artwork pasted onto the model as a decal — or `null` when the caller should fall back to a plain opaque crop. Default mode keys against the whole image's backdrop colour with a soft alpha ramp; `surfacePrint` mode keys the opposite target — the crop's own border colour (the surface the ink sits on), switching to chroma keying on neutral black/white/grey surfaces so only the chromatic ink survives. The crop keeps the bbox's full extent so the decal's authored size still matches.\n\n## Import\n\n```ts\nimport { cropWithBackgroundRemoved } from '../util/silhouette';\n```\n\n## Usage\n\nCalled by the studio's texture-crop step (`components/studio-isolated.gts`) when cutting decal artwork from the reference before writing it to the realm as a WebP texture.", + "ref": { + "module": "../util/silhouette", + "name": "cropWithBackgroundRemoved" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "cropWithBackgroundRemoved", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/50bf6f70-b73b-42a4-ae5e-ba44295bbd70.json b/4376bf-img-to-3d-generator/Spec/50bf6f70-b73b-42a4-ae5e-ba44295bbd70.json new file mode 100644 index 00000000..8d507109 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/50bf6f70-b73b-42a4-ae5e-ba44295bbd70.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# parseAnalysisJson\n\n## Summary\n\nParses the analysis-stage reply (stage 1 of the v2 pipeline): the object type, per-part plan with bboxes, build recipe, identity features, attachments, and build directives. Like the other spec-io parsers it tolerates markdown fences and trailing commas. It throws when `partPlan` is missing or empty, defaults `buildRecipe`, `identityFeatures`, `attachments`, and `directives` to empty arrays, and drops `camera` unless both `azimuthDeg` and `elevationDeg` are numbers.\n\n## Import\n\n```ts\nimport { parseAnalysisJson } from '../util/spec-io';\n```\n\n## Usage\n\n`AnalyzeReferenceCommand` (`commands/analyze-reference.gts`) passes it as the `parser` argument to `requestSpec`, so the analysis vision call returns a validated plan object. The parsed plan's recipe is then injected into the build request, and its `revolved` parts drive deterministic silhouette tracing.", + "ref": { + "module": "../util/spec-io", + "name": "parseAnalysisJson" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "parseAnalysisJson", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/5270b8aa-d411-4892-9fc7-29ec246d0239.json b/4376bf-img-to-3d-generator/Spec/5270b8aa-d411-4892-9fc7-29ec246d0239.json new file mode 100644 index 00000000..313acddd --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/5270b8aa-d411-4892-9fc7-29ec246d0239.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# mulberry32\n\n## Summary\n\nA tiny seeded pseudo-random number generator: given a 32-bit seed it returns a function producing deterministic floats in [0, 1). It is what makes every procedural surface finish reproducible — the same material id always paints the same grime blotches, scratches, and streaks, so a model looks identical on every rebuild.\n\n## Import\n\n```ts\nimport { mulberry32 } from '../util/finishes';\n```\n\n## Usage\n\n`makeFinishTexture` in the same module seeds it from the material id (via the FNV-1a `seedFrom` helper) and hands the generator to each finish painter. It is also a member of `FINISH_RUNTIME_SOURCES`, which `../util/code-export` emits verbatim via `Function.prototype.toString()` into exported model .js files, so standalone models paint their finishes with exactly the same RNG.", + "ref": { + "module": "../util/finishes", + "name": "mulberry32" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "mulberry32", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/52a46e5e-ba44-495b-bd70-155e047a4dc4.json b/4376bf-img-to-3d-generator/Spec/52a46e5e-ba44-495b-bd70-155e047a4dc4.json new file mode 100644 index 00000000..e9059f09 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/52a46e5e-ba44-495b-bd70-155e047a4dc4.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# generateViewerHarnessHtml\n\n## Summary\n\nGenerates the shared viewer page's full HTML (one per generator, not per model): a three.js r0.147 scene with the same lighting rig and orbit as the studio viewport, loaded from CDN script tags. The harness resolves its model source in priority order — `window.SCULPT_MODEL_INLINE` (draft builds), `window.SCULPT_MODEL_URL` (baked in via the `bakedModelUrl` argument), or the `?model=` query param (external iframes) — executes the model code via `new Function`, and exposes `captureScreenshot()` / `captureViews(refCamera)` / `exportGlb()` on `window` for same-origin embedders.\n\n## Import\n\n```ts\nimport { generateViewerHarnessHtml } from '../util/code-export';\n```\n\n## Usage\n\nCalled by `generateViewerSrcdoc` (which bakes a model URL in) and `generateViewerSrcdocInline` (which injects inline code) in the same module; those two are what the studio and `SculptedModel` actually embed.", + "ref": { + "module": "../util/code-export", + "name": "generateViewerHarnessHtml" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "generateViewerHarnessHtml", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/541fdc6f-52d4-42c6-aa5b-2a8da1c2b4ac.json b/4376bf-img-to-3d-generator/Spec/541fdc6f-52d4-42c6-aa5b-2a8da1c2b4ac.json new file mode 100644 index 00000000..9b185ce7 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/541fdc6f-52d4-42c6-aa5b-2a8da1c2b4ac.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# MaterialSpecField\n\n## Summary\n\nThe PBR parameters for one `THREE.MeshStandardMaterial`, referenced from component nodes by `materialId`. Fields: `materialId`, `baseColor` and `emissive` (ColorField), `roughness`, `metalness`, `opacity`, plus the optional physical extensions `clearcoat`, `sheen` and `transmission` — setting any of these switches the interpreter to `MeshPhysicalMaterial`, with `transmission` rendering real see-through glass with refraction — an optional procedural `finish` (worn | brushed | hazard | tread | camo | louver | patina | knurl) painted by the interpreter, and `emissiveIntensity` (0–2). The computed title is the materialId.\n\n## Import\n\n```ts\nimport { MaterialSpecField } from '../fields/sculpt-spec';\n```\n\n## Usage\n\nContained as `containsMany` in `SculptSpecField.materials`; instances are built from parsed LLM output by `specFieldFromParsed` in `util/spec-io.gts`, and `SculptSpecField`'s embedded view renders each one as a hex-validated color swatch.", + "ref": { + "module": "../fields/sculpt-spec", + "name": "MaterialSpecField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Sculpt Material", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/55541fdc-6f52-44c2-866a-5b2a8da1c2b4.json b/4376bf-img-to-3d-generator/Spec/55541fdc-6f52-44c2-866a-5b2a8da1c2b4.json new file mode 100644 index 00000000..b388d180 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/55541fdc-6f52-44c2-866a-5b2a8da1c2b4.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# SculptSpecField\n\n## Summary\n\nThe FieldDef holding one complete procedural model spec — the studio's structured form of an LLM spec-stage response. Fields: `objectName`, `inputKind` (object | flat-graphic), `identityFeatures` (the 3–5 features the refine loop's per-feature gate verifies one by one), `objectClass` (hard-surface | organic | hybrid), `buildBackend` (primitive | mesh), `complexity`, `components` (containsMany `ComponentNodeField`) and `materials` (containsMany `MaterialSpecField`). The module also exports the `PRIMITIVES` vocabulary the prompts interpolate. Its embedded view shows the object name, part count, class/backend/complexity/kind chips, up to 8 material swatches (only validated hex colors reach inline styles), and the identity-feature list; the atom view is `name · N parts`.\n\n## Import\n\n```ts\nimport { SculptSpecField } from '../fields/sculpt-spec';\n```\n\n## Usage\n\nMaterialized from parsed LLM JSON by `specFieldFromParsed` in `util/spec-io.gts`; `util/code-export.gts` translates it into a standalone three.js model file (with the spec embedded as its `SCULPT_SPEC` constant), and the structure passes in `util/spec-passes/` repair it between pipeline stages.", + "ref": { + "module": "../fields/sculpt-spec", + "name": "SculptSpecField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Sculpt Spec", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/5a34afd0-c162-438a-90bf-6f70b73b52a4.json b/4376bf-img-to-3d-generator/Spec/5a34afd0-c162-438a-90bf-6f70b73b52a4.json new file mode 100644 index 00000000..8f2900f4 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/5a34afd0-c162-438a-90bf-6f70b73b52a4.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# stripRedundantLabelParts\n\n## Summary\n\nA pruning pass in the img-to-3d pipeline for textured labels. A part carrying a real cropped-artwork image (`textureRef`) IS the whole printed label — its text, wordmarks, crests and borders already baked in. When the model also stacks re-typed textDecals, crest discs, or border boxes on top of it, they double-print the same content blurred and offset. This drops every untextured flat-graphic part (textDecal, curvedDecal, disc, plane, roundedPlate, box, extruded shapes) whose `attachTo` chain leads to a textured decal. Genuine solids that merely touch the label are never dropped.\n\n## Import\n\n```ts\nimport { stripRedundantLabelParts } from '../util/spec-passes/prune';\n```\n\n## Usage\n\nNot part of `runStructurePasses` — it runs in the studio's `applyParsedSpec` (components/studio-isolated.gts), after `applyTextures` and the envelope clamp, before `fitCurvedDecals`. Receives the parsed spec, replaces `parsed.components` in place, returns log lines per dropped part.\n", + "ref": { + "module": "../util/spec-passes/prune", + "name": "stripRedundantLabelParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "stripRedundantLabelParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/5b6a47de-b1a5-4f18-bbdf-6350f6902d3b.json b/4376bf-img-to-3d-generator/Spec/5b6a47de-b1a5-4f18-bbdf-6350f6902d3b.json new file mode 100644 index 00000000..40410390 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/5b6a47de-b1a5-4f18-bbdf-6350f6902d3b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# localCentreOffset\n\n## Summary\n\nReturns where a primitive's geometry sits relative to its node origin, as an `[x, y, z]` offset. For every primitive except `extrudedPolygon`/`extrudedSpline` the answer is `[0, 0, 0]` — their geometry is centred. The extruded shapes are centred in Z only: their outline keeps the author's own X/Y coordinates, so this function reads the outline points from `dimensions`, computes the outline's midpoint, and scales it by the node's scale. Ignoring this offset once mis-measured exactly the part that matters most on a traced vehicle — its body.\n\n## Import\n\n```ts\nimport { localCentreOffset } from '../util/spec-geometry';\n```\n\n## Usage\n\nCalled only by `specBox` in the same module, which adds the offset (rotated into the node's frame when the part is rotated) to the authored position before applying half extents.", + "ref": { + "module": "../util/spec-geometry", + "name": "localCentreOffset" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "localCentreOffset", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/5bbd7015-5e04-4a4d-840b-408c48521d92.json b/4376bf-img-to-3d-generator/Spec/5bbd7015-5e04-4a4d-840b-408c48521d92.json new file mode 100644 index 00000000..e0b323f0 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/5bbd7015-5e04-4a4d-840b-408c48521d92.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# separateCoplanarLayers\n\n## Summary\n\nAn overlap pass in the img-to-3d structure chain. Coplanar overlapping faces have no depth order, so the renderer picks a winner per pixel from floating-point noise and the surface strobes as the camera moves — a starburst icon with twelve rays at exactly z 0.105 flickered in a checkerboard at the hub. This pass groups genuinely FLAT parts (z half-extent under a quarter of the face's smaller side) by the z they were authored at to four decimals, and where members of a layer actually overlap in the plane, staggers each subsequent member by 0.0005 — far below anything visible, but enough for the depth buffer to sort.\n\n## Import\n\n```ts\nimport { separateCoplanarLayers } from '../util/spec-passes/overlap';\n```\n\n## Usage\n\nRuns as the final pass in `runStructurePasses`. Receives the parsed spec, mutates z positions in place, and returns one log line per separated layer.\n", + "ref": { + "module": "../util/spec-passes/overlap", + "name": "separateCoplanarLayers" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "separateCoplanarLayers", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/5eba4429-5bbd-4015-9e04-7a4dc40b408c.json b/4376bf-img-to-3d-generator/Spec/5eba4429-5bbd-4015-9e04-7a4dc40b408c.json new file mode 100644 index 00000000..5340ca2a --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/5eba4429-5bbd-4015-9e04-7a4dc40b408c.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# narrowRemovalTargets\n\n## Summary\n\nDecides which lasso-selected parts a \"remove X\" instruction actually means. A lasso over a sticker also catches the solid under it, so when the instruction names a noun, the selection is narrowed to parts whose `nodeId`/`partRef`/`note` contain that noun — and for sticker/label/decal words, to the decal primitives (`textDecal`/`curvedDecal`), since the noun can never name the solid the decal rides on. The narrowed set is returned only when it is a real, non-empty subset; otherwise the original selection stands, so this can never delete MORE than before.\n\n## Import\n\n```ts\nimport { narrowRemovalTargets } from '../util/spec-diff';\n```\n\n## Usage\n\nCalled by the studio's deterministic-delete path (`components/studio-isolated.gts`) with the spec's components, the lasso's target ids, and the instruction text; the result feeds `applySpecDiff` as `removedNodeIds`.", + "ref": { + "module": "../util/spec-diff", + "name": "narrowRemovalTargets" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "narrowRemovalTargets", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/62238a50-bf6f-40b7-bb52-a46e5eba4429.json b/4376bf-img-to-3d-generator/Spec/62238a50-bf6f-40b7-bb52-a46e5eba4429.json new file mode 100644 index 00000000..630df7c8 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/62238a50-bf6f-40b7-bb52-a46e5eba4429.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# revolvedSilhouetteBbox\n\n## Summary\n\nDecides which region of the reference to trace as THE object's revolved silhouette, or why not to trace at all. It unions the bboxes of the analysis plan's `revolved` parts, then enforces two conditions: the parts must share one axis (checked pairwise — each pair must overlap by at least 60% of the narrower width), and the union must BE the body (at least 60% of the object's height and 50% of its width), so a single round detail like a drum magazine cannot hand its outline to the whole object. Returns `{ bbox }` or `{ skipped: reason }`.\n\n## Import\n\n```ts\nimport { revolvedSilhouetteBbox } from '../util/silhouette';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) calls it with the analysis part plan; a returned bbox goes to `traceLatheProfile`, and a `skipped` reason is logged instead of tracing.", + "ref": { + "module": "../util/silhouette", + "name": "revolvedSilhouetteBbox" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "revolvedSilhouetteBbox", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/6350f690-2d3b-4e86-bd4a-4c5602baa7c6.json b/4376bf-img-to-3d-generator/Spec/6350f690-2d3b-4e86-bd4a-4c5602baa7c6.json new file mode 100644 index 00000000..071d780a --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/6350f690-2d3b-4e86-bd4a-4c5602baa7c6.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ImgTo3dStudio\n\n## Summary\n\nThe workbench card of the img-to-3d listing (displayName \"Img-to-3D Generator\", wide-format). It deliberately stores no results of its own: every generation's details — code file, name, analysis, critique, score — live on the `SculptedModel` it produced, and the studio only points at creations.\n\nFields:\n- `references` — a `MultiImageSourceField` holding every reference photo in one field; the first image is the primary view, the rest are side/back/detail shots, and all of them feed the initial generation.\n- `selectedCreation` — linksTo `SculptedModel`; what the viewport iframe shows. Moves when you pick a history version, re-attaching that version's details wholesale.\n- `latestCreation` — linksTo `SculptedModel`; the newest saved round, serving as the history-walk root and round counter. Only advances on new generations.\n- `llmModel` — an enum over `VISION_MODEL_OPTIONS` from `util/llm-request`.\n- `title` — computed from the selected (then latest) creation's objectName, falling back to \"Img-to-3D Studio\".\n\nThe isolated experience is `StudioIsolated` (`components/studio-isolated.gts`): drop reference photos in, hit Generate, and watch the analyze → spec → repair → refine pipeline build a procedural three.js model live in the viewport, then lasso-edit parts or walk the round history. The embedded and fitted templates render a compact tile — title, primary reference image, \"rebuilt in code\" tag and round number — with an `i3d-default-theme` dark palette applied when no theme card is linked.\n\n## Import\n\n```ts\nimport { ImgTo3dStudio } from '../img-to-3d-studio';\n```\n\n## Usage\n\nThe listing's entry-point card: users create an instance, attach references, and generate. `StudioIsolated` stamps each saved round's `sourceStudio` link with this card so the history search can scope to one studio, and `commands/refine-sculpt-command.gts` resolves its `selectedCreation`/`latestCreation` links to find the round to refine.", + "ref": { + "module": "../img-to-3d-studio", + "name": "ImgTo3dStudio" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Img-to-3D Generator", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/68805b6a-47de-41a5-af18-fbdf6350f690.json b/4376bf-img-to-3d-generator/Spec/68805b6a-47de-41a5-af18-fbdf6350f690.json new file mode 100644 index 00000000..832f175f --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/68805b6a-47de-41a5-af18-fbdf6350f690.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# specFieldFromParsed\n\n## Summary\n\nMaterializes a parsed spec reply (the output of `parseSpecJson`) into a `SculptSpecField` instance: it constructs `MaterialSpecField` and `ComponentNodeField` children, coerces ids to strings, caps `identityFeatures` at six entries, normalizes `inputKind` to `'object'` unless it is exactly `'flat-graphic'`, and JSON-stringifies each component's `dimensions`/`position`/`rotation`/`scale`/`repeat` into the string-backed field storage. It is the inverse direction of `serializeSpecForPrompt`.\n\n## Import\n\n```ts\nimport { specFieldFromParsed } from '../util/spec-io';\n```\n\n## Usage\n\nReceives the plain parsed object and returns a new `SculptSpecField`. No component or command in this listing currently imports it — the studio works with plain parsed specs and persists them inside generated model .js files — but it is the packaged path for putting a parsed spec into the card fields defined in `../fields/sculpt-spec`.", + "ref": { + "module": "../util/spec-io", + "name": "specFieldFromParsed" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "specFieldFromParsed", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/695270b8-aad4-41a8-921f-c729ec246d02.json b/4376bf-img-to-3d-generator/Spec/695270b8-aad4-41a8-921f-c729ec246d02.json new file mode 100644 index 00000000..5b4cb1cb --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/695270b8-aad4-41a8-921f-c729ec246d02.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# expandRepeatInstances\n\n## Summary\n\nExpands one declared part into N placed copies (count capped at 48): `linear` mode steps each clone by `offset`, `radial` mode carries clones rigidly around a ring — the orbital spin composes OUTSIDE the part's own orientation so pre-aimed parts stay parallel, and the ring centre comes from the declared host's bounding box rather than the part's own position (which would double the radius). Index 0 re-places the original itself so it sits on its own ring. Written as a hoisting declaration with no module-level references and no `?.`/`??`, because its source is emitted verbatim into exported models.\n\n## Import\n\n```ts\nimport { expandRepeatInstances } from '../util/repeat-expand';\n```\n\n## Usage\n\nThis is the ONE implementation: `../util/code-export` emits it via `Function.prototype.toString()` into every generated model .js that uses `repeat`, so the studio viewport and standalone exports array parts identically — hand-copied twins drifted once.", + "ref": { + "module": "../util/repeat-expand", + "name": "expandRepeatInstances" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "expandRepeatInstances", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/6a47deb1-a52f-48fb-9f63-50f6902d3b1e.json b/4376bf-img-to-3d-generator/Spec/6a47deb1-a52f-48fb-9f63-50f6902d3b1e.json new file mode 100644 index 00000000..dd0daa5b --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/6a47deb1-a52f-48fb-9f63-50f6902d3b1e.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# hasNeutralAncestry\n\n## Summary\n\nAnswers whether a component's authored position can be read as a world coordinate: it walks the parent chain (via the `byId` map) and returns `true` only when every ancestor's position and rotation are ~0 and scale is ~1. A parentId cycle returns `false`; an unknown parent counts as root. This gate exists because the spec-side geometry passes treat positions as world space, which is wrong for parts under a real transform — moving those would drag correctly-placed parts somewhere wrong.\n\n## Import\n\n```ts\nimport { hasNeutralAncestry } from '../util/spec-geometry';\n```\n\n## Usage\n\nCalled by the structure passes in `util/spec-passes/` (`placement.gts`, `shape.gts`, `overlap.gts`) to filter which components each pass may measure or move; anything under a non-neutral transform is skipped and left to the interpreter's own world-space solvers.", + "ref": { + "module": "../util/spec-geometry", + "name": "hasNeutralAncestry" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "hasNeutralAncestry", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/6d023917-4f00-4f30-bc8b-bf2ed08bb743.json b/4376bf-img-to-3d-generator/Spec/6d023917-4f00-4f30-bc8b-bf2ed08bb743.json new file mode 100644 index 00000000..3a8dbacf --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/6d023917-4f00-4f30-bc8b-bf2ed08bb743.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# selectRecipeNames\n\n## Summary\n\nThe pure selector deciding which `RECIPES` blocks an object's spec prompt gets. It unions two independent sources: (1) the directive names stage 1 nominated in `analysis.directives`, filtered to names that exist in `RECIPES`, and (2) what the plan's own structure implies — declared part `approach` values, an `artwork` bbox, `objectClass`, and regex matches over objectType/part names/surface notes (face-feature words trigger `character`, wall/roof words trigger `architectural`, wheel/hull words trigger `vehicle`, and so on). Analysis in, ordered recipe-name array out, no side effects — so it is cheap to unit-test.\n\n## Import\n\n```ts\nimport { selectRecipeNames } from '../prompts/recipes';\n```\n\n## Usage\n\n`selectRecipes` maps its result to recipe text for the spec prompt; `StudioIsolated` also calls it directly to log which directives a generation selected (`> directives: ...` in the build log).", + "ref": { + "module": "../prompts/recipes", + "name": "selectRecipeNames" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "selectRecipeNames", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/6e5eba44-295b-4d70-955e-047a4dc40b40.json b/4376bf-img-to-3d-generator/Spec/6e5eba44-295b-4d70-955e-047a4dc40b40.json new file mode 100644 index 00000000..2c49fe73 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/6e5eba44-295b-4d70-955e-047a4dc40b40.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# parseDiffJson\n\n## Summary\n\nParses a refine or targeted-edit change-set reply: critique/score/featureCheck plus the `changed`, `removedNodeIds`, `materialsChanged`, and `added` arrays. It strips markdown fences, slices out the JSON object, retries once with trailing commas removed, and throws when no JSON is present. Unlike `parseSpecJson`, all four change arrays may legitimately be empty, so each is simply defaulted to `[]` rather than rejected.\n\n## Import\n\n```ts\nimport { parseDiffJson } from '../util/spec-io';\n```\n\n## Usage\n\nPassed as the `parser` to `requestSpec` by the studio's refine and targeted-edit calls (`components/studio-isolated.gts`) and by `RefineSculptCommand` (`commands/refine-sculpt-command.gts`). The parsed diff then goes to `applySpecDiff` in `../util/spec-diff` to be merged into the current spec.", + "ref": { + "module": "../util/spec-io", + "name": "parseDiffJson" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "parseDiffJson", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/6f70b73b-52a4-4e5e-ba44-295bbd70155e.json b/4376bf-img-to-3d-generator/Spec/6f70b73b-52a4-4e5e-ba44-295bbd70155e.json new file mode 100644 index 00000000..19b4275b --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/6f70b73b-52a4-4e5e-ba44-295bbd70155e.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# dropUnplannedParts\n\n## Summary\n\nA pruning pass in the img-to-3d structure-repair chain. The analysis `partPlan` is the agreed inventory of what the photo contains, and every component must name the plan entry it realizes in `partRef`. This pass deletes any non-group component whose `partRef` matches no planned part — the invented mould seams, extra ribs, and collars the spec stage over-produces, which the assembly solver would otherwise glue onto their nearest neighbour. Matching is tolerant (alphanumerics only, containment counts for stems of 4+ characters), shadow-named parts are exempt, and deletion cascades to children. A spec without a plan passes through untouched.\n\n## Import\n\n```ts\nimport { dropUnplannedParts } from '../util/spec-passes/prune';\n```\n\n## Usage\n\nRuns second in `runStructurePasses`, after `dropHairlineParts`. It receives the parsed spec and the analysis, mutates `parsed.components` in place, and returns log lines naming each dropped part and why.\n", + "ref": { + "module": "../util/spec-passes/prune", + "name": "dropUnplannedParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "dropUnplannedParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/70b73b52-a46e-4eba-8429-5bbd70155e04.json b/4376bf-img-to-3d-generator/Spec/70b73b52-a46e-4eba-8429-5bbd70155e04.json new file mode 100644 index 00000000..424361a9 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/70b73b52-a46e-4eba-8429-5bbd70155e04.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# flagFlatPalette\n\n## Summary\n\nA report-only materials pass in the img-to-3d structure chain. A reply that gives every part the same mid-grey satisfies the schema and produces a model nobody can read — one brass-and-teal machine came back with nine of twelve materials inside a single olive band, because colours were sampled off a warm-lit render. Two object-agnostic measurements: material pairs whose baseColor is perceptually indistinguishable (a green-weighted RGB distance under ~12 — two spec slots spent on one surface), and, when at least six materials carry a hue, 70%+ of hues packed into one 40° band. Finishes that replace the base colour (hazard, camo, louver) are excluded. Which colour should change is a judgement about the photo, so it only reports.\n\n## Import\n\n```ts\nimport { flagFlatPalette } from '../util/spec-passes/materials';\n```\n\n## Usage\n\nRuns in `runStructurePasses` among the late flag passes. Receives the parsed spec, mutates nothing, returns log lines.\n", + "ref": { + "module": "../util/spec-passes/materials", + "name": "flagFlatPalette" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "flagFlatPalette", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/70b8aad4-11a8-421f-8729-ec246d023917.json b/4376bf-img-to-3d-generator/Spec/70b8aad4-11a8-421f-8729-ec246d023917.json new file mode 100644 index 00000000..1b3f9f07 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/70b8aad4-11a8-421f-8729-ec246d023917.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# repairMirroredAttachments\n\n## Summary\n\nAn attachment pass in the img-to-3d structure chain. `attachTo` names the part that holds this one up, and the builder's joint solver pulls the subject until it overlaps that target — so a wrong joint drags geometry across the model. A spec once set `wheel-right attachTo wheel-left`, and the solver hauled the whole right-hand wheel row onto the left flank. This detects a part whose target is its own MIRROR — same primitive and dimensions, sitting apart on opposite sides of the object's centre — and re-points the joint at the target's own `attachTo` when it has one, otherwise deletes it. Touching copies (a stacked rib, a chain link) are a real joint and left alone.\n\n## Import\n\n```ts\nimport { repairMirroredAttachments } from '../util/spec-passes/attachments';\n```\n\n## Usage\n\nRuns first of the attachment passes in `runStructurePasses`, before `enforceAttachments`. Mutates `attachTo` fields in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/attachments", + "name": "repairMirroredAttachments" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "repairMirroredAttachments", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/7c8bbf2e-d08b-4743-905a-34afd0c16223.json b/4376bf-img-to-3d-generator/Spec/7c8bbf2e-d08b-4743-905a-34afd0c16223.json new file mode 100644 index 00000000..e137fc0b --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/7c8bbf2e-d08b-4743-905a-34afd0c16223.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# serializeSpecForPrompt\n\n## Summary\n\nProduces a compact plain-JSON view of the current sculpt spec for a model prompt: object metadata, trimmed material entries (optional PBR fields only when present), and component entries with `dimensions`/`position`/`rotation`/`scale` normalized to arrays (JSON-parsing string-stored values). The `repeat` object is deliberately passed through verbatim — coercing it through the array normalizer once destroyed every repeat/mirror system on each refine round.\n\n## Import\n\n```ts\nimport { serializeSpecForPrompt } from '../util/spec-io';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) and `RefineSculptCommand` serialize the working spec with it before sending refine/targeted-edit requests, and `applySpecDiff` in `../util/spec-diff` uses it as the base representation a diff is merged onto. Receives a spec (card field or plain object), returns a plain serializable object.", + "ref": { + "module": "../util/spec-io", + "name": "serializeSpecForPrompt" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "serializeSpecForPrompt", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/805b6a47-deb1-452f-98fb-df6350f6902d.json b/4376bf-img-to-3d-generator/Spec/805b6a47-deb1-452f-98fb-df6350f6902d.json new file mode 100644 index 00000000..460dee7d --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/805b6a47-deb1-452f-98fb-df6350f6902d.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# groundSupports\n\n## Summary\n\nA placement repair in the img-to-3d structure chain, and the last line of defence when the plan and spec are wrong the same way (an analysis once wrote \"wheels rests-on main hull body\", putting the tyres on the roof). What no plan can overrule is that a thing stands on its supports: any part whose name matches `SUPPORT_NAME` (wheel, tyre, track, foot, leg, skid, …) whose centre sits above the midline of the body — the biggest non-support volume — is mirrored to the other side of the body, preserving the distance the spec intended. It also reports non-support parts hanging below the lowest support (they would scrape the ground).\n\n## Import\n\n```ts\nimport { groundSupports } from '../util/spec-passes/placement';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after `repairMirroredAttachments` and `enforceAttachments`, which deliberately refuse inverted support lines so this pass can fix them from coordinates. Mutates support positions in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/placement", + "name": "groundSupports" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "groundSupports", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/8a50bf6f-70b7-4b52-a46e-5eba44295bbd.json b/4376bf-img-to-3d-generator/Spec/8a50bf6f-70b7-4b52-a46e-5eba44295bbd.json new file mode 100644 index 00000000..6ac7ce76 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/8a50bf6f-70b7-4b52-a46e-5eba44295bbd.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# specFromModelJs\n\n## Summary\n\nReads the embedded `SCULPT_SPEC` back out of a generated model .js file: it matches the `^var SCULPT_SPEC = (.*);$` line and JSON-parses the capture, returning the plain spec object or `null` when the line is absent or unparseable. This is the studio's only way to recover a working spec from a persisted model, since the .js file — not the card instance — carries the spec.\n\n## Import\n\n```ts\nimport { specFromModelJs } from '../util/code-export';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) calls it after fetching a model file's source to restore `workingSpec` when loading a creation or a previous round, and `RefineSculptCommand` reads the current spec out of the model file before applying a diff and regenerating.", + "ref": { + "module": "../util/code-export", + "name": "specFromModelJs" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "specFromModelJs", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/8bb74310-5a34-4fd0-8162-238a50bf6f70.json b/4376bf-img-to-3d-generator/Spec/8bb74310-5a34-4fd0-8162-238a50bf6f70.json new file mode 100644 index 00000000..d239f508 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/8bb74310-5a34-4fd0-8162-238a50bf6f70.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# parseSpecJson\n\n## Summary\n\nExtracts and parses the sculpt-spec JSON out of a raw model reply. It strips markdown code fences, slices from the first `{` to the last `}`, and on a parse failure retries once with trailing commas removed (a common LLM slip). It throws if no JSON is present or if `components` is missing/empty, and normalizes `materials` to an array.\n\n## Import\n\n```ts\nimport { parseSpecJson } from '../util/spec-io';\n```\n\n## Usage\n\nIt is the default `parser` argument of `requestSpec` in `../util/llm-request`, so any spec-stage vision call that does not pass its own parser runs its reply through this function. Its \"did not return JSON / no components\" error messages are what the retry loop in `requestSpec` matches to trigger a corrective re-ask.", + "ref": { + "module": "../util/spec-io", + "name": "parseSpecJson" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "parseSpecJson", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/8bbf2ed0-8bb7-4310-9a34-afd0c162238a.json b/4376bf-img-to-3d-generator/Spec/8bbf2ed0-8bb7-4310-9a34-afd0c162238a.json new file mode 100644 index 00000000..b3fee62e --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/8bbf2ed0-8bb7-4310-9a34-afd0c162238a.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# seatRecesses\n\n## Summary\n\nA placement repair in the img-to-3d structure chain — the mirror of `resolveBuriedParts`. The pipeline assumes every feature protrudes, but a window well, grille cavity, sunken panel or air intake goes the other way: its outer face sits just below the surrounding surface, and the shadow in that dip is what reads as depth. For each part `isRecessed` matches (excluding interior/cavity boxes, which `clampInteriorCavities` owns), it finds the host (the `attachTo` target or the smallest enclosing solid), picks the host face the feature sits on (the axis of largest offset from the host centre), and moves the part along that axis until its outer face is 0.03 under the host surface.\n\n## Import\n\n```ts\nimport { seatRecesses } from '../util/spec-passes/placement';\n```\n\n## Usage\n\nRuns in `runStructurePasses` right after `clampInteriorCavities`, before `attachOrphans`. Mutates positions in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/placement", + "name": "seatRecesses" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "seatRecesses", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/921fc729-ec24-4d02-b917-4f00cf307c8b.json b/4376bf-img-to-3d-generator/Spec/921fc729-ec24-4d02-b917-4f00cf307c8b.json new file mode 100644 index 00000000..322c9c7e --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/921fc729-ec24-4d02-b917-4f00cf307c8b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# SculptedModel\n\n## Summary\n\nOne finished reconstruction, saved as its own card so every generation is independently searchable, linkable and viewable (mirroring the AiImage pattern). The model itself lives OUTSIDE the card as a realm .js file — real code with the source spec embedded as its `SCULPT_SPEC` constant — and the card links to it.\n\nFields:\n- `references` — the same `MultiImageSourceField` set the studio held, so selecting a saved round re-attaches all its views.\n- `codeFile` — linksTo `FileDef`; the generated model .js. A link, not a URL string, so it serializes realm-relative; read `codeFile.url` for the absolute URL.\n- `objectName`, `buildBackend`, `critique`, `score`, `modelUsed`, `createdAt` — headline facts about the round.\n- `analysis` — the stage-1 analysis JSON this build followed; its reference signature drives the regenerate cache.\n- `buildMetrics` — JSON of residual, warnings, featureCheck, planned vs built parts, kept so prompt changes can be compared round to round.\n- `renderScreenshot` — linksTo `ImageDef`, a save-time render snapshot for gallery tiles.\n- `parentCreation` — linksTo `SculptedModel`; history is a backward linked list.\n- `sourceStudio` — linksTo `CardDef` (typed loosely to avoid a module cycle); scopes the studio's prerendered history search.\n- `round` and `revision` — round number, plus a counter bumped on in-place edits to cache-bust the viewport iframe.\n\nThe isolated view renders the model in an iframe via `generateViewerSrcdoc(codeFile.url)` with the title, score and critique around it; embedded/fitted tiles show the render screenshot (falling back to the primary reference).\n\n## Import\n\n```ts\nimport { SculptedModel } from '../sculpted-model';\n```\n\n## Usage\n\n`StudioIsolated` creates and saves one per generation round and links it as the studio's `latestCreation`; `commands/refine-sculpt-command.gts` edits an existing round's .js in place, bumping `revision` instead of spawning a new card.", + "ref": { + "module": "../sculpted-model", + "name": "SculptedModel" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Sculpted Model", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/a46e5eba-4429-4bbd-b015-5e047a4dc40b.json b/4376bf-img-to-3d-generator/Spec/a46e5eba-4429-4bbd-b015-5e047a4dc40b.json new file mode 100644 index 00000000..e1a4487c --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/a46e5eba-4429-4bbd-b015-5e047a4dc40b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# flagOverbuiltParts\n\n## Summary\n\nA report-only pass in the img-to-3d structure-repair chain. The `partRef` gate catches parts the plan never mentions; this catches the other half of padding — burying many invented components under ONE legitimate part name (a single planned screwcap realized as body + skirt + rib band + 20 knurl blocks). The allowance comes from the part's own plan approach: `curved-chain` and `freeform-mesh` parts may honestly be up to 10 components, everything else 4. Which of a padded part's components are the invented ones cannot be known from counting, so nothing is deleted — the log makes the padding visible for the refine round or a human.\n\n## Import\n\n```ts\nimport { flagOverbuiltParts } from '../util/spec-passes/prune';\n```\n\n## Usage\n\nRuns in `runStructurePasses` right after `flagUnrealizedParts`. Receives (parsed, analysis), mutates nothing, returns log lines naming each over-built part and its component count.\n", + "ref": { + "module": "../util/spec-passes/prune", + "name": "flagOverbuiltParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "flagOverbuiltParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/a52f18fb-df63-40f6-902d-3b1e86bd4a4c.json b/4376bf-img-to-3d-generator/Spec/a52f18fb-df63-40f6-902d-3b1e86bd4a4c.json new file mode 100644 index 00000000..e95bb3ab --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/a52f18fb-df63-40f6-902d-3b1e86bd4a4c.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# attachOrphans\n\n## Summary\n\nA placement pass in the img-to-3d structure chain. The render's joint solver and contact backstop only seat a part toward the support it DECLARED in `attachTo`; a part with none is left exactly where authored — so a face feature or forgotten detail hangs in mid-air. This pass gives every orphan (a non-group, non-support part with no `attachTo`, excluding the anchor body, shadows and glows) the joint it lacks, pointed at the mass its bounding box is nearest to, with ties broken toward the LARGER neighbour so details hang off bodies, not other details. It only assigns `attachTo` — it moves nothing; the render's own solvers do the seating.\n\n## Import\n\n```ts\nimport { attachOrphans } from '../util/spec-passes/placement';\n```\n\n## Usage\n\nRuns as the last placement step in `runStructurePasses`, after all movers have settled final coordinates and after the face passes. Mutates `attachTo` fields in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/placement", + "name": "attachOrphans" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "attachOrphans", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/a8921fc7-29ec-446d-8239-174f00cf307c.json b/4376bf-img-to-3d-generator/Spec/a8921fc7-29ec-446d-8239-174f00cf307c.json new file mode 100644 index 00000000..c89bdaa0 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/a8921fc7-29ec-446d-8239-174f00cf307c.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# fitCurvedDecals\n\n## Summary\n\nDeterministic decal fitting in the img-to-3d pipeline — the lateral cousin of gravity snap. A curvedDecal must hug the body it attaches to, but the model's authored radius is only probabilistically right, and a too-large radius reads as a label floating beside the bottle. For each curvedDecal it walks the `attachTo` chain to the first solid host, snaps the decal's x/z onto the host axis, fits its radius to the host's real radius at the decal's own height (+0.01 skin) — sampling a lathe profile within the decal's band — and clamps its height and vertical centre inside the host. It also slims cylinders riding a lathe (caps, foils) to 1.3× the neck wall.\n\n## Import\n\n```ts\nimport { fitCurvedDecals } from '../util/spec-passes/attachments';\n```\n\n## Usage\n\nNot in `runStructurePasses` — the studio calls it in `applyParsedSpec` and again after `reconcileProportions` corrections. Mutates dimensions/positions; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/attachments", + "name": "fitCurvedDecals" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "fitCurvedDecals", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/aa000001-0000-4000-8000-000000000001.json b/4376bf-img-to-3d-generator/Spec/aa000001-0000-4000-8000-000000000001.json new file mode 100644 index 00000000..0e3e40c8 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/aa000001-0000-4000-8000-000000000001.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ANALYZE_SYSTEM_PROMPT\n\n## Summary\n\nThe stage-1 system prompt of the image-to-3D pipeline. It tells a vision model to study one or more reference views of an object — without building geometry — and reply with a JSON construction plan: objectType, objectClass, complexity, a buildBackend recommendation (primitive vs mesh), a camera estimate, a partPlan whose entries carry measured normalized bboxes, depthRatio, material/surface notes and optional artwork, printMode and face-landmark data, an attachments list, an object-specific buildRecipe, and the directive names that select craft-rule blocks for stage 2. The primitive vocabulary is interpolated from `PRIMITIVES` in `fields/sculpt-spec`.\n\n## Import\n\n```ts\nimport { ANALYZE_SYSTEM_PROMPT } from '../prompts/analyze';\n```\n\n## Usage\n\nSent by `AnalyzeReferenceCommand.run()` (`commands/analyze-reference.gts`) via `requestSpec`, with a seed derived from the reference URLs and `reasoning: { effort: 'low' }`, so re-running on the same photo set reproduces the same plan; the reply is parsed with `parseAnalysisJson`.", + "ref": { + "module": "../prompts/analyze", + "name": "ANALYZE_SYSTEM_PROMPT" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "analyze", + "cardDescription": "System prompt module: prompts/analyze.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/aa000002-0000-4000-8000-000000000002.json b/4376bf-img-to-3d-generator/Spec/aa000002-0000-4000-8000-000000000002.json new file mode 100644 index 00000000..21235854 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/aa000002-0000-4000-8000-000000000002.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# COMPLETENESS_CRITIC_PROMPT\n\n## Summary\n\nThe system prompt for the completeness-critic pass: the model receives a comparison sheet (reference photo leftmost, then front/side/three-quarter renders) plus the current spec JSON, and must find every part the reference clearly shows that the render is missing — with an explicit reminder that glazing and openings are the most-missed parts. Unlike the conservative refine pass, this pass is allowed to ADD parts (and nudge grossly misplaced ones), returning `critique`, `score`, `changed`, `added` and `materialsChanged`; it may not change primitives or dimensions, and may not remove parts.\n\n## Import\n\n```ts\nimport { COMPLETENESS_CRITIC_PROMPT } from '../prompts/completeness';\n```\n\n## Usage\n\nSent by `StudioIsolated` (`components/studio-isolated.gts`) as an extra vision round during generation; the pass is skipped when the build already realized every planned part, unless the user sets the `forceCompleteness` toggle.", + "ref": { + "module": "../prompts/completeness", + "name": "COMPLETENESS_CRITIC_PROMPT" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "completeness", + "cardDescription": "System prompt module: prompts/completeness.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/aa000003-0000-4000-8000-000000000003.json b/4376bf-img-to-3d-generator/Spec/aa000003-0000-4000-8000-000000000003.json new file mode 100644 index 00000000..057caca8 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/aa000003-0000-4000-8000-000000000003.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# REFINE_SYSTEM_PROMPT\n\n## Summary\n\nThe system prompt for the self-review refine pass. The model compares a comparison sheet (reference photo plus labeled renders, including a \"RENDER @ REF ANGLE\" pane when available) against the current spec JSON and returns a MINIMAL change set: `changed` entries repositioning/rescaling existing nodeIds and `materialsChanged` recoloring materials, plus a `critique`, a `score` capped at 90, and a per-identityFeature `featureCheck` gate. It explicitly forbids shape changes — no new components, no deletions, no edits to `primitive` or `dimensions` — and puts orientation fixes (wheels lying flat, tines pointing sideways) first.\n\n## Import\n\n```ts\nimport { REFINE_SYSTEM_PROMPT } from '../prompts/refine';\n```\n\n## Usage\n\nSent by `StudioIsolated` during the auto-refine rounds of a generation (up to `AUTO_REFINE_ROUNDS`, stopping at `REFINE_TARGET_SCORE`); the returned diff is applied to the spec with `applySpecDiff`.", + "ref": { + "module": "../prompts/refine", + "name": "REFINE_SYSTEM_PROMPT" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "refine", + "cardDescription": "System prompt module: prompts/refine.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/aa000004-0000-4000-8000-000000000004.json b/4376bf-img-to-3d-generator/Spec/aa000004-0000-4000-8000-000000000004.json new file mode 100644 index 00000000..dcd363c1 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/aa000004-0000-4000-8000-000000000004.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# SPEC_JSON_SHAPE\n\n## Summary\n\nThe response contract for the spec stage, as one prose constant: the JSON shape the model must return (materials with PBR fields and procedural finishes, components with partRef/textureRef/repeat/attachTo/anchor/grounded), the dimension semantics of every primitive (interpolated from `PRIMITIVES` in `fields/sculpt-spec`), objectClass strategy, material families, the canonical world frame (-Z forward, +Y up, +X right), an orientation cheat sheet, and the universal modelling rules. Craft rules only some objects need live in `prompts/recipes` instead; invariants the repair passes enforce get one line each.\n\n## Import\n\n```ts\nimport { SPEC_JSON_SHAPE } from '../prompts/spec-shape';\n```\n\n## Usage\n\nInterpolated into `SPEC_SYSTEM_PROMPT` in `prompts/spec.gts`, the invariant first block of the stage-2 system prompt built by `buildSpecSystemPrompt`; keeping it byte-identical across objects is what preserves the upstream prompt-cache hit.", + "ref": { + "module": "../prompts/spec-shape", + "name": "SPEC_JSON_SHAPE" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "spec-shape", + "cardDescription": "System prompt module: prompts/spec-shape.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/aa000005-0000-4000-8000-000000000005.json b/4376bf-img-to-3d-generator/Spec/aa000005-0000-4000-8000-000000000005.json new file mode 100644 index 00000000..fb06610b --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/aa000005-0000-4000-8000-000000000005.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# TARGETED_EDIT_PROMPT\n\n## Summary\n\nThe system prompt for the user-directed edit pass: the model receives the current spec JSON, a list of lasso-SELECTED nodeIds, the user's instruction, and a comparison sheet, and must apply the instruction to those parts only — always diagnosing against the reference photo first. It may reposition, resize (scale or dimensions), recolor via the two sanctioned recolor paths, ground, remove listed parts, and ADD missing parts authored like spec components, but never change an existing part's primitive. When the selection is empty the model resolves targets itself from the instruction and the semantic nodeIds.\n\n## Import\n\n```ts\nimport { TARGETED_EDIT_PROMPT } from '../prompts/targeted-edit';\n```\n\n## Usage\n\nSent from two places: `StudioIsolated`'s lasso inpaint flow (which overwrites the current round in place with one-click undo), and `commands/refine-sculpt-command.gts`, the AI-assistant refine command that edits a SculptedModel's .js in place instead of spawning a new round.", + "ref": { + "module": "../prompts/targeted-edit", + "name": "TARGETED_EDIT_PROMPT" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "targeted-edit", + "cardDescription": "System prompt module: prompts/targeted-edit.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/aad411a8-921f-4729-ac24-6d0239174f00.json b/4376bf-img-to-3d-generator/Spec/aad411a8-921f-4729-ac24-6d0239174f00.json new file mode 100644 index 00000000..a6984e50 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/aad411a8-921f-4729-ac24-6d0239174f00.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# buildSpecSystemPrompt\n\n## Summary\n\nBuilds the stage-2 (spec build) system prompt as an array of content blocks: `SPEC_SYSTEM_PROMPT` — the invariant contract carrying `SPEC_JSON_SHAPE` — first, then, only when `selectRecipes(analysis)` returns text, a second block of build directives selected from this object's own analysis. Two blocks instead of one concatenated string keeps the invariant half byte-identical across objects so it keeps its prompt-cache hit; with no analysis (or no partPlan) it degrades to the self-contained contract alone.\n\n## Import\n\n```ts\nimport { buildSpecSystemPrompt } from '../prompts/spec';\n```\n\n## Usage\n\nCalled by `StudioIsolated` (`components/studio-isolated.gts`) when it issues the spec-build LLM request after the analysis stage; the returned block array is passed to `requestSpec`, whose `systemMessage()` in `util/llm-request.gts` exploits the cacheable first block.", + "ref": { + "module": "../prompts/spec", + "name": "buildSpecSystemPrompt" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "buildSpecSystemPrompt", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/afd0c162-238a-40bf-af70-b73b52a46e5e.json b/4376bf-img-to-3d-generator/Spec/afd0c162-238a-40bf-af70-b73b52a46e5e.json new file mode 100644 index 00000000..bd306d1b --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/afd0c162-238a-40bf-af70-b73b52a46e5e.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# seatSurfaceParts\n\n## Summary\n\nSeats feature parts on the rounded mass they are mounted on, fixing the two placement errors box tests call correct: BURIED (a part entirely inside its host) and hidden-side placement (an authored offset pointing into the model instead of out). It models each mass as an ellipsoid, orders joints host-first, carries children of moved hosts along, and only acts on compact features clearly smaller than their host — a correction larger than the host's own radius is logged and left for refine. Returns log lines describing what moved. Written with no module-level references or `?.`/`??` so its source can be emitted verbatim.\n\n## Import\n\n```ts\nimport { seatSurfaceParts } from '../util/surface-seat';\n```\n\n## Usage\n\n`../util/code-export` emits it via `Function.prototype.toString()` into any generated model whose parts declare `attachTo`, so exported models and the studio viewport seat parts identically.", + "ref": { + "module": "../util/surface-seat", + "name": "seatSurfaceParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "seatSurfaceParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/b1a52f18-fbdf-4350-b690-2d3b1e86bd4a.json b/4376bf-img-to-3d-generator/Spec/b1a52f18-fbdf-4350-b690-2d3b1e86bd4a.json new file mode 100644 index 00000000..38d5b88c --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/b1a52f18-fbdf-4350-b690-2d3b1e86bd4a.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# plainSpec\n\n## Summary\n\nReturns the normalized, JSON-safe form of a sculpt spec: object name/kind/class/complexity, identity features, materials via `specMaterials`, and components via `specNodes` reduced to plain nodeId/parentId/primitive/dimensions/position/rotation/scale/material/decal/repeat/attachTo/note fields. This is the shape that rides inside every generated model .js file as the `SCULPT_SPEC` constant — card instances no longer persist the spec, so the file is the carrier.\n\n## Import\n\n```ts\nimport { plainSpec } from '../util/code-export';\n```\n\n## Usage\n\nCalled by `generateModelJs` in the same module when emitting the `var SCULPT_SPEC = …` line; `specFromModelJs` reads that JSON back out later. Receives a spec (card field or plain object), returns a plain object safe to `JSON.stringify`.", + "ref": { + "module": "../util/code-export", + "name": "plainSpec" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "plainSpec", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/b73b52a4-6e5e-4a44-a95b-bd70155e047a.json b/4376bf-img-to-3d-generator/Spec/b73b52a4-6e5e-4a44-a95b-bd70155e047a.json new file mode 100644 index 00000000..22515dc7 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/b73b52a4-6e5e-4a44-a95b-bd70155e047a.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# runStructurePasses\n\n## Summary\n\nThe one ordered run of the img-to-3d deterministic structure-repair chain. Every pass it calls is pure — `(parsed[, analysis]) => string[]` log lines, mutating the spec in place — and the ORDER is load-bearing: inventory pruning first (hairline, unplanned, unrealized, overbuilt), then attachment repair and enforcement, grounding of supports, face synthesis and alignment, ring-collar seating, burial resolution, interior and recess fitting, orphan attachment, and finally the flag passes plus primitive-convention and coplanar-layer fixes. Centralising the sequence here keeps the \"why this order\" notes next to the calls and lets a test run the whole chain instead of each pass in isolation.\n\n## Import\n\n```ts\nimport { runStructurePasses } from '../util/spec-passes/run-all';\n```\n\n## Usage\n\nCalled by the studio's `generate()` (components/studio-isolated.gts) on a freshly built spec, which prefixes each returned line with \"> \" for its console, and by the integration test. Receives (parsed, analysis); returns every pass's log lines in order.\n", + "ref": { + "module": "../util/spec-passes/run-all", + "name": "runStructurePasses" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "runStructurePasses", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/b743105a-34af-40c1-a223-8a50bf6f70b7.json b/4376bf-img-to-3d-generator/Spec/b743105a-34af-40c1-a223-8a50bf6f70b7.json new file mode 100644 index 00000000..486149d6 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/b743105a-34af-40c1-a223-8a50bf6f70b7.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# flagInstanceCollisions\n\n## Summary\n\nAn overlap pass in the img-to-3d structure chain. Copies of one feature that overlap fuse into a single lump and z-fight into a dark smear — a six-wheeled truck came out with a black streak for wheels. Two causes are measured off the part's own extents, no naming involved: a linear `repeat` whose step is shorter than the part is REPAIRED (the step is widened to the part's extent on the repeat axis, keeping direction — copies must not overlap), while radial repeats whose chord is too tight and individually authored copies (same primitive, same dimensions, overlapping by over 10% of the smaller volume) are reported only, since collapsing hand-placed wheels is not this pass's call.\n\n## Import\n\n```ts\nimport { flagInstanceCollisions } from '../util/spec-passes/overlap';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after the placement movers. Mutates only repeat offsets; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/overlap", + "name": "flagInstanceCollisions" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "flagInstanceCollisions", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/b8aad411-a892-4fc7-a9ec-246d0239174f.json b/4376bf-img-to-3d-generator/Spec/b8aad411-a892-4fc7-a9ec-246d0239174f.json new file mode 100644 index 00000000..34323386 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/b8aad411-a892-4fc7-a9ec-246d0239174f.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# seatRingCollars\n\n## Summary\n\nAn attachment pass in the img-to-3d structure chain — the ring cousin of `fitCurvedDecals`. A ring that wraps a barrel (a muzzle collar, barrel clamp, hose ferrule) must have its hole facing along the barrel's axis and its centre on the centreline, but models routinely author it as an upright hoop standing beside the barrel. For each torus/flatRing/arch attached to a cylinder or capsule, it derives the barrel's axis from the host's Euler rotation, snaps the ring's rotation to the nearest canonical axis, centres it on the barrel's two off-axis coordinates (keeping its position along the barrel), and fits torus/flatRing radii so the hole hugs the barrel radius plus a 0.01 skin. Arches keep their sweep.\n\n## Import\n\n```ts\nimport { seatRingCollars } from '../util/spec-passes/attachments';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after the face passes, before `resolveBuriedParts`, so a seated collar is not read as floating. Mutates rotation/position/dimensions; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/attachments", + "name": "seatRingCollars" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "seatRingCollars", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/ba44295b-bd70-455e-847a-4dc40b408c48.json b/4376bf-img-to-3d-generator/Spec/ba44295b-bd70-455e-847a-4dc40b408c48.json new file mode 100644 index 00000000..422159d2 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/ba44295b-bd70-455e-847a-4dc40b408c48.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# generateViewerSrcdoc\n\n## Summary\n\nReturns the viewer harness HTML with the model .js URL baked in (`window.SCULPT_MODEL_URL`), for use as an iframe's `srcdoc` attribute. Because the document arrives via srcdoc, the browser never requests an .html file from the realm — so it works regardless of how the realm routes text/html navigations — and a srcdoc document inherits the embedding page's origin, so the studio can still call the harness's window API and the model .js fetch stays same-origin. Implemented as a one-line delegation to `generateViewerHarnessHtml(modelUrl)`.\n\n## Import\n\n```ts\nimport { generateViewerSrcdoc } from '../util/code-export';\n```\n\n## Usage\n\n`SculptedModel` (`sculpted-model.gts`) renders every exported model through it, and the studio (`components/studio-isolated.gts`) falls back to it when reading the model source for inline embedding fails.", + "ref": { + "module": "../util/code-export", + "name": "generateViewerSrcdoc" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "generateViewerSrcdoc", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/bd70155e-047a-4dc4-8b40-8c48521d9237.json b/4376bf-img-to-3d-generator/Spec/bd70155e-047a-4dc4-8b40-8c48521d9237.json new file mode 100644 index 00000000..ea8d9367 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/bd70155e-047a-4dc4-8b40-8c48521d9237.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ensureFaceParts\n\n## Summary\n\nA face pass in the img-to-3d structure chain: guaranteeing a face HAS a face. The LLM routinely ships a character with no eyes or mouth, and `dropUnplannedParts` guarantees nothing downstream can add them back. For anything that reads as a face — a head-named solid plus ears, a muzzle/face mass, or a stage-1 `character` directive — it synthesises the missing core features (two eyes with pupils, a nose, a mouth) as ellipsoid spheres sized and placed from the head/muzzle geometry, reusing or creating white/black/red materials. Additive and idempotent: existing features under any name are left alone, and a beak/bill/snout counts as both nose and mouth, so a duck never grows a second nose.\n\n## Import\n\n```ts\nimport { ensureFaceParts } from '../util/spec-passes/face';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after the pruning gates and before `alignFaceFeatures`, which then refines the synthesized parts. Pushes new components into `parsed.components`; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/face", + "name": "ensureFaceParts" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "ensureFaceParts", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/bf2ed08b-b743-405a-b4af-d0c162238a50.json b/4376bf-img-to-3d-generator/Spec/bf2ed08b-b743-405a-b4af-d0c162238a50.json new file mode 100644 index 00000000..20fa9c47 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/bf2ed08b-b743-405a-b4af-d0c162238a50.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# isRemovalInstruction\n\n## Summary\n\nTests whether a user's targeted-edit instruction is a removal: it trims the string and matches it against the removal-verb pattern `^(remove|delete|erase|drop|get rid of)\\b` (case-insensitive). Null/undefined input returns `false`. It is the gate that decides whether a lasso-selected edit can take the deterministic delete path instead of a model round-trip.\n\n## Import\n\n```ts\nimport { isRemovalInstruction } from '../util/spec-diff';\n```\n\n## Usage\n\nCalled by the studio's targeted-edit handler (`components/studio-isolated.gts`): when lasso targets exist and the instruction is a removal, the studio narrows the targets with `narrowRemovalTargets` and deletes them directly via `applySpecDiff` with `allowRemoval`, skipping the LLM entirely for a plain \"remove X\".", + "ref": { + "module": "../util/spec-diff", + "name": "isRemovalInstruction" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "isRemovalInstruction", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/bf6f70b7-3b52-446e-9eba-44295bbd7015.json b/4376bf-img-to-3d-generator/Spec/bf6f70b7-3b52-446e-9eba-44295bbd7015.json new file mode 100644 index 00000000..d3c93d4c --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/bf6f70b7-3b52-446e-9eba-44295bbd7015.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# clampInteriorCavities\n\n## Summary\n\nA placement repair in the img-to-3d structure chain for hollow bodies. The hollow-body recipe adds a dark \"interior\" box behind the windows so a cab reads as a cabin, but the model sizes it by eye — an interior as big as its shell pokes through the walls and the dark box eats the body. This pass finds parts named interior/cavity, resolves their shell (the `attachTo` host, else the smallest solid box containing the interior's centre), shrinks any axis where the interior reaches past 85% of the shell's half-extent (via scale — it never grows anything), and recentres it inside the shell.\n\n## Import\n\n```ts\nimport { clampInteriorCavities } from '../util/spec-passes/placement';\n```\n\n## Usage\n\nRuns in `runStructurePasses` after `resolveBuriedParts` (which is told to leave interiors alone) so the shell is already placed when the cavity is fitted to it. Mutates scale/position in place; returns log lines.\n", + "ref": { + "module": "../util/spec-passes/placement", + "name": "clampInteriorCavities" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "clampInteriorCavities", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/c0ffee00-1234-4abc-8def-0123456789ab.json b/4376bf-img-to-3d-generator/Spec/c0ffee00-1234-4abc-8def-0123456789ab.json new file mode 100644 index 00000000..aecde6d3 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/c0ffee00-1234-4abc-8def-0123456789ab.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# AUTO_REFINE_ROUNDS\n\n## Summary\n\nLoop-policy constant: how many automatic refine passes run after the initial generation, where each pass costs one vision call and several minutes. It is `0` by default, so one Generate produces exactly one model file; setting it above zero re-enables the render-vs-reference correction loop. It lives in `pipeline-config` — separate from the request transport — so the knobs worth turning when the pipeline is too slow or not accurate enough are findable without reading request code (alongside `REFINE_TARGET_SCORE` and `AUTO_VERIFY_ROUNDS`).\n\n## Import\n\n```ts\nimport { AUTO_REFINE_ROUNDS } from '../util/pipeline-config';\n```\n\n## Usage\n\nThe studio's generation task (`components/studio-isolated.gts`) iterates `for (let round = 0; round < AUTO_REFINE_ROUNDS; round++)` after the deterministic build, running one comparison-sheet refine call per round.", + "ref": { + "module": "../util/pipeline-config", + "name": "AUTO_REFINE_ROUNDS" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "pipeline-config", + "cardDescription": "Loop-policy knobs for the generate/refine cycle (AUTO_REFINE_ROUNDS, AUTO_VERIFY_ROUNDS, REFINE_TARGET_SCORE).", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/c162238a-50bf-4f70-b73b-52a46e5eba44.json b/4376bf-img-to-3d-generator/Spec/c162238a-50bf-4f70-b73b-52a46e5eba44.json new file mode 100644 index 00000000..8a64d86a --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/c162238a-50bf-4f70-b73b-52a46e5eba44.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# seedFromStrings\n\n## Summary\n\nHashes a list of strings into a deterministic sampling seed using 32-bit FNV-1a, with a separator byte between parts so `['ab','c']` and `['a','bc']` cannot collide, and the result kept below 2^31 because some providers reject larger seeds. Derived from the reference image URLs rather than random, it closes the reproducibility gap that temperature 0 alone leaves: the same photos always take the same sampling path, while a new photo set gets its own seed instead of inheriting the previous object's.\n\n## Import\n\n```ts\nimport { seedFromStrings } from '../util/llm-request';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) and `AnalyzeReferenceCommand` compute `seedFromStrings(referenceUrls)` and pass the result as the `seed` option to `requestSpec`, which forwards it to providers that honour it (Gemini and OpenAI-family; Anthropic ignores it harmlessly).", + "ref": { + "module": "../util/llm-request", + "name": "seedFromStrings" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "seedFromStrings", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/c729ec24-6d02-4917-8f00-cf307c8bbf2e.json b/4376bf-img-to-3d-generator/Spec/c729ec24-6d02-4917-8f00-cf307c8bbf2e.json new file mode 100644 index 00000000..1cee3e5a --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/c729ec24-6d02-4917-8f00-cf307c8bbf2e.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# ImgTo3dPopover\n\n## Summary\n\nThe shared popover shell for the img-to-3d studio, rendered into the catalog ``'s `:tools` block. The catalog Popover owns floating-ui positioning and Esc/outside-click dismissal; this shell paints its own deep-space dark surface over the catalog chrome and — because the Popover portals content out of the card's theme scope — re-declares the `--i3d-*` token block so yielded content resolves the same tokens as inside `.studio`. It renders a sticky header (kicker/title, or a custom `:header` block, always with an appended ✕ close button wired to `@onClose`), a scrollable `:body`, and an optional pinned `:foot`. Args pass through anchor selector, open flag, and Popover positioning (kind, anchoring, placement, size, backdrop, elevation, offset) with studio defaults; it also sets `--bx-popover-radius: 3px` to square off the floating chrome.\n\n## Import\n\n```ts\nimport ImgTo3dPopover from '../components/i3d-popover';\n```\n\n## Usage\n\nUsed by `StudioIsolated` (`components/studio-isolated.gts`) as the shell of the history popover, which lists past generation rounds; the shell owns the panel's dark surface, scrolling and width.", + "ref": { + "module": "../components/i3d-popover", + "name": "default" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "default", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/cf307c8b-bf2e-408b-b743-105a34afd0c1.json b/4376bf-img-to-3d-generator/Spec/cf307c8b-bf2e-408b-b743-105a34afd0c1.json new file mode 100644 index 00000000..ed7ccf41 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/cf307c8b-bf2e-408b-b743-105a34afd0c1.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# fetchAsDataUrl\n\n## Summary\n\nFetches an image URL and returns it as a data URL sized for vision-model input. It reads the bytes directly, falling back to the Boxel proxy (`SendRequestViaProxyCommand`) when a cross-origin CDN blocks the browser read, then downscales anything whose long edge exceeds `maxEdge` (default 1568 — the edge vision providers resize to anyway) and re-encodes to WebP, which keeps alpha. Images already small enough are returned untouched, a degraded encode is discarded in favour of the original, and decode failures never block a generation.\n\n## Import\n\n```ts\nimport { fetchAsDataUrl } from '../util/realm-image';\n```\n\n## Usage\n\nThe studio (`components/studio-isolated.gts`) and `AnalyzeReferenceCommand` call it on every reference image before a vision request, passing stage-specific `maxEdge` values (`ANALYZE_MAX_EDGE`, `BUILD_SECONDARY_EDGE`) and the command context for the proxy fallback.", + "ref": { + "module": "../util/realm-image", + "name": "fetchAsDataUrl" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "fetchAsDataUrl", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/d08bb743-105a-44af-90c1-62238a50bf6f.json b/4376bf-img-to-3d-generator/Spec/d08bb743-105a-44af-90c1-62238a50bf6f.json new file mode 100644 index 00000000..4a165cd7 --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/d08bb743-105a-44af-90c1-62238a50bf6f.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# requestSpec\n\n## Summary\n\nThe one place this pipeline talks to a model: one vision round-trip through the Boxel proxy to OpenRouter, returning the parsed reply. It pins temperature to 0 by default, forwards an optional seed and reasoning control, and marks the first system block cacheable on Anthropic models. Transient failures retry with backoff — 4 attempts for bad replies (with corrective nudges for truncation, non-JSON, or a `validate` complaint), 7 with longer delays for network drops. An incomplete-but-parseable reply is re-asked once, then used anyway. A 403 is surfaced as \"out of AI credits\".\n\n## Import\n\n```ts\nimport { requestSpec } from '../util/llm-request';\n```\n\n## Usage\n\nEvery stage calls it — the studio's spec/refine/targeted-edit calls, `AnalyzeReferenceCommand` (with `parseAnalysisJson`), and `RefineSculptCommand` (with `parseDiffJson`); the `parser` argument defaults to `parseSpecJson`.", + "ref": { + "module": "../util/llm-request", + "name": "requestSpec" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "requestSpec", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/Spec/d0c16223-8a50-4f6f-b0b7-3b52a46e5eba.json b/4376bf-img-to-3d-generator/Spec/d0c16223-8a50-4f6f-b0b7-3b52a46e5eba.json new file mode 100644 index 00000000..86ac8bbf --- /dev/null +++ b/4376bf-img-to-3d-generator/Spec/d0c16223-8a50-4f6f-b0b7-3b52a46e5eba.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# generateViewerSrcdocInline\n\n## Summary\n\nReturns the viewer harness HTML with the model CODE embedded directly (`window.SCULPT_MODEL_INLINE`), so no realm file is involved. The injected script is ` { + static actionVerb = 'Analyze'; + static displayName = 'Analyze Reference Images'; + + async getInputType() { + return AnalyzeReferenceInput; + } + + protected async run( + input: AnalyzeReferenceInput, + ): Promise { + let urls = (input.imageUrls ?? []).filter(Boolean).slice(0, 6); + if (!urls.length) { + throw new Error('At least one reference image URL is required.'); + } + + let imageParts = await Promise.all( + urls.map(async (url) => ({ + type: 'image_url', + image_url: { + url: await fetchAsDataUrl(url, { + maxEdge: input.maxEdge || undefined, + commandContext: this.commandContext, + }), + }, + })), + ); + + // the analysis is the gate for everything downstream (object type, part + // count, bboxes, revolved flags), so it is the stage that must not drift: + // the seed comes from the reference URLs, not from chance, so re-running + // this command on one photo set reproduces the same plan. + let analysis = await requestSpec( + this.commandContext, + input.model || VISION_MODEL, + ANALYZE_SYSTEM_PROMPT, + [ + { + type: 'text', + text: 'Analyze the object in these reference view(s) and write the construction plan.', + }, + ...imageParts, + ], + undefined, + parseAnalysisJson, + // analysis is pure structural perception. This model MAKES reasoning + // mandatory (disabling it 400s), so instead push the thinking budget to + // the floor — the stage was measured at ~29s with default reasoning. + { seed: seedFromStrings(urls), reasoning: { effort: 'low' } }, + ); + + return new AnalyzeReferenceOutput({ + analysisJson: JSON.stringify(analysis), + objectType: analysis.objectType ?? '', + objectClass: analysis.objectClass ?? '', + buildBackend: analysis.buildBackend ?? '', + backendReason: analysis.backendReason ?? '', + partCount: analysis.partPlan?.length ?? 0, + identityFeatures: analysis.identityFeatures ?? [], + }); + } +} diff --git a/4376bf-img-to-3d-generator/commands/refine-sculpt-command.gts b/4376bf-img-to-3d-generator/commands/refine-sculpt-command.gts new file mode 100644 index 00000000..c8291bcc --- /dev/null +++ b/4376bf-img-to-3d-generator/commands/refine-sculpt-command.gts @@ -0,0 +1,174 @@ +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import { Command } from '@cardstack/runtime-common'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import WriteTextFileCommand from '@cardstack/boxel-host/tools/write-text-file'; + +import { SculptedModel } from '../sculpted-model'; +import { requestSpec, REFINE_MODEL } from '../util/llm-request'; +import { serializeSpecForPrompt, parseDiffJson } from '../util/spec-io'; +import { applySpecDiff } from '../util/spec-diff'; +import { generateModelJs, specFromModelJs } from '../util/code-export'; +import { TARGETED_EDIT_PROMPT } from '../prompts/targeted-edit'; + +// What the AI Assistant hands us after it has DIAGNOSED the model against the +// reference (the assistant can see both images in the chat) and the user has +// agreed on a change: which round to edit, and one precise instruction. +class RefineSculptInput extends CardDef { + @field sculptedModelId = contains(StringField, { + description: + 'The id of the SculptedModel round to refine — use the id of the attached model card (its selectedCreation/latestCreation).', + }); + @field instruction = contains(StringField, { + description: + 'ONE precise, self-contained edit in plain words — what to change and how, naming the part(s). Examples: "recolor the shorts to bright red #d81f2a", "make the ears 30% bigger", "move the bill to the front centre of the head", "add a left wheel mirroring the right", "remove the extra nose". Describe a single change; the user approves each one.', + }); +} + +// Applies ONE user-approved refinement to a sculpted model, WITHOUT re-running +// the whole generate pipeline. The heavy visual diagnosis (what differs from +// the reference) is done by the AI Assistant itself — it can see the reference +// and the round's render screenshot in the chat — so this command only has to +// TURN THE INSTRUCTION INTO A SPEC EDIT and save the result as a new round. +// +// It reads the current spec back out of the round's generated .js (the spec +// rides inside it as SCULPT_SPEC), asks the model for the minimal change set +// for the instruction, applies it, regenerates the .js as a NEW round file, and +// points the source studio at that round so the open viewport live-reloads. +export default class RefineSculptCommand extends Command< + typeof RefineSculptInput, + undefined +> { + static actionVerb = 'Refine Model'; + static displayName = 'Refine Model'; + + description = + 'Apply one described edit (recolor / resize / move / add / remove a part) to the attached sculpted model, in place. Use after diagnosing the model against its reference and agreeing the change with the user.'; + + requireInputFields = ['sculptedModelId', 'instruction']; + + async getInputType() { + return RefineSculptInput; + } + + // the realm root of a generated model url: everything before the per-studio + // asset folder (or the legacy flat exports/ dir) + private realmOf(url: string): string { + for (let marker of ['/img-to-3d/', '/exports/']) { + let i = url.indexOf(marker); + if (i >= 0) return url.slice(0, i + 1); + } + // fall back to the directory + return url.slice(0, url.lastIndexOf('/') + 1); + } + + protected async run(input: RefineSculptInput): Promise { + if (!input.sculptedModelId) { + throw new Error('sculptedModelId is required'); + } + let instruction = (input.instruction ?? '').trim(); + if (!instruction) { + throw new Error('instruction is required'); + } + + let attached = (await new GetCardCommand(this.commandContext).execute({ + cardId: input.sculptedModelId, + })) as SculptedModel; + + // Edit the round the studio is CURRENTLY showing, not whatever round was + // attached when the assistant room opened — the room's attached card is a + // snapshot from open-time, so after the user navigates history it goes + // stale and edits would land on the wrong (older) round. Resolve the live + // current round from the source studio; fall back to the attached card. + let creation = attached; + if (attached?.sourceStudio?.id) { + let studio: any = await new GetCardCommand(this.commandContext).execute({ + cardId: attached.sourceStudio.id, + }); + let current = studio?.selectedCreation ?? studio?.latestCreation; + if (current?.id && current.codeFile?.url) { + creation = current as SculptedModel; + } + } + + let codeFileUrl = creation.codeFile?.url; + if (!codeFileUrl) { + throw new Error('that model has no code file to refine'); + } + + // read the spec back out of the round's source (ask for the source bytes, + // not the realm's transpiled form, which reflows the SCULPT_SPEC constant) + let response = await fetch(codeFileUrl, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) { + throw new Error(`could not load the model file (${response.status})`); + } + let spec = specFromModelJs(await response.text()); + if (!spec?.components?.length) { + throw new Error('the model file carries no readable spec'); + } + + // instruction → minimal change set. No image is sent: the assistant has + // already looked at the reference vs the render and produced a precise + // instruction, so the targeted-edit prompt resolves it from the spec alone. + let diff = await requestSpec( + this.commandContext, + REFINE_MODEL, + TARGETED_EDIT_PROMPT, + [ + { + type: 'text', + text: + `PURPOSE: you are editing an EXISTING 3D model — the SCULPT_SPEC read below out of the model's own three.js file. Your change set is applied to that spec and the file is regenerated in place, so it is what re-renders in the viewport iframe. Change ONLY what the instruction asks; leave every other part exactly as it is.\n\n` + + `SELECTED nodeIds: []\n` + + `No parts were pre-selected — resolve the targets from the instruction and the spec below.\n` + + `INSTRUCTION: ${instruction}\n\nCURRENT SPEC:\n${JSON.stringify( + serializeSpecForPrompt(spec), + )}`, + }, + ], + () => {}, + parseDiffJson, + ); + + let merged = applySpecDiff(spec, diff, { + allowRemoval: true, + allowReshape: true, + allowAdditions: true, + }); + + // Edit IN PLACE — overwrite this same round's file and update the same + // SculptedModel, rather than spawning a new round each turn. A refine + // conversation can run many turns, so a new instance per turn would litter + // the realm with SculptedModel cards and history rounds; editing in place + // keeps one card per model, matching the lasso "Edit a part" semantics. + let realm = this.realmOf(codeFileUrl); + let rel = codeFileUrl.slice(realm.length); + await new WriteTextFileCommand(this.commandContext).execute({ + path: rel, + content: generateModelJs(merged, { + round: creation.round ?? 1, + score: typeof merged.score === 'number' ? merged.score : null, + }), + realm, + overwrite: true, + } as any); + + // update this round's own review fields in place (no new instance) + creation.critique = String(merged.critique ?? `refined: ${instruction}`); + if (typeof merged.score === 'number') { + creation.score = merged.score; + } + // bump revision so the studio viewport cache-busts and re-fetches the + // just-overwritten (same-url) .js instead of showing the stale render + creation.revision = (creation.revision ?? 0) + 1; + await new SaveCardCommand(this.commandContext).execute({ + card: creation, + realm, + } as any); + + return undefined; + } +} diff --git a/4376bf-img-to-3d-generator/components/i3d-popover.gts b/4376bf-img-to-3d-generator/components/i3d-popover.gts new file mode 100644 index 00000000..992e03f5 --- /dev/null +++ b/4376bf-img-to-3d-generator/components/i3d-popover.gts @@ -0,0 +1,261 @@ +import Component from '@glimmer/component'; +import { on } from '@ember/modifier'; +import { htmlSafe, type SafeString } from '@ember/template'; +import Popover from '@cardstack/catalog/46f065-popover/popover'; +import type { Placement } from '@floating-ui/dom'; + +type PopKind = 'details' | 'edit' | 'tools'; +type PopAnchoring = 'beside' | 'overlay' | 'center'; +type PopSize = 'compact' | 'comfortable' | 'spacious' | 'auto'; +type PopBackdrop = 'none' | 'tint' | 'blur' | 'dim'; +type PopElevation = 'flat' | 'raised' | 'elevated' | 'floating'; + +interface Signature { + Element: HTMLDivElement; + Args: { + /** CSS selector the popover velcros to. */ + anchor: string; + /** Called on Esc / outside-click AND the header ✕ button. */ + onClose: () => void; + /** Header title — shown when no `:header` block is provided. */ + title?: string; + /** Small uppercase kicker above the title (default header only). */ + kicker?: string; + /** When false the popover is unmounted. Default true. */ + open?: boolean; + /** Extra class on the shell root for per-popover tweaks. */ + class?: string; + /* ---- Popover positioning pass-through (sensible defaults) ---- */ + kind?: PopKind; + anchoring?: PopAnchoring; + placement?: Placement; + size?: PopSize; + backdrop?: PopBackdrop; + elevation?: PopElevation; + /** Gap in px between the anchor and the popover. */ + offset?: number; + /** Accessible label for the popover surface. Falls back to `title`. */ + label?: string; + }; + Blocks: { + /** Custom header content. The ✕ close button is always appended by + * the shell. Omit to get the default title/kicker header. */ + header: []; + /** The scrollable body — the popover's main content. */ + body: []; + /** Optional pinned footer (actions that shouldn't scroll away). */ + foot: []; + }; +} + +/** + * `` — the shared popover shell for the img-to-3d studio. + * Wraps the catalog `` (which owns floating-ui positioning and + * Esc / outside-click dismissal) but paints its OWN deep-space dark surface + * over the catalog chrome, so it never shows the light default. Provides a + * sticky header (title/kicker or a custom `:header`, plus a ✕ close), a + * scrollable `:body`, and an optional pinned `:foot`. + * + * The catalog Popover portals content out of the card's theme scope, so the + * shell re-declares the `--i3d-*` token block the studio's content uses — + * yielded `.history-*` / `.inpaint-*` elements keep their host scoped-CSS + * classes AND resolve the same tokens they do inside `.studio`. + */ +export default class ImgTo3dPopover extends Component { + get open() { + return this.args.open ?? true; + } + + get kind(): PopKind { + return this.args.kind ?? 'tools'; + } + + get anchoring(): PopAnchoring { + return this.args.anchoring ?? 'beside'; + } + + get placement(): Placement { + return this.args.placement ?? 'bottom-end'; + } + + get size(): PopSize { + return this.args.size ?? 'auto'; + } + + get backdrop(): PopBackdrop { + return this.args.backdrop ?? 'none'; + } + + get elevation(): PopElevation { + return this.args.elevation ?? 'floating'; + } + + get label() { + return this.args.label ?? this.args.title ?? 'Panel'; + } + + // The rounded chrome you see is the catalog Popover's own surface, not this + // shell — and at elevation 'floating' it computes `--bx-popover-radius * 2`, + // which is why setting a small radius on .i3d-pop alone changed nothing. + // Popover spreads ...attributes onto that surface, so the variable can be set + // from here: 3px doubles to a 6px corner, and the floating shadow is kept. + get shellStyle(): SafeString { + return htmlSafe('--bx-popover-radius: 3px;'); + } + + +} diff --git a/4376bf-img-to-3d-generator/components/studio-isolated.gts b/4376bf-img-to-3d-generator/components/studio-isolated.gts new file mode 100644 index 00000000..a58cad06 --- /dev/null +++ b/4376bf-img-to-3d-generator/components/studio-isolated.gts @@ -0,0 +1,3641 @@ +// The studio's working view — the whole instrument panel: the reference +// strip, the viewport with its draft renders, the generate/refine pipeline, +// the lasso/instruction editor, and the history popover. It lives apart from +// the card definition for the same reason the pipeline utils do: the card +// file declares what a studio IS (fields, embedded/fitted tiles), while this +// class is the several-thousand-line answer to what the isolated view DOES. + +import { + Component, + realmURL, + FileDef, +} from 'https://cardstack.com/base/card-api'; +import { codeRef } from '@cardstack/runtime-common'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { restartableTask } from 'ember-concurrency'; + +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import WriteTextFileCommand from '@cardstack/boxel-host/tools/write-text-file'; +import UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant'; +import ImgTo3dPopover from './i3d-popover'; + +import { + fetchAsDataUrl, + slugify, + writeRealmImage, + ANALYZE_MAX_EDGE, + BUILD_SECONDARY_EDGE, +} from '../util/realm-image'; +import { + cropWithBackgroundRemoved, + revolvedSilhouetteBbox, + traceLatheProfile, +} from '../util/silhouette'; +import { + generateModelJs, + generateViewerSrcdoc, + generateViewerSrcdocInline, + specFromModelJs, +} from '../util/code-export'; +import { + VISION_MODEL, + ASSISTANT_MODEL, + ANALYSIS_MODEL, + requestSpec, + seedFromStrings, +} from '../util/llm-request'; +import { + AUTO_REFINE_ROUNDS, + AUTO_VERIFY_ROUNDS, + REFINE_TARGET_SCORE, +} from '../util/pipeline-config'; +import { composeComparison } from '../util/comparison-sheet'; +import { serializeSpecForPrompt, parseDiffJson } from '../util/spec-io'; +import { + applySpecDiff, + isRemovalInstruction, + narrowRemovalTargets, +} from '../util/spec-diff'; +import { + fitCurvedDecals, + stripRedundantLabelParts, + dropUnplannedParts, + dropHairlineParts, + flagUnrealizedParts, + clampToEnvelope, + reconcileProportions, +} from '../util/spec-passes/index'; +import { runStructurePasses } from '../util/spec-passes/run-all'; +import { buildSpecSystemPrompt } from '../prompts/spec'; +import { selectRecipeNames } from '../prompts/recipes'; +import { REFINE_SYSTEM_PROMPT } from '../prompts/refine'; +import { TARGETED_EDIT_PROMPT } from '../prompts/targeted-edit'; +import { COMPLETENESS_CRITIC_PROMPT } from '../prompts/completeness'; + +import ImageSourceField from '@cardstack/catalog/fields/image-source/image-source'; +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; +import GeneratingOverlay from '@cardstack/catalog/components/generating-overlay'; + +import { AnalyzeReferenceCommand } from '../commands/analyze-reference'; +import { SculptedModel } from '../sculpted-model'; +import type { ImgTo3dStudio } from '../img-to-3d-studio'; + +/* @ts-expect-error import.meta is valid ESM */ +const here: string = import.meta.url; +const studioRef = codeRef(here, '../img-to-3d-studio', 'ImgTo3dStudio'); +const sculptedModelRef = codeRef(here, '../sculpted-model', 'SculptedModel'); + +type StudioPhase = 'idle' | 'analyzing' | 'building' | 'done' | 'error'; + +// Glint does not accept decorators on members of the class expression used by +// CardDef's inline isolated component. Keep those reactive values in a regular +// top-level class and proxy them from the component instead. +// one past generation, flattened for the history popover so the strip renders +// without holding live card instances +interface HistoryEntry { + id: string; + codeFileUrl: string; + objectName: string; + round: number | undefined; + screenshotUrl: string | undefined; + srcdoc: string; +} + +class StudioViewState { + @tracked phase: StudioPhase = 'idle'; + @tracked errorMessage: string | null = null; + @tracked logLines: string[] = []; + @tracked draftViewerSrcdoc: string | undefined; + // the persisted model's viewer, with its .js source INLINED into the srcdoc. + // The iframe is a bare document with no realm auth, so a private realm 401s a + // fetch-by-URL viewer ("could not load model (401)"); inlining the source + // (fetched by the studio, which IS authenticated) sidesteps that entirely. + @tracked persistedSrcdoc: string | undefined; + @tracked stopRequested = false; + @tracked historyOpen = false; + @tracked historyItems: HistoryEntry[] = []; + @tracked historyLoading = false; + @tracked openMenuId: string | null = null; + // lasso inpaint: mode toggle, whether a polygon has been drawn+resolved, + // the instruction text, how many parts fell under the lasso, and busy flag + @tracked inpaintMode = false; + @tracked lassoDrawn = false; + @tracked inpaintInstruction = ''; + @tracked inpaintTargetCount = 0; + @tracked inpaintBusy = false; + // after a lasso edit, the pre-edit spec is remembered so one click can + // revert (lasso edits overwrite the CURRENT round in place — no new instance) + @tracked inpaintUndoAvailable = false; + // bumped to force the viewport iframe to re-fetch the same (overwritten) + // file URL after an in-place edit — the srcdoc gets a fresh cache-bust key + @tracked inPlaceReloadKey = 0; + // the .js URL shown in the viewport DURING a build — the brief window after + // the file is written but before its SculptedModel is saved & linked as + // latestCreation. Once linked, this clears and the viewport reads the + // creation. It is the only place the studio holds a code URL of its own. + @tracked pendingViewportUrl: string | undefined; + // escape hatch for a BAD cached analysis. Reusing the analysis on an + // unchanged reference is what keeps re-generates stable, but when the plan + // itself is wrong (parts missing, budget wasted) that stability just + // reproduces the mistake forever — the only exit used to be swapping the + // reference photos. Set by the sidebar button; the next Generate re-runs the + // analysis stage AND skips the structure lock (which would otherwise freeze + // the old plan's part graph anyway), then clears the flag. + @tracked reanalyzeRequested = false; + // the completeness pass is a whole extra vision+render round-trip, so it is + // skipped when the build already realized every planned part. This flag lets + // the user force it back on for a complex object where the plan itself may + // have under-described the reference. + @tracked forceCompleteness = false; +} + +export class StudioIsolated extends Component { + private state = new StudioViewState(); + + get phase() { + return this.state.phase; + } + + set phase(value: StudioPhase) { + this.state.phase = value; + } + + get errorMessage() { + return this.state.errorMessage; + } + + set errorMessage(value: string | null) { + this.state.errorMessage = value; + } + + get logLines() { + return this.state.logLines; + } + + set logLines(value: string[]) { + this.state.logLines = value; + } + + // the source spec of the model in the viewport, held in memory for the + // duration of a generate/refine run — it persists inside the generated + // .js file (SCULPT_SPEC), never on the card instance + workingSpec: any = null; + + // world-space traced silhouette envelope ({y, half}[]) for the current + // build — set when a revolved body's outline is traced, consumed by + // clampToEnvelope to keep every solid part inside the outline. Cleared when + // the trace fails so nothing clamps against a bad LLM profile. + tracedEnvelope: { y: number; half: number }[] | null = null; + + // During the deterministic measurement pass the same iframe renders an + // in-memory candidate before any realm file is written. Once accepted, + // this is cleared and the iframe switches to the persisted model URL. + get draftViewerSrcdoc() { + return this.state.draftViewerSrcdoc; + } + + set draftViewerSrcdoc(value: string | undefined) { + this.state.draftViewerSrcdoc = value; + } + + get persistedSrcdoc() { + return this.state.persistedSrcdoc; + } + + set persistedSrcdoc(value: string | undefined) { + this.state.persistedSrcdoc = value; + } + + private draftSequence = 0; + + // the viewport is the shared exports/viewer.html harness in an iframe — + // the SAME page any external site embeds; same-origin, so the studio can + // call the window API it exposes (screenshot / views / glb) + frameEl: HTMLIFrameElement | undefined; + + onFrameLoad = (event: Event) => { + this.frameEl = event.target as HTMLIFrameElement; + }; + + get frameWindow(): any { + return this.frameEl?.contentWindow as any; + } + + get realmHref(): string | undefined { + return (this.args.model as any)?.[realmURL]?.href; + } + + // one folder per studio card for this run's RUNTIME artifacts — the model + // .js, its render screenshots, and any cropped textures all live under + // img-to-3d// instead of filling the realm root. Lets a studio's + // whole output be found (and deleted) in one place, and avoids cross-studio + // filename clashes. These files are never part of a listing push (they are + // referenced by URL at runtime, not imported), so this is dev-realm tidiness + // only. Uses the id's last path segment; falls back when the card is unsaved. + get studioAssetDir(): string { + let id = (this.args.model as any)?.id as string | undefined; + let seg = id ? (id.split('/').filter(Boolean).pop() ?? '') : ''; + return `img-to-3d/${slugify(seg, 'studio')}`; + } + + // the studio viewport embeds the harness via srcdoc (no .html request to + // the realm, so no dependency on how text/html navigations route); the + // URL form of the SAME page — viewer.html?model=… — is what Copy iframe + // hands to external sites + get inPlaceReloadKey() { + return this.state.inPlaceReloadKey; + } + set inPlaceReloadKey(v: number) { + this.state.inPlaceReloadKey = v; + } + + // identity of the model the viewport should show: its url plus the two + // reload triggers (the studio's own lasso edits via inPlaceReloadKey, and the + // AI Refine command's `revision` bump). When this changes, the source is + // re-fetched and re-inlined, which is how an in-place overwrite reloads. + get viewerKey(): string | undefined { + let url = this.currentCodeFileUrl; + if (!url) return undefined; + let rev = Number((this.currentCreation as any)?.revision ?? 0); + return `${url}|${this.inPlaceReloadKey}|${rev}`; + } + + // non-tracked dedupe guard so the render-time getter kicks the loader off at + // most once per key without writing tracked state mid-render. + private requestedViewerKey: string | undefined; + + get viewerSrcdoc(): string | undefined { + if (this.draftViewerSrcdoc) return this.draftViewerSrcdoc; + let key = this.viewerKey; + if (!key) return undefined; + if (key !== this.requestedViewerKey) { + this.requestedViewerKey = key; + this.loadPersistedViewer.perform(this.currentCodeFileUrl!); + } + return this.persistedSrcdoc; + } + + // Fetch the model's .js source over the studio's AUTHENTICATED network and + // embed it directly in the viewer srcdoc, so the iframe never issues an + // unauthenticated fetch-by-URL to the realm (which 401s on a private realm). + // The codeFileUrl doubles as the readiness token, matching waitForViewer. + async buildInlineSrcdoc(url: string): Promise { + try { + let response = await fetch(url, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) return undefined; + let code = await response.text(); + return generateViewerSrcdocInline(code, url); + } catch { + return undefined; + } + } + + loadPersistedViewer = restartableTask(async (url: string) => { + let srcdoc = await this.buildInlineSrcdoc(url); + // fall back to the fetch-by-URL viewer if the source read failed — on a + // public realm that still renders; on a private one it surfaces the 401. + this.persistedSrcdoc = srcdoc ?? generateViewerSrcdoc(url); + }); + + // ---- current selection (all detail is read from the linked creation) ---- + // the creation shown in the viewport: the explicitly selected one, else + // the newest saved round + get currentCreation() { + return this.args.model?.selectedCreation ?? this.args.model?.latestCreation; + } + + // the .js in the viewport: the just-built file mid-run (pendingViewportUrl), + // otherwise the selected creation's file + get currentCodeFileUrl(): string | undefined { + return this.pendingViewportUrl ?? this.currentCreation?.codeFile?.url; + } + + get currentObjectName(): string | undefined { + return this.currentCreation?.objectName; + } + + get pendingViewportUrl() { + return this.state.pendingViewportUrl; + } + + set pendingViewportUrl(value: string | undefined) { + this.state.pendingViewportUrl = value; + } + + // the analysis in play for the current run — held transiently during a + // build (the run owns it before any creation exists), else read back from + // the selected creation. This replaces the old studio.analysis field. + activeAnalysis: any = null; + + get currentAnalysis(): any { + if (this.activeAnalysis) return this.activeAnalysis; + try { + return JSON.parse(this.currentCreation?.analysis || 'null'); + } catch { + return null; + } + } + + // resolves when the iframe has loaded THIS model file and built the scene + async waitForViewer(codeFileUrl: string, timeoutMs = 30000) { + let started = Date.now(); + while (Date.now() - started < timeoutMs) { + let win = this.frameWindow; + try { + if (win?.sculptViewerReady && win?.SCULPT_MODEL_URL === codeFileUrl) { + return true; + } + } catch { + // transiently unreadable during navigation + } + await new Promise((r) => setTimeout(r, 200)); + } + return false; + } + + get hasLinkedTheme() { + return Boolean(this.args.model?.cardInfo?.theme); + } + + get hasModel() { + return Boolean(this.currentCodeFileUrl); + } + + get hasReference() { + return Boolean(this.args.model?.references?.primaryUrl); + } + + // the fingerprint of the current reference set — the same signature the + // generate task stamps onto the cached analysis (_refSig). Comparing the + // two tells us whether the photos changed since the last analysis. + get currentRefSig(): string { + let urls = (this.args.model?.references?.resolvedUrls ?? []).filter( + Boolean, + ); + return urls.join('|'); + } + + // true when the reference photos were added / removed / swapped since the + // analysis that's currently cached on the card. Drives the sidebar hint + // and lets Generate know the gate must be re-run. + get referencesChanged(): boolean { + let prev = this.currentAnalysis; + if (!prev) return false; + return prev._refSig != null && prev._refSig !== this.currentRefSig; + } + + get latestCreation() { + return this.args.model?.latestCreation; + } + + get isRunning() { + return this.phase === 'analyzing' || this.phase === 'building'; + } + + get latestLogLine() { + return this.logLines[this.logLines.length - 1]; + } + + get viewportHint() { + if (this.hasModel || this.isRunning) return undefined; + return this.hasReference ? 'ready — hit generate' : 'add a reference photo'; + } + + lastBuildWarnings: string[] = []; + + // The round's own report card, persisted on the creation. Warnings are + // bucketed by their leading phrase rather than kept verbatim: the text + // carries part names, so counting raw strings compares two objects' names + // instead of their failure modes. + buildMetrics(parsed: any) { + let kinds: Record = {}; + for (let warning of this.lastBuildWarnings) { + let kind = String(warning).split(/[':(]/)[0].trim().toLowerCase(); + kinds[kind] = (kinds[kind] ?? 0) + 1; + } + let featureCheck = parsed?.featureCheck ?? {}; + return { + residual: this.lastCalibrationResidual, + score: typeof parsed?.score === 'number' ? parsed.score : null, + warningCount: this.lastBuildWarnings.length, + warningKinds: kinds, + featuresPassed: Object.values(featureCheck).filter((v) => v === true) + .length, + featuresFailed: Object.values(featureCheck).filter((v) => v !== true) + .length, + plannedParts: this.activeAnalysis?.partPlan?.length ?? null, + builtParts: Array.isArray(parsed?.components) + ? parsed.components.filter((c: any) => c?.primitive !== 'group').length + : null, + objectClass: this.activeAnalysis?.objectClass ?? null, + approaches: [ + ...new Set( + (this.activeAnalysis?.partPlan ?? []) + .map((p: any) => p?.approach) + .filter(Boolean), + ), + ], + }; + } + + // Mean deviation of the latest calibrated build from the analysis + // targets. The automatic verification loop only spends another vision + // pass while deterministic measurement still shows a material mismatch. + lastCalibrationResidual: number | null = null; + + startGenerate = () => { + this.stopRequested = false; + this.inpaintUndoAvailable = false; + this.inpaintUndoSpec = null; + this.generate.perform(); + }; + + // one on-demand correction round over the model on screen — surgical + // (diff-based) edits, never a from-scratch regenerate. The source spec + // comes from memory or is read back out of the model file's embedded + // SCULPT_SPEC, so refine works even after a reload or on a restored + // history round. + startRefine = () => { + this.stopRequested = false; + this.refineOnce.perform(); + }; + + refineOnce = restartableTask(async () => { + let model = this.args.model; + let currentUrl = this.currentCodeFileUrl; + if (!currentUrl || !this.args.context?.commandContext) return; + try { + this.errorMessage = null; + this.phase = 'analyzing'; + // the run needs the selected creation's analysis as its measured target + this.activeAnalysis = this.currentAnalysis; + if (!this.workingSpec) { + this.log('> reading spec back from the model file…'); + // ask for the SOURCE bytes — the default .js response is the + // realm's transpiled form, which reformats the single-line + // SCULPT_SPEC constant beyond what specFromModelJs can read + let response = await fetch(currentUrl, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) { + throw new Error(`could not load model file (${response.status})`); + } + this.workingSpec = specFromModelJs(await response.text()); + if (!this.workingSpec) { + throw new Error('model file carries no readable SCULPT_SPEC'); + } + } + let previousCreation = model?.latestCreation; + let previousCodeFileUrl = currentUrl; + let previousSpec = JSON.parse(JSON.stringify(this.workingSpec)); + let previousScore = previousCreation?.score; + let referenceDataUrl = await this.encodeReference(); + let round = (this.latestCreation?.round ?? 0) + 1; + let started = Date.now(); + let outcome = await this.runRefinePass(referenceDataUrl, round); + if ( + !outcome.featuresOk || + (typeof previousScore === 'number' && + typeof outcome.score === 'number' && + outcome.score < previousScore) + ) { + if (model) model.latestCreation = previousCreation; + this.pendingViewportUrl = undefined; + this.workingSpec = previousSpec; + this.log( + !outcome.featuresOk + ? '> refinement failed an identity feature; restored previous round' + : `> refinement regressed ${previousScore} → ${outcome.score}; restored previous round`, + ); + if (previousCodeFileUrl) { + await this.waitForViewer(previousCodeFileUrl); + } + } + this.log(`> refine done (${Math.round((Date.now() - started) / 1000)}s)`); + this.phase = 'done'; + } catch (e: any) { + this.draftViewerSrcdoc = undefined; + if (this.stopRequested) { + this.phase = 'idle'; + this.log('> stopped'); + return; + } + this.phase = 'error'; + this.errorMessage = e?.message ?? 'refine failed'; + } + }); + + // graceful stop: the current vision round finishes (its result is kept + // and archived), then the refine loop exits instead of starting another + get stopRequested() { + return this.state.stopRequested; + } + + set stopRequested(value: boolean) { + this.state.stopRequested = value; + } + + get reanalyzeRequested() { + return this.state.reanalyzeRequested; + } + + set reanalyzeRequested(value: boolean) { + this.state.reanalyzeRequested = value; + } + + get forceCompleteness() { + return this.state.forceCompleteness; + } + + set forceCompleteness(value: boolean) { + this.state.forceCompleteness = value; + } + + toggleForceCompleteness = () => { + this.forceCompleteness = !this.forceCompleteness; + this.log( + this.forceCompleteness + ? '> completeness pass forced ON for the next Generate' + : '> completeness pass back to auto (skipped when nothing is missing)', + ); + }; + + // the escape hatch for a bad plan — visible only when there IS a cached + // plan the next Generate would otherwise silently reuse + get canRequestReanalyze(): boolean { + let prev = this.currentAnalysis; + return Boolean( + prev && + Array.isArray(prev.partPlan) && + prev.partPlan.length && + !this.referencesChanged, + ); + } + + toggleReanalyze = () => { + this.reanalyzeRequested = !this.reanalyzeRequested; + this.log( + this.reanalyzeRequested + ? '> next Generate will discard the cached plan and re-analyze' + : '> re-analyze cancelled — the cached plan will be reused', + ); + }; + + // hard stop: flag every cooperative boundary AND cancel the running + // tasks so an in-flight generate/refine unwinds at its next await instead + // of finishing the whole chain. The catch blocks see stopRequested and end + // quietly (phase idle) rather than reporting an error. + stopRefinement = () => { + this.stopRequested = true; + this.log('> stopping all steps…'); + this.generate.cancelAll(); + this.refineOnce.cancelAll(); + // drop any half-built in-memory draft so the viewport falls back to the + // last persisted model (viewerSrcdoc uses codeFileUrl when no draft) and + // release the working spec/envelope so a stale build can't leak forward + this.draftViewerSrcdoc = undefined; + this.pendingViewportUrl = undefined; + this.tracedEnvelope = null; + this.phase = 'idle'; + }; + + // ---- generation history (popover) ------------------------------------- + get historyOpen() { + return this.state.historyOpen; + } + + set historyOpen(value: boolean) { + this.state.historyOpen = value; + } + + get historyItems(): HistoryEntry[] { + return this.state.historyItems; + } + + set historyItems(value: HistoryEntry[]) { + this.state.historyItems = value; + } + + get historyLoading() { + return this.state.historyLoading; + } + + set historyLoading(value: boolean) { + this.state.historyLoading = value; + } + + // wrap a generated .js URL as an openable FileDef link (the .js file + // already exists in the realm — this is just its clickable reference) + makeCodeFileDef(url: string) { + let name = decodeURIComponent(url.split('/').pop() || 'model.js'); + // linksTo(FileDef) requires an id — for a realm file that IS its URL + // (same pattern as writeRealmImage's ImageDef), so a FileDef built by + // createFileDef (no id) is rejected on save. Construct it with id. + return new FileDef({ + id: url, + url, + sourceUrl: url, + name, + contentType: 'text/javascript', + } as any); + } + + isCurrentEntry = (entry: HistoryEntry) => { + return entry.codeFileUrl === this.currentCodeFileUrl; + }; + + // the picked round, cloned to the top of the popover as a "current" strip + get currentHistoryEntry(): HistoryEntry | undefined { + let url = this.currentCodeFileUrl; + if (!url) return undefined; + return this.historyItems.find((e) => e.codeFileUrl === url); + } + + get openMenuId() { + return this.state.openMenuId; + } + + set openMenuId(value: string | null) { + this.state.openMenuId = value; + } + + isMenuOpen = (entry: HistoryEntry) => { + return this.openMenuId === entry.id; + }; + + // each history tile renders its own live viewer iframe — track those + // windows so the per-tile menu can export THAT version's .glb without + // switching the main viewport + historyFrames = new Map(); + + onHistoryFrameLoad = (id: string, event: Event) => { + this.historyFrames.set(id, event.target as HTMLIFrameElement); + }; + + toggleEntryMenu = (id: string) => { + this.openMenuId = this.openMenuId === id ? null : id; + }; + + // the top strip shares the menu-open slot under a fixed sentinel key so it + // toggles independently of the grid cards + get stripMenuOpen() { + return this.openMenuId === '__strip__'; + } + + toggleStripMenu = () => { + this.openMenuId = this.stripMenuOpen ? null : '__strip__'; + }; + + // per-version menu: export that round as .glb from its own live iframe + exportHistoryGlb = async (entry: HistoryEntry) => { + this.openMenuId = null; + try { + let win = this.historyFrames.get(entry.id)?.contentWindow as any; + let buffer = await win?.exportGlb?.(); + if (!buffer) { + this.log('> that version’s viewer isn’t ready yet — try again'); + return; + } + let name = slugify(entry.objectName || 'model', 'model'); + let url = URL.createObjectURL( + new Blob([buffer], { type: 'model/gltf-binary' }), + ); + let a = document.createElement('a'); + a.href = url; + a.download = `${name}-round-${entry.round ?? 1}.glb`; + a.click(); + URL.revokeObjectURL(url); + this.log(`> exported round ${entry.round ?? '?'} .glb ✓`); + } catch (e) { + this.errorMessage = `glb export failed: ${(e as Error).message}`; + } + }; + + // per-version menu: copy an embeddable iframe for that exact round + copyHistoryEmbed = async (entry: HistoryEntry) => { + this.openMenuId = null; + let escaped = entry.srcdoc.replace(/&/g, '&').replace(/"/g, '"'); + let snippet = ``; + try { + await navigator.clipboard.writeText(snippet); + this.log(`> round ${entry.round ?? '?'} iframe copied ✓`); + } catch { + this.log('> could not access clipboard'); + } + }; + + // per-version menu: open that round’s SculptedModel card in a side stack. + // viewCard wants the CARD INSTANCE (realm cards pass the object, not a URL), + // so resolve it from the store first. + openHistoryCard = async (entry: HistoryEntry) => { + this.openMenuId = null; + if (!this.args.viewCard) { + this.log('> open unavailable in this context'); + return; + } + try { + let store = (this.args.context as any)?.store; + let card = store?.get && entry.id ? await store.get(entry.id) : undefined; + if (!card || (card as any).isCardError) { + this.log('> could not load that creation'); + return; + } + this.args.viewCard(card as any, 'isolated', { + openCardInRightMostStack: true, + }); + } catch (e) { + this.log(`> could not open creation: ${(e as Error).message}`); + } + }; + + toggleHistory = () => { + this.historyOpen = !this.historyOpen; + this.openMenuId = null; + if (this.historyOpen) { + this.historyFrames.clear(); + this.loadHistory.perform(); + } + }; + + // catalog Popover fires this on Esc / outside-click + closeHistory = () => { + this.historyOpen = false; + this.openMenuId = null; + }; + + // ---- lasso inpaint (targeted edit) ------------------------------------ + get inpaintMode() { + return this.state.inpaintMode; + } + set inpaintMode(v: boolean) { + this.state.inpaintMode = v; + } + get lassoDrawn() { + return this.state.lassoDrawn; + } + set lassoDrawn(v: boolean) { + this.state.lassoDrawn = v; + } + get inpaintInstruction() { + return this.state.inpaintInstruction; + } + set inpaintInstruction(v: string) { + this.state.inpaintInstruction = v; + } + get inpaintTargetCount() { + return this.state.inpaintTargetCount; + } + set inpaintTargetCount(v: number) { + this.state.inpaintTargetCount = v; + } + get inpaintBusy() { + return this.state.inpaintBusy; + } + set inpaintBusy(v: boolean) { + this.state.inpaintBusy = v; + } + get inpaintUndoAvailable() { + return this.state.inpaintUndoAvailable; + } + set inpaintUndoAvailable(v: boolean) { + this.state.inpaintUndoAvailable = v; + } + // the pre-edit spec to write back on undo (not tracked) + inpaintUndoSpec: any = null; + + revertInpaint = restartableTask(async () => { + let spec = this.inpaintUndoSpec; + if (!spec || !this.args.context?.commandContext) return; + this.inpaintBusy = true; + try { + this.errorMessage = null; + this.activeAnalysis = this.currentAnalysis; + // rewrite the current round's file back to the pre-edit spec in place + await this.applyParsedSpec(JSON.parse(JSON.stringify(spec)), { + inPlace: true, + }); + this.inpaintUndoAvailable = false; + this.inpaintUndoSpec = null; + this.phase = 'done'; + this.log('> reverted lasso edit'); + } catch (e: any) { + this.phase = 'error'; + this.errorMessage = e?.message ?? 'revert failed'; + } finally { + this.inpaintBusy = false; + } + }); + + startRevertInpaint = () => { + this.revertInpaint.perform(); + }; + + // lasso polygon in viewport CSS pixels, the overlay canvas, drawing flag, + // and the raw mesh names the lasso covered (normalized to nodeIds on apply) + lassoPixels: { x: number; y: number }[] = []; + inpaintCanvas: HTMLCanvasElement | undefined; + drawing = false; + inpaintTargets: string[] = []; + + toggleInpaint = () => { + this.inpaintMode = !this.inpaintMode; + this.resetLasso(); + }; + + resetLasso = () => { + this.lassoPixels = []; + this.drawing = false; + this.lassoDrawn = false; + this.inpaintTargetCount = 0; + this.inpaintTargets = []; + this.inpaintInstruction = ''; + this.clearCanvas(); + }; + + clearCanvas = () => { + let c = this.inpaintCanvas; + let ctx = c?.getContext('2d'); + if (c && ctx) ctx.clearRect(0, 0, c.width, c.height); + }; + + drawLasso = (close = false) => { + let c = this.inpaintCanvas; + let ctx = c?.getContext('2d'); + if (!c || !ctx) return; + ctx.clearRect(0, 0, c.width, c.height); + if (this.lassoPixels.length < 2) return; + ctx.beginPath(); + ctx.moveTo(this.lassoPixels[0].x, this.lassoPixels[0].y); + for (let p of this.lassoPixels.slice(1)) ctx.lineTo(p.x, p.y); + if (close) ctx.closePath(); + ctx.lineWidth = 2; + ctx.strokeStyle = '#38e8ff'; + if (close) { + ctx.fillStyle = 'rgba(56, 232, 255, 0.12)'; + ctx.fill(); + } + ctx.stroke(); + }; + + // `{{on}}` types its handler as (event: Event), so these take the base + // type and narrow — the canvas only ever receives pointer events. + lassoDown = (event: Event) => { + let e = event as PointerEvent; + if (!this.inpaintMode || this.inpaintBusy) return; + let canvas = e.currentTarget as HTMLCanvasElement; + this.inpaintCanvas = canvas; + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + this.drawing = true; + this.lassoDrawn = false; + this.lassoPixels = [{ x: e.offsetX, y: e.offsetY }]; + try { + canvas.setPointerCapture(e.pointerId); + } catch { + // capture is best-effort + } + this.drawLasso(); + }; + + lassoMove = (event: Event) => { + let e = event as PointerEvent; + if (!this.drawing) return; + this.lassoPixels.push({ x: e.offsetX, y: e.offsetY }); + this.drawLasso(); + }; + + lassoUp = () => { + if (!this.drawing) return; + this.drawing = false; + if (this.lassoPixels.length < 3) { + this.resetLasso(); + return; + } + this.lassoDrawn = true; + this.drawLasso(true); + // raw mesh names under the lasso — normalized to real nodeIds on apply + let names: string[] = + this.frameWindow?.pickInRegion?.(this.lassoPixels) ?? []; + this.inpaintTargets = names; + this.inpaintTargetCount = names.length; + this.log(`> lasso covered ${names.length} part(s)`); + }; + + setInstruction = (e: Event) => { + this.inpaintInstruction = (e.target as HTMLInputElement).value; + }; + + startInpaint = () => { + this.applyInpaint.perform(); + }; + + applyInpaint = restartableTask(async () => { + if (this.inpaintBusy) return; + let model = this.args.model; + let commandContext = this.args.context?.commandContext; + if (!model || !commandContext) return; + let instruction = this.inpaintInstruction.trim(); + if (!instruction) { + this.log('> type an edit instruction first'); + return; + } + // no lasso is a valid edit: the instruction names the part instead. The + // request already says "No parts were pre-selected — resolve the targets + // from the instruction", TARGETED_EDIT_PROMPT has a section for it, and + // the bar reads "all parts" when nothing is drawn. This guard was the one + // layer that disagreed, and it made naming a part impossible — the quicker + // route of the two. + this.inpaintBusy = true; + try { + this.errorMessage = null; + // make sure the source spec is in memory (read it back like refine does) + if (!this.workingSpec?.components?.length) { + let url = this.currentCodeFileUrl; + if (url) { + let resp = await fetch(url, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (resp.ok) this.workingSpec = specFromModelJs(await resp.text()); + } + } + if (!this.workingSpec?.components?.length) { + this.log('> could not load the model spec'); + return; + } + // snapshot the pre-edit spec so the in-place edit can be reverted + let undoSpec = JSON.parse(JSON.stringify(this.workingSpec)); + // the run needs the selected creation's analysis as its measured target + this.activeAnalysis = this.currentAnalysis; + // normalize lasso mesh names to real spec nodeIds (repeat clones carry + // a '-' suffix — fold them back to their base component) + let ids = new Set( + this.workingSpec.components.map((c: any) => String(c.nodeId)), + ); + let targets: string[] = []; + for (let n of this.inpaintTargets) { + let s = String(n); + if (ids.has(s)) targets.push(s); + else { + let base = s.replace(/-\d+$/, ''); + if (ids.has(base)) targets.push(base); + } + } + targets = [...new Set(targets)]; + // An empty selection is a legitimate way to work: describing the part is + // often easier than orbiting until it is visible and lassoing it, and the + // spec's ids are semantic enough for the model to resolve them. Only a + // lasso that WAS drawn and matched nothing is a real miss. + if (!targets.length && this.lassoDrawn) { + this.log('> selection did not map to any part'); + return; + } + this.log( + targets.length + ? `> edit: ${targets.length} selected part(s) — “${instruction}”` + : `> edit by description — “${instruction}”`, + ); + + // the deterministic delete needs to know WHAT to delete, so it only + // applies to an explicit selection; described removals go to the model + if (targets.length && isRemovalInstruction(instruction)) { + // the lasso reports every mesh under it, so a sticker selection also + // catches the blade behind it. When the instruction names what to + // remove, that noun narrows the selection so "remove sticker" cannot + // take the blade with it. + let removeTargets = narrowRemovalTargets( + this.workingSpec.components, + targets, + instruction, + ); + if (removeTargets.length < targets.length) { + this.log( + `> narrowed removal to ${removeTargets.length} part(s) the instruction names`, + ); + } + // deterministic removal — no LLM needed + let spec = JSON.parse(JSON.stringify(this.workingSpec)); + let dead = new Set(removeTargets.map(String)); + let changed = true; + while (changed) { + changed = false; + for (let c of spec.components) { + if ( + c.parentId != null && + dead.has(String(c.parentId)) && + !dead.has(String(c.nodeId)) + ) { + dead.add(String(c.nodeId)); + changed = true; + } + } + } + spec.components = spec.components.filter( + (c: any) => !dead.has(String(c.nodeId)), + ); + this.phase = 'building'; + await this.applyParsedSpec(spec, { inPlace: true }); + this.log(`> removed ${dead.size} part(s) ✓`); + } else { + this.phase = 'analyzing'; + this.log( + targets.length + ? '> comparing selected part(s) against the reference…' + : '> resolving which parts the instruction means…', + ); + let promptText = + `SELECTED nodeIds: ${JSON.stringify(targets)}\n` + + (targets.length + ? '' + : 'No parts were pre-selected — resolve the targets from the instruction and the spec below.\n') + + `INSTRUCTION: ${instruction}\n\nCURRENT SPEC:\n${JSON.stringify( + serializeSpecForPrompt(this.workingSpec), + )}`; + let content: any[] = [{ type: 'text', text: promptText }]; + // build the reference-vs-render comparison sheet so the model can + // diagnose the selected parts visually (placement / proportion / + // material / color) exactly like the refine pass does + let flat = this.workingSpec?.inputKind === 'flat-graphic'; + let renders = flat + ? [this.frameWindow?.captureScreenshot?.()].filter(Boolean) + : ( + this.frameWindow?.captureViews?.(this.activeAnalysis?.camera) ?? + [] + ).map((v: any) => v?.dataUrl ?? v); + let referenceDataUrl = await this.encodeReference(); + if (referenceDataUrl && renders.length) { + let comparison = await composeComparison( + referenceDataUrl, + renders as string[], + { + firstIsReferenceAngle: + Boolean(this.activeAnalysis?.camera) && !flat, + }, + ); + content.push({ type: 'image_url', image_url: { url: comparison } }); + } + let diff = await this.visionRequest( + TARGETED_EDIT_PROMPT, + content, + parseDiffJson, + ); + // a person looking at the result and asking for a change outranks the + // plan: they may resize a part and add one the build left out. The + // automatic refine pass gets none of these — see applySpecDiff. + let merged = applySpecDiff(this.workingSpec, diff, { + allowRemoval: true, + allowReshape: true, + allowAdditions: true, + }); + let addedCount = (diff.added ?? []).length; + if (addedCount) { + this.log(`> added ${addedCount} new part(s)`); + } + await this.applyParsedSpec(merged, { inPlace: true }); + this.log('> inpaint applied ✓'); + } + this.phase = 'done'; + // enable one-click revert (re-apply the pre-edit spec in place) + this.inpaintUndoSpec = undoSpec; + this.inpaintUndoAvailable = true; + this.inpaintMode = false; + this.resetLasso(); + } catch (e: any) { + this.phase = 'error'; + this.errorMessage = e?.message ?? 'inpaint failed'; + } finally { + this.inpaintBusy = false; + } + }); + + // walk the parentCreation chain back from the latest saved round so the + // popover lists every generation newest-first, without a search query + // EVERY round this studio produced, not just the chain hanging off + // latestCreation. Each SculptedModel is stamped with sourceStudioId when it + // is saved — the field exists for exactly this — so a query finds rounds the + // backwards walk cannot: anything newer than a stale latestCreation pointer, + // and any branch created by generating again from an older round. + get historyQuery() { + let id = this.args.model?.id; + if (!id) return undefined; + return { + filter: { + on: sculptedModelRef, + eq: { 'sourceStudio.id': id }, + }, + sort: [{ on: sculptedModelRef, by: 'createdAt', direction: 'desc' }], + }; + } + + get historyRealms() { + let href = (this.args.model as any)?.[realmURL]?.href; + return href ? [href] : []; + } + + historyCards = (this.args.context as any)?.getCards?.( + this, + () => this.historyQuery, + () => this.historyRealms, + { isLive: true }, + ); + + loadHistory = restartableTask(async () => { + this.historyLoading = true; + try { + let store = (this.args.context as any)?.store; + + // the live getCards query resolves asynchronously; on a fresh page load + // it is still running when the popover first opens. Reading `.instances` + // now would see an empty set and fall through to the (possibly stale) + // parentCreation walk — so wait for the query to settle first, bounded + // by a timeout in case the realm can't answer it. + let started = Date.now(); + while (this.historyCards?.isLoading && Date.now() - started < 10000) { + await new Promise((r) => setTimeout(r, 150)); + } + + // preferred: the studio-scoped query + // MERGE both sources rather than one-or-the-other: the live index query + // can lag (a just-generated round is not indexed yet) or miss rounds that + // predate sourceStudioId, while the in-memory parentCreation walk catches + // exactly those but not branches the walk can't reach. Union + dedup so a + // round shows if EITHER source knows about it. + let byKey = new Map(); + let add = (card: any) => { + let codeFileUrl = card?.codeFile?.url; + if (!codeFileUrl) return; + let key = card.id || codeFileUrl; + if (byKey.has(key)) return; + byKey.set(key, { + id: card.id, + codeFileUrl, + objectName: card.objectName || 'model', + round: card.round ?? undefined, + screenshotUrl: card.renderScreenshot?.url, + // filled in below with the source INLINED (see buildInlineSrcdoc) so + // each history tile's iframe needs no authed fetch on a private realm + srcdoc: '', + }); + }; + + // source 1: the studio-scoped index query + for (let card of this.historyCards?.instances ?? []) add(card); + + // source 2: walk parentCreation back from the newest in-memory rounds + // (latest AND the currently selected), which are present before the index + // catches up — this is what makes a freshly-made round appear immediately + for (let seed of [ + this.args.model?.latestCreation, + this.args.model?.selectedCreation, + ]) { + let node: any = seed; + let guard = 0; + while (node && guard++ < 100) { + let card: any = node; + if (store?.get && card.id) { + let got = await store.get(card.id); + if (got && !(got as any).isCardError) card = got; + } + add(card); + node = card?.parentCreation; + } + } + + // inline each tile's source (authenticated fetch by the studio) so the + // tiles render on a private realm; fall back to the URL viewer per-entry. + let entries = [...byKey.values()]; + await Promise.all( + entries.map(async (e) => { + e.srcdoc = + (await this.buildInlineSrcdoc(e.codeFileUrl)) ?? + generateViewerSrcdoc(e.codeFileUrl); + }), + ); + // newest first by ROUND — createdAt is only minute-granular, so + // same-minute rounds would shuffle if sorted by time + this.historyItems = entries.sort( + (a, b) => (b.round ?? 0) - (a.round ?? 0), + ); + } finally { + this.historyLoading = false; + } + }); + + // persist-select: point selectedCreation at the picked round's Sculpted + // model card. Everything shown in the studio (viewport, name, analysis, + // file link) is read from selectedCreation, so this one assignment + // re-attaches that round's details wholesale. latestCreation is untouched, + // so the history list still walks from the newest round. + selectHistoryEntry = async (entry: HistoryEntry) => { + let model = this.args.model; + if (!model) return; + this.draftViewerSrcdoc = undefined; + // ALWAYS switch the viewport to the picked round's file first, from the + // entry's own codeFileUrl — so the click is never a silent no-op even when + // the store can't resolve the card yet (a just-made / not-yet-indexed + // round). Resolving selectedCreation below then re-attaches its full detail. + this.pendingViewportUrl = entry.codeFileUrl; + this.workingSpec = null; + this.activeAnalysis = null; + this.inpaintUndoAvailable = false; + this.inpaintUndoSpec = null; + this.inPlaceReloadKey = 0; + this.historyOpen = false; + this.log(`> selected round ${entry.round ?? '?'} as current`); + + let store = (this.args.context as any)?.store; + let card = store?.get && entry.id ? await store.get(entry.id) : undefined; + if (card && !(card as any).isCardError) { + model.selectedCreation = card as SculptedModel; + // the creation now drives the viewport; retire the transient pointer + this.pendingViewportUrl = undefined; + } + await this.waitForViewer(entry.codeFileUrl); + }; + + // every sculpture is its own studio card — starting a new one opens a + // fresh instance in the stack instead of resetting this card's project + newSculpture = () => { + let realm = (this.args.model as any)?.[realmURL]; + this.args.createCard?.(studioRef, undefined, { + realmURL: realm, + cardModeAfterCreation: 'isolated', + }); + }; + + aiRefineLaunching = false; + + // open the AI Assistant on the current round with the refine skill attached, + // so the assistant can SEE this model's reference + render, diagnose the + // differences conversationally, and (on approval) call the Refine Model + // command to apply each change as a new round. One room per studio. + startRefineWithAi = async () => { + let commandContext = this.args.context?.commandContext; + let creation = this.currentCreation; + if (this.aiRefineLaunching || !commandContext || !creation?.id) return; + this.aiRefineLaunching = true; + try { + let skillCardId = new URL('../Skill/refine-sculpt-skill', here).href; + await new UseAiAssistantCommand(commandContext).execute({ + roomName: `Refine ${this.currentObjectName || 'model'}`, + // force a FRESH room each time — without this the command reuses + // whatever assistant room is currently open, carrying over unrelated + // context; 'new' makes it always create one + roomId: 'new', + openRoom: true, + // non-Anthropic on purpose — an Anthropic model here trips the ai-bot's + // inline-system-message ordering against the tightened Anthropic API + llmModel: ASSISTANT_MODEL, + // 'ask' so the assistant PROPOSES each Refine Model command and the + // user approves it before the model changes (paired with the command's + // requiresApproval: true in the skill). + llmMode: 'ask', + skillCardIds: [skillCardId], + attachedCardIds: [creation.id], + openCardIds: [creation.id], + // a natural opener shown as the user's own message — the diagnostic + // behaviour lives in the skill instructions, not here + prompt: `Let's refine this model against its reference — what looks off?`, + } as any); + } catch (e: any) { + this.errorMessage = e?.message ?? 'could not open the AI assistant'; + } finally { + this.aiRefineLaunching = false; + } + }; + + // embedding the model anywhere = an iframe whose srcdoc carries the + // viewer harness with this model's .js URL baked in. srcdoc (instead of + // a viewer-page URL) keeps the whole feature realm-content-only — no + // special text/html routing on the realm server is involved. + copyEmbed = async () => { + let srcdoc = this.viewerSrcdoc; + if (!srcdoc) return; + let escaped = srcdoc.replace(/&/g, '&').replace(/"/g, '"'); + let snippet = ``; + try { + await navigator.clipboard.writeText(snippet); + this.log('> iframe embed copied ✓'); + } catch { + this.log('> could not access clipboard'); + } + }; + + // Editable Output: alongside the always-editable model code, the build + // exports as a standard binary glTF for any DCC tool or engine + downloadGlb = async () => { + try { + let buffer = await this.frameWindow?.exportGlb?.(); + if (!buffer) return; + let name = slugify(this.currentObjectName || 'model', 'model'); + let url = URL.createObjectURL( + new Blob([buffer], { type: 'model/gltf-binary' }), + ); + let a = document.createElement('a'); + a.href = url; + a.download = `${name}.glb`; + a.click(); + URL.revokeObjectURL(url); + this.log('> exported .glb ✓'); + } catch (e) { + this.errorMessage = `glb export failed: ${(e as Error).message}`; + } + }; + + log(line: string) { + this.logLines = [...this.logLines.slice(-5), line]; + } + + // Compact the previous build to its STRUCTURAL identity — the fields that + // define the part graph (ids, parentage, primitive, attachment, anchor, + // grounding, repeat) — and wrap it in an instruction that forbids adding, + // removing, renaming, reparenting or retyping any component. Sizes, + // positions, scale and materials stay free so the re-roll still corrects + // proportions against the photo. This is what stops the same-image + // regenerate from decomposing into a different part set. + buildStructureLock(spec: any): string { + let skeleton = (spec.components ?? []).map((c: any) => ({ + nodeId: c.nodeId, + parentId: c.parentId ?? null, + primitive: c.primitive, + ...(c.attachTo !== undefined ? { attachTo: c.attachTo } : {}), + ...(c.anchor !== undefined ? { anchor: c.anchor } : {}), + ...(c.grounded !== undefined ? { grounded: c.grounded } : {}), + ...(c.repeat !== undefined ? { repeat: c.repeat } : {}), + ...(c.materialId !== undefined ? { materialId: c.materialId } : {}), + ...(c.textureRef !== undefined ? { textureRef: c.textureRef } : {}), + })); + return ( + `\n\nSTRUCTURE LOCK (this is a re-generation of the SAME object — a ` + + `previous build already exists). Reuse EXACTLY this component graph: ` + + `keep every nodeId, parentId, primitive type, attachTo, anchor, ` + + `grounded and repeat unchanged, and keep the SAME number of ` + + `components. Do NOT add, remove, rename, reparent, or change the ` + + `primitive of any component. Only adjust dimensions, position, ` + + `rotation, scale and material colors to match the photo more ` + + `closely.\nEXISTING STRUCTURE:\n${JSON.stringify(skeleton)}` + ); + } + + generate = restartableTask(async () => { + let model = this.args.model; + if (!model || !this.args.context?.commandContext) return; + if (!model.references?.primaryUrl) { + this.phase = 'error'; + this.errorMessage = 'Add a reference photo first.'; + return; + } + try { + this.errorMessage = null; + this.logLines = []; + // per-stage wall-clock: the LLM+render round-trips are the whole cost + // of a generate, so time each one — you cannot decide which stage to + // cut without seeing where the minute actually goes. + let genStart = Date.now(); + let secSince = (t: number) => ((Date.now() - t) / 1000).toFixed(1); + // the log panel only keeps the last ~6 lines, so per-stage timings + // scroll off before the run ends. Collect them and print one surviving + // summary line at the end (also mirrored to the browser console). + let timings: string[] = []; + this.phase = 'analyzing'; + this.log('> probing reference image…'); + let referenceDataUrl = await this.encodeReference(); + + let allViews = await this.encodeAllReferences(); + if (allViews.length > 1) { + this.log(`> using ${allViews.length} reference views…`); + } + // the payload is the request's exposure to a dropped connection, so it + // is worth seeing: base64 carries a third more than the bytes it encodes + let payloadMb = + allViews.reduce((sum, url) => sum + url.length, 0) / 1024 / 1024; + this.log(`> reference payload ${payloadMb.toFixed(1)} MB`); + let imageParts = allViews.map((url) => ({ + type: 'image_url', + image_url: { url }, + })); + + // stage 1 — analysis. It is the GATE (classification, per-part bboxes, + // camera, revolved flags) that everything downstream keys off, so + // re-rolling it every Generate makes the whole result swing. Reuse the + // cached analysis when the reference set is unchanged: clicking Generate + // again then only re-rolls the BUILD (spec), keeping results stable. A + // changed reference (different signature) forces a fresh analysis. + let refUrls = (model.references?.resolvedUrls ?? []).filter(Boolean); + let refSig = refUrls.join('|'); + let tAnalyze = Date.now(); + let analysis: any = null; + let sameReference = false; + // the cached analysis now lives on the selected creation, not the + // studio — read it back from there when the reference is unchanged. + // A pending Re-analyze request overrides the cache entirely: the user + // has judged the PLAN wrong, and a wrong plan reproduces its mistakes + // through every re-generate no matter how the build re-rolls. + let prev = this.currentAnalysis; + if ( + !this.reanalyzeRequested && + prev && + prev._refSig === refSig && + Array.isArray(prev.partPlan) && + prev.partPlan.length + ) { + analysis = prev; + sameReference = true; + } + if (analysis) { + this.log( + '> reusing cached analysis (same reference) — re-rolling the build only', + ); + } else { + if (this.reanalyzeRequested) { + this.log('> re-analyze requested — discarding the cached plan'); + } + this.log('> analyzing object & planning parts…'); + let analysisResult = await new AnalyzeReferenceCommand( + this.args.context!.commandContext!, + ).execute({ + imageUrls: refUrls, + model: ANALYSIS_MODEL, + maxEdge: ANALYZE_MAX_EDGE, + } as any); + analysis = JSON.parse(analysisResult.analysisJson); + // stamp the reference signature so a later Generate can tell the + // analysis still matches the current photo(s). It is persisted onto + // the SculptedModel this run produces (not the studio card). + analysis._refSig = refSig; + } + // the request is consumed either way: the fresh analysis is now the + // cache, and sameReference stayed false so no structure lock will + // resurrect the old plan's part graph + this.reanalyzeRequested = false; + // the run owns this analysis until it is stamped onto the saved + // creation; applyParsedSpec / the save block read it from here + this.activeAnalysis = analysis; + this.log( + `> plan: ${analysis.objectType ?? 'object'} · ${ + analysis.partPlan.length + } parts · ${analysis.buildRecipe.length} recipe notes`, + ); + if (analysis.camera) { + this.log( + `> camera: ${analysis.camera.azimuthDeg}° az / ${analysis.camera.elevationDeg}° el`, + ); + } + // backend recommendation: warn when the analysis judged the primitive + // vocabulary can only approximate this object (a firearm, a face). The + // mesh backend is not wired yet, so this is an honest heads-up, not a + // route — the build still runs on primitives below. + if (analysis.buildBackend === 'mesh') { + this.log( + `> ⚑ mesh recommended — primitive can only approximate this${ + analysis.backendReason ? ` (${analysis.backendReason})` : '' + }`, + ); + } + timings.push( + `analyze ${secSince(tAnalyze)}s${sameReference ? ' (cached)' : ''}`, + ); + this.log(`> ⏱ ${timings[timings.length - 1]}`); + + // a stop request lands at the next stage boundary — the in-flight + // vision call cannot be recalled, but the NEXT one can be skipped + if (this.stopRequested) { + this.log('> stopped before building'); + this.phase = 'idle'; + return; + } + + // structure lock: on a same-reference re-generation, hand the LLM the + // PREVIOUS build's component graph and forbid structural changes, so + // the re-roll only varies sizes/materials instead of inventing a + // different (often decomposed) part set each time + let structureLock = ''; + if (sameReference) { + let prevSpec = this.workingSpec; + let prevUrl = this.currentCodeFileUrl; + if (!prevSpec?.components?.length && prevUrl) { + try { + let response = await fetch(prevUrl, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (response.ok) { + prevSpec = specFromModelJs(await response.text()); + } + } catch { + prevSpec = null; + } + } + if (prevSpec?.components?.length) { + // the lock freezes the previous graph, so any part that graph should + // never have contained would be reproduced forever — a re-generate + // would keep handing back the same mould seams no matter what the + // rules now say. Clean the graph BEFORE locking to it, so the lock + // preserves the structure that survived review rather than the + // structure that happened to be authored first. + for (let line of dropHairlineParts(prevSpec)) { + this.log(`> structure lock: ${line}`); + } + for (let line of dropUnplannedParts(prevSpec, analysis)) { + this.log(`> structure lock: ${line}`); + } + structureLock = this.buildStructureLock(prevSpec); + this.log( + `> structure lock: reusing ${prevSpec.components.length}-part graph (only sizes/materials re-roll)`, + ); + } + } + + // stage 2 — build the spec, honoring the stage-1 plan + // the header status otherwise stays on "ANALYZING" through the entire + // build/render/completeness stretch, making a slow build look like a + // hung analysis — move it to "BUILDING" now that analysis is done. + this.phase = 'building'; + this.log('> authoring sculpt spec…'); + let tBuild = Date.now(); + // the plan's own length IS the part budget. The system prompt can only + // say "follow the plan" in the abstract; stating the number here — plus + // the fact that unplanned parts are deleted rather than rendered — + // removes any incentive to pad the spec toward a quota. + let plannedCount = analysis.partPlan.length; + let partBudget = `\n\nPART BUDGET: the plan lists ${plannedCount} part${ + plannedCount === 1 ? '' : 's' + }, so author roughly ${plannedCount}-${ + plannedCount * 2 + } components (groups and the one ground shadow do not count). Every planned part must appear. Nothing else may: a component whose partRef is not one of the ${plannedCount} planned names is DELETED before the model is built, so inventing extra parts only throws away your own work.`; + // the invariant contract, plus only the build directives this object's + // own plan calls for + let specSystemPrompt = buildSpecSystemPrompt(analysis); + if (specSystemPrompt.length > 1) { + this.log( + `> directives: ${selectRecipeNames(analysis).join(', ') || 'none'}`, + ); + } + let parsed = await this.visionRequest( + specSystemPrompt, + [ + { + type: 'text', + text: + (allViews.length > 1 + ? `Rebuild the object shown in these ${allViews.length} views of the SAME object as a procedural sculpt spec. Reconcile all views — side/orthographic views define thickness and depth.` + : 'Rebuild the object in this photo as a procedural sculpt spec.') + + `\n\nANALYSIS (follow this plan):\n${JSON.stringify(analysis)}` + + partBudget + + structureLock, + }, + ...imageParts, + ], + undefined, + // a reply that leaves out a planned part is re-asked for once, naming the + // parts it skipped. This is the difference between logging that a bottle's + // label is missing and actually getting the label. + (candidate: any) => { + let missing = flagUnrealizedParts(candidate, analysis).filter( + (line) => line.includes('NO component realizes'), + ); + if (!missing.length) return null; + let names = missing + .map((line) => line.match(/^'([^']+)'/)?.[1]) + .filter(Boolean); + return ( + `Your previous reply left these planned parts out of "components" entirely: ${names.join(', ')}. ` + + `Every partPlan entry must be realized by at least one component whose "partRef" is that part's exact name. ` + + `Send the WHOLE JSON object again with those parts included, built the way their "approach" says.` + ); + }, + ); + timings.push(`build ${secSince(tBuild)}s`); + this.log(`> ⏱ ${timings[timings.length - 1]}`); + // the analysis owns identity — its features gate the refine rounds + if (analysis.identityFeatures.length) { + parsed.identityFeatures = analysis.identityFeatures; + } + // the analysis also owns the backend recommendation — carry it onto the + // spec so it persists on the saved model (provenance for a future mesh + // route). The build LLM does not emit it. + if (analysis.buildBackend) { + parsed.buildBackend = analysis.buildBackend; + } + // the analysis also owns the part INVENTORY: anything the build stage + // invented on top of the plan goes now, before tracing / clamping / + // texturing spend work on it. Only the generate path is gated — a lasso + // edit is allowed to add parts the plan never mentioned, because there + // the user asked for them. + // the full deterministic structure/geometry repair chain, in its one + // documented order (see util/spec-passes/run-all.gts) — drop unplanned + // parts, enforce joints, guarantee + place the face, unbury, flag the + // rest. Kept as one call so the ordering lives (and is tested) in one place. + for (let line of runStructurePasses(parsed, analysis)) { + this.log(`> ${line}`); + } + let tRender = Date.now(); + let outcome = await this.applyParsedSpec(parsed); + timings.push(`render ${secSince(tRender)}s`); + this.log(`> ⏱ ${timings[timings.length - 1]}`); + + // completeness audit: one vision pass that ADDS whatever the reference + // shows and this build lacks (a missing eye, wheel, handle, limb). It is + // a whole extra vision+render round-trip, so gate it: when the build + // already realized every planned part there is nothing for it to add, and + // it is skipped. A user can force it back on (forceCompleteness) for a + // complex object whose plan may itself have under-described the photo. + let unrealized = flagUnrealizedParts(parsed, analysis).filter((line) => + line.includes('NO component realizes'), + ); + if ( + !this.stopRequested && + (this.forceCompleteness || unrealized.length) + ) { + if (this.forceCompleteness) { + this.log('> completeness: forced on'); + } else { + this.log( + `> completeness: ${unrealized.length} planned part(s) unrealized — auditing`, + ); + } + let tComplete = Date.now(); + let completed = await this.runCompletenessPass(referenceDataUrl); + if (completed) outcome = completed; + timings.push(`completeness ${secSince(tComplete)}s`); + this.log(`> ⏱ ${timings[timings.length - 1]}`); + } else if (!this.stopRequested) { + this.log('> completeness: skipped (all planned parts realized)'); + timings.push('completeness skipped'); + } + + let best = { + score: outcome.score ?? -1, + spec: this.workingSpec, + codeFileUrl: this.currentCodeFileUrl, + creation: this.currentCreation, + }; + + for (let round = 0; round < AUTO_REFINE_ROUNDS; round++) { + // user asked to stop — keep what the finished rounds produced + if (this.stopRequested) { + this.log('> refinement stopped'); + break; + } + // per-feature gate: early stop needs a good score AND every + // identity feature passing + if ( + typeof outcome.score === 'number' && + outcome.score >= REFINE_TARGET_SCORE && + outcome.featuresOk + ) { + break; + } + let started = Date.now(); + outcome = await this.runRefinePass(referenceDataUrl, round + 1); + this.log( + `> refine ${round + 1} done (${Math.round( + (Date.now() - started) / 1000, + )}s)`, + ); + if ( + typeof outcome.score === 'number' && + outcome.score > best.score && + outcome.featuresOk + ) { + best = { + score: outcome.score, + spec: this.workingSpec, + codeFileUrl: this.currentCodeFileUrl, + creation: this.currentCreation, + }; + } + } + + // If deterministic calibration still cannot meet the measured target, + // let the vision pass correct placement/color, then measure again. + // The best-round restoration below prevents a lower-scoring result + // from replacing the stronger saved model. + let autoVerifyRounds = 0; + while ( + !this.stopRequested && + autoVerifyRounds < AUTO_VERIFY_ROUNDS && + typeof this.lastCalibrationResidual === 'number' && + this.lastCalibrationResidual > 0.12 + ) { + autoVerifyRounds++; + this.log( + `> auto-verify: residual ${(this.lastCalibrationResidual * 100).toFixed(0)}% — refining (${autoVerifyRounds}/${AUTO_VERIFY_ROUNDS})…`, + ); + outcome = await this.runRefinePass(referenceDataUrl, autoVerifyRounds); + if ( + typeof outcome.score === 'number' && + outcome.score > best.score && + outcome.featuresOk + ) { + best = { + score: outcome.score, + spec: this.workingSpec, + codeFileUrl: this.currentCodeFileUrl, + creation: this.currentCreation, + }; + } + } + + // refine rounds can regress — end on the best-scoring round, not + // merely the last one (its model file already exists; just point the + // viewport back at it) + if ( + best.codeFileUrl && + (!outcome.featuresOk || + (typeof outcome.score === 'number' && best.score > outcome.score)) + ) { + this.pendingViewportUrl = undefined; + if (best.creation) { + this.args.model!.selectedCreation = best.creation; + this.args.model!.latestCreation = best.creation; + } + this.workingSpec = best.spec; + this.log(`> restored best round (score ${best.score})`); + } + + this.phase = 'done'; + timings.push(`total ${secSince(genStart)}s`); + let timingSummary = `⏱ ${timings.join(' · ')}`; + // one line that survives the last-6 log window, plus a console copy so + // the full per-stage breakdown persists for inspection after the run. + this.log(`> ${timingSummary}`); + console.log(`[img-to-3d] ${timingSummary}`); + this.log('> rebuilt in code ✓'); + } catch (e: any) { + this.draftViewerSrcdoc = undefined; + if (this.stopRequested) { + this.phase = 'idle'; + this.log('> stopped'); + return; + } + this.phase = 'error'; + this.errorMessage = e?.message ?? 'generation failed'; + } + }); + + // one render-vs-reference correction round: screenshot the current build, + // pack it beside the reference, and ask the model for a corrected spec + async runRefinePass( + referenceDataUrl: string, + round: number, + ): Promise<{ score: number | undefined; featuresOk: boolean }> { + let flat = this.workingSpec?.inputKind === 'flat-graphic'; + let analysis: any = this.currentAnalysis; + let renders = flat + ? [this.frameWindow?.captureScreenshot?.()].filter(Boolean) + : (this.frameWindow?.captureViews?.(analysis?.camera) ?? []).map( + (v: any) => v?.dataUrl ?? v, + ); + if (!renders.length) return { score: undefined, featuresOk: true }; + this.phase = 'analyzing'; + this.log( + `> refine ${round}: comparing ${renders.length} view(s) vs reference…`, + ); + let comparison = await composeComparison( + referenceDataUrl, + renders as string[], + { firstIsReferenceAngle: Boolean(analysis?.camera) && !flat }, + ); + let currentSpecJson = JSON.stringify( + serializeSpecForPrompt(this.workingSpec), + ); + let assemblyFacts = this.lastBuildWarnings.length + ? `\n\nMachine-checked assembly problems in the current build (fix these FIRST — move parts so they overlap their neighbours by 0.02-0.05):\n- ${this.lastBuildWarnings.join('\n- ')}` + : ''; + // measured targets from stage-1: per-part reference bboxes + + // attachment constraints the render must satisfy numerically + let measuredTargets = + analysis?.partPlan?.length || analysis?.attachments?.length + ? `\n\nMEASURED TARGETS (from the reference analysis — check proportions and joints against these):\n${JSON.stringify( + { + parts: (analysis.partPlan ?? []).map((p: any) => ({ + part: p.part, + bbox: p.bbox, + })), + attachments: analysis.attachments ?? [], + }, + )}` + : ''; + let firstPaneNote = analysis?.camera + ? ' The first render pane is captured from the estimated reference camera angle — compare it to the reference pane like-for-like.' + : ''; + let diff = await this.visionRequest( + REFINE_SYSTEM_PROMPT, + [ + { + type: 'text', + text: `Current spec JSON:\n${currentSpecJson}\n\nLEFT = reference photo, RIGHT = current render.${firstPaneNote}${assemblyFacts}${measuredTargets}\n\nOutput the minimal change set.`, + }, + { type: 'image_url', image_url: { url: comparison } }, + ], + parseDiffJson, + ); + + // nothing to change and every feature passing → keep this round free + let noChanges = + diff.changed.length === 0 && diff.materialsChanged.length === 0; + let failed = Object.entries(diff.featureCheck ?? {}).filter( + ([, v]) => String(v).toLowerCase() === 'fail', + ); + if (noChanges && failed.length === 0) { + this.log('> no changes needed'); + return { + score: typeof diff.score === 'number' ? diff.score : undefined, + featuresOk: true, + }; + } + this.log( + `> applying ${diff.changed.length} placement change(s), ${diff.materialsChanged.length} material change(s)…`, + ); + let merged = applySpecDiff(this.workingSpec, diff); + return await this.applyParsedSpec(merged); + } + + // one completeness audit: compare the render to the reference and ADD the + // parts the build left out. This is the category-agnostic counterpart to the + // hardcoded face backstop — it catches a missing eye, wheel, handle or limb + // by looking at the two images, not by knowing what the object is. Unlike + // runRefinePass it allows additions (but never reshape/removal), so an + // omitted part can come back. Returns the build outcome, or the unchanged + // one when nothing was missing. + async runCompletenessPass( + referenceDataUrl: string, + ): Promise<{ score: number | undefined; featuresOk: boolean } | undefined> { + let flat = this.workingSpec?.inputKind === 'flat-graphic'; + let analysis: any = this.currentAnalysis; + let renders = flat + ? [this.frameWindow?.captureScreenshot?.()].filter(Boolean) + : (this.frameWindow?.captureViews?.(analysis?.camera) ?? []).map( + (v: any) => v?.dataUrl ?? v, + ); + if (!renders.length) return undefined; + this.phase = 'analyzing'; + this.log( + '> completeness check: what does the reference show that this build lacks?', + ); + let comparison = await composeComparison( + referenceDataUrl, + renders as string[], + { + firstIsReferenceAngle: Boolean(analysis?.camera) && !flat, + }, + ); + let currentSpecJson = JSON.stringify( + serializeSpecForPrompt(this.workingSpec), + ); + let diff = await this.visionRequest( + COMPLETENESS_CRITIC_PROMPT, + [ + { + type: 'text', + text: `Current spec JSON:\n${currentSpecJson}\n\nLEFT = reference photo, RIGHT = current render. Add every part the reference shows that the render is missing.`, + }, + { type: 'image_url', image_url: { url: comparison } }, + ], + parseDiffJson, + ); + let addedCount = (diff.added ?? []).length; + let changedCount = (diff.changed ?? []).length; + if (!addedCount && !changedCount) { + this.log('> completeness check: nothing missing'); + return undefined; + } + this.log( + `> completeness: added ${addedCount} missing part(s), nudged ${changedCount}`, + ); + // additions + placement only — the critic must not delete or reshape the + // build it is completing + let merged = applySpecDiff(this.workingSpec, diff, { + allowAdditions: true, + }); + return await this.applyParsedSpec(merged); + } + + async encodeReference(): Promise { + let url = this.args.model?.references?.primaryUrl; + if (!url) throw new Error('reference image is missing'); + return fetchAsDataUrl(url, { + commandContext: this.args.context?.commandContext, + }); + } + + async encodeAllReferences(): Promise { + let urls = (this.args.model?.references?.resolvedUrls ?? []).filter( + Boolean, + ); + return Promise.all( + urls.slice(0, 6).map((u: string, i: number) => + fetchAsDataUrl(u, { + // primary view keeps full detail (labels/artwork are cropped from + // it); the extra views only inform depth, so shrink them to cut the + // build stage's input vision tokens. + maxEdge: i === 0 ? undefined : BUILD_SECONDARY_EDGE, + commandContext: this.args.context?.commandContext, + }), + ), + ); + } + + // one seed per reference set, shared by every vision stage this studio + // runs (spec build, refine diff, targeted edit). A new photo set gets a + // new seed; the same photos re-run the same sampling path. + get referenceSeed(): number { + return seedFromStrings( + (this.args.model?.references?.resolvedUrls ?? []).filter(Boolean), + ); + } + + async visionRequest( + systemPrompt: string | string[], + userContent: any[], + parser?: (raw: string) => any, + validate?: (parsed: any) => string | null, + ) { + return requestSpec( + this.args.context!.commandContext!, + this.args.model?.llmModel || VISION_MODEL, + systemPrompt, + userContent, + (line) => this.log(line), + parser, + { seed: this.referenceSeed, validate }, + ); + } + + parsedAnalysis(): any { + return this.currentAnalysis ?? undefined; + } + + async renderDraftAndMeasure(parsed: any, round: number) { + let token = `img-to-3d-draft-${round}-${++this.draftSequence}`; + this.draftViewerSrcdoc = generateViewerSrcdocInline( + generateModelJs(parsed, { + round, + score: typeof parsed.score === 'number' ? parsed.score : null, + }), + token, + ); + if (!(await this.waitForViewer(token))) { + this.log('> draft viewer timed out — skipped measured reconciliation'); + return undefined; + } + return this.frameWindow?.measureParts?.(); + } + + // The vision model supplies semantic part names; the iframe supplies the + // actual post-transform world-space boxes. Reconcile those before writing + // the model file so reference bboxes constrain real geometry instead of + // remaining prompt-only advice. + async reconcileDraftProportions( + parsed: any, + round: number, + skipRefs: string[], + ) { + this.lastCalibrationResidual = null; + let analysis = this.parsedAnalysis(); + if (!analysis?.partPlan?.length) return; + let mappedParts = (parsed.components ?? []).filter( + (component: any) => component?.partRef, + ).length; + if (!mappedParts) { + this.log('> no partRef mappings — skipped measured reconciliation'); + return; + } + + // Each pass costs a full draft render: write the srcdoc, wait for the + // iframe to load three.js and build the scene, read the world boxes back. + // A second pass only pays for itself if it actually reaches the target, and + // observed residuals ran 8-27% — never close to the 4% target — so the + // second render was bought and thrown away every time. One pass it is; + // raise this if a future change makes the correction converge. + const MAX_PASSES = 1; + const TARGET_RESIDUAL = 0.04; + for (let pass = 0; pass < MAX_PASSES; pass++) { + let measured = await this.renderDraftAndMeasure(parsed, round); + if (!measured) return; + let result = reconcileProportions(parsed, analysis, measured, skipRefs); + this.lastCalibrationResidual = result.residual; + if (result.residual !== null) { + this.log( + `> measured proportion residual ${result.residual.toFixed(3)}`, + ); + } + for (let line of result.logs) { + this.log(`> ${line}`); + } + if (result.logs.length) { + for (let line of fitCurvedDecals(parsed)) { + this.log(`> ${line}`); + } + } + if ( + !result.logs.length || + (result.residual !== null && result.residual <= TARGET_RESIDUAL) + ) { + break; + } + } + if (typeof this.lastCalibrationResidual === 'number') { + this.log( + `> calibration residual ${(this.lastCalibrationResidual * 100).toFixed(0)}%`, + ); + } + } + + async loadReferenceImage(): Promise { + try { + let dataUrl = await this.encodeReference(); + return await new Promise((resolve, reject) => { + let img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('reference image unreadable')); + img.src = dataUrl; + }); + } catch { + return undefined; + } + } + + // silhouette tracing: revolved bodies (bottles, vases, cans) get their + // lathe profile measured straight from the reference pixels — the traced + // outline replaces whatever profile the model invented, so cone-shaped + // bottles cannot happen. Analysis usually splits ONE revolved silhouette + // into stacked parts (body / shoulder / neck / base); those share an + // axis, so they are traced as a single union outline. Objects whose + // revolved parts are NOT one stacked silhouette are left alone — see the + // guards below. Returns the part names the trace now embodies — + // reconciliation must not rescale them individually (that is what turned a + // bottle into a spinning top). + async applyTracedProfiles(parsed: any): Promise { + // stale envelope from a prior build must not clamp this one — every + // path out of here below is a path that never reaches the trace + this.tracedEnvelope = null; + let plan: any[] = this.parsedAnalysis()?.partPlan ?? []; + let revolved = plan.filter( + (p: any) => p?.approach === 'revolved' && p?.bbox?.width > 0, + ); + if (!revolved.length) return []; + let lathes = (parsed.components ?? []).filter( + (c: any) => c?.primitive === 'lathe', + ); + if (!lathes.length) return []; + let image = await this.loadReferenceImage(); + if (!image) return []; + + // only trace when the revolved parts really are ONE stacked silhouette + // and that silhouette is the object itself — a round component inside a + // machine is neither, and tracing it hands the whole build a wrong + // envelope + let { bbox: cropBbox, skipped } = revolvedSilhouetteBbox(plan); + if (!cropBbox) { + this.log(`> ${skipped} — skipped silhouette trace`); + return []; + } + let traceDiag: string[] = []; + let traced = traceLatheProfile(image, cropBbox, 16, traceDiag); + if (!traced) { + this.log( + `> silhouette trace failed (${traceDiag[0] ?? 'unknown'}) — kept authored profiles`, + ); + return []; + } + + // the tallest lathe is the object's revolved silhouette — replace its + // profile; its authored height stays as the world-size anchor + let span = (lathe: any): { minY: number; maxY: number } => { + let d: number[] = Array.isArray(lathe.dimensions) + ? lathe.dimensions + : JSON.parse(lathe.dimensions || '[]'); + let ys: number[] = []; + for (let i = 1; i < d.length; i += 2) ys.push(d[i]); + return { + minY: ys.length ? Math.min(...ys) : -0.5, + maxY: ys.length ? Math.max(...ys) : 0.5, + }; + }; + let primary = lathes.reduce((a: any, b: any) => { + let sa = span(a); + let sb = span(b); + return sb.maxY - sb.minY > sa.maxY - sa.minY ? b : a; + }); + let { minY, maxY } = span(primary); + let height = maxY - minY || 1; + let scaled: number[] = []; + for (let i = 0; i + 1 < traced.length; i += 2) { + scaled.push( + Number((traced[i] * height).toFixed(4)), + Number((minY + traced[i + 1] * height).toFixed(4)), + ); + } + primary.dimensions = scaled; + // the world-space profile IS the reconcile envelope — every solid part + // gets clamped inside it (clampToEnvelope) so nothing floats wider than + // the traced outline at its height. The lathe's own position offsets its + // local geometry into world space, and the other parts are authored in + // world coords, so the envelope MUST carry that offset — otherwise a body + // shifted down (e.g. pos.y -1.35) leaves the cap comparing against a + // phantom top and floating far above. + let lathePos = Array.isArray(primary.position) + ? primary.position + : [0, 0, 0]; + let latheY = lathePos[1] ?? 0; + let envelope: { y: number; half: number }[] = []; + for (let i = 0; i + 1 < scaled.length; i += 2) { + envelope.push({ half: scaled[i], y: scaled[i + 1] + latheY }); + } + this.tracedEnvelope = envelope; + + // the traced lathe IS the entire glass body (silhouette includes the + // neck + lip). Any OTHER on-axis cylinder/lathe of the same glass + // material the model stacked on is a duplicate neck/shoulder that just + // floats — drop it. Non-cylinder glass features (a punt sphere) and parts + // of other materials (cap, foil, band) are kept. + let glassMat = primary.materialId; + let axial = (c: any) => { + let p = Array.isArray(c.position) ? c.position : [0, 0, 0]; + return Math.abs(p[0] ?? 0) < 0.08 && Math.abs(p[2] ?? 0) < 0.08; + }; + let before = parsed.components.length; + parsed.components = parsed.components.filter( + (c: any) => + c === primary || + !(c.primitive === 'cylinder' || c.primitive === 'lathe') || + c.materialId !== glassMat || + !axial(c), + ); + let removed = before - parsed.components.length; + if (removed > 0) { + this.log( + `> removed ${removed} duplicate glass body part(s) the lathe already covers`, + ); + } + + // surface any mask-repair note (white-label gap fill) alongside success + for (let line of traceDiag) this.log(`> ${line}`); + this.log( + `> traced '${primary.nodeId}' silhouette from reference (${scaled.length / 2} points)`, + ); + return revolved.map((p: any) => String(p.part)); + } + + // superimpose real artwork: for every decal that names an analysis part + // (textureRef), crop that part's bbox out of the reference photo, write + // it into the realm as a webp, and point the decal's textureUrl at it — + // the model wears its OWN label instead of a canvas-painted stand-in + async applyTextures(parsed: any) { + let decals = (parsed.components ?? []).filter( + (c: any) => + c?.textureRef && + (c.primitive === 'textDecal' || c.primitive === 'curvedDecal') && + !c.textureUrl, + ); + if (!decals.length) return; + let plan: any[] = this.parsedAnalysis()?.partPlan ?? []; + if (!plan.length) return; + let image = await this.loadReferenceImage(); + if (!image) return; // decals fall back to painted text + for (let decal of decals) { + let wanted = String(decal.textureRef).trim().toLowerCase(); + let part = plan.find( + (p: any) => + String(p?.part ?? '') + .trim() + .toLowerCase() === wanted, + ); + // prefer the plan's "artwork" region over the part's own bbox: the part + // bbox covers the whole component, while artwork points at just the + // printed graphic on it. Cropping the whole part drags in the + // surrounding paint and shading, which is why a label crop can arrive + // with a band of bottle glass down its edge. + let bbox = part?.artwork ?? part?.bbox; + if ( + !bbox || + !(bbox.width > 0) || + !(bbox.height > 0) || + bbox.left < 0 || + bbox.top < 0 + ) { + continue; + } + if (part?.artwork) { + this.log(`> cropping '${decal.textureRef}' from its artwork region`); + } + // Knock the photo's backdrop out of the crop first. A bbox is a + // rectangle and the artwork inside it usually is not, so an opaque crop + // carries a band of the backdrop onto the model — a foil capsule arrived + // as the capsule plus two white wings. Segmentation returns null when + // there is no background to remove (a label on glass, a placard on + // painted metal), and the plain opaque crop is right in that case. + // + // direct-print graphics (a logo laser-printed on a mug, a print on a + // shirt) are NOT a separate patch: the surface they sit on is the part + // itself, so key out the crop's own surface colour and leave only the + // ink — otherwise the crop lands as an opaque rectangle of surface. + let surfacePrint = part?.printMode === 'direct-print'; + let cut = cropWithBackgroundRemoved(image, bbox, undefined, { + surfacePrint, + }); + if (cut) { + this.log( + surfacePrint + ? `> keyed the surface out of '${decal.textureRef}' — only the print remains` + : `> cut background out of '${decal.textureRef}' crop`, + ); + } + let canvas = cut; + if (!canvas) { + let sx = Math.round(bbox.left * image.width); + let sy = Math.round(bbox.top * image.height); + let sw = Math.max(1, Math.round(bbox.width * image.width)); + let sh = Math.max(1, Math.round(bbox.height * image.height)); + canvas = document.createElement('canvas'); + canvas.width = sw; + canvas.height = sh; + canvas.getContext('2d')!.drawImage(image, sx, sy, sw, sh, 0, 0, sw, sh); + } + let base64 = canvas.toDataURL('image/webp', 0.92).split(',')[1]; + if (!base64) continue; + let written = await writeRealmImage(this.args.context!.commandContext!, { + realm: this.realmHref, + path: `${this.studioAssetDir}/textures/${slugify(decal.textureRef, 'artwork')}.webp`, + base64, + contentType: 'image/webp', + }); + if (written?.url) { + decal.textureUrl = written.url; + this.log(`> cropped '${decal.textureRef}' artwork from reference`); + } + } + } + + async applyParsedSpec( + parsed: any, + opts: { inPlace?: boolean } = {}, + ): Promise<{ score: number | undefined; featuresOk: boolean }> { + let model = this.args.model!; + let commandContext = this.args.context!.commandContext!; + this.phase = 'building'; + this.log(`> building ${parsed.components.length} components…`); + let tracedPartRefs = await this.applyTracedProfiles(parsed); + await this.applyTextures(parsed); + // reconcile every solid part inside the traced silhouette envelope (only + // when the trace succeeded — a bad LLM profile is not a boundary) + if (this.tracedEnvelope) { + for (let line of clampToEnvelope(parsed, this.tracedEnvelope, true)) { + this.log(`> ${line}`); + } + } + for (let line of stripRedundantLabelParts(parsed)) { + this.log(`> ${line}`); + } + for (let line of fitCurvedDecals(parsed)) { + this.log(`> ${line}`); + } + + let current = this.currentCreation; + // in-place mode (lasso edits): overwrite the CURRENT round's file + card + // instead of spawning a new instance. Falls back to a new round if there + // is no current creation to overwrite. + let inPlace = Boolean(opts.inPlace && current?.codeFile?.url); + let previous = model.latestCreation; + let round = inPlace ? (current!.round ?? 1) : (previous?.round ?? 0) + 1; + let meta = { + round, + score: typeof parsed.score === 'number' ? parsed.score : null, + }; + // A LASSO EDIT IS THE USER OVERRULING THE PLAN, so the plan must not + // overrule it back. Measured reconciliation resizes and re-centres every + // partRef group toward the analysis bbox — which is exactly the number the + // user just rejected by hand. Left running, a "this label covers too much + // of the bottle" edit was shrunk by the model and then dragged back toward + // the planned label bbox by this pass, and what survived was a thin band at + // the shoulder with the artwork gone. + if (inPlace) { + this.log('> hand edit: skipping measured reconciliation'); + } else { + await this.reconcileDraftProportions(parsed, round, tracedPartRefs); + } + this.workingSpec = parsed; + + let slug = slugify(parsed.objectName || 'model', 'model'); + + if (inPlace) { + // rewrite the current round's exact file, keep the same card + let curUrl = current!.codeFile!.url!; + let rel = + this.realmHref && curUrl.startsWith(this.realmHref) + ? curUrl.slice(this.realmHref.length) + : `${this.studioAssetDir}/exports/${slug}-round-${round}.js`; + await new WriteTextFileCommand(commandContext).execute({ + path: rel, + content: generateModelJs(parsed, meta), + realm: this.realmHref, + overwrite: true, + } as any); + // force the iframe to re-fetch the same (now overwritten) URL + this.inPlaceReloadKey = this.inPlaceReloadKey + 1; + this.draftViewerSrcdoc = undefined; + this.pendingViewportUrl = undefined; + let viewUrl = + curUrl + + (curUrl.includes('?') ? '&' : '?') + + 'rk=' + + this.inPlaceReloadKey; + if (!(await this.waitForViewer(viewUrl))) { + this.log('> viewer reload timed out — render may be stale'); + } + // update the SAME SculptedModel in place (no new card) + current!.critique = String(parsed.critique ?? ''); + if (typeof parsed.score === 'number') current!.score = parsed.score; + let shot = await this.persistRenderScreenshot( + parsed.objectName || 'model', + round, + ); + if (shot) current!.renderScreenshot = shot; + await new SaveCardCommand(commandContext).execute({ + card: current as any, + realm: (model as any)[realmURL]?.href, + } as any); + let failedInPlace = Object.entries(parsed.featureCheck ?? {}) + .filter(([, v]) => String(v).toLowerCase() === 'fail') + .map(([k]) => k); + for (let f of failedInPlace) this.log(`> feature FAIL: ${f}`); + return { + score: typeof parsed.score === 'number' ? parsed.score : undefined, + featuresOk: failedInPlace.length === 0, + }; + } + + // the model's stored form: a real three.js module in the realm, with + // the source spec riding inside it as SCULPT_SPEC + let written = await new WriteTextFileCommand(commandContext).execute({ + path: `${this.studioAssetDir}/exports/${slug}-round-${round}.js`, + content: generateModelJs(parsed, meta), + realm: this.realmHref, + useNonConflictingFilename: true, + } as any); + let codeFileUrl = (written as any)?.fileIdentifier as string; + // switch the viewport to the just-written file for the screenshot — the + // studio holds no code URL of its own, so this transient pointer bridges + // the gap until the SculptedModel below is saved and linked + this.pendingViewportUrl = codeFileUrl; + this.draftViewerSrcdoc = undefined; + // a fresh round supersedes any in-place reload key + this.inPlaceReloadKey = 0; + // the viewport iframe reloads on the src change; wait for the scene so + // the archived screenshot shows THIS round + if (!(await this.waitForViewer(codeFileUrl))) { + this.log('> persisted viewer timed out — render archive may be missing'); + } + + // persist this round as its own SculptedModel card and link it, carrying + // EVERY reference view it was built from (not just the primary) — each + // image copied in its own mode (ImageDef link for linked files, url + // otherwise) so the saved round holds the same multi-image set the studio does + let sourceImages = model.references?.images ?? []; + let referencesCopy = new MultiImageSourceField({ + images: sourceImages.map((img: any) => + img?.sourceMode === 'file' && img?.file + ? new ImageSourceField({ file: img.file, sourceMode: 'file' }) + : new ImageSourceField({ url: img?.resolvedUrl, sourceMode: 'url' }), + ), + }); + let creation = new SculptedModel({ + references: referencesCopy, + codeFile: this.makeCodeFileDef(codeFileUrl), + objectName: parsed.objectName || 'model', + buildBackend: + parsed.buildBackend ?? this.activeAnalysis?.buildBackend ?? undefined, + // the analysis this build followed rides on the creation itself, so + // selecting this version later re-attaches its measured targets + analysis: this.activeAnalysis + ? JSON.stringify(this.activeAnalysis) + : undefined, + critique: String(parsed.critique ?? ''), + score: typeof parsed.score === 'number' ? parsed.score : null, + buildMetrics: JSON.stringify(this.buildMetrics(parsed)), + renderScreenshot: await this.persistRenderScreenshot( + parsed.objectName || 'model', + round, + ), + parentCreation: previous ?? undefined, + round, + modelUsed: model.llmModel || VISION_MODEL, + createdAt: new Date(), + // scopes the prerendered history search to this studio card + sourceStudio: model, + }); + let saved = (await new SaveCardCommand(commandContext).execute({ + card: creation, + realm: (model as any)[realmURL]?.href, + // keep each round's card beside this studio's own assets, under + // img-to-3d//rounds/, instead of the realm root + localDir: `${this.studioAssetDir}/rounds`, + } as any)) as SculptedModel; + // SaveCard returns a detached, GC-eligible instance — re-resolve a + // store-tracked one before linking it as latestCreation + let store = (this.args.context as any)?.store; + let tracked = + saved?.id && store?.get ? await store.get(saved.id) : undefined; + let creationCard = + tracked && !(tracked as any).isCardError ? tracked : saved; + // this round is now both the newest (latestCreation) and what's shown + // (selectedCreation); the transient viewport pointer can retire — the + // viewport now reads the creation's own codeFileUrl + model.latestCreation = creationCard; + model.selectedCreation = creationCard; + this.pendingViewportUrl = undefined; + // persist the studio card itself so these links survive a page reload — + // the parentCreation history walk roots at latestCreation, and relying on + // host auto-save alone left the pointer unsaved (empty history on refresh) + try { + await new SaveCardCommand(commandContext).execute({ + card: model, + } as any); + } catch (e) { + this.log(`> could not persist studio link: ${(e as Error).message}`); + } + + // per-feature gate (img2threejs rule): a good global score cannot + // excuse a failing identity feature + let failed = Object.entries(parsed.featureCheck ?? {}) + .filter(([, v]) => String(v).toLowerCase() === 'fail') + .map(([k]) => k); + for (let f of failed) { + this.log(`> feature FAIL: ${f}`); + } + return { + score: typeof parsed.score === 'number' ? parsed.score : undefined, + featuresOk: failed.length === 0, + }; + } + + // captures the current viewport and writes it into the realm as a WebP, + // returning a file-backed ImageDef to archive on the creation + async persistRenderScreenshot(name: string, round: number): Promise { + try { + let dataUrl = this.frameWindow?.captureScreenshot?.(); + let base64 = dataUrl?.split(',')[1]; + if (!base64) return undefined; + return await writeRealmImage(this.args.context!.commandContext!, { + realm: (this.args.model as any)?.[realmURL]?.href, + path: `${this.studioAssetDir}/renders/${slugify(name, 'model')}-round-${round}.webp`, + base64, + contentType: 'image/webp', + }); + } catch { + // archiving the screenshot is best-effort; the spec is still kept + return undefined; + } + } + + +} diff --git a/4376bf-img-to-3d-generator/fields/sculpt-spec.gts b/4376bf-img-to-3d-generator/fields/sculpt-spec.gts new file mode 100644 index 00000000..8d1cc768 --- /dev/null +++ b/4376bf-img-to-3d-generator/fields/sculpt-spec.gts @@ -0,0 +1,311 @@ +import { + FieldDef, + Component, + field, + contains, + containsMany, +} from 'https://cardstack.com/base/card-api'; +import { htmlSafe } from '@ember/template'; + +// swatch colors come from LLM output — only a validated hex may reach an +// inline style +function safeSwatchStyle(color: string) { + let hex = /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : '#8a8f9c'; + return htmlSafe(`background-color: ${hex}`); +} +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import ColorField from 'https://cardstack.com/base/color'; +import enumField from 'https://cardstack.com/base/enum'; + +export const PRIMITIVES = [ + 'group', + 'box', + 'roundedBox', + 'cylinder', + 'capsule', + 'sphere', + 'hemisphere', + 'cone', + 'torus', + 'lathe', + 'plane', + 'disc', + 'flatRing', + 'arch', + 'prism', + 'tube', + 'bone', + 'rock', + 'blob', + 'meshAsset', + 'glow', + 'roundedPlate', + 'extrudedPolygon', + 'extrudedSpline', + 'textDecal', + 'curvedDecal', +] as const; + +const PrimitiveField = enumField(StringField, { + options: [...PRIMITIVES], + displayName: 'Primitive', +}); + +const ObjectClassField = enumField(StringField, { + options: ['hard-surface', 'organic', 'hybrid'], + displayName: 'Object Class', +}); + +const ComplexityField = enumField(StringField, { + options: ['simple', 'moderate', 'complex'], + displayName: 'Complexity', +}); + +// which backend the analysis recommends for this object. "primitive" is the +// only one wired today; "mesh" flags an object (a firearm, a face) the +// primitive vocabulary can only approximate, so a future mesh service can +// route it — see prompts/analyze.gts BACKEND rule. +const BuildBackendField = enumField(StringField, { + options: ['primitive', 'mesh'], + displayName: 'Build Backend', +}); + +// PBR parameters for one THREE.MeshStandardMaterial, referenced from +// component nodes by materialId. +export class MaterialSpecField extends FieldDef { + static displayName = 'Sculpt Material'; + @field title = contains(StringField, { + computeVia: function (this: MaterialSpecField) { + return this.materialId || 'material'; + }, + }); + @field materialId = contains(StringField); + @field baseColor = contains(ColorField); + @field roughness = contains(NumberField); + @field metalness = contains(NumberField); + @field opacity = contains(NumberField); + @field emissive = contains(ColorField); + // optional MeshPhysicalMaterial extensions — either set switches the + // interpreter from MeshStandardMaterial to MeshPhysicalMaterial + @field clearcoat = contains(NumberField); + @field sheen = contains(NumberField); + // 0-1 see-through glass (window panes, bottles, lenses) — rendered with + // refraction, unlike plain low opacity + @field transmission = contains(NumberField); + // optional procedural surface finish painted by the interpreter + // (worn | brushed | hazard | tread | camo | louver | patina | knurl) + @field finish = contains(StringField); + // optional emissive strength 0-2 (LED strips vs faint warm glow) + @field emissiveIntensity = contains(NumberField); +} + +// One node in the model tree. Nodes form a flat list linked by parentId +// (FieldDefs cannot nest recursively); the interpreter rebuilds the tree. +// dimensions/position/rotation/scale hold JSON number arrays as strings so +// the LLM can author them directly and humans can tweak them in edit view. +export class ComponentNodeField extends FieldDef { + static displayName = 'Sculpt Component'; + @field title = contains(StringField, { + computeVia: function (this: ComponentNodeField) { + let name = this.nodeId || 'node'; + return this.primitive ? `${name} · ${this.primitive}` : name; + }, + }); + @field nodeId = contains(StringField); + @field parentId = contains(StringField); + @field primitive = contains(PrimitiveField); + @field dimensions = contains(StringField); + @field position = contains(StringField); + @field rotation = contains(StringField); + @field scale = contains(StringField); + @field materialId = contains(StringField); + // textDecal only: the label/wordmark text rendered onto the decal plane + @field text = contains(StringField); + // analysis partPlan name this component realizes. The draft measurement + // pass groups components by this value and reconciles their world-space + // bounds against the reference-image bbox for that semantic part. + @field partRef = contains(StringField); + // analysis partPlan name whose reference crop supplies decal artwork, plus + // the resolved realm URL written by the studio before code export. + @field textureRef = contains(StringField); + @field textureUrl = contains(StringField); + // optional repetition system (JSON string): one declared part expands into + // N placed clones — rivet rows, wheel sets, vent slats (War-Hauler style) + @field repeat = contains(StringField); + // meshAsset only: URL of a .glb in the realm (hybrid pipeline — a + // service-generated or exported mesh placed inside the procedural graph) + @field assetUrl = contains(StringField); + // nodeId of the part this one physically mounts on — the interpreter's + // constraint solver pulls the part into contact with it (fixes lateral + // disconnection that gravity snap can't reach) + @field attachTo = contains(StringField); + @field note = contains(StringField); +} + +const InputKindField = enumField(StringField, { + options: ['object', 'flat-graphic'], + displayName: 'Input Kind', +}); + +export class SculptSpecField extends FieldDef { + static displayName = 'Sculpt Spec'; + @field objectName = contains(StringField); + @field inputKind = contains(InputKindField); + // 3-5 identity-defining features the refine loop must verify one by one — + // a pass is not accepted while any of these still fails (img2threejs's + // per-feature gate: a good global score cannot excuse a wrong feature) + @field identityFeatures = containsMany(StringField); + @field objectClass = contains(ObjectClassField); + @field buildBackend = contains(BuildBackendField); + @field complexity = contains(ComplexityField); + @field components = containsMany(ComponentNodeField); + @field materials = containsMany(MaterialSpecField); + + static embedded = class Embedded extends Component { + get partCount() { + return this.args.model?.components?.length ?? 0; + } + get materialSwatches() { + return (this.args.model?.materials ?? []) + .map((m: any) => ({ + id: m?.materialId ?? '', + style: safeSwatchStyle(m?.baseColor || '#8a8f9c'), + })) + .slice(0, 8); + } + + }; + + static atom = class Atom extends Component { + get partCount() { + return this.args.model?.components?.length ?? 0; + } + + }; +} diff --git a/4376bf-img-to-3d-generator/img-to-3d-studio.gts b/4376bf-img-to-3d-generator/img-to-3d-studio.gts new file mode 100644 index 00000000..c78d97fc --- /dev/null +++ b/4376bf-img-to-3d-generator/img-to-3d-studio.gts @@ -0,0 +1,233 @@ +import { + CardDef, + Component, + field, + contains, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import enumField from 'https://cardstack.com/base/enum'; + +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; + +import { VISION_MODEL_OPTIONS } from './util/llm-request'; +import { StudioIsolated } from './components/studio-isolated'; +import { SculptedModel } from './sculpted-model'; + +export class ImgTo3dStudio extends CardDef { + static displayName = 'Img-to-3D Generator'; + static prefersWideFormat = true; + + // all reference photos in one multi-image field: the first image is the + // primary view, the rest are side / back / detail shots — every view feeds + // the initial generation; thickness and hidden sides come from exactly these + @field references = contains(MultiImageSourceField); + // The studio is a workbench, not a store of results: every generation's + // details (code file, name, analysis, critique, score) live on the + // SculptedModel it produced. The studio only points at creations — + // the viewport, name and analysis are all read from the SELECTED one, so + // selecting a history version re-attaches that version's details wholesale. + // + // selectedCreation = what the iframe shows (moves when you pick a history + // version); latestCreation = the newest saved round (history walk root and + // round counter, only advances on new generations). + @field selectedCreation = linksTo(() => SculptedModel); + @field latestCreation = linksTo(() => SculptedModel); + @field llmModel = contains( + enumField(StringField, { + options: VISION_MODEL_OPTIONS, + displayName: 'Vision Model', + }), + ); + @field title = contains(StringField, { + computeVia: function (this: ImgTo3dStudio) { + return ( + this.selectedCreation?.objectName || + this.latestCreation?.objectName || + 'Img-to-3D Studio' + ); + }, + }); + + // The working view is its own module: the card file says what a studio IS, + // components/studio-isolated.gts is what the isolated view DOES. + static isolated = StudioIsolated; + + static embedded = class Embedded extends Component { + get hasLinkedTheme() { + return Boolean(this.args.model?.cardInfo?.theme); + } + + }; + + static fitted = class Fitted extends Component { + get hasLinkedTheme() { + return Boolean(this.args.model?.cardInfo?.theme); + } + + }; +} diff --git a/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/exports/red-cab-over-delivery-truck-round-1.js b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/exports/red-cab-over-delivery-truck-round-1.js new file mode 100644 index 00000000..155bcf60 --- /dev/null +++ b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/exports/red-cab-over-delivery-truck-round-1.js @@ -0,0 +1,723 @@ +/* eslint-disable */ +// Red Cab-Over Delivery Truck — procedural three.js model +// generated by Boxel Img-to-3D Studio from its sculpt spec · round 1 · score 74 +// +// Usage (three.js r0.147): +// var built = buildSculpture(THREE); +// scene.add(built.group); +// +// Every part below is one addPart() call — dimensions, positions and +// materials are plain numbers you can edit directly. + +function buildSculpture(THREE) { + 'use strict'; + var root = new THREE.Group(); + root.name = 'Red Cab-Over Delivery Truck'; + + // ===== materials ===== + var MATERIALS = {}; + MATERIALS['m-red-gloss'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#d81118'), roughness: 0.3, metalness: 0.1, clearcoat: 0.6, clearcoatRoughness: 0.3, envMapIntensity: 0.35 }); + MATERIALS['m-red-satin'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#d81118'), roughness: 0.55, metalness: 0.15, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + MATERIALS['m-dark-steel'] = new THREE.MeshStandardMaterial({ color: new THREE.Color('#1e2022'), roughness: 0.6, metalness: 0.7, envMapIntensity: 0.35 }); + MATERIALS['m-grille'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#1a1b1e'), roughness: 0.7, metalness: 0.3, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + (function () { + var tex = makeFinishTexture(THREE, 'louver', 'm-grille', '#1a1b1e'); + if (!tex) return; + var mat = MATERIALS['m-grille']; + mat.map = tex; mat.roughnessMap = tex; + mat.color = new THREE.Color('#ffffff'); + mat.needsUpdate = true; + })(); + MATERIALS['m-glass'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#dfeef5'), roughness: 0.1, metalness: 0, transmission: 0.85, ior: 1.5, thickness: 0.05, envMapIntensity: 1 }); + MATERIALS['m-mirror'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#22252a'), roughness: 0.6, metalness: 0.2, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + MATERIALS['m-rubber'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#242628'), roughness: 0.9, metalness: 0, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + (function () { + var tex = makeFinishTexture(THREE, 'tread', 'm-rubber', '#242628'); + if (!tex) return; + var mat = MATERIALS['m-rubber']; + mat.bumpMap = tex; mat.bumpScale = 0.02; + mat.needsUpdate = true; + })(); + MATERIALS['m-silver-metal'] = new THREE.MeshStandardMaterial({ color: new THREE.Color('#9fa5aa'), roughness: 0.35, metalness: 0.9, envMapIntensity: 0.35 }); + MATERIALS['m-guard-white'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#cfd4d8'), roughness: 0.5, metalness: 0.3, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + MATERIALS['m-headlight'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#f5f5f0'), roughness: 0.15, metalness: 0, emissive: new THREE.Color('#fff2c0'), emissiveIntensity: 0.4, transmission: 0.9, ior: 1.5, thickness: 0.05, envMapIntensity: 1 }); + var FALLBACK_MATERIAL = new THREE.MeshStandardMaterial({ color: 0x8a8f9c, roughness: 0.55, metalness: 0.25 }); + + // rounded-rect outline used by rounded slabs/plates + function roundedRectShape(len, depth, r) { + var s = new THREE.Shape(); + var hx = Math.max(len / 2 - r, 0.001), hz = Math.max(depth / 2 - r, 0.001); + s.absarc(-hx, -hz, r, Math.PI, Math.PI * 1.5); + s.absarc(hx, -hz, r, Math.PI * 1.5, 0); + s.absarc(hx, hz, r, 0, Math.PI * 0.5); + s.absarc(-hx, hz, r, Math.PI * 0.5, Math.PI); + return s; + } + + // procedural surface finishes — the painted canvas doubles as colour and + // roughness map, which is what makes a surface read as weathered metal + // instead of plastic. Painter sources are emitted from the studio module, + // so this is the same paint the studio viewport shows. + function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; + a = a + 0x6d2b79f5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + function seedFrom(text) { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; + } + function paintLouver(ctx, S) { + ctx.fillStyle = '#3a3d40'; + ctx.fillRect(0, 0, S, S); + let rows = 12; + let rowH = S / rows; + for (let r = 0; r < rows; r++) { + ctx.fillStyle = '#15171a'; + ctx.fillRect(0, r * rowH + rowH * 0.45, S, rowH * 0.4); + ctx.fillStyle = '#6a6e72'; + ctx.fillRect(0, r * rowH + rowH * 0.05, S, rowH * 0.12); + } + } + function paintTread(ctx, S, rand) { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, S, S); + let rows = 14; + let rowH = S / rows; + for (let r = 0; r < rows; r++) { + // dark groove line per row + ctx.fillStyle = '#5f5f5f'; + ctx.fillRect(0, r * rowH + rowH * 0.62, S, rowH * 0.3); + // staggered lug notches + ctx.fillStyle = '#7a7a7a'; + let offset = r % 2 * (S / 12); + for (let x = -S / 12; x < S; x += S / 6) { + ctx.fillRect(x + offset, r * rowH + rowH * 0.1, S / 24, rowH * 0.45); + } + if (rand() > 2) break; // keep rand consumed signature-compatible + } + } + function makeFinishTexture(THREE, finish, seedText, baseColor) { + var S = 1024; + var cv = document.createElement('canvas'); + cv.width = cv.height = S; + var ctx = cv.getContext('2d'); + var rand = mulberry32(seedFrom(seedText)); + switch (finish) { + case 'louver': paintLouver(ctx, S); break; + case 'tread': paintTread(ctx, S, rand); break; + default: return undefined; + } + var tex = new THREE.CanvasTexture(cv); + tex.encoding = THREE.sRGBEncoding; + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.anisotropy = 8; + return tex; + } + + function expandRepeatInstances(THREE, original, rep, host, root, onClone) { + if (!rep || typeof rep !== 'object') return; + let count = Math.min(48, Math.max(0, Math.round(rep.count || 0))); + if (count < 2) return; + let parent = original.parent || root; + let axis = rep.axis === 'x' ? 'x' : rep.axis === 'z' ? 'z' : 'y'; + let basePos = original.position.clone(); + let baseQuat = original.quaternion.clone(); + // RING CENTER for a radial array. The part's own in-plane position is + // already a point ON the intended circle, so adding the radius to it put + // every clone at twice the radius — 20 knurl ridges orbited at 0.29 around a + // 0.16 cap, reading as a spiked collar floating off the cap. The circle + // belongs to the part this array wraps around, so its axis supplies the + // in-plane center; the position ALONG the axis stays where the part was + // authored. With no declared host there is nothing to centre on, so the + // part's own position is kept as the centre. + let center = basePos.clone(); + if (rep.mode === 'radial' && host && host !== original) { + let hostBox = new THREE.Box3().setFromObject(host); + if (!hostBox.isEmpty()) { + let hostCenter = hostBox.getCenter(new THREE.Vector3()); + parent.worldToLocal(hostCenter); + if (axis === 'y') { + center.x = hostCenter.x; + center.z = hostCenter.z; + } else if (axis === 'x') { + center.y = hostCenter.y; + center.z = hostCenter.z; + } else { + center.x = hostCenter.x; + center.y = hostCenter.y; + } + } + } + // place instance i — index 0 is the original itself, so a radial array is one + // coherent ring instead of the original sitting off the circle its own clones + // orbit on + let place = (obj, i) => { + if (rep.mode === 'radial') { + let radius = rep.radius != null ? rep.radius : 0.5; + let angle = i / count * Math.PI * 2; + let ca = Math.cos(angle) * radius; + let sa = Math.sin(angle) * radius; + // The instance is carried around the ring RIGIDLY: the orbital angle + // composes OUTSIDE the part's own orientation, so a part already aimed + // along the ring axis keeps that aim. Adding the angle to the matching + // Euler component instead composes it inside that orientation, and since + // Euler order is XYZ that tilts every clone by its own angle — six + // minigun barrels laid along z came out crossed like an asterisk rather + // than parallel. Positions sweep +x→+z about y, which is a rotation + // about −y, so that one axis spins the opposite way to stay in step. + let spin = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(axis === 'x' ? 1 : 0, axis === 'y' ? 1 : 0, axis === 'z' ? 1 : 0), axis === 'y' ? -angle : angle); + obj.quaternion.copy(baseQuat).premultiply(spin); + if (axis === 'y') { + obj.position.set(center.x + ca, center.y, center.z + sa); + } else if (axis === 'x') { + obj.position.set(center.x, center.y + ca, center.z + sa); + } else { + obj.position.set(center.x + ca, center.y + sa, center.z); + } + } else { + let offset = Array.isArray(rep.offset) ? rep.offset : [0.2, 0, 0]; + obj.position.set(basePos.x + offset[0] * i, basePos.y + offset[1] * i, basePos.z + offset[2] * i); + } + }; + place(original, 0); + for (let i = 1; i < count; i++) { + let clone = original.clone(true); + clone.name = `${original.name || 'part'}-${i}`; + place(clone, i); + parent.add(clone); + onClone(clone); + } + } + + function seatSurfaceParts(THREE, objects, joints) { + let logs = []; + if (!joints || !joints.length) return logs; + // ellipsoid radius along a unit direction, from the half extents of a box + function radiusAlong(half, unit) { + let hx = Math.max(half.x, 1e-6); + let hy = Math.max(half.y, 1e-6); + let hz = Math.max(half.z, 1e-6); + let q = unit.x / hx * (unit.x / hx) + unit.y / hy * (unit.y / hy) + unit.z / hz * (unit.z / hz); + if (!(q > 0)) return 0; + return 1 / Math.sqrt(q); + } + function measure(obj) { + let box = new THREE.Box3().setFromObject(obj); + if (box.isEmpty()) return undefined; + let size = box.getSize(new THREE.Vector3()); + return { + center: box.getCenter(new THREE.Vector3()), + half: size.multiplyScalar(0.5), + mean: (size.x + size.y + size.z) / 3 + }; + } + let hostOf = new Map(); + for (let i = 0; i < joints.length; i++) { + let joint = joints[i]; + if (joint && joint.id && joint.to) hostOf.set(joint.id, joint.to); + } + // Hosts settle before the features on them: a pupil is seated on an eyeball + // that may itself be moving onto the muzzle this same pass, and reading a + // stale eyeball position would seat the pupil against a surface that is no + // longer there. Depth in the joint chain is that order. + function depthOf(id) { + let depth = 0; + let at = id; + let seen = {}; + while (hostOf.has(at) && !seen[at]) { + seen[at] = true; + at = hostOf.get(at); + depth++; + if (depth > 32) break; + } + return depth; + } + let ordered = joints.slice().sort(function (a, b) { + return depthOf(a.id) - depthOf(b.id); + }); + // What this pass has already moved, so a feature declared on a moved host + // travels with it. A pupil is authored against its eyeball's position; once + // the eyeball slides round to the front of the face, the pupil's offset is + // stale rather than wrong, and re-seating it from where it was left would + // read the wrong direction and then reject the correction as too large. + let carried = new Map(); + for (let n = 0; n < ordered.length; n++) { + let joint = ordered[n]; + let obj = objects.get(joint.id); + let host = objects.get(joint.to); + if (!obj || !host || obj === host) continue; + let inherited = carried.get(joint.to); + if (inherited) { + let world = obj.getWorldPosition(new THREE.Vector3()).add(inherited); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(world) : world); + obj.updateWorldMatrix(true, true); + carried.set(joint.id, inherited.clone()); + } + // a nested part's box lives inside its parent's by construction, so + // "buried" is what it is supposed to be + let ancestor = obj.parent; + let nested = false; + while (ancestor) { + if (ancestor === host) { + nested = true; + break; + } + ancestor = ancestor.parent; + } + if (nested) continue; + let c = measure(obj); + let h = measure(host); + if (!c || !h || !(h.mean > 0) || !(c.mean > 0)) continue; + // Only a FEATURE gets seated. Two parts of comparable size are a + // structural stack (torso on shorts, roof on storey) whose placement the + // declared joint already owns, and pulling one to the other's surface + // would be this pass overruling the assembly graph. + if (c.mean > 0.85 * h.mean) continue; + // ...and only a COMPACT one. An ellipsoid centred on the part is a fair + // model of an eye, a nose or a wheel hub, and a useless model of an arm: + // a limb's centre is half its length away from the joint it hangs off, so + // seating that centre on the shoulder buries the whole arm in the torso. + // The elongated parts are exactly the ones whose placement the declared + // joint already handles well, so requiring roundness costs nothing. + let cH = [c.half.x, c.half.y, c.half.z]; + let cMin = Math.min(cH[0], cH[1], cH[2]); + let cMax = Math.max(cH[0], cH[1], cH[2]); + if (!(cMin > 0) || cMax > 2 * cMin) continue; + let dir = c.center.clone().sub(h.center); + if (dir.lengthSq() < 1e-8) continue; + let dist = dir.length(); + let unit = dir.clone().divideScalar(dist); + let hostR = radiusAlong(h.half, unit); + let childR = radiusAlong(c.half, unit); + if (!(hostR > 0) || !(childR > 0)) continue; + // WHICH WAY IS OUT — the error that survives every other check, because + // depth alone cannot see it. For a feature on a free-standing mass, "out" + // is simply away from that mass's centre. But when the host is ITSELF a + // feature bolted to something bigger — a muzzle on a skull — the exposed + // side of the muzzle is the side pointing away from the skull, and a + // feature whose authored offset points the other way is behind the face. + // It can be perfectly seated on the muzzle's surface and still be inside + // the head, visible from nowhere: this Mickey's eyes sat 0.24 behind the + // muzzle's centre and every box test called them attached. + // + // The offset's tangential part carries the feature's left/right and + // up/down placement on the face and is kept as authored; only the + // through-the-face component is rebuilt, positive by construction. + let flipped = false; + let grandId = hostOf.get(joint.to); + let grand = grandId ? objects.get(grandId) : undefined; + let g = grand && grand !== host ? measure(grand) : undefined; + if (g && h.mean <= 0.8 * g.mean) { + let outward = h.center.clone().sub(g.center); + if (outward.lengthSq() > 1e-8) { + outward.normalize(); + let along = dir.dot(outward); + if (along < 0.15 * dist) { + let rebuilt = dir.clone().addScaledVector(outward, -along).addScaledVector(outward, 0.6 * dist); + if (rebuilt.lengthSq() > 1e-8) { + dir = rebuilt; + dist = dir.length(); + unit = dir.clone().divideScalar(dist); + hostR = radiusAlong(h.half, unit); + childR = radiusAlong(c.half, unit); + flipped = true; + } + } + } + } + if (!(hostR > 0) || !(childR > 0)) continue; + // Depth. A seated feature bites into its host by about a quarter of its + // own radius: enough that the join reads as one form, little enough that + // the feature is still mostly proud of the surface. + let seated = hostR + 0.75 * childR; + // Only the two errors the box solver next door CANNOT see. A part merely + // sitting in mid-air short of its host is that solver's case and it + // handles it well; this pass reaching for it too meant an upper arm being + // "seated" 0.34 into the torso, because an ellipsoid centred on a limb is + // nowhere near the shoulder the limb actually hangs from. + let buried = dist + childR <= hostR; + if (!flipped && !buried) continue; + let move = seated - dist; + if (Math.abs(move) < 1e-4) continue; + // never relocate a part across the model: a correction larger than the + // host itself means the spec is wrong somewhere this pass cannot see + if (Math.abs(move) > 1.2 * hostR) { + logs.push(`'${joint.id}' sits more than its own host's radius out of place on '${joint.to}' — left for refine`); + continue; + } + let why = flipped ? 'was on the hidden side' : 'was buried'; + let shift = h.center.clone().addScaledVector(unit, seated).sub(c.center); + let previous = carried.get(joint.id); + carried.set(joint.id, previous ? previous.add(shift.clone()) : shift.clone()); + let worldPos = obj.getWorldPosition(new THREE.Vector3()).add(shift); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + logs.push(`seated '${joint.id}' on '${joint.to}' (${why}, moved ${Math.abs(move).toFixed(2)})`); + } + return logs; + } + + // geometry per primitive — same defaults as the studio interpreter + function buildGeometry(primitive, d) { + switch (primitive) { + case 'box': + return new THREE.BoxGeometry(d[0] ?? 1, d[1] ?? 1, d[2] ?? 1); + case 'roundedBox': { + // [w, h, d, cornerRadius?, bevel?] — needs the RoundedBoxGeometry + // add-on; falls back to an extruded rounded-rect slab without it + var w = d[0] ?? 1, h = d[1] ?? 0.3, dep = d[2] ?? 1; + var r = Math.min(Math.abs(d[3] ?? 0.1), Math.min(w, dep) / 2 - 0.001); + if (THREE.RoundedBoxGeometry) { + var radius = Math.min(r, Math.min(w, h, dep) / 2 - 0.001); + return new THREE.RoundedBoxGeometry(w, h, dep, 3, Math.max(0.01, radius)); + } + var bevel = Math.min(Math.abs(d[4] ?? 0.02), h / 3); + var geo = new THREE.ExtrudeGeometry(roundedRectShape(w, dep, r), { + depth: Math.max(h - bevel * 2, 0.001), bevelEnabled: bevel > 0, + bevelThickness: bevel, bevelSize: bevel, bevelSegments: 3, curveSegments: 24, + }); + geo.rotateX(-Math.PI / 2); + geo.translate(0, bevel - h / 2, 0); + return geo; + } + case 'prism': { + // [lengthAlongRidge, span, height] — triangular prism, ridge along X + var length = Math.abs(d[0] ?? 1.5), span = Math.abs(d[1] ?? 1), height = Math.abs(d[2] ?? 0.6); + var shape = new THREE.Shape(); + shape.moveTo(-span / 2, 0); + shape.lineTo(span / 2, 0); + shape.lineTo(0, height); + shape.closePath(); + var geo = new THREE.ExtrudeGeometry(shape, { depth: length, bevelEnabled: false }); + geo.translate(0, -height / 2, -length / 2); + geo.rotateY(Math.PI / 2); + return geo; + } + case 'cylinder': + return new THREE.CylinderGeometry( + d[0] ?? 0.5, d[1] ?? d[0] ?? 0.5, d[2] ?? 1, Math.max(3, Math.round(d[3] ?? 48))); + default: + return null; // 'group' carries no geometry + } + } + + // ===== assembly plumbing (mirrors the studio interpreter) ===== + var objects = new Map(); + var meshes = []; + var parentIds = new Map(); + var attachments = []; + var repeats = []; + // true when the spec drives its own heights via "grounded"; the final + // stand-on-the-ground step defers to it + var groundedDeclared = false; + + function material(id) { + if (id && MATERIALS[id]) return MATERIALS[id]; + var first = Object.keys(MATERIALS)[0]; + return first ? MATERIALS[first] : FALLBACK_MATERIAL; + } + + function addPart(p) { + if (objects.has(p.id)) return; + var obj; + // decal/glow builders are emitted only when the spec uses them — the + // typeof guards keep this shared runtime valid either way + if (p.primitive === 'glow' && typeof buildGlowSprite === 'function') { + obj = buildGlowSprite(p.d || [], p.color || '#eeeeee'); + obj.name = p.id; + obj.position.set(p.pos[0] || 0, p.pos[1] || 0, p.pos[2] || 0); + objects.set(p.id, obj); + parentIds.set(p.id, p.parent || null); + return; + } + if (p.primitive === 'textDecal' && typeof buildTextDecal === 'function') { + obj = buildTextDecal(p.d || [], p.text || '', p.color || '#eeeeee', p.texture); + meshes.push(obj); + } else if ( + p.primitive === 'curvedDecal' && + typeof buildCurvedDecal === 'function' + ) { + obj = buildCurvedDecal(p.d || [], p.text || '', p.color || '#eeeeee', p.texture); + meshes.push(obj); + } else { + var geometry = buildGeometry(p.primitive, p.d || []); + if (geometry) { + obj = new THREE.Mesh(geometry, material(p.mat)); + obj.castShadow = obj.receiveShadow = true; + meshes.push(obj); + } else { + obj = new THREE.Group(); + } + } + obj.name = p.id; + obj.position.set(p.pos[0] || 0, p.pos[1] || 0, p.pos[2] || 0); + var rot = p.rot || [0, 0, 0]; + var ry = rot[1] || 0; + // double-correction guard: buildGeometry already squares up a 4-segment + // cone, so an author-supplied 45° Y rotation stacks to 90° and turns the + // hip roof back into an overhanging diamond + var coneSegs = p.primitive === 'cone' ? Math.max(3, Math.round((p.d || [])[2] != null ? (p.d || [])[2] : 24)) : 0; + if (coneSegs === 4 && ry) { + var quarter = Math.PI / 2; + // subtract the spurious 45°, never snap: 45° is exactly halfway to 90°, + // and resolving it upward would swap the roof's width and depth + if (Math.abs((((ry % quarter) + quarter) % quarter) - Math.PI / 4) < 0.09) { + ry -= Math.sign(ry) * (Math.PI / 4); + } + } + obj.rotation.set(rot[0] || 0, ry, rot[2] || 0); + var scl = p.scl || [1, 1, 1]; + obj.scale.set(scl[0] || 1, scl[1] || 1, scl[2] || 1); + objects.set(p.id, obj); + parentIds.set(p.id, p.parent || null); + if (p.attachTo) attachments.push({ id: p.id, to: p.attachTo, prim: p.primitive }); + if (p.repeat) repeats.push({ id: p.id, rep: p.repeat, to: p.attachTo }); + if (p.grounded) groundedDeclared = true; + } + + function assemble() { + // parent linkage — unknown/missing/self parents attach to root + objects.forEach(function (obj, id) { + var pid = parentIds.get(id); + var parent = pid && pid !== id ? objects.get(pid) : undefined; + (parent || root).add(obj); + }); + // cycle guard — anything orphaned by a parentId cycle reattaches to root + objects.forEach(function (obj) { + var ancestor = obj.parent; + while (ancestor && ancestor !== root) ancestor = ancestor.parent; + if (ancestor !== root) { + if (obj.removeFromParent) obj.removeFromParent(); + root.add(obj); + } + }); + // declared joints — pull each part into ~0.03 overlap with its support. + // The ceiling scales with the object (as the backstop's does below) so a + // solver can only ever close an authoring gap, never carry a part across + // the model to the wrong side of it. + root.updateWorldMatrix(true, true); + var jointSize = new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3()); + var maxPull = 0.15 * Math.max(jointSize.x, jointSize.y, jointSize.z, 0.001); + attachments.forEach(function (att) { + var obj = objects.get(att.id); + var target = objects.get(att.to); + if (!obj || !target || obj === target) return; + var ancestor = obj.parent; + while (ancestor) { + if (ancestor === target) return; // nested — contact guaranteed + ancestor = ancestor.parent; + } + var a = new THREE.Box3().setFromObject(obj); + var b = new THREE.Box3().setFromObject(target); + if (a.isEmpty() || b.isEmpty()) return; + var margin = 0.03; + var delta = new THREE.Vector3(); + ['x', 'y', 'z'].forEach(function (axis) { + if (a.min[axis] > b.max[axis]) delta[axis] = b.max[axis] - a.min[axis] + margin; + else if (a.max[axis] < b.min[axis]) delta[axis] = b.min[axis] - a.max[axis] + margin; + }); + if (delta.lengthSq() === 0 || delta.length() > maxPull) return; + var worldPos = obj.getWorldPosition(new THREE.Vector3()); + worldPos.add(delta); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + }); + // …then seat the features contact alone leaves wrong — buried inside the + // mass they mount on, or floating off it on one grazing rim + if (typeof seatSurfaceParts === 'function') { + seatSurfaceParts(THREE, objects, attachments.filter(function (att) { + return att.prim !== 'curvedDecal' && att.prim !== 'textDecal' + && att.prim !== 'glow' && att.prim !== 'tube'; + })); + } + // repeat expansion — one declared part clones into N placed copies. + // expandRepeatInstances is emitted from the studio's own module source, so + // this arrays parts exactly the way the viewport does. + repeats.forEach(function (req) { + var original = objects.get(req.id); + if (!original) return; + expandRepeatInstances( + THREE, + original, + req.rep, + req.to ? objects.get(req.to) : undefined, + root, + function (clone) { + clone.traverse(function (child) { if (child.isMesh) meshes.push(child); }); + } + ); + }); + // contact backstop — a part touching nothing is pulled into ~0.03 overlap + // with the support it DECLARED, via the minimal per-axis translation + // (closes gaps in ANY direction, not just straight down). A part with no + // declared attachTo stays exactly where it was authored: guessing the + // nearest neighbour used to drag parts the reference never contained onto + // whatever happened to be closest, welding them into a clump. maxSnap + // scales with the object (shared with the inset pass below) so a solver can + // only close an authoring gap, never relocate a part across the model. + root.updateWorldMatrix(true, true); + var solverSize = new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3()); + var maxSnap = 0.15 * Math.max(solverSize.x, solverSize.y, solverSize.z, 0.001); + if (meshes.length > 1) { + root.updateWorldMatrix(true, true); + var boxes = meshes.map(function (mesh) { + var b = new THREE.Box3().setFromObject(mesh); + b.expandByScalar(0.03); + return b; + }); + var floating = []; + for (var i = 0; i < meshes.length; i++) { + var touches = false; + for (var j = 0; j < meshes.length; j++) { + if (i !== j && boxes[i].intersectsBox(boxes[j])) { touches = true; break; } + } + if (!touches) floating.push(i); + } + var attachToByName = new Map(); + attachments.forEach(function (att) { attachToByName.set(att.id, att.to); }); + var contactDelta = function (a, b) { + var margin = 0.03; + var delta = new THREE.Vector3(); + ['x', 'y', 'z'].forEach(function (axis) { + if (a.min[axis] > b.max[axis]) delta[axis] = b.max[axis] - a.min[axis] + margin; + else if (a.max[axis] < b.min[axis]) delta[axis] = b.min[axis] - a.max[axis] + margin; + }); + return delta; + }; + floating.forEach(function (i) { + var name = meshes[i].name || ''; + var baseName = name.replace(/-\d+$/, ''); + var targetBox; + // the declared joint is the only snap target + var attachTo = attachToByName.has(name) ? attachToByName.get(name) : attachToByName.get(baseName); + var targetObj = attachTo ? objects.get(attachTo) : undefined; + if (targetObj && targetObj !== meshes[i]) { + var tb = new THREE.Box3().setFromObject(targetObj); + if (!tb.isEmpty()) { targetBox = tb; } + } + if (!targetBox) return; + var delta = contactDelta(boxes[i], targetBox); + var dist = delta.length(); + if (dist === 0 || dist > maxSnap) return; + var worldPos = meshes[i].getWorldPosition(new THREE.Vector3()); + worldPos.add(delta); + meshes[i].position.copy(meshes[i].parent ? meshes[i].parent.worldToLocal(worldPos) : worldPos); + boxes[i].translate(delta); + }); + root.updateWorldMatrix(true, true); + } + // inset thin panels (windows / glass / signs) flush into their wall. The + // panel's thinnest world axis is its surface normal; using the WALL's + // thinnest axis moves side windows onto roofs whenever Y is the wall's + // smallest dimension. + // rings and bands never qualify: a torus/flatRing/arch encircles its host + // instead of sitting in one of its faces, and its thinnest axis is the one + // it wraps around — so insetting slides a collar or a cap rib to the end of + // the very part it should be banding. + root.updateWorldMatrix(true, true); + var NEVER_INSET = ['torus', 'flatRing', 'arch']; + attachments.forEach(function (att) { + if (NEVER_INSET.indexOf(att.prim) !== -1) return; + var obj = objects.get(att.id); + var wall = objects.get(att.to); + if (!obj || !wall || obj === wall) return; + var p = new THREE.Box3().setFromObject(obj); + var w = new THREE.Box3().setFromObject(wall); + if (p.isEmpty() || w.isEmpty()) return; + var pSize = p.getSize(new THREE.Vector3()); + var wSize = w.getSize(new THREE.Vector3()); + var axes = ['x', 'y', 'z']; + var normal = axes.reduce(function (a, b) { return pSize[b] < pSize[a] ? b : a; }); + var faceAxes = axes.filter(function (a) { return a !== normal; }); + // a panel must be a PLATE in its own right — thickness a small fraction + // of its own face. Comparing only against the wall let any small part + // qualify: a screwcap is "thinner" than a bottle on all three axes, so it + // was flush-mounted to the bottle's SIDE and sat off-axis beside the neck. + // A roughly square cross-section (caps, knobs, wheels) never qualifies. + var face = faceAxes.map(function (a) { return pSize[a]; }); + var plateLike = pSize[normal] < 0.3 * Math.min(face[0], face[1]); + var thin = pSize[normal] < wSize[normal] * 0.6; + var fits = faceAxes.every(function (a) { return pSize[a] <= wSize[a] * 1.1; }); + if (!plateLike || !thin || !fits) return; + var pCenter = p.getCenter(new THREE.Vector3()); + var wCenter = w.getCenter(new THREE.Vector3()); + var side = pCenter[normal] >= wCenter[normal] ? 1 : -1; + var d = side > 0 ? w.max[normal] - p.max[normal] : w.min[normal] - p.min[normal]; + if (Math.abs(d) < 0.001 || Math.abs(d) > maxSnap) return; + var worldPos = obj.getWorldPosition(new THREE.Vector3()); + worldPos[normal] += d; + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + }); + root.updateWorldMatrix(true, true); + + // stand the object on the ground — LAST, once every solver above has + // finished, and only when the spec never used the "grounded" flag (which has + // its own drop). The model does not set that flag in practice, so without + // this an object sits wherever its coordinates landed: sunk into the ground, + // or floating above its own contact shadow. + if (!groundedDeclared) { + var standing = new THREE.Box3(); + for (var si = 0; si < meshes.length; si++) { + var sm = meshes[si]; + if (/shadow/i.test(sm.name) || sm.isSprite) continue; + standing.union(new THREE.Box3().setFromObject(sm)); + } + if (!standing.isEmpty()) { + var gdy = -standing.min.y; + var gh = standing.max.y - standing.min.y; + if (Math.abs(gdy) > 0.02 && gh > 0 && Math.abs(gdy) <= gh) { + root.position.y += gdy; + root.updateWorldMatrix(true, true); + } + } + } + } + + // ===== parts ===== + addPart({ id: 'root', primitive: 'group', pos: [0, 0, 0] }); + addPart({ id: 'chassis-frame', parent: 'root', primitive: 'box', d: [0.9, 0.3, 3], pos: [0, 0.6114, 0.2], scl: [1, 1.902, 1], mat: 'm-dark-steel', attachTo: 'tank-left' }); // structural beam under cab and box + addPart({ id: 'main-cab', parent: 'root', primitive: 'roundedBox', d: [1, 1.3, 1.3, 0.08, 0.03], pos: [0, 1.2227, -1.1], scl: [1, 1.5676, 1], mat: 'm-red-gloss', attachTo: 'chassis-frame' }); // hollow driver cab shell + addPart({ id: 'roof-fairing', parent: 'root', primitive: 'prism', d: [0.95, 0.9, 0.35], pos: [0, 2.4455, -0.75], scl: [1, 1.3974, 1], mat: 'm-red-gloss', attachTo: 'main-cab' }); // aero fairing tapering to cargo box + addPart({ id: 'cargo-box', parent: 'root', primitive: 'box', d: [1.05, 1.5, 1.9], pos: [0, 1.773, 0.47], scl: [1, 1.2227, 1], mat: 'm-red-satin', attachTo: 'chassis-frame' }); // enclosed rear cargo body + addPart({ id: 'front-bumper', parent: 'root', primitive: 'roundedBox', d: [1, 0.5, 0.35, 0.05, 0.02], pos: [0, 0.4891, -1.88], scl: [1, 1.3042, 1], mat: 'm-red-gloss', attachTo: 'main-cab' }); // lower front housing headlights + addPart({ id: 'headlight-left', parent: 'root', primitive: 'box', d: [0.18, 0.12, 0.05], pos: [-0.32, 0.5543, -2.05], scl: [1, 1.3042, 1], mat: 'm-headlight', attachTo: 'front-bumper' }); // multi-lens headlight cluster + addPart({ id: 'headlight-right', parent: 'root', primitive: 'box', d: [0.18, 0.12, 0.05], pos: [0.32, 0.5543, -2.05], scl: [1, 1.3042, 1], mat: 'm-headlight', attachTo: 'front-bumper' }); // multi-lens headlight cluster + addPart({ id: 'front-grille', parent: 'root', primitive: 'box', d: [0.5, 0.35, 0.08], pos: [0, 0.9782, -1.68], scl: [1, 1.3974, 1], mat: 'm-grille', attachTo: 'main-cab' }); // tiered louvered grille slats + addPart({ id: 'windshield', parent: 'root', primitive: 'box', d: [0.9, 0.5, 0.05], pos: [0, 1.7387, -1.6222], rot: [-0.3, 0, 0], mat: 'm-glass', attachTo: 'main-cab' }); // raked panoramic windshield + addPart({ id: 'side-window-left', parent: 'root', primitive: 'box', d: [0.05, 0.4, 0.5], pos: [-0.445, 1.6136, -1], scl: [1, 1.3431, 1], mat: 'm-glass', attachTo: 'main-cab' }); // driver side window + addPart({ id: 'side-window-right', parent: 'root', primitive: 'box', d: [0.05, 0.4, 0.5], pos: [0.445, 1.6136, -1], scl: [1, 1.3431, 1], mat: 'm-glass', attachTo: 'main-cab' }); // passenger side window + addPart({ id: 'side-mirror', parent: 'root', primitive: 'roundedBox', d: [0.12, 0.35, 0.15, 0.02, 0.01], pos: [-0.54, 1.7118, -1.55], scl: [1, 1.3974, 1], mat: 'm-mirror', attachTo: 'main-cab' }); // tall driver side mirror housing + addPart({ id: 'front-wheel-left', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.3, 20], pos: [-0.62, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame' }); // front steer tire + addPart({ id: 'front-wheel-right', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.3, 20], pos: [0.62, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame' }); // front steer tire + addPart({ id: 'front-wheel-rim-left', parent: 'root', primitive: 'cylinder', d: [0.22, 0.22, 0.05, 16], pos: [-0.715, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-silver-metal', attachTo: 'front-wheel-left' }); // silver hub recessed in tire + addPart({ id: 'front-wheel-rim-right', parent: 'root', primitive: 'cylinder', d: [0.22, 0.22, 0.05, 16], pos: [0.715, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-silver-metal', attachTo: 'front-wheel-right' }); // silver hub recessed in tire + addPart({ id: 'rear-wheel-left', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.25, 20], pos: [-0.62, 0.5502, 1.3], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame', repeat: { count: 2, mode: 'linear', offset: [-0.27, 0, 0] } }); // dual rear tire pair + addPart({ id: 'rear-wheel-right', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.25, 20], pos: [0.62, 0.5502, 1.3], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame', repeat: { count: 2, mode: 'linear', offset: [0.27, 0, 0] } }); // dual rear tire pair + addPart({ id: 'tank-left', parent: 'root', primitive: 'cylinder', d: [0.18, 0.18, 0.9, 16], pos: [-0.5, 0.6114, 0.5], rot: [1.5708, 0, 0], mat: 'm-silver-metal', attachTo: 'chassis-frame' }); // aluminum fuel tank + addPart({ id: 'tank-right', parent: 'root', primitive: 'cylinder', d: [0.18, 0.18, 0.9, 16], pos: [0.5, 0.6114, 0.5], rot: [1.5708, 0, 0], mat: 'm-silver-metal', attachTo: 'chassis-frame' }); // aluminum fuel tank + addPart({ id: 'guard-left', parent: 'root', primitive: 'box', d: [0.06, 0.15, 1.6], pos: [-0.56, 0.4755, 0.5], scl: [1, 0.9057, 1], mat: 'm-guard-white', attachTo: 'chassis-frame' }); // underrun safety guard rail + addPart({ id: 'guard-right', parent: 'root', primitive: 'box', d: [0.06, 0.15, 1.6], pos: [0.56, 0.4755, 0.5], scl: [1, 0.9057, 1], mat: 'm-guard-white', attachTo: 'chassis-frame' }); // underrun safety guard rail + + assemble(); + return { group: root, meshes: meshes }; +} + +// machine-readable source spec — the studio reads this back to refine or +// regenerate; editing addPart() lines above without updating it is fine +// for one-off tweaks, but regeneration works from this data +var SCULPT_SPEC = {"objectName":"Red Cab-Over Delivery Truck","inputKind":"object","objectClass":"hard-surface","complexity":"complex","identityFeatures":["bright red cab-over truck body","aerodynamic roof fairing spoiler above driver cab","large rectangular red cargo box with lower curtain trim","tiered front grille with horizontal dark slats","integrated multi-lens headlight clusters on front bumper","side safety guard rails and cylindrical silver fuel tanks","dual rear wheels and front steer wheels with silver rims"],"materials":[{"materialId":"m-red-gloss","baseColor":"#d81118","roughness":0.3,"metalness":0.1,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":0.6,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-red-satin","baseColor":"#d81118","roughness":0.55,"metalness":0.15,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-dark-steel","baseColor":"#1e2022","roughness":0.6,"metalness":0.7,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-grille","baseColor":"#1a1b1e","roughness":0.7,"metalness":0.3,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":"louver"},{"materialId":"m-glass","baseColor":"#2a3540","roughness":0.1,"metalness":0,"opacity":1,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":0.85,"finish":null},{"materialId":"m-mirror","baseColor":"#22252a","roughness":0.6,"metalness":0.2,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-rubber","baseColor":"#242628","roughness":0.9,"metalness":0,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":"tread"},{"materialId":"m-silver-metal","baseColor":"#9fa5aa","roughness":0.35,"metalness":0.9,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-guard-white","baseColor":"#cfd4d8","roughness":0.5,"metalness":0.3,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-headlight","baseColor":"#f5f5f0","roughness":0.15,"metalness":0,"opacity":null,"emissive":"#fff2c0","emissiveIntensity":0.4,"clearcoat":null,"sheen":null,"transmission":0.5,"finish":null}],"components":[{"nodeId":"root","parentId":null,"primitive":"group","dimensions":[],"position":[0,0,0],"rotation":[0,0,0],"scale":[1,1,1],"materialId":null,"text":null,"partRef":null,"textureRef":null,"textureUrl":null,"repeat":null,"attachTo":null,"note":null},{"nodeId":"chassis-frame","parentId":"root","primitive":"box","dimensions":[0.9,0.3,3],"position":[0,0.6114,0.2],"rotation":[0,0,0],"scale":[1,1.902,1],"materialId":"m-dark-steel","text":null,"partRef":"chassis-frame","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"tank-left","note":"structural beam under cab and box"},{"nodeId":"main-cab","parentId":"root","primitive":"roundedBox","dimensions":[1,1.3,1.3,0.08,0.03],"position":[0,1.2227,-1.1],"rotation":[0,0,0],"scale":[1,1.5676,1],"materialId":"m-red-gloss","text":null,"partRef":"main-cab","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"hollow driver cab shell"},{"nodeId":"roof-fairing","parentId":"root","primitive":"prism","dimensions":[0.95,0.9,0.35],"position":[0,2.4455,-0.75],"rotation":[0,0,0],"scale":[1,1.3974,1],"materialId":"m-red-gloss","text":null,"partRef":"roof-fairing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"aero fairing tapering to cargo box"},{"nodeId":"cargo-box","parentId":"root","primitive":"box","dimensions":[1.05,1.5,1.9],"position":[0,1.773,0.47],"rotation":[0,0,0],"scale":[1,1.2227,1],"materialId":"m-red-satin","text":null,"partRef":"cargo-box","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"enclosed rear cargo body"},{"nodeId":"front-bumper","parentId":"root","primitive":"roundedBox","dimensions":[1,0.5,0.35,0.05,0.02],"position":[0,0.4891,-1.88],"rotation":[0,0,0],"scale":[1,1.3042,1],"materialId":"m-red-gloss","text":null,"partRef":"front-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"lower front housing headlights"},{"nodeId":"headlight-left","parentId":"root","primitive":"box","dimensions":[0.18,0.12,0.05],"position":[-0.32,0.5543,-2.05],"rotation":[0,0,0],"scale":[1,1.3042,1],"materialId":"m-headlight","text":null,"partRef":"front-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-bumper","note":"multi-lens headlight cluster"},{"nodeId":"headlight-right","parentId":"root","primitive":"box","dimensions":[0.18,0.12,0.05],"position":[0.32,0.5543,-2.05],"rotation":[0,0,0],"scale":[1,1.3042,1],"materialId":"m-headlight","text":null,"partRef":"front-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-bumper","note":"multi-lens headlight cluster"},{"nodeId":"front-grille","parentId":"root","primitive":"box","dimensions":[0.5,0.35,0.08],"position":[0,0.9782,-1.68],"rotation":[0,0,0],"scale":[1,1.3974,1],"materialId":"m-grille","text":null,"partRef":"front-grille","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"tiered louvered grille slats"},{"nodeId":"windshield","parentId":"root","primitive":"box","dimensions":[0.9,0.5,0.05],"position":[0,1.7387,-1.6222],"rotation":[-0.3,0,0],"scale":[1,1,1],"materialId":"m-glass","text":null,"partRef":"cab-glazing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"raked panoramic windshield"},{"nodeId":"side-window-left","parentId":"root","primitive":"box","dimensions":[0.05,0.4,0.5],"position":[-0.445,1.6136,-1],"rotation":[0,0,0],"scale":[1,1.3431,1],"materialId":"m-glass","text":null,"partRef":"cab-glazing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"driver side window"},{"nodeId":"side-window-right","parentId":"root","primitive":"box","dimensions":[0.05,0.4,0.5],"position":[0.445,1.6136,-1],"rotation":[0,0,0],"scale":[1,1.3431,1],"materialId":"m-glass","text":null,"partRef":"cab-glazing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"passenger side window"},{"nodeId":"side-mirror","parentId":"root","primitive":"roundedBox","dimensions":[0.12,0.35,0.15,0.02,0.01],"position":[-0.54,1.7118,-1.55],"rotation":[0,0,0],"scale":[1,1.3974,1],"materialId":"m-mirror","text":null,"partRef":"side-mirrors","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"tall driver side mirror housing"},{"nodeId":"front-wheel-left","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.3,20],"position":[-0.62,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"front steer tire"},{"nodeId":"front-wheel-right","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.3,20],"position":[0.62,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"front steer tire"},{"nodeId":"front-wheel-rim-left","parentId":"root","primitive":"cylinder","dimensions":[0.22,0.22,0.05,16],"position":[-0.715,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-wheel-left","note":"silver hub recessed in tire"},{"nodeId":"front-wheel-rim-right","parentId":"root","primitive":"cylinder","dimensions":[0.22,0.22,0.05,16],"position":[0.715,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-wheel-right","note":"silver hub recessed in tire"},{"nodeId":"rear-wheel-left","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.25,20],"position":[-0.62,0.5502,1.3],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"rear-wheels","textureRef":null,"textureUrl":null,"repeat":{"count":2,"mode":"linear","offset":[-0.27,0,0]},"attachTo":"chassis-frame","note":"dual rear tire pair"},{"nodeId":"rear-wheel-right","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.25,20],"position":[0.62,0.5502,1.3],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"rear-wheels","textureRef":null,"textureUrl":null,"repeat":{"count":2,"mode":"linear","offset":[0.27,0,0]},"attachTo":"chassis-frame","note":"dual rear tire pair"},{"nodeId":"tank-left","parentId":"root","primitive":"cylinder","dimensions":[0.18,0.18,0.9,16],"position":[-0.5,0.6114,0.5],"rotation":[1.5708,0,0],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"aluminum fuel tank"},{"nodeId":"tank-right","parentId":"root","primitive":"cylinder","dimensions":[0.18,0.18,0.9,16],"position":[0.5,0.6114,0.5],"rotation":[1.5708,0,0],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"aluminum fuel tank"},{"nodeId":"guard-left","parentId":"root","primitive":"box","dimensions":[0.06,0.15,1.6],"position":[-0.56,0.4755,0.5],"rotation":[0,0,0],"scale":[1,0.9057,1],"materialId":"m-guard-white","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"underrun safety guard rail"},{"nodeId":"guard-right","parentId":"root","primitive":"box","dimensions":[0.06,0.15,1.6],"position":[0.56,0.4755,0.5],"rotation":[0,0,0],"scale":[1,0.9057,1],"materialId":"m-guard-white","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"underrun safety guard rail"}]}; + +if (typeof module !== 'undefined') { + module.exports = { buildSculpture: buildSculpture, SCULPT_SPEC: SCULPT_SPEC }; +} diff --git a/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/exports/red-cab-over-delivery-truck-round-2.js b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/exports/red-cab-over-delivery-truck-round-2.js new file mode 100644 index 00000000..7baba576 --- /dev/null +++ b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/exports/red-cab-over-delivery-truck-round-2.js @@ -0,0 +1,733 @@ +/* eslint-disable */ +// Red Cab-Over Delivery Truck — procedural three.js model +// generated by Boxel Img-to-3D Studio from its sculpt spec · round 2 +// +// Usage (three.js r0.147): +// var built = buildSculpture(THREE); +// scene.add(built.group); +// +// Every part below is one addPart() call — dimensions, positions and +// materials are plain numbers you can edit directly. + +function buildSculpture(THREE) { + 'use strict'; + var root = new THREE.Group(); + root.name = 'Red Cab-Over Delivery Truck'; + + // ===== materials ===== + var MATERIALS = {}; + MATERIALS['m-red-gloss'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#d81118'), roughness: 0.3, metalness: 0.1, clearcoat: 0.6, clearcoatRoughness: 0.3, envMapIntensity: 0.35 }); + MATERIALS['m-red-satin'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#d81118'), roughness: 0.55, metalness: 0.15, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + MATERIALS['m-dark-steel'] = new THREE.MeshStandardMaterial({ color: new THREE.Color('#1e2022'), roughness: 0.6, metalness: 0.7, envMapIntensity: 0.35 }); + MATERIALS['m-grille'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#1a1b1e'), roughness: 0.9, metalness: 0.1, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + (function () { + var tex = makeFinishTexture(THREE, 'louver', 'm-grille', '#1a1b1e'); + if (!tex) return; + var mat = MATERIALS['m-grille']; + mat.map = tex; mat.roughnessMap = tex; + mat.color = new THREE.Color('#ffffff'); + mat.needsUpdate = true; + })(); + MATERIALS['m-glass'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#dfeef5'), roughness: 0.05, metalness: 0.1, transparent: true, opacity: 0.85, transmission: 0.9, ior: 1.5, thickness: 0.05, envMapIntensity: 1 }); + MATERIALS['m-mirror'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#22252a'), roughness: 0.6, metalness: 0.2, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + MATERIALS['m-rubber'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#242628'), roughness: 0.9, metalness: 0, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + (function () { + var tex = makeFinishTexture(THREE, 'tread', 'm-rubber', '#242628'); + if (!tex) return; + var mat = MATERIALS['m-rubber']; + mat.bumpMap = tex; mat.bumpScale = 0.02; + mat.needsUpdate = true; + })(); + MATERIALS['m-silver-metal'] = new THREE.MeshStandardMaterial({ color: new THREE.Color('#9fa5aa'), roughness: 0.35, metalness: 0.9, envMapIntensity: 0.35 }); + MATERIALS['m-guard-white'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#cfd4d8'), roughness: 0.5, metalness: 0.3, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + MATERIALS['m-headlight'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#f5f5f0'), roughness: 0.15, metalness: 0, emissive: new THREE.Color('#fff2c0'), emissiveIntensity: 0.4, transmission: 0.9, ior: 1.5, thickness: 0.05, envMapIntensity: 1 }); + MATERIALS['m-bezel'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#1a1b1e'), roughness: 0.9, metalness: 0.1, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + MATERIALS['m-brake-light'] = new THREE.MeshPhysicalMaterial({ color: new THREE.Color('#e60000'), roughness: 0.2, metalness: 0.1, emissive: new THREE.Color('#990000'), emissiveIntensity: 0.3, clearcoat: 0.15, clearcoatRoughness: 0.35, envMapIntensity: 0.35 }); + var FALLBACK_MATERIAL = new THREE.MeshStandardMaterial({ color: 0x8a8f9c, roughness: 0.55, metalness: 0.25 }); + + // rounded-rect outline used by rounded slabs/plates + function roundedRectShape(len, depth, r) { + var s = new THREE.Shape(); + var hx = Math.max(len / 2 - r, 0.001), hz = Math.max(depth / 2 - r, 0.001); + s.absarc(-hx, -hz, r, Math.PI, Math.PI * 1.5); + s.absarc(hx, -hz, r, Math.PI * 1.5, 0); + s.absarc(hx, hz, r, 0, Math.PI * 0.5); + s.absarc(-hx, hz, r, Math.PI * 0.5, Math.PI); + return s; + } + + // procedural surface finishes — the painted canvas doubles as colour and + // roughness map, which is what makes a surface read as weathered metal + // instead of plastic. Painter sources are emitted from the studio module, + // so this is the same paint the studio viewport shows. + function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; + a = a + 0x6d2b79f5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + function seedFrom(text) { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; + } + function paintLouver(ctx, S) { + ctx.fillStyle = '#3a3d40'; + ctx.fillRect(0, 0, S, S); + let rows = 12; + let rowH = S / rows; + for (let r = 0; r < rows; r++) { + ctx.fillStyle = '#15171a'; + ctx.fillRect(0, r * rowH + rowH * 0.45, S, rowH * 0.4); + ctx.fillStyle = '#6a6e72'; + ctx.fillRect(0, r * rowH + rowH * 0.05, S, rowH * 0.12); + } + } + function paintTread(ctx, S, rand) { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, S, S); + let rows = 14; + let rowH = S / rows; + for (let r = 0; r < rows; r++) { + // dark groove line per row + ctx.fillStyle = '#5f5f5f'; + ctx.fillRect(0, r * rowH + rowH * 0.62, S, rowH * 0.3); + // staggered lug notches + ctx.fillStyle = '#7a7a7a'; + let offset = r % 2 * (S / 12); + for (let x = -S / 12; x < S; x += S / 6) { + ctx.fillRect(x + offset, r * rowH + rowH * 0.1, S / 24, rowH * 0.45); + } + if (rand() > 2) break; // keep rand consumed signature-compatible + } + } + function makeFinishTexture(THREE, finish, seedText, baseColor) { + var S = 1024; + var cv = document.createElement('canvas'); + cv.width = cv.height = S; + var ctx = cv.getContext('2d'); + var rand = mulberry32(seedFrom(seedText)); + switch (finish) { + case 'louver': paintLouver(ctx, S); break; + case 'tread': paintTread(ctx, S, rand); break; + default: return undefined; + } + var tex = new THREE.CanvasTexture(cv); + tex.encoding = THREE.sRGBEncoding; + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.anisotropy = 8; + return tex; + } + + function expandRepeatInstances(THREE, original, rep, host, root, onClone) { + if (!rep || typeof rep !== 'object') return; + let count = Math.min(48, Math.max(0, Math.round(rep.count || 0))); + if (count < 2) return; + let parent = original.parent || root; + let axis = rep.axis === 'x' ? 'x' : rep.axis === 'z' ? 'z' : 'y'; + let basePos = original.position.clone(); + let baseQuat = original.quaternion.clone(); + // RING CENTER for a radial array. The part's own in-plane position is + // already a point ON the intended circle, so adding the radius to it put + // every clone at twice the radius — 20 knurl ridges orbited at 0.29 around a + // 0.16 cap, reading as a spiked collar floating off the cap. The circle + // belongs to the part this array wraps around, so its axis supplies the + // in-plane center; the position ALONG the axis stays where the part was + // authored. With no declared host there is nothing to centre on, so the + // part's own position is kept as the centre. + let center = basePos.clone(); + if (rep.mode === 'radial' && host && host !== original) { + let hostBox = new THREE.Box3().setFromObject(host); + if (!hostBox.isEmpty()) { + let hostCenter = hostBox.getCenter(new THREE.Vector3()); + parent.worldToLocal(hostCenter); + if (axis === 'y') { + center.x = hostCenter.x; + center.z = hostCenter.z; + } else if (axis === 'x') { + center.y = hostCenter.y; + center.z = hostCenter.z; + } else { + center.x = hostCenter.x; + center.y = hostCenter.y; + } + } + } + // place instance i — index 0 is the original itself, so a radial array is one + // coherent ring instead of the original sitting off the circle its own clones + // orbit on + let place = (obj, i) => { + if (rep.mode === 'radial') { + let radius = rep.radius != null ? rep.radius : 0.5; + let angle = i / count * Math.PI * 2; + let ca = Math.cos(angle) * radius; + let sa = Math.sin(angle) * radius; + // The instance is carried around the ring RIGIDLY: the orbital angle + // composes OUTSIDE the part's own orientation, so a part already aimed + // along the ring axis keeps that aim. Adding the angle to the matching + // Euler component instead composes it inside that orientation, and since + // Euler order is XYZ that tilts every clone by its own angle — six + // minigun barrels laid along z came out crossed like an asterisk rather + // than parallel. Positions sweep +x→+z about y, which is a rotation + // about −y, so that one axis spins the opposite way to stay in step. + let spin = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(axis === 'x' ? 1 : 0, axis === 'y' ? 1 : 0, axis === 'z' ? 1 : 0), axis === 'y' ? -angle : angle); + obj.quaternion.copy(baseQuat).premultiply(spin); + if (axis === 'y') { + obj.position.set(center.x + ca, center.y, center.z + sa); + } else if (axis === 'x') { + obj.position.set(center.x, center.y + ca, center.z + sa); + } else { + obj.position.set(center.x + ca, center.y + sa, center.z); + } + } else { + let offset = Array.isArray(rep.offset) ? rep.offset : [0.2, 0, 0]; + obj.position.set(basePos.x + offset[0] * i, basePos.y + offset[1] * i, basePos.z + offset[2] * i); + } + }; + place(original, 0); + for (let i = 1; i < count; i++) { + let clone = original.clone(true); + clone.name = `${original.name || 'part'}-${i}`; + place(clone, i); + parent.add(clone); + onClone(clone); + } + } + + function seatSurfaceParts(THREE, objects, joints) { + let logs = []; + if (!joints || !joints.length) return logs; + // ellipsoid radius along a unit direction, from the half extents of a box + function radiusAlong(half, unit) { + let hx = Math.max(half.x, 1e-6); + let hy = Math.max(half.y, 1e-6); + let hz = Math.max(half.z, 1e-6); + let q = unit.x / hx * (unit.x / hx) + unit.y / hy * (unit.y / hy) + unit.z / hz * (unit.z / hz); + if (!(q > 0)) return 0; + return 1 / Math.sqrt(q); + } + function measure(obj) { + let box = new THREE.Box3().setFromObject(obj); + if (box.isEmpty()) return undefined; + let size = box.getSize(new THREE.Vector3()); + return { + center: box.getCenter(new THREE.Vector3()), + half: size.multiplyScalar(0.5), + mean: (size.x + size.y + size.z) / 3 + }; + } + let hostOf = new Map(); + for (let i = 0; i < joints.length; i++) { + let joint = joints[i]; + if (joint && joint.id && joint.to) hostOf.set(joint.id, joint.to); + } + // Hosts settle before the features on them: a pupil is seated on an eyeball + // that may itself be moving onto the muzzle this same pass, and reading a + // stale eyeball position would seat the pupil against a surface that is no + // longer there. Depth in the joint chain is that order. + function depthOf(id) { + let depth = 0; + let at = id; + let seen = {}; + while (hostOf.has(at) && !seen[at]) { + seen[at] = true; + at = hostOf.get(at); + depth++; + if (depth > 32) break; + } + return depth; + } + let ordered = joints.slice().sort(function (a, b) { + return depthOf(a.id) - depthOf(b.id); + }); + // What this pass has already moved, so a feature declared on a moved host + // travels with it. A pupil is authored against its eyeball's position; once + // the eyeball slides round to the front of the face, the pupil's offset is + // stale rather than wrong, and re-seating it from where it was left would + // read the wrong direction and then reject the correction as too large. + let carried = new Map(); + for (let n = 0; n < ordered.length; n++) { + let joint = ordered[n]; + let obj = objects.get(joint.id); + let host = objects.get(joint.to); + if (!obj || !host || obj === host) continue; + let inherited = carried.get(joint.to); + if (inherited) { + let world = obj.getWorldPosition(new THREE.Vector3()).add(inherited); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(world) : world); + obj.updateWorldMatrix(true, true); + carried.set(joint.id, inherited.clone()); + } + // a nested part's box lives inside its parent's by construction, so + // "buried" is what it is supposed to be + let ancestor = obj.parent; + let nested = false; + while (ancestor) { + if (ancestor === host) { + nested = true; + break; + } + ancestor = ancestor.parent; + } + if (nested) continue; + let c = measure(obj); + let h = measure(host); + if (!c || !h || !(h.mean > 0) || !(c.mean > 0)) continue; + // Only a FEATURE gets seated. Two parts of comparable size are a + // structural stack (torso on shorts, roof on storey) whose placement the + // declared joint already owns, and pulling one to the other's surface + // would be this pass overruling the assembly graph. + if (c.mean > 0.85 * h.mean) continue; + // ...and only a COMPACT one. An ellipsoid centred on the part is a fair + // model of an eye, a nose or a wheel hub, and a useless model of an arm: + // a limb's centre is half its length away from the joint it hangs off, so + // seating that centre on the shoulder buries the whole arm in the torso. + // The elongated parts are exactly the ones whose placement the declared + // joint already handles well, so requiring roundness costs nothing. + let cH = [c.half.x, c.half.y, c.half.z]; + let cMin = Math.min(cH[0], cH[1], cH[2]); + let cMax = Math.max(cH[0], cH[1], cH[2]); + if (!(cMin > 0) || cMax > 2 * cMin) continue; + let dir = c.center.clone().sub(h.center); + if (dir.lengthSq() < 1e-8) continue; + let dist = dir.length(); + let unit = dir.clone().divideScalar(dist); + let hostR = radiusAlong(h.half, unit); + let childR = radiusAlong(c.half, unit); + if (!(hostR > 0) || !(childR > 0)) continue; + // WHICH WAY IS OUT — the error that survives every other check, because + // depth alone cannot see it. For a feature on a free-standing mass, "out" + // is simply away from that mass's centre. But when the host is ITSELF a + // feature bolted to something bigger — a muzzle on a skull — the exposed + // side of the muzzle is the side pointing away from the skull, and a + // feature whose authored offset points the other way is behind the face. + // It can be perfectly seated on the muzzle's surface and still be inside + // the head, visible from nowhere: this Mickey's eyes sat 0.24 behind the + // muzzle's centre and every box test called them attached. + // + // The offset's tangential part carries the feature's left/right and + // up/down placement on the face and is kept as authored; only the + // through-the-face component is rebuilt, positive by construction. + let flipped = false; + let grandId = hostOf.get(joint.to); + let grand = grandId ? objects.get(grandId) : undefined; + let g = grand && grand !== host ? measure(grand) : undefined; + if (g && h.mean <= 0.8 * g.mean) { + let outward = h.center.clone().sub(g.center); + if (outward.lengthSq() > 1e-8) { + outward.normalize(); + let along = dir.dot(outward); + if (along < 0.15 * dist) { + let rebuilt = dir.clone().addScaledVector(outward, -along).addScaledVector(outward, 0.6 * dist); + if (rebuilt.lengthSq() > 1e-8) { + dir = rebuilt; + dist = dir.length(); + unit = dir.clone().divideScalar(dist); + hostR = radiusAlong(h.half, unit); + childR = radiusAlong(c.half, unit); + flipped = true; + } + } + } + } + if (!(hostR > 0) || !(childR > 0)) continue; + // Depth. A seated feature bites into its host by about a quarter of its + // own radius: enough that the join reads as one form, little enough that + // the feature is still mostly proud of the surface. + let seated = hostR + 0.75 * childR; + // Only the two errors the box solver next door CANNOT see. A part merely + // sitting in mid-air short of its host is that solver's case and it + // handles it well; this pass reaching for it too meant an upper arm being + // "seated" 0.34 into the torso, because an ellipsoid centred on a limb is + // nowhere near the shoulder the limb actually hangs from. + let buried = dist + childR <= hostR; + if (!flipped && !buried) continue; + let move = seated - dist; + if (Math.abs(move) < 1e-4) continue; + // never relocate a part across the model: a correction larger than the + // host itself means the spec is wrong somewhere this pass cannot see + if (Math.abs(move) > 1.2 * hostR) { + logs.push(`'${joint.id}' sits more than its own host's radius out of place on '${joint.to}' — left for refine`); + continue; + } + let why = flipped ? 'was on the hidden side' : 'was buried'; + let shift = h.center.clone().addScaledVector(unit, seated).sub(c.center); + let previous = carried.get(joint.id); + carried.set(joint.id, previous ? previous.add(shift.clone()) : shift.clone()); + let worldPos = obj.getWorldPosition(new THREE.Vector3()).add(shift); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + logs.push(`seated '${joint.id}' on '${joint.to}' (${why}, moved ${Math.abs(move).toFixed(2)})`); + } + return logs; + } + + // geometry per primitive — same defaults as the studio interpreter + function buildGeometry(primitive, d) { + switch (primitive) { + case 'box': + return new THREE.BoxGeometry(d[0] ?? 1, d[1] ?? 1, d[2] ?? 1); + case 'roundedBox': { + // [w, h, d, cornerRadius?, bevel?] — needs the RoundedBoxGeometry + // add-on; falls back to an extruded rounded-rect slab without it + var w = d[0] ?? 1, h = d[1] ?? 0.3, dep = d[2] ?? 1; + var r = Math.min(Math.abs(d[3] ?? 0.1), Math.min(w, dep) / 2 - 0.001); + if (THREE.RoundedBoxGeometry) { + var radius = Math.min(r, Math.min(w, h, dep) / 2 - 0.001); + return new THREE.RoundedBoxGeometry(w, h, dep, 3, Math.max(0.01, radius)); + } + var bevel = Math.min(Math.abs(d[4] ?? 0.02), h / 3); + var geo = new THREE.ExtrudeGeometry(roundedRectShape(w, dep, r), { + depth: Math.max(h - bevel * 2, 0.001), bevelEnabled: bevel > 0, + bevelThickness: bevel, bevelSize: bevel, bevelSegments: 3, curveSegments: 24, + }); + geo.rotateX(-Math.PI / 2); + geo.translate(0, bevel - h / 2, 0); + return geo; + } + case 'prism': { + // [lengthAlongRidge, span, height] — triangular prism, ridge along X + var length = Math.abs(d[0] ?? 1.5), span = Math.abs(d[1] ?? 1), height = Math.abs(d[2] ?? 0.6); + var shape = new THREE.Shape(); + shape.moveTo(-span / 2, 0); + shape.lineTo(span / 2, 0); + shape.lineTo(0, height); + shape.closePath(); + var geo = new THREE.ExtrudeGeometry(shape, { depth: length, bevelEnabled: false }); + geo.translate(0, -height / 2, -length / 2); + geo.rotateY(Math.PI / 2); + return geo; + } + case 'cylinder': + return new THREE.CylinderGeometry( + d[0] ?? 0.5, d[1] ?? d[0] ?? 0.5, d[2] ?? 1, Math.max(3, Math.round(d[3] ?? 48))); + default: + return null; // 'group' carries no geometry + } + } + + // ===== assembly plumbing (mirrors the studio interpreter) ===== + var objects = new Map(); + var meshes = []; + var parentIds = new Map(); + var attachments = []; + var repeats = []; + // true when the spec drives its own heights via "grounded"; the final + // stand-on-the-ground step defers to it + var groundedDeclared = false; + + function material(id) { + if (id && MATERIALS[id]) return MATERIALS[id]; + var first = Object.keys(MATERIALS)[0]; + return first ? MATERIALS[first] : FALLBACK_MATERIAL; + } + + function addPart(p) { + if (objects.has(p.id)) return; + var obj; + // decal/glow builders are emitted only when the spec uses them — the + // typeof guards keep this shared runtime valid either way + if (p.primitive === 'glow' && typeof buildGlowSprite === 'function') { + obj = buildGlowSprite(p.d || [], p.color || '#eeeeee'); + obj.name = p.id; + obj.position.set(p.pos[0] || 0, p.pos[1] || 0, p.pos[2] || 0); + objects.set(p.id, obj); + parentIds.set(p.id, p.parent || null); + return; + } + if (p.primitive === 'textDecal' && typeof buildTextDecal === 'function') { + obj = buildTextDecal(p.d || [], p.text || '', p.color || '#eeeeee', p.texture); + meshes.push(obj); + } else if ( + p.primitive === 'curvedDecal' && + typeof buildCurvedDecal === 'function' + ) { + obj = buildCurvedDecal(p.d || [], p.text || '', p.color || '#eeeeee', p.texture); + meshes.push(obj); + } else { + var geometry = buildGeometry(p.primitive, p.d || []); + if (geometry) { + obj = new THREE.Mesh(geometry, material(p.mat)); + obj.castShadow = obj.receiveShadow = true; + meshes.push(obj); + } else { + obj = new THREE.Group(); + } + } + obj.name = p.id; + obj.position.set(p.pos[0] || 0, p.pos[1] || 0, p.pos[2] || 0); + var rot = p.rot || [0, 0, 0]; + var ry = rot[1] || 0; + // double-correction guard: buildGeometry already squares up a 4-segment + // cone, so an author-supplied 45° Y rotation stacks to 90° and turns the + // hip roof back into an overhanging diamond + var coneSegs = p.primitive === 'cone' ? Math.max(3, Math.round((p.d || [])[2] != null ? (p.d || [])[2] : 24)) : 0; + if (coneSegs === 4 && ry) { + var quarter = Math.PI / 2; + // subtract the spurious 45°, never snap: 45° is exactly halfway to 90°, + // and resolving it upward would swap the roof's width and depth + if (Math.abs((((ry % quarter) + quarter) % quarter) - Math.PI / 4) < 0.09) { + ry -= Math.sign(ry) * (Math.PI / 4); + } + } + obj.rotation.set(rot[0] || 0, ry, rot[2] || 0); + var scl = p.scl || [1, 1, 1]; + obj.scale.set(scl[0] || 1, scl[1] || 1, scl[2] || 1); + objects.set(p.id, obj); + parentIds.set(p.id, p.parent || null); + if (p.attachTo) attachments.push({ id: p.id, to: p.attachTo, prim: p.primitive }); + if (p.repeat) repeats.push({ id: p.id, rep: p.repeat, to: p.attachTo }); + if (p.grounded) groundedDeclared = true; + } + + function assemble() { + // parent linkage — unknown/missing/self parents attach to root + objects.forEach(function (obj, id) { + var pid = parentIds.get(id); + var parent = pid && pid !== id ? objects.get(pid) : undefined; + (parent || root).add(obj); + }); + // cycle guard — anything orphaned by a parentId cycle reattaches to root + objects.forEach(function (obj) { + var ancestor = obj.parent; + while (ancestor && ancestor !== root) ancestor = ancestor.parent; + if (ancestor !== root) { + if (obj.removeFromParent) obj.removeFromParent(); + root.add(obj); + } + }); + // declared joints — pull each part into ~0.03 overlap with its support. + // The ceiling scales with the object (as the backstop's does below) so a + // solver can only ever close an authoring gap, never carry a part across + // the model to the wrong side of it. + root.updateWorldMatrix(true, true); + var jointSize = new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3()); + var maxPull = 0.15 * Math.max(jointSize.x, jointSize.y, jointSize.z, 0.001); + attachments.forEach(function (att) { + var obj = objects.get(att.id); + var target = objects.get(att.to); + if (!obj || !target || obj === target) return; + var ancestor = obj.parent; + while (ancestor) { + if (ancestor === target) return; // nested — contact guaranteed + ancestor = ancestor.parent; + } + var a = new THREE.Box3().setFromObject(obj); + var b = new THREE.Box3().setFromObject(target); + if (a.isEmpty() || b.isEmpty()) return; + var margin = 0.03; + var delta = new THREE.Vector3(); + ['x', 'y', 'z'].forEach(function (axis) { + if (a.min[axis] > b.max[axis]) delta[axis] = b.max[axis] - a.min[axis] + margin; + else if (a.max[axis] < b.min[axis]) delta[axis] = b.min[axis] - a.max[axis] + margin; + }); + if (delta.lengthSq() === 0 || delta.length() > maxPull) return; + var worldPos = obj.getWorldPosition(new THREE.Vector3()); + worldPos.add(delta); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + }); + // …then seat the features contact alone leaves wrong — buried inside the + // mass they mount on, or floating off it on one grazing rim + if (typeof seatSurfaceParts === 'function') { + seatSurfaceParts(THREE, objects, attachments.filter(function (att) { + return att.prim !== 'curvedDecal' && att.prim !== 'textDecal' + && att.prim !== 'glow' && att.prim !== 'tube'; + })); + } + // repeat expansion — one declared part clones into N placed copies. + // expandRepeatInstances is emitted from the studio's own module source, so + // this arrays parts exactly the way the viewport does. + repeats.forEach(function (req) { + var original = objects.get(req.id); + if (!original) return; + expandRepeatInstances( + THREE, + original, + req.rep, + req.to ? objects.get(req.to) : undefined, + root, + function (clone) { + clone.traverse(function (child) { if (child.isMesh) meshes.push(child); }); + } + ); + }); + // contact backstop — a part touching nothing is pulled into ~0.03 overlap + // with the support it DECLARED, via the minimal per-axis translation + // (closes gaps in ANY direction, not just straight down). A part with no + // declared attachTo stays exactly where it was authored: guessing the + // nearest neighbour used to drag parts the reference never contained onto + // whatever happened to be closest, welding them into a clump. maxSnap + // scales with the object (shared with the inset pass below) so a solver can + // only close an authoring gap, never relocate a part across the model. + root.updateWorldMatrix(true, true); + var solverSize = new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3()); + var maxSnap = 0.15 * Math.max(solverSize.x, solverSize.y, solverSize.z, 0.001); + if (meshes.length > 1) { + root.updateWorldMatrix(true, true); + var boxes = meshes.map(function (mesh) { + var b = new THREE.Box3().setFromObject(mesh); + b.expandByScalar(0.03); + return b; + }); + var floating = []; + for (var i = 0; i < meshes.length; i++) { + var touches = false; + for (var j = 0; j < meshes.length; j++) { + if (i !== j && boxes[i].intersectsBox(boxes[j])) { touches = true; break; } + } + if (!touches) floating.push(i); + } + var attachToByName = new Map(); + attachments.forEach(function (att) { attachToByName.set(att.id, att.to); }); + var contactDelta = function (a, b) { + var margin = 0.03; + var delta = new THREE.Vector3(); + ['x', 'y', 'z'].forEach(function (axis) { + if (a.min[axis] > b.max[axis]) delta[axis] = b.max[axis] - a.min[axis] + margin; + else if (a.max[axis] < b.min[axis]) delta[axis] = b.min[axis] - a.max[axis] + margin; + }); + return delta; + }; + floating.forEach(function (i) { + var name = meshes[i].name || ''; + var baseName = name.replace(/-\d+$/, ''); + var targetBox; + // the declared joint is the only snap target + var attachTo = attachToByName.has(name) ? attachToByName.get(name) : attachToByName.get(baseName); + var targetObj = attachTo ? objects.get(attachTo) : undefined; + if (targetObj && targetObj !== meshes[i]) { + var tb = new THREE.Box3().setFromObject(targetObj); + if (!tb.isEmpty()) { targetBox = tb; } + } + if (!targetBox) return; + var delta = contactDelta(boxes[i], targetBox); + var dist = delta.length(); + if (dist === 0 || dist > maxSnap) return; + var worldPos = meshes[i].getWorldPosition(new THREE.Vector3()); + worldPos.add(delta); + meshes[i].position.copy(meshes[i].parent ? meshes[i].parent.worldToLocal(worldPos) : worldPos); + boxes[i].translate(delta); + }); + root.updateWorldMatrix(true, true); + } + // inset thin panels (windows / glass / signs) flush into their wall. The + // panel's thinnest world axis is its surface normal; using the WALL's + // thinnest axis moves side windows onto roofs whenever Y is the wall's + // smallest dimension. + // rings and bands never qualify: a torus/flatRing/arch encircles its host + // instead of sitting in one of its faces, and its thinnest axis is the one + // it wraps around — so insetting slides a collar or a cap rib to the end of + // the very part it should be banding. + root.updateWorldMatrix(true, true); + var NEVER_INSET = ['torus', 'flatRing', 'arch']; + attachments.forEach(function (att) { + if (NEVER_INSET.indexOf(att.prim) !== -1) return; + var obj = objects.get(att.id); + var wall = objects.get(att.to); + if (!obj || !wall || obj === wall) return; + var p = new THREE.Box3().setFromObject(obj); + var w = new THREE.Box3().setFromObject(wall); + if (p.isEmpty() || w.isEmpty()) return; + var pSize = p.getSize(new THREE.Vector3()); + var wSize = w.getSize(new THREE.Vector3()); + var axes = ['x', 'y', 'z']; + var normal = axes.reduce(function (a, b) { return pSize[b] < pSize[a] ? b : a; }); + var faceAxes = axes.filter(function (a) { return a !== normal; }); + // a panel must be a PLATE in its own right — thickness a small fraction + // of its own face. Comparing only against the wall let any small part + // qualify: a screwcap is "thinner" than a bottle on all three axes, so it + // was flush-mounted to the bottle's SIDE and sat off-axis beside the neck. + // A roughly square cross-section (caps, knobs, wheels) never qualifies. + var face = faceAxes.map(function (a) { return pSize[a]; }); + var plateLike = pSize[normal] < 0.3 * Math.min(face[0], face[1]); + var thin = pSize[normal] < wSize[normal] * 0.6; + var fits = faceAxes.every(function (a) { return pSize[a] <= wSize[a] * 1.1; }); + if (!plateLike || !thin || !fits) return; + var pCenter = p.getCenter(new THREE.Vector3()); + var wCenter = w.getCenter(new THREE.Vector3()); + var side = pCenter[normal] >= wCenter[normal] ? 1 : -1; + var d = side > 0 ? w.max[normal] - p.max[normal] : w.min[normal] - p.min[normal]; + if (Math.abs(d) < 0.001 || Math.abs(d) > maxSnap) return; + var worldPos = obj.getWorldPosition(new THREE.Vector3()); + worldPos[normal] += d; + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + }); + root.updateWorldMatrix(true, true); + + // stand the object on the ground — LAST, once every solver above has + // finished, and only when the spec never used the "grounded" flag (which has + // its own drop). The model does not set that flag in practice, so without + // this an object sits wherever its coordinates landed: sunk into the ground, + // or floating above its own contact shadow. + if (!groundedDeclared) { + var standing = new THREE.Box3(); + for (var si = 0; si < meshes.length; si++) { + var sm = meshes[si]; + if (/shadow/i.test(sm.name) || sm.isSprite) continue; + standing.union(new THREE.Box3().setFromObject(sm)); + } + if (!standing.isEmpty()) { + var gdy = -standing.min.y; + var gh = standing.max.y - standing.min.y; + if (Math.abs(gdy) > 0.02 && gh > 0 && Math.abs(gdy) <= gh) { + root.position.y += gdy; + root.updateWorldMatrix(true, true); + } + } + } + } + + // ===== parts ===== + addPart({ id: 'root', primitive: 'group', pos: [0, 0, 0] }); + addPart({ id: 'chassis-frame', parent: 'root', primitive: 'box', d: [0.9, 0.3, 3], pos: [0, 0.5, 0.2], mat: 'm-dark-steel', attachTo: 'tank-left' }); // structural beam under cab and box + addPart({ id: 'main-cab', parent: 'root', primitive: 'roundedBox', d: [1, 1.3, 1.3, 0.08, 0.03], pos: [0, 0.93, -1.1], mat: 'm-red-gloss', attachTo: 'chassis-frame' }); // hollow driver cab shell + addPart({ id: 'roof-fairing', parent: 'root', primitive: 'prism', d: [0.95, 0.9, 0.35], pos: [0, 1.88, -0.75], mat: 'm-red-gloss', attachTo: 'main-cab' }); // aero fairing tapering to cargo box + addPart({ id: 'cargo-box', parent: 'root', primitive: 'box', d: [1.05, 1.5, 1.9], pos: [0, 1.45, 0.47], mat: 'm-red-satin', attachTo: 'chassis-frame' }); // enclosed rear cargo body + addPart({ id: 'front-bumper', parent: 'root', primitive: 'roundedBox', d: [1, 0.5, 0.35, 0.05, 0.02], pos: [0, 0.61, -1.88], mat: 'm-red-gloss', attachTo: 'main-cab' }); // lower front housing headlights + addPart({ id: 'headlight-left', parent: 'root', primitive: 'box', d: [0.18, 0.12, 0.05], pos: [-0.32, 0.66, -2.05], mat: 'm-headlight', attachTo: 'front-bumper' }); // multi-lens headlight cluster + addPart({ id: 'headlight-right', parent: 'root', primitive: 'box', d: [0.18, 0.12, 0.05], pos: [0.32, 0.66, -2.05], mat: 'm-headlight', attachTo: 'front-bumper' }); // multi-lens headlight cluster + addPart({ id: 'front-grille', parent: 'root', primitive: 'box', d: [0.5, 0.35, 0.08], pos: [0, 0.82, -1.68], mat: 'm-grille', attachTo: 'main-cab' }); // tiered louvered grille slats + addPart({ id: 'side-window-left', parent: 'root', primitive: 'box', d: [0.05, 0.4, 0.5], pos: [-0.445, 1.22, -1], mat: 'm-glass', attachTo: 'main-cab' }); // driver side window + addPart({ id: 'side-window-right', parent: 'root', primitive: 'box', d: [0.05, 0.4, 0.5], pos: [0.445, 1.22, -1], mat: 'm-glass', attachTo: 'main-cab' }); // passenger side window + addPart({ id: 'side-mirror', parent: 'root', primitive: 'roundedBox', d: [0.12, 0.35, 0.15, 0.02, 0.01], pos: [-0.54, 1.35, -1.55], mat: 'm-mirror', attachTo: 'main-cab' }); // tall driver side mirror housing + addPart({ id: 'front-wheel-left', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.3, 20], pos: [-0.47, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame' }); // front steer tire + addPart({ id: 'front-wheel-right', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.3, 20], pos: [0.47, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame' }); // front steer tire + addPart({ id: 'front-wheel-rim-left', parent: 'root', primitive: 'cylinder', d: [0.22, 0.22, 0.05, 16], pos: [-0.565, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-silver-metal', attachTo: 'front-wheel-left' }); // silver hub recessed in tire + addPart({ id: 'front-wheel-rim-right', parent: 'root', primitive: 'cylinder', d: [0.22, 0.22, 0.05, 16], pos: [0.565, 0.42, -1], rot: [0, 0, 1.5708], mat: 'm-silver-metal', attachTo: 'front-wheel-right' }); // silver hub recessed in tire + addPart({ id: 'rear-wheel-left', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.3, 20], pos: [-0.2, 0.42, 1.3], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame', repeat: { count: 2, mode: 'linear', offset: [-0.27, 0, 0] } }); // dual rear tire pair + addPart({ id: 'rear-wheel-right', parent: 'root', primitive: 'cylinder', d: [0.42, 0.42, 0.3, 20], pos: [0.2, 0.42, 1.3], rot: [0, 0, 1.5708], mat: 'm-rubber', attachTo: 'chassis-frame', repeat: { count: 2, mode: 'linear', offset: [0.27, 0, 0] } }); // dual rear tire pair + addPart({ id: 'tank-left', parent: 'root', primitive: 'cylinder', d: [0.18, 0.18, 0.9, 16], pos: [-0.42, 0.6233, 0.15], rot: [1.5708, 0, 0], mat: 'm-silver-metal', attachTo: 'chassis-frame' }); // aluminum fuel tank + addPart({ id: 'tank-right', parent: 'root', primitive: 'cylinder', d: [0.18, 0.18, 0.9, 16], pos: [0.42, 0.6233, 0.15], rot: [1.5708, 0, 0], mat: 'm-silver-metal', attachTo: 'chassis-frame' }); // aluminum fuel tank + addPart({ id: 'guard-left', parent: 'root', primitive: 'box', d: [0.06, 0.15, 1.6], pos: [-0.48, 0.55, 0.15], mat: 'm-guard-white', attachTo: 'chassis-frame' }); // underrun safety guard rail + addPart({ id: 'guard-right', parent: 'root', primitive: 'box', d: [0.06, 0.15, 1.6], pos: [0.48, 0.55, 0.15], mat: 'm-guard-white', attachTo: 'chassis-frame' }); // underrun safety guard rail + addPart({ id: 'side-mirror-right', parent: 'root', primitive: 'roundedBox', d: [0.12, 0.35, 0.15, 0.02, 0.01], pos: [0.54, 1.35, -1.55], mat: 'm-mirror', attachTo: 'main-cab' }); // tall passenger side mirror housing, mirrored counterpart + addPart({ id: 'windshield-block', parent: 'root', primitive: 'box', d: [0.98, 0.9, 0.1], pos: [0, 1.25, -1.72], rot: [0.26, 0, 0], mat: 'm-glass', attachTo: 'main-cab' }); // large rectangular black glass windshield block + addPart({ id: 'rear-wheel-rim-left', parent: 'root', primitive: 'cylinder', d: [0.22, 0.22, 0.05, 16], pos: [-0.565, 0.42, 1.3], rot: [0, 0, 1.5708], mat: 'm-silver-metal', attachTo: 'rear-wheel-left' }); // silver hub recessed in tire + addPart({ id: 'rear-wheel-rim-right', parent: 'root', primitive: 'cylinder', d: [0.22, 0.22, 0.05, 16], pos: [0.565, 0.42, 1.3], rot: [0, 0, 1.5708], mat: 'm-silver-metal', attachTo: 'rear-wheel-right' }); // silver hub recessed in tire + addPart({ id: 'rear-bumper', parent: 'root', primitive: 'box', d: [1, 0.12, 0.08], pos: [0, 0.45, 1.7], mat: 'm-dark-steel', attachTo: 'chassis-frame' }); // rear bumper bar mounted to chassis + addPart({ id: 'brake-light-left', parent: 'root', primitive: 'box', d: [0.15, 0.06, 0.02], pos: [-0.3, 0.45, 1.75], mat: 'm-brake-light', attachTo: 'rear-bumper' }); // left red brake light lens + addPart({ id: 'brake-light-right', parent: 'root', primitive: 'box', d: [0.15, 0.06, 0.02], pos: [0.3, 0.45, 1.75], mat: 'm-brake-light', attachTo: 'rear-bumper' }); // right red brake light lens + addPart({ id: 'mudguard-left', parent: 'root', primitive: 'roundedBox', d: [0.55, 0.2, 0.9, 0.1, 3], pos: [-0.75, 0.85, 1.3], mat: 'm-dark-steel', attachTo: 'chassis-frame' }); // protective curved mudguard arch above left rear wheels + addPart({ id: 'mudguard-right', parent: 'root', primitive: 'roundedBox', d: [0.55, 0.2, 0.9, 0.1, 3], pos: [0.75, 0.85, 1.3], mat: 'm-dark-steel', attachTo: 'chassis-frame' }); // protective curved mudguard arch above right rear wheels + + assemble(); + return { group: root, meshes: meshes }; +} + +// machine-readable source spec — the studio reads this back to refine or +// regenerate; editing addPart() lines above without updating it is fine +// for one-off tweaks, but regeneration works from this data +var SCULPT_SPEC = {"objectName":"Red Cab-Over Delivery Truck","inputKind":"object","objectClass":"hard-surface","complexity":"complex","identityFeatures":["bright red cab-over truck body","aerodynamic roof fairing spoiler above driver cab","large rectangular red cargo box with lower curtain trim","tiered front grille with horizontal dark slats","integrated multi-lens headlight clusters on front bumper","side safety guard rails and cylindrical silver fuel tanks","dual rear wheels and front steer wheels with silver rims"],"materials":[{"materialId":"m-red-gloss","baseColor":"#d81118","roughness":0.3,"metalness":0.1,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":0.6,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-red-satin","baseColor":"#d81118","roughness":0.55,"metalness":0.15,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-dark-steel","baseColor":"#1e2022","roughness":0.6,"metalness":0.7,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-grille","baseColor":"#1a1b1e","roughness":0.9,"metalness":0.1,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":"louver"},{"materialId":"m-glass","baseColor":"#15181c","roughness":0.05,"metalness":0.1,"opacity":0.85,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":0.2,"finish":null},{"materialId":"m-mirror","baseColor":"#22252a","roughness":0.6,"metalness":0.2,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-rubber","baseColor":"#242628","roughness":0.9,"metalness":0,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":"tread"},{"materialId":"m-silver-metal","baseColor":"#9fa5aa","roughness":0.35,"metalness":0.9,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-guard-white","baseColor":"#cfd4d8","roughness":0.5,"metalness":0.3,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-headlight","baseColor":"#f5f5f0","roughness":0.15,"metalness":0,"opacity":null,"emissive":"#fff2c0","emissiveIntensity":0.4,"clearcoat":null,"sheen":null,"transmission":0.5,"finish":null},{"materialId":"m-bezel","baseColor":"#1a1b1e","roughness":0.9,"metalness":0.1,"opacity":null,"emissive":null,"emissiveIntensity":null,"clearcoat":null,"sheen":null,"transmission":null,"finish":null},{"materialId":"m-brake-light","baseColor":"#e60000","roughness":0.2,"metalness":0.1,"opacity":null,"emissive":"#990000","emissiveIntensity":0.3,"clearcoat":null,"sheen":null,"transmission":null,"finish":null}],"components":[{"nodeId":"root","parentId":null,"primitive":"group","dimensions":[],"position":[0,0,0],"rotation":[0,0,0],"scale":[1,1,1],"materialId":null,"text":null,"partRef":null,"textureRef":null,"textureUrl":null,"repeat":null,"attachTo":null,"note":null},{"nodeId":"chassis-frame","parentId":"root","primitive":"box","dimensions":[0.9,0.3,3],"position":[0,0.5,0.2],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-dark-steel","text":null,"partRef":"chassis-frame","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"tank-left","note":"structural beam under cab and box"},{"nodeId":"main-cab","parentId":"root","primitive":"roundedBox","dimensions":[1,1.3,1.3,0.08,0.03],"position":[0,0.93,-1.1],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-red-gloss","text":null,"partRef":"main-cab","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"hollow driver cab shell"},{"nodeId":"roof-fairing","parentId":"root","primitive":"prism","dimensions":[0.95,0.9,0.35],"position":[0,1.88,-0.75],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-red-gloss","text":null,"partRef":"roof-fairing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"aero fairing tapering to cargo box"},{"nodeId":"cargo-box","parentId":"root","primitive":"box","dimensions":[1.05,1.5,1.9],"position":[0,1.45,0.47],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-red-satin","text":null,"partRef":"cargo-box","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"enclosed rear cargo body"},{"nodeId":"front-bumper","parentId":"root","primitive":"roundedBox","dimensions":[1,0.5,0.35,0.05,0.02],"position":[0,0.61,-1.88],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-red-gloss","text":null,"partRef":"front-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"lower front housing headlights"},{"nodeId":"headlight-left","parentId":"root","primitive":"box","dimensions":[0.18,0.12,0.05],"position":[-0.32,0.66,-2.05],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-headlight","text":null,"partRef":"front-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-bumper","note":"multi-lens headlight cluster"},{"nodeId":"headlight-right","parentId":"root","primitive":"box","dimensions":[0.18,0.12,0.05],"position":[0.32,0.66,-2.05],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-headlight","text":null,"partRef":"front-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-bumper","note":"multi-lens headlight cluster"},{"nodeId":"front-grille","parentId":"root","primitive":"box","dimensions":[0.5,0.35,0.08],"position":[0,0.82,-1.68],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-grille","text":null,"partRef":"front-grille","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"tiered louvered grille slats"},{"nodeId":"side-window-left","parentId":"root","primitive":"box","dimensions":[0.05,0.4,0.5],"position":[-0.445,1.22,-1],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-glass","text":null,"partRef":"cab-glazing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"driver side window"},{"nodeId":"side-window-right","parentId":"root","primitive":"box","dimensions":[0.05,0.4,0.5],"position":[0.445,1.22,-1],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-glass","text":null,"partRef":"cab-glazing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"passenger side window"},{"nodeId":"side-mirror","parentId":"root","primitive":"roundedBox","dimensions":[0.12,0.35,0.15,0.02,0.01],"position":[-0.54,1.35,-1.55],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-mirror","text":null,"partRef":"side-mirrors","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"tall driver side mirror housing"},{"nodeId":"front-wheel-left","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.3,20],"position":[-0.47,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"front steer tire"},{"nodeId":"front-wheel-right","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.3,20],"position":[0.47,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"front steer tire"},{"nodeId":"front-wheel-rim-left","parentId":"root","primitive":"cylinder","dimensions":[0.22,0.22,0.05,16],"position":[-0.565,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-wheel-left","note":"silver hub recessed in tire"},{"nodeId":"front-wheel-rim-right","parentId":"root","primitive":"cylinder","dimensions":[0.22,0.22,0.05,16],"position":[0.565,0.42,-1],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"front-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"front-wheel-right","note":"silver hub recessed in tire"},{"nodeId":"rear-wheel-left","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.3,20],"position":[-0.2,0.42,1.3],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"rear-wheels","textureRef":null,"textureUrl":null,"repeat":{"count":2,"mode":"linear","offset":[-0.27,0,0]},"attachTo":"chassis-frame","note":"dual rear tire pair"},{"nodeId":"rear-wheel-right","parentId":"root","primitive":"cylinder","dimensions":[0.42,0.42,0.3,20],"position":[0.2,0.42,1.3],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-rubber","text":null,"partRef":"rear-wheels","textureRef":null,"textureUrl":null,"repeat":{"count":2,"mode":"linear","offset":[0.27,0,0]},"attachTo":"chassis-frame","note":"dual rear tire pair"},{"nodeId":"tank-left","parentId":"root","primitive":"cylinder","dimensions":[0.18,0.18,0.9,16],"position":[-0.42,0.6233,0.15],"rotation":[1.5708,0,0],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"aluminum fuel tank"},{"nodeId":"tank-right","parentId":"root","primitive":"cylinder","dimensions":[0.18,0.18,0.9,16],"position":[0.42,0.6233,0.15],"rotation":[1.5708,0,0],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"aluminum fuel tank"},{"nodeId":"guard-left","parentId":"root","primitive":"box","dimensions":[0.06,0.15,1.6],"position":[-0.48,0.55,0.15],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-guard-white","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"underrun safety guard rail"},{"nodeId":"guard-right","parentId":"root","primitive":"box","dimensions":[0.06,0.15,1.6],"position":[0.48,0.55,0.15],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-guard-white","text":null,"partRef":"underbody-guards-and-tanks","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"underrun safety guard rail"},{"nodeId":"side-mirror-right","parentId":"root","primitive":"roundedBox","dimensions":[0.12,0.35,0.15,0.02,0.01],"position":[0.54,1.35,-1.55],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-mirror","text":null,"partRef":"side-mirrors","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"tall passenger side mirror housing, mirrored counterpart"},{"nodeId":"windshield-block","parentId":"root","primitive":"box","dimensions":[0.98,0.9,0.1],"position":[0,1.25,-1.72],"rotation":[0.26,0,0],"scale":[1,1,1],"materialId":"m-glass","text":null,"partRef":"cab-glazing","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"main-cab","note":"large rectangular black glass windshield block"},{"nodeId":"rear-wheel-rim-left","parentId":"root","primitive":"cylinder","dimensions":[0.22,0.22,0.05,16],"position":[-0.565,0.42,1.3],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"rear-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"rear-wheel-left","note":"silver hub recessed in tire"},{"nodeId":"rear-wheel-rim-right","parentId":"root","primitive":"cylinder","dimensions":[0.22,0.22,0.05,16],"position":[0.565,0.42,1.3],"rotation":[0,0,1.5708],"scale":[1,1,1],"materialId":"m-silver-metal","text":null,"partRef":"rear-wheels","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"rear-wheel-right","note":"silver hub recessed in tire"},{"nodeId":"rear-bumper","parentId":"root","primitive":"box","dimensions":[1,0.12,0.08],"position":[0,0.45,1.7],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-dark-steel","text":null,"partRef":"rear-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"rear bumper bar mounted to chassis"},{"nodeId":"brake-light-left","parentId":"root","primitive":"box","dimensions":[0.15,0.06,0.02],"position":[-0.3,0.45,1.75],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-brake-light","text":null,"partRef":"rear-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"rear-bumper","note":"left red brake light lens"},{"nodeId":"brake-light-right","parentId":"root","primitive":"box","dimensions":[0.15,0.06,0.02],"position":[0.3,0.45,1.75],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-brake-light","text":null,"partRef":"rear-bumper","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"rear-bumper","note":"right red brake light lens"},{"nodeId":"mudguard-left","parentId":"root","primitive":"roundedBox","dimensions":[0.55,0.2,0.9,0.1,3],"position":[-0.75,0.85,1.3],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-dark-steel","text":null,"partRef":"mudguards","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"protective curved mudguard arch above left rear wheels"},{"nodeId":"mudguard-right","parentId":"root","primitive":"roundedBox","dimensions":[0.55,0.2,0.9,0.1,3],"position":[0.75,0.85,1.3],"rotation":[0,0,0],"scale":[1,1,1],"materialId":"m-dark-steel","text":null,"partRef":"mudguards","textureRef":null,"textureUrl":null,"repeat":null,"attachTo":"chassis-frame","note":"protective curved mudguard arch above right rear wheels"}]}; + +if (typeof module !== 'undefined') { + module.exports = { buildSculpture: buildSculpture, SCULPT_SPEC: SCULPT_SPEC }; +} diff --git a/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/renders/red-cab-over-delivery-truck-round-1.webp b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/renders/red-cab-over-delivery-truck-round-1.webp new file mode 100644 index 00000000..c5d24e86 Binary files /dev/null and b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/renders/red-cab-over-delivery-truck-round-1.webp differ diff --git a/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/renders/red-cab-over-delivery-truck-round-2.webp b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/renders/red-cab-over-delivery-truck-round-2.webp new file mode 100644 index 00000000..64595df3 Binary files /dev/null and b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/renders/red-cab-over-delivery-truck-round-2.webp differ diff --git a/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/8d9287b6-a160-4aea-82fe-b205578e81ba.json b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/8d9287b6-a160-4aea-82fe-b205578e81ba.json new file mode 100644 index 00000000..c71314dd --- /dev/null +++ b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/8d9287b6-a160-4aea-82fe-b205578e81ba.json @@ -0,0 +1,59 @@ +{ + "data": { + "type": "card", + "attributes": { + "references": { + "images": [ + { + "url": "https://static.vecteezy.com/system/resources/thumbnails/027/182/338/small/trailler-truck-isolated-on-a-transparent-background-png.png", + "sourceMode": "url" + } + ] + }, + "objectName": "Red Cab-Over Delivery Truck", + "analysis": "{\"objectType\":\"delivery truck\",\"objectClass\":\"hard-surface\",\"complexity\":\"complex\",\"identityFeatures\":[\"bright red cab-over truck body\",\"aerodynamic roof fairing spoiler above driver cab\",\"large rectangular red cargo box with lower curtain trim\",\"tiered front grille with horizontal dark slats\",\"integrated multi-lens headlight clusters on front bumper\",\"side safety guard rails and cylindrical silver fuel tanks\",\"dual rear wheels and front steer wheels with silver rims\"],\"camera\":{\"azimuthDeg\":-35,\"elevationDeg\":12,\"note\":\"front-left three-quarter view showing cab profile and full left side of cargo box\"},\"partPlan\":[{\"part\":\"main-cab\",\"approach\":\"rounded-shell\",\"bbox\":{\"left\":0.12,\"top\":0.28,\"width\":0.35,\"height\":0.5},\"primitives\":\"roundedBox forming the main driver cabin volume with bevelled front corners\",\"material\":\"painted red metal #d81118\",\"depthRatio\":0.85,\"surface\":\"gloss finish with smooth glossy clearcoat\",\"details\":\"hollow interior cab shell housing windshield and side door cutouts\"},{\"part\":\"roof-fairing\",\"approach\":\"rounded-shell\",\"bbox\":{\"left\":0.16,\"top\":0.17,\"width\":0.32,\"height\":0.12},\"primitives\":\"extrudedPolygon or curved prism tapering backward to direct airflow over cargo box\",\"material\":\"painted red plastic #d81118\",\"depthRatio\":0.8,\"surface\":\"gloss finish matching cab paint\",\"details\":\"smooth top contour aligning with cargo box height\"},{\"part\":\"cargo-box\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.45,\"top\":0.17,\"width\":0.47,\"height\":0.45},\"primitives\":\"box forming the main enclosed rear cargo body with subtle bottom skirt paneling\",\"material\":\"painted red metal #d81118\",\"depthRatio\":0.55,\"surface\":\"satin smooth metal side panels with bottom trim lip\",\"details\":\"linear seam details along lower edge with small safety reflectors\"},{\"part\":\"chassis-frame\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.35,\"top\":0.61,\"width\":0.56,\"height\":0.14},\"primitives\":\"box steel beam network extending from cab underside to rear axle\",\"material\":\"painted dark steel #1e2022\",\"depthRatio\":0.45,\"surface\":\"satin black structural metal\",\"details\":\"carries suspension mounts, rear mudguards, and bumper beam\"},{\"part\":\"front-bumper\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.12,\"top\":0.63,\"width\":0.28,\"height\":0.16},\"primitives\":\"roundedBox wrapping lower front of cab housing fog lights and license plate panel\",\"material\":\"painted red plastic #d81118\",\"depthRatio\":0.8,\"surface\":\"gloss painted red with clear polycarbonate headlight lenses\",\"details\":\"integrated rectangular headlight units and step cutouts\"},{\"part\":\"front-grille\",\"approach\":\"flat-cutout\",\"bbox\":{\"left\":0.13,\"top\":0.53,\"width\":0.19,\"height\":0.12},\"primitives\":\"box cutout recessed into front cab face with horizontal louver slats\",\"material\":\"matte dark grey plastic #1a1b1e\",\"depthRatio\":0.1,\"surface\":\"matte louver finish\",\"details\":\"three horizontal ventilation bars with center emblem spacing\"},{\"part\":\"cab-glazing\",\"approach\":\"flat-cutout\",\"bbox\":{\"left\":0.13,\"top\":0.32,\"width\":0.28,\"height\":0.18},\"primitives\":\"curved plane / thin box windows for front windshield and side windows\",\"material\":\"window glass #1c2830\",\"depthRatio\":0.75,\"surface\":\"transparent dark tint glass with black trim border\",\"details\":\"windshield wipers mounted at lower edge\"},{\"part\":\"side-mirrors\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.08,\"top\":0.35,\"width\":0.06,\"height\":0.12},\"primitives\":\"tall roundedBox mirror housings attached via tubular support arms to A-pillars\",\"material\":\"matte black plastic #22252a\",\"depthRatio\":0.15,\"surface\":\"satin black body with reflective glass face\",\"details\":\"vertical dual-mirror housing on driver side\"},{\"part\":\"front-wheels\",\"approach\":\"revolved\",\"bbox\":{\"left\":0.36,\"top\":0.65,\"width\":0.1,\"height\":0.18},\"primitives\":\"cylinder tire with metallic rim hub\",\"material\":\"rubber #242628 and metallic steel #8e9499\",\"depthRatio\":0.3,\"surface\":\"matte treaded rubber tire around silver hub with lug bolts\",\"details\":\"single front steer wheel pair\"},{\"part\":\"rear-wheels\",\"approach\":\"revolved\",\"bbox\":{\"left\":0.72,\"top\":0.61,\"width\":0.12,\"height\":0.17},\"primitives\":\"wide dual cylinders forming dual-wheel rear axle assembly\",\"material\":\"rubber #242628 and metallic steel #8e9499\",\"depthRatio\":0.6,\"surface\":\"treaded rubber dual tires on silver hub\",\"details\":\"dual tires per side under black mudguard arc\"},{\"part\":\"underbody-guards-and-tanks\",\"approach\":\"revolved\",\"bbox\":{\"left\":0.55,\"top\":0.64,\"width\":0.2,\"height\":0.08},\"primitives\":\"cylinders for fuel tanks behind rectangular tubular side underrun protection frame\",\"material\":\"metal #9fa5aa and painted white steel #cfd4d8\",\"depthRatio\":0.35,\"surface\":\"satin aluminum metallic tank with white guard rails\",\"details\":\"horizontal protective safety barrier over aluminum fuel tank\"}],\"attachments\":[\"roof-fairing centered-above main-cab\",\"front-bumper attached-front main-cab\",\"front-grille inset-into main-cab\",\"cab-glazing inset-into main-cab\",\"side-mirrors attached-left main-cab\",\"cargo-box attached-back main-cab\",\"main-cab rests-on chassis-frame\",\"cargo-box rests-on chassis-frame\",\"chassis-frame rests-on front-wheels\",\"chassis-frame rests-on rear-wheels\",\"underbody-guards-and-tanks inset-into chassis-frame\"],\"buildRecipe\":[\"Establish primary chassis frame running from x=0.35 to x=0.91 near bottom rail height.\",\"Build main cab block over front section of chassis with bevelled front vertical edges.\",\"Place top roof aerodynamic fairing seamlessly flush atop driver cab roof.\",\"Cut inset openings in cab front for panoramic windshield, side windows, and dark horizontal grille.\",\"Attach front bumper assembly with wide multi-section headlights and license plate recess.\",\"Build rear cargo box as an elongated rectangular body sitting directly behind cab on chassis.\",\"Add underbody protection rails and cylindrical silver fuel tanks in the space between front and rear axles.\",\"Place front single wheels and rear dual-wheel pairs with dark curved wheel arches.\",\"Mount tall black side mirror assemblies on support brackets attached to front pillars.\"],\"directives\":[\"vehicle\",\"boxy\",\"roundedShell\",\"machineDetail\"],\"_refSig\":\"https://static.vecteezy.com/system/resources/thumbnails/027/182/338/small/trailler-truck-isolated-on-a-transparent-background-png.png\"}", + "critique": "Captures the cab-over silhouette, roof fairing, boxy cargo body, grille/headlight cluster and dual rear wheels with underbody tanks; window trim, mirror arm detail and rivet-level bodywork are simplified.", + "score": 74, + "buildMetrics": "{\"residual\":0.3570875117226524,\"score\":74,\"warningCount\":0,\"warningKinds\":{},\"featuresPassed\":0,\"featuresFailed\":0,\"plannedParts\":11,\"builtParts\":22,\"objectClass\":\"hard-surface\",\"approaches\":[\"rounded-shell\",\"boxy\",\"flat-cutout\",\"revolved\"]}", + "round": 1, + "revision": null, + "modelUsed": "anthropic/claude-sonnet-5", + "createdAt": "2026-07-28T14:42:25.757Z", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + }, + "buildBackend": null + }, + "relationships": { + "codeFile": { + "links": { + "self": "../../exports/red-cab-over-delivery-truck-round-1.js" + } + }, + "renderScreenshot": { + "links": { + "self": "../../renders/red-cab-over-delivery-truck-round-1.webp" + } + }, + "parentCreation": { + "links": { + "self": null + } + }, + "sourceStudio": { + "links": { + "self": "../../../../ImgTo3dStudio/282993ec-cba6-4130-bc27-582b997caf06" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../../../../sculpted-model", + "name": "SculptedModel" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/feb20557-8e81-4aef-b70f-c842a1584b43.json b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/feb20557-8e81-4aef-b70f-c842a1584b43.json new file mode 100644 index 00000000..b085194c --- /dev/null +++ b/4376bf-img-to-3d-generator/img-to-3d/282993ec-cba6-4130-bc27-582b997caf06/rounds/SculptedModel/feb20557-8e81-4aef-b70f-c842a1584b43.json @@ -0,0 +1,59 @@ +{ + "data": { + "type": "card", + "attributes": { + "references": { + "images": [ + { + "url": "https://static.vecteezy.com/system/resources/thumbnails/027/182/338/small/trailler-truck-isolated-on-a-transparent-background-png.png", + "sourceMode": "url" + } + ] + }, + "objectName": "Red Cab-Over Delivery Truck", + "analysis": "{\"objectType\":\"delivery truck\",\"objectClass\":\"hard-surface\",\"complexity\":\"complex\",\"identityFeatures\":[\"bright red cab-over truck body\",\"aerodynamic roof fairing spoiler above driver cab\",\"large rectangular red cargo box with lower curtain trim\",\"tiered front grille with horizontal dark slats\",\"integrated multi-lens headlight clusters on front bumper\",\"side safety guard rails and cylindrical silver fuel tanks\",\"dual rear wheels and front steer wheels with silver rims\"],\"camera\":{\"azimuthDeg\":-35,\"elevationDeg\":12,\"note\":\"front-left three-quarter view showing cab profile and full left side of cargo box\"},\"partPlan\":[{\"part\":\"main-cab\",\"approach\":\"rounded-shell\",\"bbox\":{\"left\":0.12,\"top\":0.28,\"width\":0.35,\"height\":0.5},\"primitives\":\"roundedBox forming the main driver cabin volume with bevelled front corners\",\"material\":\"painted red metal #d81118\",\"depthRatio\":0.85,\"surface\":\"gloss finish with smooth glossy clearcoat\",\"details\":\"hollow interior cab shell housing windshield and side door cutouts\"},{\"part\":\"roof-fairing\",\"approach\":\"rounded-shell\",\"bbox\":{\"left\":0.16,\"top\":0.17,\"width\":0.32,\"height\":0.12},\"primitives\":\"extrudedPolygon or curved prism tapering backward to direct airflow over cargo box\",\"material\":\"painted red plastic #d81118\",\"depthRatio\":0.8,\"surface\":\"gloss finish matching cab paint\",\"details\":\"smooth top contour aligning with cargo box height\"},{\"part\":\"cargo-box\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.45,\"top\":0.17,\"width\":0.47,\"height\":0.45},\"primitives\":\"box forming the main enclosed rear cargo body with subtle bottom skirt paneling\",\"material\":\"painted red metal #d81118\",\"depthRatio\":0.55,\"surface\":\"satin smooth metal side panels with bottom trim lip\",\"details\":\"linear seam details along lower edge with small safety reflectors\"},{\"part\":\"chassis-frame\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.35,\"top\":0.61,\"width\":0.56,\"height\":0.14},\"primitives\":\"box steel beam network extending from cab underside to rear axle\",\"material\":\"painted dark steel #1e2022\",\"depthRatio\":0.45,\"surface\":\"satin black structural metal\",\"details\":\"carries suspension mounts, rear mudguards, and bumper beam\"},{\"part\":\"front-bumper\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.12,\"top\":0.63,\"width\":0.28,\"height\":0.16},\"primitives\":\"roundedBox wrapping lower front of cab housing fog lights and license plate panel\",\"material\":\"painted red plastic #d81118\",\"depthRatio\":0.8,\"surface\":\"gloss painted red with clear polycarbonate headlight lenses\",\"details\":\"integrated rectangular headlight units and step cutouts\"},{\"part\":\"front-grille\",\"approach\":\"flat-cutout\",\"bbox\":{\"left\":0.13,\"top\":0.53,\"width\":0.19,\"height\":0.12},\"primitives\":\"box cutout recessed into front cab face with horizontal louver slats\",\"material\":\"matte dark grey plastic #1a1b1e\",\"depthRatio\":0.1,\"surface\":\"matte louver finish\",\"details\":\"three horizontal ventilation bars with center emblem spacing\"},{\"part\":\"cab-glazing\",\"approach\":\"flat-cutout\",\"bbox\":{\"left\":0.13,\"top\":0.32,\"width\":0.28,\"height\":0.18},\"primitives\":\"curved plane / thin box windows for front windshield and side windows\",\"material\":\"window glass #1c2830\",\"depthRatio\":0.75,\"surface\":\"transparent dark tint glass with black trim border\",\"details\":\"windshield wipers mounted at lower edge\"},{\"part\":\"side-mirrors\",\"approach\":\"boxy\",\"bbox\":{\"left\":0.08,\"top\":0.35,\"width\":0.06,\"height\":0.12},\"primitives\":\"tall roundedBox mirror housings attached via tubular support arms to A-pillars\",\"material\":\"matte black plastic #22252a\",\"depthRatio\":0.15,\"surface\":\"satin black body with reflective glass face\",\"details\":\"vertical dual-mirror housing on driver side\"},{\"part\":\"front-wheels\",\"approach\":\"revolved\",\"bbox\":{\"left\":0.36,\"top\":0.65,\"width\":0.1,\"height\":0.18},\"primitives\":\"cylinder tire with metallic rim hub\",\"material\":\"rubber #242628 and metallic steel #8e9499\",\"depthRatio\":0.3,\"surface\":\"matte treaded rubber tire around silver hub with lug bolts\",\"details\":\"single front steer wheel pair\"},{\"part\":\"rear-wheels\",\"approach\":\"revolved\",\"bbox\":{\"left\":0.72,\"top\":0.61,\"width\":0.12,\"height\":0.17},\"primitives\":\"wide dual cylinders forming dual-wheel rear axle assembly\",\"material\":\"rubber #242628 and metallic steel #8e9499\",\"depthRatio\":0.6,\"surface\":\"treaded rubber dual tires on silver hub\",\"details\":\"dual tires per side under black mudguard arc\"},{\"part\":\"underbody-guards-and-tanks\",\"approach\":\"revolved\",\"bbox\":{\"left\":0.55,\"top\":0.64,\"width\":0.2,\"height\":0.08},\"primitives\":\"cylinders for fuel tanks behind rectangular tubular side underrun protection frame\",\"material\":\"metal #9fa5aa and painted white steel #cfd4d8\",\"depthRatio\":0.35,\"surface\":\"satin aluminum metallic tank with white guard rails\",\"details\":\"horizontal protective safety barrier over aluminum fuel tank\"}],\"attachments\":[\"roof-fairing centered-above main-cab\",\"front-bumper attached-front main-cab\",\"front-grille inset-into main-cab\",\"cab-glazing inset-into main-cab\",\"side-mirrors attached-left main-cab\",\"cargo-box attached-back main-cab\",\"main-cab rests-on chassis-frame\",\"cargo-box rests-on chassis-frame\",\"chassis-frame rests-on front-wheels\",\"chassis-frame rests-on rear-wheels\",\"underbody-guards-and-tanks inset-into chassis-frame\"],\"buildRecipe\":[\"Establish primary chassis frame running from x=0.35 to x=0.91 near bottom rail height.\",\"Build main cab block over front section of chassis with bevelled front vertical edges.\",\"Place top roof aerodynamic fairing seamlessly flush atop driver cab roof.\",\"Cut inset openings in cab front for panoramic windshield, side windows, and dark horizontal grille.\",\"Attach front bumper assembly with wide multi-section headlights and license plate recess.\",\"Build rear cargo box as an elongated rectangular body sitting directly behind cab on chassis.\",\"Add underbody protection rails and cylindrical silver fuel tanks in the space between front and rear axles.\",\"Place front single wheels and rear dual-wheel pairs with dark curved wheel arches.\",\"Mount tall black side mirror assemblies on support brackets attached to front pillars.\"],\"directives\":[\"vehicle\",\"boxy\",\"roundedShell\",\"machineDetail\"],\"_refSig\":\"https://static.vecteezy.com/system/resources/thumbnails/027/182/338/small/trailler-truck-isolated-on-a-transparent-background-png.png\"}", + "critique": "I shifted the front and rear wheels inward so that their outer edges are perfectly aligned at X = 0.62 (and -0.62) and sit tucked neatly within the truck's body and mudguard footprint.", + "score": 72, + "buildMetrics": "{\"residual\":0.054441143124999175,\"score\":72,\"warningCount\":0,\"warningKinds\":{},\"featuresPassed\":0,\"featuresFailed\":0,\"plannedParts\":11,\"builtParts\":23,\"objectClass\":\"hard-surface\",\"approaches\":[\"rounded-shell\",\"boxy\",\"flat-cutout\",\"revolved\"]}", + "round": 2, + "revision": 14, + "modelUsed": "anthropic/claude-sonnet-5", + "createdAt": "2026-07-28T14:43:01.182Z", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + }, + "buildBackend": null + }, + "relationships": { + "codeFile": { + "links": { + "self": "../../exports/red-cab-over-delivery-truck-round-2.js" + } + }, + "renderScreenshot": { + "links": { + "self": "../../renders/red-cab-over-delivery-truck-round-2.webp" + } + }, + "parentCreation": { + "links": { + "self": "./8d9287b6-a160-4aea-82fe-b205578e81ba" + } + }, + "sourceStudio": { + "links": { + "self": "../../../../ImgTo3dStudio/282993ec-cba6-4130-bc27-582b997caf06" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../../../../sculpted-model", + "name": "SculptedModel" + } + } + } +} diff --git a/4376bf-img-to-3d-generator/prompts/analyze.gts b/4376bf-img-to-3d-generator/prompts/analyze.gts new file mode 100644 index 00000000..d5d01e56 --- /dev/null +++ b/4376bf-img-to-3d-generator/prompts/analyze.gts @@ -0,0 +1,61 @@ +// Stage 1 of the pipeline: study the reference views and write the build plan. + +import { PRIMITIVES } from '../fields/sculpt-spec'; + +// v2 pipeline stage 1: instead of shipping hand-written per-category recipes +// (vehicles, buildings, ...) in the system prompt, the model analyzes THIS +// object and writes its own build recipe — the recipe becomes per-generation +// data that stage 2 (the spec build) is instructed to honor. +export const ANALYZE_SYSTEM_PROMPT = `You are the analysis stage of an image-to-3D pipeline. You receive one or MORE reference views of one object. Do NOT build geometry yet — study the object and write the expert construction plan a procedural modeler will follow. Respond with ONLY a JSON object — no markdown fences, no prose. + +PICK YOUR PRIMARY VIEW FIRST: when several views are given, find the most 3D-informative one — a perspective / three-quarter view showing TWO OR MORE faces of the object. Base the part inventory and depth reasoning on THAT view (a flat frontal view hides side details and makes parts look like one flat shape, so counting parts from it under-builds the model). Use flat/orthographic views only for measuring the face they show. If every view is flat, say so in the camera note and infer depth from shading and category knowledge. + +COLLAGE / CHARACTER-SHEET REFERENCES: a reference may be a multi-panel sheet (orthographic views, detail crops, a beauty render, text labels, UI chrome). Treat every panel as a view of ONE object. Ignore text bars, borders, logos and background scenery entirely. Sample colors from the OBJECT'S OWN pixels in the flattest-lit panel (usually the orthographic views) — NEVER from scene lighting, rim glow, or a stylized beauty shot; a dark olive machine under warm gold studio light is still dark olive, not gold. + +The modeler's vocabulary (its only primitives): ${PRIMITIVES.join(', ')}. It supports per-part "repeat" systems (linear rows, radial circles), procedural finishes (worn/brushed/hazard/tread/camo/louver/patina/knurl), textDecal labels, and glow sprites. + +JSON shape: +{ + "objectType": "1-3 words naming what this is (delivery truck, two-story house, running shoe...)", + "objectClass": "hard-surface" | "organic" | "hybrid", + "complexity": "simple" | "moderate" | "complex", + "buildBackend": "primitive" | "mesh" (which backend this object is best built with — see the BACKEND rule below; judge from the object itself, NOT from objectClass alone), + "backendReason": "one short line explaining the buildBackend pick", + "identityFeatures": ["3-8 short phrases naming what makes this object recognizable — MUST include detail-level features (railings, trims, antennas, fence pickets, grilles), not only the big volumes"], + "camera": { "azimuthDeg": -180..180 (0 = the FIRST image looks straight at the object's front; positive = camera moved to the object's right), "elevationDeg": 0..60 (0 = eye level), "note": "1 short line, e.g. 'slightly right of front, near eye level'" }, + "partPlan": [ + { "part": "semantic part name (cab, roof, left handle loop...)", + "approach": "boxy" | "rounded-shell" | "curved-chain" | "revolved" | "flat-cutout" | "wrap-decal" | "freeform-mesh" (revolved = rotationally symmetric bodies: bottles, cans, vases, lamp bases; wrap-decal = labels/stickers/prints on a curved body; freeform-mesh = draped fabric, complex character bodies — routed to a mesh service when available, else blob/curved-chain), + "bbox": { "left": 0..1, "top": 0..1, "width": 0..1, "height": 0..1 } (this part's bounding box in the FIRST image, normalized to image size — MEASURE it, this drives all proportions), + "primitives": "which vocabulary primitives fit this part and why (1 sentence)", + "material": "category + sampled color, e.g. 'painted metal #c23b2e', 'window glass #a8c8d8', 'wood #8a6142' (categories: metal, painted, plastic, wood, glass, fabric, rubber, concrete, ceramic, foliage)", + "depthRatio": 0.02..2 (this part's DEPTH divided by its bbox width — 0.05 = thin panel/railing, 0.5 = half as deep as wide, 1 = as deep as wide; estimate from side/three-quarter views, this is the thickness target the builder must hit), + "surface": "matte | satin | gloss | transparent, plus any texture note (1 short phrase) — describe what the REFERENCE shows: a flat-shaded / cartoon reference has clean untextured surfaces, a photo may show wear", + "details": "repeats / finishes / decals worth adding (1 sentence)", + "artwork": "OPTIONAL — set this ONLY when this part carries printed artwork the reference actually shows: a label, wordmark, stencil, warning placard, screen, badge, painted marking. Give the normalized bbox of the region in the FIRST image whose pixels are that artwork, as {\\"left\\":0..1,\\"top\\":0..1,\\"width\\":0..1,\\"height\\":0..1}. The builder crops those exact pixels and applies them to the part, so the real graphic appears instead of an approximation — this is what makes a bottle's label read as that bottle. A reference SHEET usually has a detail row worth harvesting: cropped panels, placards, markings. Leave it out for plain painted surfaces.", + "printMode": "OPTIONAL — set alongside \\"artwork\\" to say HOW the graphic sits on the surface. \\"label\\" = a separate applied patch with its OWN background/border (a paper wine label, a sticker, a metal placard) — the builder keeps that patch as a rectangle. \\"direct-print\\" = ink/paint/laser/silk-screen/engraving applied DIRECTLY onto the part's own surface with NO patch of its own (a logo printed on a mug or phone, a graphic on a T-shirt, an etched marking on metal) — the builder keys out the surface so only the ink remains and the part's real colour shows through. Choose by whether the graphic has its own physical background in the photo (label) or sits straight on the body's material (direct-print). Default to \\"direct-print\\" for a logo/print on a solid-colour body.", + "face": "OPTIONAL — set this ONLY on the HEAD / SKULL part of a character (mascot, doll, animal, creature, robot bust). Locate the facial landmarks in the FIRST image and give each as a normalized [x,y] measured INSIDE this part's own bbox (0,0 = the head bbox's top-left corner, 1,1 = its bottom-right), as {\\"leftEye\\":[x,y],\\"rightEye\\":[x,y],\\"nose\\":[x,y],\\"mouth\\":[x,y]}: {\\"landmarks\\": {...}}. Omit any landmark the view hides. The builder snaps the eyes/nose/mouth to these measured heights and onto the front surface of the head, which is what stops the face landing on the SIDE of the skull. Leave it out for anything that is not a face." } + ], + "attachments": ["one line per structural joint: ' ' using constraints centered-above | flush-top | attached-left | attached-right | attached-front | attached-back | inset-into | rests-on — e.g. 'roof centered-above upper-floor', 'carport attached-right lower-floor', 'windows inset-into front-wall'"], + "buildRecipe": ["5-12 imperative, object-SPECIFIC construction notes — proportions to respect, orientation pitfalls, what repeats how many times, which surfaces get which finish, where labels go. Write what an expert would pin above the workbench for THIS object."], + "directives": ["which of the modeler's standing build directives this object needs, from EXACTLY these names: revolved (any rotationally symmetric body traced as a lathe profile) | organic (soft/curved masses built as blob or sphere chains) | flatGraphic (the reference is a logo/icon/drawing, not a 3D object) | wrapDecal (a label or print wrapped round a curved body) | boxy | roundedShell | artwork (a part carries printed graphics worth cropping from the photo) | character (anything with a FACE — mascot, doll, animal, creature, robot bust: eyes, muzzle, nose, mouth, ears) | architectural (anything with walls, storeys, roofs, or detail mounted flat on a face) | vehicle (a body with flanks, a chassis, or side panels) | machineDetail (a feature that repeats: rivet rows, bolt circles, vent slats, wheel sets) | finishes (a surface with real weathering, camouflage, hazard striping or knurling). List every one that applies and nothing else — each is a page of build rules the modeler is given, so a name you leave out is guidance it will not have, and one you add needlessly is attention spent elsewhere."] +} + +Rules: +- DECOMPOSE FULLY: break the object down the way a human would describe it piece by piece — every distinct volume, every attachment, every surface detail gets its own partPlan entry with its own material, depth and surface. Whatever the object is, the reader of your plan should be able to rebuild it without ever looking at the photo. +- Part budget scales with complexity: simple = 4-6 parts, moderate = 6-12, complex = 12-20. Use what the object needs — never pad a simple object, never truncate a complex one. +- A BUILDING OR A SCENE gets up to 30: a house is not one object but a small inventory — each storey, each roof, the window band on EVERY visible face, the door, balcony, railing, carport, boundary wall, its coping, the hedges, the gate. Listing "windows" once for a house whose four faces are all glazed under-describes it by a factor of four. The modeler builds exactly the inventory you list and nothing more, so a part you leave out is a part the model will not have. +- If the reference gives you an ORTHOGRAPHIC side or front view, say so in the camera note — those views are the most reliable thing to measure proportions from. +- A REPEATED FEATURE IS **ONE** PART: six wheels are one "wheels" entry, not six. Same for rivet rows, vent slats, fence pickets, bolt circles — the modeler expands them with a repeat, so listing each copy separately spends your budget on nothing. A truck plan that spends 4 of its 13 entries naming individual wheels has no entries left for the hull panelling, the cab, the grille and the exhaust — and that is exactly what makes a heavily panelled vehicle come out as one smooth box. +- THIN STRUCTURAL DETAILS ARE PARTS TOO: railings, balusters, handrails, fence pickets, antennas, roof trims, window grilles. Never drop them to save budget — each is ONE part built as a thin cylinder/box with a linear repeat, so it costs one partPlan entry no matter how many pieces repeat. +- NEVER MERGE A RECOGNISABLE SUB-STRUCTURE INTO ITS PARENT: the small features a viewer checks FIRST to tell what something is — a face's eyes and mouth, a vehicle's wheels, a chair's legs — each get their OWN partPlan entry, never folded into the volume they sit on. A plan that lists a head but no eyes, or a body but no wheels, is under-described exactly where recognition lives. List every such feature the reference actually shows (mirror bilateral ones as a left and a right entry). +- MARK WHAT IS HOLLOW / OPEN: if a body is one you can see INTO in the reference — a vehicle cab or cabin (through its windows), a mug or bin (through its mouth), a hollow frame — note it in that part's "details" as hollow / open, and list its window/opening panes as their own parts. The builder gives a hollow body a dark interior so its openings read as space, not a painted-on surface; a body left unmarked is treated as a solid block. +- MEASURE, don't guess: every bbox comes from actually reading the part's extent in the image. Relative bbox widths/heights become the model's proportions, so a sloppy bbox is a wrong model. +- List an attachment line for EVERY part except the root volume — a part with no attachment will float. +- GET THE DIRECTION RIGHT: every line reads " ", and "rests-on" / "centered-above" both mean A sits ON TOP OF B. So write what holds what: a vehicle's HULL rests on its WHEELS, never "wheels rests-on hull" — that line turns the vehicle upside down and puts the tyres on the roof. Same trap for a chassis and its tracks, a table top and its legs, a lamp shade and its stem. If B holds A up, A rests-on B; if A hangs UNDER B, use "attached-back"/"attached-front"/"inset-into" or name B as the part that rests on A instead. +- Recipe notes must be concrete and measurable ("six wheels in two rows of three", "roof overhangs walls by ~15%"), never generic advice. +- objectClass drives strategy: hard-surface = crisp boxes/cylinders; organic = overlapping sphere/capsule chains; hybrid = mix per part. +- BACKEND (set "buildBackend"): decide whether the modeler's primitive vocabulary can actually capture this object, or whether it needs a real mesh. This is NOT the same question as objectClass — a blocky table and a rifle are both "hard-surface", yet the table is trivial for primitives and the rifle is not. + - "mesh" when the object is a PRECISION MECHANICAL assembly (firearm, engine, camera, power tool), an ORGANIC continuous surface (a face, an animal, a human figure), OR any object dominated by long thin parts that must align precisely or by continuous curves a box/cylinder/lathe can only approximate. These are the shapes primitives render as "a pile of blocks". + - "primitive" when the object can be described well by a handful of regular solids plus a little repeat or revolution: bottles, mugs, furniture, buildings, boxes, simple blocky or cartoon characters. + - When unsure, prefer "primitive" (it is the only backend wired today) and say so in backendReason.`; diff --git a/4376bf-img-to-3d-generator/prompts/completeness.gts b/4376bf-img-to-3d-generator/prompts/completeness.gts new file mode 100644 index 00000000..7781b46a --- /dev/null +++ b/4376bf-img-to-3d-generator/prompts/completeness.gts @@ -0,0 +1,33 @@ +// The completeness critic: a category-AGNOSTIC pass that compares the render +// against the reference and, unlike the conservative refine pass, is allowed to +// ADD the parts the build left out. +// +// This is the general answer to "the model is missing a part" — instead of +// hardcoding per-category checklists in the analysis prompt (a face has eyes, a +// car has wheels, a chair has legs …), we look at what the reference actually +// shows and add whatever the render is missing, whatever the object is. + +export const COMPLETENESS_CRITIC_PROMPT = `You are a 3D reconstruction engine auditing your own build for COMPLETENESS against the reference. You receive a comparison sheet — the LEFTMOST panel is the REFERENCE photo, the remaining panels are the CURRENT render (front / side / three-quarter) — plus the current spec JSON. + +YOUR ONE JOB: find every part the REFERENCE clearly shows that the render is MISSING, or that is so grossly misplaced it reads as absent, and fix it. Make NO assumptions about what kind of object this is — do not reason from "this is a face / car / chair"; reason ONLY from what the two images show. Walk the reference feature by feature and ask, for each: is there a matching part in the render? If not, ADD it. This is the opposite of the refine pass — here, ADDING what is missing is the whole point. + +What counts as missing: +- a distinct volume visible in the reference with no counterpart in the render (a wheel, an eye, a handle, a fin, a button, a limb, a label); +- a feature the render collapsed into its neighbour (two eyes drawn as one blob, four fingers as one mitten) — split it out; +- a part hidden inside another because its position is wrong — pull it out to where the reference shows it. +- GLAZING AND OPENINGS ARE THE MOST-MISSED PARTS, so check for them deliberately: windows, a windshield, side/rear glass, a sunroof, a screen, a lens, a vent or grille opening. They are flat and low-contrast (clear glass on a light body barely registers), so the earlier stages routinely drop them — but the reference almost always shows them on a vehicle, cab, building or device. If the reference shows a window the render lacks, ADD it as a thin glass pane ("inset": true, a transmission material, near-clear tint) recessed into the body. +Do NOT invent detail the reference does not show, and do NOT re-model parts that are already present and roughly right — a build that is already complete gets an empty answer. + +Author each ADDED part exactly like a spec component: pick a primitive from the vocabulary the spec already uses, size it against the parts around it (their dimensions are in the spec), give it a "partRef" naming what it is, an "attachTo" naming the part it mounts on, and read the reference for its position, relative size and colour. If it needs a colour no existing material has, add that material in "materialsChanged" and reference its id. Mirror bilateral features (left/right eye, both wheels) as two parts with x negated. The object frame is fixed: -Z FORWARD, +Y UP, +X RIGHT. + +Respond with ONLY this JSON object: +{ + "critique": "1 sentence: what was missing and what you added", + "score": 0-100 (honest completeness estimate AFTER your additions, never above 90), + "changed": [ { "nodeId": "", "position": [x,y,z]?, "rotation": [x,y,z]?, "scale": [x,y,z]? } — ONLY for a present part that is so misplaced it reads as missing; include only the fields you change ], + "added": [ { "nodeId": "new-kebab-id", "parentId": "", "primitive": "", "dimensions": [numbers], "position": [x,y,z], "rotation": [x,y,z], "scale": [x,y,z], "materialId": "m-...", "partRef": "what this part is", "attachTo": "", "note": "short" } — the parts the reference shows and the render lacks ], + "materialsChanged": [ COMPLETE material objects for any new colour an added part needs ] +} + +- Do NOT change an existing part's "primitive" or "dimensions", and do NOT remove parts — this pass only ADDS what is missing and nudges a badly-hidden part into view. +- If the build already shows everything the reference shows, return empty "added" and "changed" arrays.`; diff --git a/4376bf-img-to-3d-generator/prompts/recipes.gts b/4376bf-img-to-3d-generator/prompts/recipes.gts new file mode 100644 index 00000000..dcdd0804 --- /dev/null +++ b/4376bf-img-to-3d-generator/prompts/recipes.gts @@ -0,0 +1,195 @@ +// Craft rules that only some objects need, and the selector that picks them. +// +// The universal contract lives in spec-shape.gts and ships with every request. +// What is here is different: rules nothing in the pipeline can check, that only +// matter for a minority of objects, and that cost a couple of thousand tokens +// each of a model's attention when they don't apply. A bottle should not be +// reading about roof skirts. +// +// These are NOT per-category recipes keyed off an object taxonomy — stage 1 +// deliberately replaced those with a per-object buildRecipe it writes itself +// (see analyze.gts). Selection here keys off signals that plan ALREADY emits: +// each part's declared "approach" (a required enum), whether a part carries an +// "artwork" bbox, the objectClass, and the semantic part names. A block is sent +// when the object's own plan says it is relevant, and a novel object simply +// receives the ones its plan triggers. + +export const RECIPES: Record = { + revolved: `REVOLVED BODIES (this object has rotationally symmetric parts): +- "lathe" is ONLY for rotationally symmetric parts (vase, bottle, bowl, lamp base). NEVER use lathe for shoes, clothing, animals, or any non-symmetric form. +- ONE lathe profile traces the ENTIRE silhouette top-to-bottom (for a bottle: body, shoulder, neck AND lip all in the same profile). Do NOT also add separate stacked cylinders/cones for the neck, lip, or any part the lathe already covers — that duplicates geometry and the extra pieces end up floating above the profile. The only additional parts on a revolved body are decals (labels, foil wraps) and genuinely non-symmetric attachments. +- LATHE PROFILES ARE TRACED, NOT INVENTED: read the silhouette's half-width at 5-8 heights in the reference and write those (x,y) pairs bottom→top. A straight vertical wall = the SAME x repeated at two heights (a container body is a straight wall over most of its height — if your profile's x changes steadily with y you have drawn a CONE, which is almost always wrong). Shoulders are a short curve near the top, not a taper from the base. +- MEASURE WHERE THE WIDTH CHANGES, DON'T GUESS: every transition (where the wall starts narrowing, where the neck begins) sits at a specific FRACTION of the object's total silhouette height in the reference — measure that fraction from the photo and place the (x,y) pair there. Do not compress the main body or stretch the neck to a "typical" shape; a label or band that later floats off the wall is the symptom of a transition placed at the wrong height. Trace the FULL silhouette from the base all the way to the very top so the profile alone reproduces the whole outline.`, + + organic: `SOFT AND CURVED MASSES (this object has organic or hybrid parts): +- HYBRID GENERATION: regular geometric parts (cases, wheels, walls, frames) stay procedural — boxes/cylinders/roundedBoxes with crisp dimensions. Freeform/organic parts (cushions, plush bodies, food, natural masses) use "blob" (non-uniformly scaled, unique seed per part) or a chain of 4-8 OVERLAPPING spheres/capsules laid along the part's centerline, each non-uniformly scaled and overlapping ~40% with the next. NEVER use a box for a curved body — boxes read as bricks; boxes are ONLY for genuinely rectangular parts. +- curved-chain approach → 4-8 overlapping non-uniformly scaled spheres/capsules along the centerline. +- freeform-mesh approach → blob fallback (unique seed), non-uniformly scaled.`, + + character: `CHARACTERS, MASCOTS AND FACES: +- A FACE IS A STACK, AND THE ORDER NEVER VARIES: eyes highest, nose below the eyes, mouth below the nose. Author the three y values in that order and check them against each other before answering — a nose above the eyes is the single most recognisable way for a character to come out wrong, and it survives every downstream repair pass because each part is individually where the plan put it. +- THE MUZZLE / TAN FACE IS A CLUSTER THAT BITES INTO THE SKULL: build it from 2-3 OVERLAPPING lobes (cheeks + lower jaw), each a scaled sphere whose CENTRE sits inside the skull (about half its radius in) so face and head read as one mass. A single flat disc reads as a sticker and collapses to a wafer in profile; a lobe whose centre sits outside the skull floats in front of the face. +- FEATURES SIT ON THE HOST THEY NAME, ON ITS OUTWARD SIDE: eyes, pupils, nose and mouth attachTo the MUZZLE (or the skull when there is no muzzle), never the torso, and they sit on the side of it facing AWAY from the skull. Each is mostly proud of that surface — an eyeball whose centre is deeper than its own radius is invisible from every camera angle, which is the same as not building it. +- PUPILS RIDE THE EYE, and sit a hair further out along the same direction the eye faces. +- EARS BITE IN TOO: an ear overlaps the skull by 15-30% of its own radius. A tangent ear looks glued on and detaches the moment any solver touches it. +- MIRROR, DON'T RE-AUTHOR: eyes, ears, arms, hands, legs, feet, shoes and paired clothing details are bilaterally symmetric. Author the +X side, then give the -X twin the SAME y, the SAME z and the SAME dimensions with x negated. Two hand-authored halves drift, and asymmetry the reference does not show is read as damage. +- LIMBS AND FINGERS ARE BONES, NOT ROTATED CAPSULES: build every arm, leg and finger with the "bone" primitive — give the two joint positions [radius, x0,y0,z0, x1,y1,z1] and the engine orients it, so a limb can never point the wrong way. A BENT limb is TWO bones sharing the joint: an arm is shoulder→elbow then elbow→wrist; a leg is hip→knee then knee→ankle. Never author a limb as a single capsule with a hand-guessed rotation. +- A HAND OR A FOOT IS NOT ONE BALL: a glove is at least FOUR lobes — a palm mass, a thumb, and a grouped finger lobe (author individual fingers as short "bone" segments off the palm when the reference shows them), plus a cuff; a shoe is an elongated toe volume plus a heel plus a sole. Collapsing either to a single sphere is what makes a character read as a mannequin. Keep them simple, but keep the silhouette. +- SILHOUETTE IS THE IDENTITY: for a mascot the head-to-body ratio, the ear shape and the hand/foot silhouette carry recognition — spend parts there before spending them on surface detail.`, + + flatGraphic: `FLAT ARTWORK (this object is a logo / icon / symbol / drawing): +- Set inputKind "flat-graphic". Use roundedPlate for the background (it already faces the camera — rotation MUST stay [0,0,0]), positioned so each artwork shape sits just in front of the plate (z = plateDepth/2 + 0.03), depth ~0.06. Everything lives in the XY plane; use ZERO rotations anywhere. Sample baseColor values from the exact pixels of the reference — do not invent or "tastefully adjust" colors. NEVER approximate flat artwork with spheres or tilted boxes. +- CHOOSE THE OUTLINE PRIMITIVE BY THE SHAPE, not by habit: "extrudedSpline" curves smoothly through its points, "extrudedPolygon" joins them with straight lines. A brush stroke, a petal, a leaf, a rounded ray, a droplet, a hand-drawn mark — anything whose edges curve or whose tip is rounded — is extrudedSpline. Only genuinely straight-edged artwork (a chevron, a triangle, a rectangular bar) is extrudedPolygon. Four points joined by straight lines can only ever be a sharp kite: a starburst of soft rounded strokes came out as hard triangular spikes because each ray was authored as a 4-point polygon. +- POINT COUNT IS THE RESOLUTION OF THE SHAPE: 12-40 points per outline. A rounded tip alone needs 5-6 points to read as round. Fewer points is not simpler, it is a different shape. +- SEPARATE THE DEPTHS of shapes that overlap: give each one its own z, a hair apart (0.105, 0.1055, 0.106 …). Coplanar overlapping faces at an identical z have no depth order, so the renderer flickers between them as the camera moves — twelve rays meeting at a hub all sharing one z made the middle of an icon strobe.`, + + wrapDecal: `WRAPPED LABELS (this object has a label/print on a curved body): +- wrap-decal approach → curvedDecal wrapped at the host body's radius, attachTo the host — never a flat plate.`, + + boxy: `BOXY PARTS: +- boxy approach → roundedBox/box family with crisp bevels.`, + + grip: `CONTOURED HANDLES, GRIPS AND SCALES (a knife/tool handle, haft, hilt, scale or grip): +- A HANDLE IS ITS SIDE PROFILE, NOT A BRICK: even when the part reads as "boxy", author its lengthwise SIDE silhouette as an "extrudedSpline" — the curved outline (bellied edge, finger contour, tapered neck, rounded butt) as 10-20 [x,y] points in the XY plane (x along the handle's length, y its height), with "depth" = the handle's thickness. A plain roundedBox renders as a flat slab and throws away the shape that identifies the tool, so this OVERRIDES the boxy default for the handle part itself. +- MEASURE THE CURVE FROM THE REFERENCE: read the top and bottom edge heights at 5-8 stations along the length — a straight edge repeats the same y, a belly dips it, a taper ramps it. Do not substitute a "typical" handle taper for the measured outline. +- KEEP IT ONE PIECE: the whole scale/handle is ONE extrudedSpline; pivot pins, liner, bolster and pocket clip stay separate parts mounted on it.`, + + roundedShell: `SHELL PARTS: +- rounded-shell approach → hemisphere/blob shells.`, + + artwork: `PRINTED GRAPHICS (the plan marked artwork regions in the reference): +- REAL ARTWORK BEATS LETTERING: whenever a part carries printed graphics the reference shows — a label, stencil, placard, badge, screen, painted marking — set "textureRef" to that part's plan name so the engine crops the actual pixels out of the reference and applies them. A part whose plan entry has an "artwork" bbox is telling you exactly that. Spelling the words out with "text" instead produces flat lettering in the wrong font, and stacking two text decals to imitate a panel is strictly worse than one crop of the panel itself.`, + + architectural: `BUILDINGS AND SURFACE-MOUNTED DETAIL: +- ANCHOR SURFACE-MOUNTED PARTS, DON'T EYEBALL THEM: for anything that sits ON a face at a spot — windows, doors, balconies, railings, awnings, wall vents, trims, badges — use the "anchor" field ({targetId, face, uv, normalOffset}) instead of hand-computing world coords. The engine places it exactly on that wall/roof face at the (u,v) you give, so it can never float off or sink into the wall. This is the reliable way to position architectural detail — reach for it before authoring raw positions. +- WHY GROUND HEIGHT IS A FLAG HERE: in a tilted/isometric view you cannot read an object's ground depth from how high it sits in the image — a farther prop just looks higher. Set "grounded": true and author only horizontal (x,z) placement and size; the height is solved for you. +- A ROOF PIERCED BY AN UPPER STOREY IS A SKIRT, NOT A PYRAMID: on a two-storey house the lower roof surrounds the upper storey — it is the roof over the single-storey portion, with a hole where the upper floor rises through. A single pyramid cannot have that hole, so a full lower pyramid ends up entirely INSIDE the upper storey box and vanishes from the render. Build it as 4 separate sloped panels (prism, or thin rotated boxes) skirting the four sides of the upper storey, each sitting on the lower storey's top edge and sloping down and outward to the eaves. Only the topmost roof — the one nothing rises through — is a single pyramid.`, + + vehicle: `VEHICLE BODYWORK: +- A SKIRT, FLANK PANEL OR SIDE ARMOUR IS AN UPRIGHT PANEL, NOT A FLOOR: it stands along the vehicle's side, thin in X, tall in Y, long in Z — so it is TWO panels, one at each flank. A single slab that is wide in X and thin in Y is a floor plate lying under the vehicle, where nothing can see it. +- THE CAB IS A HOLLOW SHELL, NOT A TALL BOX: keep its height close to its width (not a narrow tower), give it a RAKED windshield (the front glass tilts back from a lower cowl, not a vertical face), and set its windows as recessed GLASS panes (transmission, near-clear) with a dark interior box behind them so the cab reads as a cabin you can see into — never a solid block with dark stickers. +- GRILLE, LIGHTS AND EMBLEM ARE INSET SOLIDS, NOT STICKERS: a grille is a thin recessed box with a dark metal / louver finish; headlights are small glass or faintly-emissive lenses set into the front; a badge/emblem is chrome (high metalness). A flat plane laid on the front reads as a decal, not a part. +- WHEELS ARE ROUND AND SEPARATE: space a wheel repeat by AT LEAST the tire diameter or the tires fuse into one tank-track band; the tire is dark matte rubber and the rim/hub is a metal disc RECESSED into the tire's outer face, never a cylinder poking out past it.`, + + machineDetail: `REPEATED MACHINE DETAIL: +- Repeated micro-details are what make a MACHINE read as real, so when the photo visibly carries rivet rows, bolt circles, vent slats, tread lugs or wheel sets, declare each ONCE with a "repeat" (rivets along an edge = small sphere + linear repeat; bolts around a hub = cylinder + radial repeat; 6 wheels = one wheel + linear repeat) — a truck or industrial machine usually earns several. Do NOT manufacture repeats for an object that has none.`, + + finishes: `PROCEDURAL FINISHES: +- A FINISH COVERS THE WHOLE PART, SO ONLY USE ONE THE WHOLE PART HAS. Two ways this goes wrong: + · "camo" paints camouflage blotches over everything the material touches. Use it ONLY when the reference visibly shows a camouflage PATTERN. A military vehicle painted in one flat colour is not camouflaged — putting camo on it smears blotches across a clean painted hull and is the most obviously wrong thing in the render. + · a LOCALISED pattern is a separate thin PANEL, not the host's material. Reference hazard striping is a narrow warning band along one edge; a vent grille is a panel on the rear. Setting "hazard" on the whole nose plate, or "louver" on the whole engine block, stripes or ribs the entire volume. Author a thin box laid on that surface and give the FINISH to that panel, leaving the host its own paint.`, +}; + +// Which blocks this object's own plan asks for, from two independent sources +// unioned together: +// +// 1. What stage 1 NOMINATED. It has looked at the photographs and knows what +// the object is; asked to name the hazards it faces, it can reach a block +// no keyword list would have matched. This is the path that carries a +// genuinely new kind of object. +// 2. What the plan's own STRUCTURE implies — the declared approaches, an +// artwork bbox, the semantic part names. Deterministic, and it holds when +// stage 1 omits the field entirely (an older cached analysis, a model that +// ignored it, a hand-edited plan). +// +// Neither is trusted alone. (1) without (2) would make the directives a matter +// of one model's mood; (2) without (1) is the category taxonomy stage 1 +// deliberately abandoned. A union costs at most a block that did not apply. +// +// Pure — analysis in, recipe names out — so it is cheap to unit-test. +export function selectRecipeNames(analysis: any): string[] { + let parts: any[] = Array.isArray(analysis?.partPlan) ? analysis.partPlan : []; + let approaches = new Set( + parts.map((p) => String(p?.approach ?? '').trim()).filter(Boolean), + ); + let text = (pick: (p: any) => unknown) => + parts + .map((p) => String(pick(p) ?? '')) + .join(' ') + .toLowerCase(); + // objectType rides along with the part names: "delivery truck" earns the + // vehicle block even when every part is named generically (body, cab, panel) + let names = `${String(analysis?.objectType ?? '')} ${text((p) => p?.part)}`; + let primitives = text((p) => p?.primitives); + let surfaces = `${text((p) => p?.details)} ${text((p) => p?.surface)} ${text( + (p) => p?.material, + )}`; + let objectClass = String(analysis?.objectClass ?? ''); + + // stage 1's nominations, filtered to names that actually exist — a + // hallucinated directive name is dropped, never sent as an empty block + let selected: string[] = ( + Array.isArray(analysis?.directives) ? analysis.directives : [] + ) + .map((name: any) => String(name).trim()) + .filter((name: string) => name in RECIPES); + let take = (name: string, when: boolean) => { + if (when && !selected.includes(name)) selected.push(name); + }; + + take('revolved', approaches.has('revolved') || /\blathe\b/.test(primitives)); + take('flatGraphic', approaches.has('flat-cutout')); + take( + 'organic', + objectClass === 'organic' || + objectClass === 'hybrid' || + approaches.has('curved-chain') || + approaches.has('freeform-mesh'), + ); + // A face is the one structure whose parts are all correct individually and + // wrong together, so it is named by its FEATURES rather than by an object + // taxonomy: whatever the thing is — mascot, doll, animal, robot bust — a + // plan listing eyes and a muzzle is describing a face and needs these rules. + take( + 'character', + /\b(muzzle|snout|face|facial|eye|eyes|eyeball|pupil|iris|eyelid|brow|nose|nostril|mouth|lip|tongue|tooth|teeth|jaw|chin|cheek|ear|ears|skull|head|torso|glove|hand|paw|finger|thumb|hoof|tail|whisker|mascot|character)s?\b/.test( + names, + ) && /\b(eye|pupil|nose|mouth|muzzle|snout|face|ear)s?\b/.test(names), + ); + take('wrapDecal', approaches.has('wrap-decal')); + take('boxy', approaches.has('boxy')); + take('roundedShell', approaches.has('rounded-shell')); + // a contoured handle/grip is the shape that identifies a tool, and it is the + // one boxy default gets wrong — trigger the extrudedSpline guidance whenever + // the plan names a handle-family part + take('grip', /\b(handle|grip|grips|scale|scales|haft|hilt)\b/.test(names)); + take( + 'artwork', + parts.some((p) => p?.artwork), + ); + take( + 'architectural', + /\b(roof|storey|story|floor|wall|balcony|window|eave|gable|facade|porch|carport|door)\b/.test( + names, + ), + ); + take( + 'vehicle', + /\b(hull|chassis|skirt|flank|track|wheel|axle|fender|bumper|cab|tyre|tire)\b/.test( + names, + ), + ); + // the rule names wheel sets alongside rivet rows, so the repeated-feature + // nouns count too — a truck whose plan says "six wheels in two rows" is + // exactly the case the rule was written for + take( + 'machineDetail', + /\b(rivet|bolt|vent|slat|louver|louvre|tread|grille|grill|lug|fastener|wheel|axle|spoke|barrel|picket|baluster|fin|tooth|teeth|stud)s?\b/.test( + `${names} ${surfaces}`, + ), + ); + take( + 'finishes', + /\b(worn|weathered|rust|rusty|patina|camo|camouflage|brushed|knurl|knurled|hazard|tread|grime|scratched|oxidi)\w*/.test( + surfaces, + ), + ); + return selected; +} + +export function selectRecipes(analysis: any): string { + return selectRecipeNames(analysis) + .map((name) => RECIPES[name]) + .filter(Boolean) + .join('\n\n'); +} diff --git a/4376bf-img-to-3d-generator/prompts/refine.gts b/4376bf-img-to-3d-generator/prompts/refine.gts new file mode 100644 index 00000000..00d144a6 --- /dev/null +++ b/4376bf-img-to-3d-generator/prompts/refine.gts @@ -0,0 +1,27 @@ +// The self-review pass: compare the render against the reference and return a +// minimal placement/colour change set. + +export const REFINE_SYSTEM_PROMPT = `You are a 3D reconstruction engine reviewing your own work. You receive a comparison sheet: the LEFTMOST panel is the reference photo; the remaining labeled panels show the current render (when a "RENDER @ REF ANGLE" pane is present it is captured from the estimated reference camera — judge placement against the reference there, like-for-like) plus FRONT, SIDE, and THREE-QUARTER angles (flat artwork gets a single head-on render). Use the side view to judge depth placement. You also receive the current spec JSON. Find what is visibly MISPLACED or MIS-COLORED and output a MINIMAL CHANGE SET — never the whole spec. + +SCOPE — this pass ONLY repositions parts and recolors materials. You may NOT change any part's SHAPE: never change a component's "primitive" or "dimensions", never add a new component, never delete one. The shapes are already correct; only their placement (position / rotation / scale) and their colors may be wrong. A box stays a box — do not "improve" a part by turning it into a sphere, blob, or any other primitive. + +Respond with ONLY this JSON object: +{ + "critique": "1-2 sentences: what you repositioned/recolored this round", + "score": 0-100 (honest fidelity estimate AFTER your changes, never above 90), + "featureCheck": { "": "pass" | "fail" }, + "changed": [ { "nodeId": "", "position": [x,y,z], "rotation": [x,y,z] (radians), "scale": [x,y,z] } — one entry per part whose PLACEMENT is wrong; include only the fields you are changing (position/rotation/scale). nodeId MUST already exist in the spec. primitive and dimensions are ignored if sent. ], + "materialsChanged": [ COMPLETE material objects (materialId + baseColor/roughness/metalness/opacity/emissive/…) for every material whose COLOR or finish is wrong — matched by materialId ] +} + +Refine conservatively: +- ORIENTATION CHECK — FIRST PRIORITY: the object uses a fixed frame — -Z is FORWARD, +Y is UP, +X is RIGHT. A rolling wheel has its axle along X (rotation [0,0,1.5708]) and rolls in Z; front plow/ram tines point -Z (rotation [-1.5708,0,0]). If a wheel/tire appears to lie FLAT or face the sky, or a spike/tine/blade points sideways (along X) instead of forward (-Z), its rotation is wrong — fix the rotation before any position work. A whole repeated row sharing one bad rotation is fixed by correcting the ONE base nodeId (the clones inherit it). +- SMALL DETAILS ARE NOT EXEMPT: rivets, studs, bolts, window slits, decals, light pods, vent stacks and other small/repeated parts are the most likely to be mislocated — floating off the surface, on the wrong face, on only one side, or sunk inside the body. Check each against the exact face it belongs to and reposition it flush; do not skip a part just because it is small. +- PLACEMENT ONLY drives the "changed" array: fix parts that are floating, offset, mis-rotated, poking through a surface, or the wrong size (scale). Use the analysis MEASURED TARGETS (normalized reference bboxes + attachment constraints) to place each part — horizontal center, vertical band, and which part it sits on/inside. +- Machine-checked assembly problems (floating parts) get fixed FIRST: move the part (position) so it overlaps its support by 0.02-0.05, or sits inside it for an inset window/panel. +- Resize with "scale" only — never by editing dimensions. A part that is too big/small keeps its primitive and gets a scale factor. +- Touch ONLY what is visibly wrong. Parts you do not mention stay exactly as they are — that is the point of the change set. +- Colors must be sampled from the reference pixels. A brightness difference caused by scene lighting is NOT a color error — if a baseColor hex already matches the reference pixel, do not touch that material. +- For flat-graphic inputs the render is captured head-on: keep the roundedPlate background at rotation [0,0,0]; fix a hidden artwork part's z position (do not delete it). +- Per-feature gate: judge EVERY identityFeature against the render in "featureCheck". A high global score cannot excuse a failing feature — a failing feature is this round's first priority. +- If nothing needs changing, return empty "changed" and "materialsChanged" arrays.`; diff --git a/4376bf-img-to-3d-generator/prompts/spec-shape.gts b/4376bf-img-to-3d-generator/prompts/spec-shape.gts new file mode 100644 index 00000000..e8159aee --- /dev/null +++ b/4376bf-img-to-3d-generator/prompts/spec-shape.gts @@ -0,0 +1,112 @@ +// The response contract for the spec stage: the JSON shape the model must +// return, the primitive dimension semantics, and the modelling rules that +// apply to every object. Kept apart from the transport and the repair passes +// because it is prose — it changes for editorial reasons, on its own cadence, +// and a diff here is a diff in what the model is told, never in what the code +// does. +// +// What belongs HERE is what every object needs, in two kinds: +// · CONTRACT — the interpreter mis-renders or the parser rejects the reply +// without it (the JSON shape, the per-primitive dimension semantics, the +// world frame, the response formatting). Never abbreviated. +// · INVARIANTS the repair passes in util/spec-passes/ already enforce +// (contact, grounding, burial, budget, hairlines). One imperative line +// each: violating them is self-healing, so the long worked examples were +// paying for a guarantee the code already gives. +// Craft rules that only SOME objects need — lathe tracing, roof skirts, flank +// panels, flat-graphic point counts, finish misuse — live in recipes.gts and +// ship only when the object's own analysis asks for them. + +import { PRIMITIVES } from '../fields/sculpt-spec'; + +export const SPEC_JSON_SHAPE = `JSON shape: +{ + "objectName": "short name", + "inputKind": "object" | "flat-graphic", + "objectClass": "hard-surface" | "organic" | "hybrid", + "complexity": "simple" | "moderate" | "complex", + "critique": "1-2 sentences: what the rebuild captures and what it approximates", + "score": 0-100 (honest fidelity estimate of THIS spec, never above 90), + "identityFeatures": ["3-5 short phrases naming the features that make this object recognizable, e.g. 'black plastic handle loops', 'bright steel blades'"], + "featureCheck": { "": "pass" | "fail" } (refine rounds only: judge each feature against the RENDER), + "materials": [ + { "materialId": "m-body", "baseColor": "#rrggbb", "roughness": 0..1, + "metalness": 0..1, "opacity": 0..1, "emissive": "#rrggbb", "emissiveIntensity": 0..2 (optional; 1.5-2 for LEDs/lamps, 0.05-0.3 for faint warmth), + "clearcoat": 0..1 (optional, glossy coated surfaces), + "sheen": 0..1 (optional, fabric/silicone), + "transmission": 0..1 (optional, REAL see-through glass — window panes, bottles, lenses; pair with roughness 0.02-0.15 and metalness 0; the engine renders it with refraction, so prefer this over low opacity for glass), + "finish": "worn" | "brushed" | "hazard" | "tread" | "camo" | "louver" | "patina" | "knurl" (optional procedural + surface texture: "worn" = grime/soot/scratches for weathered metal or + used machines; "brushed" = brushed metal; "hazard" = yellow/black + caution stripes; "tread" = tire/track blocks; "camo" = organic + camouflage blotches in tones derived from baseColor (military + vehicles, patterned panels); "louver" = dark ribbed vent slats + (grilles, radiators); "patina" = teal-green oxidation blooming from + the TOP of the surface (aged brass/copper); "knurl" = cross-hatch grip + relief (knife handles, tool grips). Use on machines, tools, + vehicles, industrial parts — it is what makes surfaces look real instead of + plastic) } + ], + "components": [ + { "nodeId": "unique-kebab-id", "parentId": "" or an EARLIER nodeId, + "primitive": "${PRIMITIVES.join('" | "')}", + "dimensions": [numbers, see semantics], + "position": [x, y, z], "rotation": [x, y, z] (radians), + "scale": [x, y, z], "materialId": "m-...", + "text": "textDecal only: the label text to render", + "partRef": "REQUIRED: exact analysis partPlan 'part' name this component realizes; every visible non-group component must have one so measured proportions can be reconciled before export", + "textureRef": "textDecal/curvedDecal only: the analysis partPlan 'part' name whose photo region carries this part's REAL artwork (labels, logos, printed graphics, screens). The engine crops that bbox out of the reference photo and applies it as the decal's texture — ALWAYS prefer this over 'text' when the reference shows actual artwork; use 'text' only for plain lettering", + "repeat": {"count": N, "mode": "linear", "offset": [x,y,z]} or {"count": N, "mode": "radial", "radius": r, "axis": "x"|"y"|"z"} (optional — expands this part into N placed clones), + "attachTo": "nodeId of the part this one physically mounts on — REQUIRED for every non-group part except the single base volume. The engine pulls the part into contact with its attachTo if your coordinates leave a gap, so pick the true structural support (baluster → its railing/floor, canopy → its posts, window → its wall)", + "anchor": "optional PRECISE placement on a target's box FACE instead of world coords — {\\"targetId\\": nodeId, \\"face\\": \\"front|back|left|right|top|bottom\\", \\"uv\\": [u,v] 0..1 across that face, \\"normalOffset\\": small gap out from the face}. USE for every part that sits ON a surface at a specific spot: windows, doors, balconies, railings, awnings, trims, wall vents, signage. u=0 is the face's left/bottom edge, u=1 its right/top; the engine computes the exact world position, so you don't have to. When you set anchor you can leave position at [0,0,0].", + "grounded": "optional true — set ONLY on parts that rest on the GROUND: the main building/vehicle body, trees, cars, fences, freestanding props. The engine drops them so their bottom sits on the ground plane. NEVER set it on roofs, windows, doors, balconies, railings, or anything mounted on another part.", + "note": "which identity feature of the photo this represents" } + ] +} + +dimensions semantics: box [w,h,d] · roundedBox [w,h,d,cornerRadius,bevel] (rounded-corner slab lying flat, face up — device bodies, cases) · roundedPlate [w,h,depth,cornerRadius] (rounded-rect plate in the XY plane FACING +Z / the camera — logo backgrounds, signs, screens; needs NO rotation) · cylinder [radiusTop,radiusBottom,height,segments?] · capsule [radius,cylinderLength] · sphere [radius] · hemisphere [radius] (dome, opens downward) · cone [radius,height,segments?] (segments=4 → square PYRAMID for hip roofs/spires. The ENGINE squares it up for you, so its edges already run along X and Z: its Y rotation MUST be 0. Do not "fix" its orientation with a 45° rotation the way plain three.js needs — that stacks with the engine's own correction into 90°, turning the roof back into a diamond whose corners stick out past the walls. Shape the footprint with non-uniform scale only. Footprint width = radius × 1.414, so for a roof that overhangs a wall of width w by ~15%, radius ≈ 0.81 × w/2) · torus [radius,tube] (ring standing UPRIGHT in the XY plane, hole facing the camera — this is three.js's own orientation and it is almost never what you want. A collar/band around an upright body (bottle lip ring, cap ribbing, barrel hoop) is rotation [-1.5708, 0, 0], which lays the hole face up. A wheel hub ring, facing sideways out of the vehicle's flank, is rotation [0, 1.5708, 0]. An unrotated torus is a hoop standing beside the object, which is a bug in every case I have seen) · plane [w,h] · disc [radius] (flat circle facing +Z — dials, hole covers) · flatRing [outerRx,outerRy,ringWidth,depth] (flat elliptical ring with a REAL hole, XY plane facing +Z — scissor handle loops, grab rings, bracelets; NEVER fake these with a squashed torus) · arch [outerR,ringWidth,depth,sweepDeg?] (partial flat ring spanning the top — wheel arches/fenders, bridge handles) · prism [lengthAlongRidge,span,height] (triangular prism, ridge along X — gable roofs, ramps, wedges; no rotation needed) · tube [radius, x0,y0,z0, x1,y1,z1, ...] (smooth tube swept along a 3D curve through the points — USE for laces, cables, hoses, curved handles, piping; 3-6 points give a natural curve) · bone [radius, x0,y0,z0, x1,y1,z1] (a capsule spanning the TWO endpoints, auto-oriented — the go-to for ARMS, LEGS and FINGERS: give the two joint positions and the engine handles the rotation, so you never compute a limb's angle. A bent limb is TWO bones sharing the elbow/knee point, e.g. shoulder→elbow then elbow→wrist. Leave position at [0,0,0] — the endpoints place it) · rock [radius, detail?] (faceted low-poly blob) · blob [radius, bumpiness 0-0.5, seed?, detail?] (smooth freeform mass — a dense sphere mesh displaced by seeded noise; the go-to for soft/organic volumes: cushions, plush bodies, bread, fruit, boulders. Non-uniform scale shapes it; vary seed per part so blobs differ) · glow [size] (camera-facing additive light glow in the material's baseColor — reactor lights, lamps, LEDs) · lathe [x0,y0,x1,y1,...] profile bottom→top (x>=0) · extrudedPolygon [depth, x0,y0, x1,y1, ...] straight-edged polygon outline in the XY plane facing +Z, centered · extrudedSpline [depth, x0,y0, x1,y1, ...] same but the outline is a SMOOTH curve through the points (organic silhouettes: shoe soles, leaves, curved panels — prefer this over extrudedPolygon whenever the reference shape has curved edges) · meshAsset [] (mounts a pre-existing .glb via its "assetUrl" field — ONLY reference asset URLs given to you in the request; NEVER invent one) · textDecal [w,h] (transparent plane rendering the "text" field in the material's baseColor — USE for wordmarks on FLAT surfaces; sits ~0.01 in front; also renders SYMBOL characters — '★' '▲' '●') · curvedDecal [radius, height, arcDeg?] (label/sticker WRAPPED around a cylindrical body — wine labels, can labels, mug prints; radius = the host body's radius + 0.01, arc defaults 120°, material baseColor = the label ground color, "text" = the label's main wordmark, attachTo = the host body; NEVER fake a label on a bottle/can with a flat plate) · group []. + +CLASSIFY FIRST — objectClass drives the build strategy (do not default to hard-surface): +- "hard-surface": rigid manufactured forms — electronics, tools, furniture, machines. Strategy: boxes/roundedBoxes/cylinders with crisp bevels. +- "organic": soft or curved natural forms — plants, food, plush toys, clothing, fabric, bodies. Strategy: overlapping sphere/capsule chains, high roughness, sheen for fabric; NO large boxes. +- "hybrid": mixed — footwear (curved leather upper + rigid sole/heel), bags, headphones with pads, upholstered furniture. Strategy: sphere/capsule chains for the soft/curved portions, boxes/cylinders ONLY for the genuinely rigid parts. A leather dress shoe is "hybrid", never "hard-surface". + +Material families (set PBR from what the surface IS): polished leather ≈ roughness 0.3-0.45 + clearcoat 0.2; matte leather/rubber ≈ roughness 0.7-0.9, metalness 0; cloth/knit ≈ roughness 0.9 + sheen 0.4-0.7; plastic ≈ roughness 0.4-0.6; metal ≈ metalness 0.9+ with roughness by finish; skin-like ≈ roughness 0.6 + sheen 0.3; glazed ceramic ≈ roughness 0.05-0.15, metalness 0, clearcoat 1.0 (the engine boosts reflections for this combo — a bottle without it reads as matte paint); window/bottle glass ≈ transmission 0.9-1.0 + roughness 0.05 + metalness 0 (see-through with refraction — use for every pane the analysis marks as glass). + +CANONICAL WORLD FRAME (fixed for EVERY object — never rotate the whole body off these axes): +- +Y is UP. -Z is FORWARD (the front of a vehicle/machine/creature — the face shown in a front view). +X is the object's RIGHT. +- Lay a directional object's LENGTH along Z with its front at -Z, its WIDTH along X, its HEIGHT along Y. A truck's cab→tail runs along Z, NOT along X. Do NOT lay the body length along X — every rotation number below assumes this frame, so an off-axis body makes the wheels look flat and the spikes point sideways. +- WHEELS: the axle runs along X (left-right). Build one wheel as a cylinder rotated [0, 0, 1.5708] so its axle points along X and it rolls in Z. Place the axles with a linear repeat along Z (front-to-back), and mirror the left and right rows along X (two rows, one at +X width, one at -X). A 6-wheel truck = 3 axle positions × 2 sides. +- FRONT PLOW / RAM TINES: rake toward -Z (rotation [-1.5708, 0, 0]) and repeat ACROSS the width along X — never along Z. + +ORIENTATION CHEAT SHEET (compute rotations, never guess signs — all relative to the CANONICAL WORLD FRAME above): +- cone / capsule / cylinder point along +Y (tip/length UP) by default. +- tip forward (-Z): rotation [-1.5708, 0, 0] · tip backward (+Z): [1.5708, 0, 0] +- tip right (+X): rotation [0, 0, -1.5708] · tip left (-X): [0, 0, 1.5708] +- a rolling wheel (axle along X): cylinder rotation [0, 0, 1.5708]. +- torus stands UPRIGHT by default (ring in the XY plane, axis +Z). It ALWAYS needs a rotation: a flat collar/band round an upright body is [-1.5708, 0, 0]; a wheel hub facing out of the flank is [0, 1.5708, 0]. Leaving it at [0, 0, 0] leaves a hoop standing beside the object. +- A spike/blade array raking FORWARD from a front plate (like a breach plow) = cones with rotation [-1.5708, 0, 0], tips pointing -Z, bases touching the plate. +- After rotating, re-check the part still overlaps its mount — rotation moves the tip, not the base. + +Rules: +- Y is up. Size the whole object to roughly 2-3 units. A child's position/rotation is RELATIVE to its parent node — if you are not fully sure of the accumulated transform, parent the component directly to the root group and give it absolute coordinates instead. +- FOLLOW THE ANALYSIS: the user message includes an ANALYSIS block (object type, semantic part plan, build recipe) produced by a prior analysis pass over the same photos. Treat its buildRecipe as your own expert construction notes — every partPlan entry must map to components (thin details like railings included — a missing part is a failed build), and every recipe instruction must be visibly honored in the spec. Honor each part's "material" (glass parts get a transmission material, metal gets metalness, etc.), its "surface" note, and its "depthRatio" — depth = bboxWidth × depthRatio is that part's thickness target; do not guess thickness the analysis already measured. +- PROPORTIONS ARE MEASURED, NEVER ASSUMED: for every pair of related parts (stacked, nested, capping, side-by-side) the size ratio between them comes from their analysis bboxes — never from what this kind of object "usually" looks like. A part that caps or shelters another is at least as wide as what it covers, with the overhang read from the bbox comparison (rarely more than 20%). A part's steepness or flatness comes from its own bbox aspect (height ÷ width) — do not substitute a canonical shape for the measured one. +- GEOMETRIC RECONSTRUCTION (semantic recognition is not enough — reconstruct measured geometry): the ANALYSIS gives each part a normalized image bbox. DERIVE dimensions and positions from those numbers, do not eyeball: for parts at similar depth, widthA/widthB = bboxA.width/bboxB.width and heightA/heightB = bboxA.height/bboxB.height; a part's horizontal center ≈ (bbox.left + bbox.width/2) mapped across the object's total width; its vertical placement comes from bbox.top/height mapped down the object's total height (image top = model top). Cross-check every major part's computed size against its bbox ratio before answering. +- EACH partPlan "approach" IS AN ORDER, not a suggestion — build that part with the primitive family the directive for that approach names. The directives for the approaches this object actually uses are given below the rules. +- ENFORCE THE ATTACHMENTS LIST: each "A B" line is a hard joint — centered-above means A's center xz = B's center xz and A's bottom overlaps B's top; attached-right means A's left face overlaps B's right face; inset-into means A sits flush INSIDE B's face (protrude ≤ 0.02); rests-on means A's bottom overlaps B's top. Every attachment must be numerically true in the final coordinates. +- ASSEMBLY IS A GRAPH: every non-group part must physically OVERLAP at least one neighbour by 0.02-0.05 units — compute it, don't eyeball it — and its "note" names what it attaches to. A machine check lists floating parts back to you; a spec with floating parts is a failed spec. +- SUPPORT RULE: every component rests on or attaches to another, and NAMES that support in "attachTo". Nothing hovers. +- GROUND PLACEMENT IS A FLAG, NOT A HEIGHT: set "grounded": true on each thing that rests on the ground rather than authoring its height, and the engine drops it onto the ground plane. +- COMPOSITION CHECK: before answering, re-verify placement NUMERICALLY for every part — compare each part's position ± half its size against its neighbour's bounds. Nothing pokes through a surface it should sit on, and no part's centre falls inside another part's box (only a flush surface feature, like a window in its wall, may sit inside another). +- COPIES MUST NOT OVERLAP EACH OTHER: when a feature repeats, the step between copies is at least as long as the copy itself — size the part from the spacing, or space it from the size. +- SURFACE MARKS ARE NOT GEOMETRY: a mould seam, panel gap, printed hairline, stitch line, scratch or grain is a mark ON a surface, never a part. If a feature has no real thickness, express it through the material's "finish" or leave it out. +- A WINDOW / WINDSHIELD / SCREEN IS GLASS WITH DEPTH, NOT A DARK PLANE: build it as a thin roundedBox INSET a little into the body (recessed below the surface), with a glass material — transmission 0.85-1.0, roughness 0.05-0.15, metalness 0, and a NEAR-CLEAR or lightly-tinted colour (never a dark grey — a dark, low-transmission "glass" renders as an opaque black patch). A zero-thickness "plane" with a dark material is the classic wrong answer: it reads as a painted hole, not a window. +- FEATURES PROTRUDE OR RECESS — BOTH ARE REAL: a bumper, mirror or badge sticks OUT; a window well, a grille cavity, a sunken door panel or an air intake goes IN. For a recessed feature, set "inset": true on the component and give it a slightly darker material — the engine sinks its outer face just below the surrounding surface, and the shadow in that dip is what reads as depth. Do not fake a recess with a flat dark plane, and do not build every detail proud of the surface; match what the reference shows going in vs sticking out. +- A HOLLOW BODY HAS INTERIOR SPACE: when the reference shows you can see INTO a body through its windows/openings (a vehicle cab, a cabin, a container mouth), it is a shell, not a solid — put a smaller DARK matte "interior" box just inside it, behind the glass, so looking through the transmissive panes reveals depth instead of a flat surface. +- Build a real hierarchy: one root group, then logical sub-groups (body, handle, lid...). +- THE PART BUDGET COMES FROM THE PLAN, NOT FROM A NUMBER: every partPlan entry must map to at least one component, and a single part may honestly need several. But NEVER add a component the reference does not show in order to look thorough — a bottle rebuilt from 5 correct parts is a success, and the same bottle padded to 18 with invented neck rings is a failure. The request states the plan's part count; author within reach of it. +- Reuse materials via materialId; 3-8 materials typical. Estimate PBR values from the photo's shading. +- NO FAKE OPTICS: never add a plane, box, or decal to simulate a highlight, reflection, glare, specular streak, or sheen on a PHOTOREAL surface. Shine and reflection come ONLY from the material (roughness / metalness / clearcoat) plus scene lighting — a "highlight" plane laid over a photographed body renders as a hard streak cutting through it. Build only geometry that is physically part of the object. The single always-allowed non-object part is one flat ground shadow disc under it. EXCEPTION for a flat-shaded / cartoon / illustrated reference (inputKind not a photo): the drawn white catchlight on an eye or the shine dot on a nose IS part of that art style, so a SMALL bright ellipsoid sitting just proud of the eye/nose is allowed and encouraged — it is drawn geometry, not a faked reflection. +- Keep every "note" under 8 words — long notes bloat the reply and get it truncated. +- Numbers only in arrays — no strings, no null.`; diff --git a/4376bf-img-to-3d-generator/prompts/spec.gts b/4376bf-img-to-3d-generator/prompts/spec.gts new file mode 100644 index 00000000..4666a7ad --- /dev/null +++ b/4376bf-img-to-3d-generator/prompts/spec.gts @@ -0,0 +1,29 @@ +// Stage 2 of the pipeline: turn the analysis plan into a primitive tree. + +import { SPEC_JSON_SHAPE } from './spec-shape'; +import { selectRecipes } from './recipes'; + +// Block 1 of the system prompt: identical for every object, every call. Kept +// its own constant because that is exactly what makes it cacheable upstream — +// see systemMessage() in util/llm-request.gts. +export const SPEC_SYSTEM_PROMPT = `You are a 3D reconstruction engine. You receive one or MORE reference views of the same object and rebuild it as a procedural Three.js primitive tree. Respond with ONLY a JSON object — no markdown fences, no prose. + +${SPEC_JSON_SHAPE}`; + +// The system prompt as content blocks: the invariant contract first, then only +// the craft rules this object's own analysis asks for. Two blocks rather than +// one concatenated string so the invariant half stays byte-identical across +// objects and keeps its cache hit. +// +// With no analysis (or an analysis with no partPlan) this degrades to the +// contract alone, which is self-contained — it carries the schema, the +// primitive semantics, the world frame and every universal rule. +export function buildSpecSystemPrompt(analysis: any): string[] { + let recipes = selectRecipes(analysis); + return recipes + ? [ + SPEC_SYSTEM_PROMPT, + `Build directives for THIS object, selected from its analysis. They are as binding as the rules above.\n\n${recipes}`, + ] + : [SPEC_SYSTEM_PROMPT]; +} diff --git a/4376bf-img-to-3d-generator/prompts/targeted-edit.gts b/4376bf-img-to-3d-generator/prompts/targeted-edit.gts new file mode 100644 index 00000000..910c257f --- /dev/null +++ b/4376bf-img-to-3d-generator/prompts/targeted-edit.gts @@ -0,0 +1,40 @@ +// The user-directed edit pass: apply one instruction to a named set of parts. + +// Lasso targeted edit: the user selected specific parts (by nodeId) on the +// viewport and typed an instruction for JUST those parts. Unlike the refine +// pass this is not a whole-render review — it is a scoped, user-directed edit. +export const TARGETED_EDIT_PROMPT = `You are a 3D reconstruction engine applying a USER-DIRECTED edit to a few specific parts of an existing model. The user lasso-selected some parts on the 3D viewport and typed an instruction for THOSE PARTS ONLY. + +BASE RULE — ALWAYS START HERE, EVERY EDIT: look at the REFERENCE photo and the CURRENT generation side by side FIRST, and decide what differs, before you change anything. The reference is the ground truth for shape, proportion, placement, colour and finish; the current render is what the model looks like now. Every field you output must be justified by that comparison — never edit from the instruction text alone. + +You receive: the current spec JSON, the list of SELECTED nodeIds, the instruction, AND a comparison-sheet image — the LEFTMOST panel is the REFERENCE photo, the remaining panels are the CURRENT render of this model (front / side / three-quarter). ALWAYS diagnose visually first: compare the SELECTED parts in the render against the same parts in the reference photo and decide what is wrong before you edit. + +DIAGNOSE the selected parts against the reference for ALL of these: +- PLACEMENT / RECONCILE: is the part floating, offset, sunk into another part, on the wrong face, or at the wrong height? Move it into contact / to the right spot. +- PROPORTION: is it too big/small versus the reference? Fix with "scale". +- MATERIAL / FINISH: does the reference show metal, glass, or a glossy/matte surface that the render gets wrong (e.g. glass looking like flat plastic, metal with no shine)? Fix metalness / roughness / transmission / opacity in materialsChanged. +- COLOR / RECOLOR — DO IT ONE OF EXACTLY TWO WAYS, NEVER A THIRD: (1) if the target part's CURRENT material is used ONLY by that part, change that material's baseColor in "materialsChanged" (send the COMPLETE material object, keyed by its existing materialId). (2) if that material is SHARED by other parts you must NOT recolor, add a NEW material in "materialsChanged" AND point the part at it by setting "materialId" on that part in "changed". CRITICAL: adding a new material WITHOUT setting some part's "materialId" to it does NOTHING — the material is orphaned and nothing renders in the new colour. So a recolor ALWAYS touches either an existing material's baseColor or a part's materialId (usually you don't need a new material at all — just recolor the existing one). +- "THE WHEEL/PART" MEANS ITS VISIBLE BULK, ON BOTH SIDES: when the user names a part generically ("make the wheel red"), recolor the material of its MAIN visible volume (the tyre, not just the rim/spoke) — and apply the SAME change to the mirrored part on the other side (both wheels, both arms), not just one. Only narrow to a sub-part (rim, spoke, trim) or one side if the user says so. + +WHEN THE SELECTION IS EMPTY: "SELECTED nodeIds" may be an empty list, because the user described the edit instead of pointing at it. Then YOU resolve the targets: read the instruction against the spec's nodeIds and notes and pick the parts it names. The ids are semantic ('front-label', 'wheel-left', 'cap-body'), so "the label is too tall" means front-label. Pick the smallest set that satisfies the instruction, name them in "changed" as usual, and if the instruction is genuinely ambiguous between several parts, edit none of them and say which ones you were torn between in "critique". + +SCOPE — edit ONLY the parts in question (and, when the instruction clearly implies them, their descendants). Do not touch anything else. You may reposition (position), reorient (rotation), resize (scale OR dimensions — set dimensions when the instruction gives a real size, scale when it gives a proportion), recolor/refinish (materialsChanged), set "grounded": true to drop a part onto the ground, REMOVE parts the instruction asks to delete, and ADD parts that are missing. You may NOT change an existing part's "primitive" — turning a box into a sphere is never what an edit meant; delete it and add the right shape instead. + +ADDING A MISSING PART — when the instruction says something is missing, absent, or should be there ("the window is missing", "add the label back", "there should be a handle"), FIRST check the spec: if no component matches it, build one in "added". A new part is authored exactly like a spec component: pick the primitive from the same vocabulary, size it against the parts around it (their dimensions are right there in the spec), give it a "partRef" naming what it is, and "attachTo" the part it mounts on. Read the reference photo for where it sits, how big it is relative to its host, and what colour it is — and if it needs a colour no existing material has, add that material in "materialsChanged" and reference it. A question phrased as a question ("why is the window missing?") is still a request to fix it: answer by building the part, and say what you added in "critique". + +The instruction is the priority; if it is vague ("fix this", "reconcile this part"), let the reference comparison decide the fix. The object uses a fixed frame: -Z FORWARD, +Y UP, +X RIGHT. "bottom" = lower Y / grounded; "top" = higher Y. + +Respond with ONLY this JSON object: +{ + "critique": "1 sentence: what was wrong vs the reference and what you changed", + "changed": [ { "nodeId": "", "position": [x,y,z]?, "rotation": [x,y,z]?, "scale": [x,y,z]?, "dimensions": [numbers]?, "grounded": true?, "materialId": "m-..."? } — include only the fields you are changing; nodeId MUST already exist in the spec. Set "materialId" to point this part at a DIFFERENT existing (or newly-added) material — this is how you recolor ONE part without recoloring others that share its old material ], + "added": [ { "nodeId": "new-kebab-id", "parentId": "", "primitive": "", "dimensions": [numbers], "position": [x,y,z], "rotation": [x,y,z], "scale": [x,y,z], "materialId": "m-...", "partRef": "what this part is", "attachTo": "", "note": "short" } — ONLY parts the instruction asks for; a nodeId that already exists is ignored ], + "removedNodeIds": [ "", ... ] (only when the instruction asks to remove/delete parts; else empty), + "materialsChanged": [ COMPLETE material objects (materialId + baseColor/roughness/metalness/opacity/transmission/emissive/…) for any material of a selected part whose color or finish is wrong vs the reference ] +} + +- Act on EXACTLY the selected nodeIds unless the instruction names others. Parts not selected and not mentioned stay untouched. +- "move to bottom" / "reconcile to bottom" → set position.y so it rests on the ground, or set "grounded": true. +- "remove" / "delete" → list the nodeId(s) in "removedNodeIds". +- Resize with "scale" for a proportion ("60% as tall"), with "dimensions" for a real measurement ("0.6 tall"). +- "why is X missing" / "X should be here" → build X in "added", do not just explain.`; diff --git a/4376bf-img-to-3d-generator/sculpted-model.gts b/4376bf-img-to-3d-generator/sculpted-model.gts new file mode 100644 index 00000000..215fcc39 --- /dev/null +++ b/4376bf-img-to-3d-generator/sculpted-model.gts @@ -0,0 +1,456 @@ +import { + CardDef, + Component, + field, + contains, + linksTo, + ImageDef, + FileDef, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import DatetimeField from 'https://cardstack.com/base/datetime'; +import enumField from 'https://cardstack.com/base/enum'; + +import MultiImageSourceField from '@cardstack/catalog/fields/multi-image-source/multi-image-source'; + +import { generateViewerSrcdoc } from './util/code-export'; +import { VISION_MODEL_OPTIONS } from './util/llm-request'; + +// One finished reconstruction: the reference it was built from, the generated +// three.js model file it produced, and its self-review. Every generation in +// the studio is saved as one of these, so each model is an independent, +// searchable, linkable card in the realm (mirroring the AiImage pattern). +// +// The model itself lives OUTSIDE the card as a realm .js file (real code, +// with the source spec embedded as a SCULPT_SPEC constant) — the card only +// links to it. Rendering goes through the shared exports/viewer.html +// harness, which any iframe can load. +export class SculptedModel extends CardDef { + static displayName = 'Sculpted Model'; + + // every reference view this build was made from — the same multi-image set + // the studio holds, so selecting a saved round re-attaches all its views, + // not just the primary photo + @field references = contains(MultiImageSourceField); + // the generated model .js — the model's stored form, with the source spec + // riding inside it as its SCULPT_SPEC constant. A link (not a URL string) so + // the realm serializes it relative to this card and resolves it against + // whichever realm the card is served from; read `codeFile.url` for the + // absolute URL the viewer harness and the file reader need. + @field codeFile = linksTo(() => FileDef); + @field objectName = contains(StringField); + // backend the analysis recommended for this object ("primitive" | "mesh"). + // Provenance for a future mesh route — also lives inside `analysis`, surfaced + // here for quick display/query. See prompts/analyze.gts BACKEND rule. + @field buildBackend = contains(StringField); + // stage-1 analysis JSON (object type, camera, per-part measured bboxes, + // attachment constraints, _refSig) this build followed — kept on the + // creation so selecting this version in the studio re-attaches its measured + // targets and its reference signature drives the regenerate cache + @field analysis = contains(StringField); + @field critique = contains(StringField); + @field score = contains(NumberField); + // How well this round actually came out, as JSON: { residual, warnings[], + // featureCheck{}, plannedParts, builtParts }. Every one of these was already + // computed during the build and then thrown away with the session, which + // left no way to tell a prompt improvement from a prompt regression — + // keeping it on the round makes before/after a diff instead of a memory. + @field buildMetrics = contains(StringField); + // snapshot of the render at save time — the kept result is viewable as an + // image (gallery tiles) without rebuilding the 3D scene + @field renderScreenshot = linksTo(() => ImageDef); + // history is a backward linked list: each round points at the round it + // refined, so the studio only ever holds the latest card and older rounds + // load on demand as you walk the chain + @field parentCreation = linksTo(() => SculptedModel); + // the ImgTo3dStudio card that produced this round — scopes the studio's + // prerendered history search, which filters on `sourceStudio.id` (a link's + // id is always in the search doc, so the hop needs no `searchable: true`). + // A link rather than an id string so the stored form is realm-relative and + // the listing stays portable across realms. Typed as CardDef because + // img-to-3d-studio.gts imports this module — naming the studio class here + // would close a module cycle — and only the link's id is ever read. + @field sourceStudio = linksTo(() => CardDef); + @field round = contains(NumberField); + // bumped every time this round's .js is edited IN PLACE (the AI Refine + // command). The studio folds it into the viewport iframe's cache-bust key, + // so an external in-place edit forces a re-fetch of the same-url file instead + // of showing the stale cached render. + @field revision = contains(NumberField); + @field modelUsed = contains( + enumField(StringField, { + options: VISION_MODEL_OPTIONS, + displayName: 'Model Used', + }), + ); + @field createdAt = contains(DatetimeField); + @field title = contains(StringField, { + computeVia: function (this: SculptedModel) { + return this.objectName || 'Sculpted Model'; + }, + }); + + static isolated = class Isolated extends Component { + get hasLinkedTheme() { + return Boolean(this.args.model?.cardInfo?.theme); + } + // the viewer harness renders the model's .js file, inlined via srcdoc + // (external sites embed the same harness by its viewer.html URL) + get viewerSrcdoc() { + let codeFileUrl = this.args.model?.codeFile?.url; + if (!codeFileUrl) return undefined; + return generateViewerSrcdoc(codeFileUrl); + } + + }; + + static embedded = class Embedded extends Component { + get hasLinkedTheme() { + return Boolean(this.args.model?.cardInfo?.theme); + } + + }; + + static fitted = class Fitted extends Component { + get hasLinkedTheme() { + return Boolean(this.args.model?.cardInfo?.theme); + } + // baked into the prerendered tile so the studio's history strip can show + // when each round was made without loading the card + get createdLabel() { + let d = this.args.model?.createdAt; + if (!d) return undefined; + return new Date(d).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + }; +} diff --git a/4376bf-img-to-3d-generator/util/audit-model.mjs b/4376bf-img-to-3d-generator/util/audit-model.mjs new file mode 100644 index 00000000..804a9601 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/audit-model.mjs @@ -0,0 +1,325 @@ +// Geometry audit for an exported img-to-3D model — a browser-free test loop. +// +// An exported model .js is self-contained: buildSculpture(THREE) plus a +// SCULPT_SPEC record of what was authored. three.js runs headlessly in node, so +// the model can be BUILT and measured without a browser, a GPU or an API call. +// Every defect in these renders has been geometric rather than visual — a part +// outside the silhouette, a cap above the bottle top, a 0.008-radius tube that +// renders as a wire — so measuring beats looking at pixels, and it takes +// milliseconds instead of a generation round. +// +// Usage: node util/audit-model.mjs [more.js ...] +// [--json out.json] write per-model findings as data +// [--baseline was.json] print the delta against an earlier --json +// Needs: npm i three (in whatever directory you run it from) +// +// Exit code is 1 when any check fails, so this can gate a change to the +// interpreter: run it over a folder of saved exports before and after. +// +// --json / --baseline exist for the other kind of change: a PROMPT edit, whose +// effect is spread thinly over many models and cannot be read off one render. +// Record a baseline over a fixture set, change the prompt, regenerate, and +// diff — a rule worth keeping shows up as findings that come back. + +import * as THREE from 'three'; +import fs from 'fs'; + +// no DOM here — decal builders ask for textures they will never get +THREE.TextureLoader.prototype.load = () => new THREE.Texture(); + +// …but the procedural finish painters DO paint, on a real 2d context, and a +// model with any finish on it would otherwise die on `document is not defined` +// before a single geometry check ran. The painted pixels are irrelevant to a +// geometry audit, so every drawing call is a no-op that returns something +// chainable. Anything the audit does care about is measured off the meshes. +const stubGradient = { addColorStop() {} }; +const stubCtx = new Proxy( + {}, + { + get(target, prop) { + if (prop in target) return target[prop]; + if (prop === 'canvas') return { width: 1, height: 1 }; + if (String(prop).startsWith('create')) return () => stubGradient; + if (prop === 'getImageData') { + return (_x, _y, w = 1, h = 1) => ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }); + } + if (prop === 'measureText') return () => ({ width: 0 }); + return () => undefined; + }, + set() { + return true; + }, + }, +); +globalThis.document = { + createElement: (tag) => + tag === 'canvas' + ? { width: 0, height: 0, getContext: () => stubCtx } + : { style: {} }, +}; + +const HAIRLINE = 0.02; // below this a tube/rib reads as a floating wire +const OUTSIDE_TOLERANCE = 0.03; // the overlap the assembly rules already allow + +function auditFile(path) { + const js = fs.readFileSync(path, 'utf8'); + const built = new Function( + 'THREE', + js + '\nreturn buildSculpture(THREE);', + )(THREE); + const group = built.group; + group.updateMatrixWorld(true); + const specMatch = js.match(/var SCULPT_SPEC = (\{[\s\S]*?\});/); + const spec = specMatch ? JSON.parse(specMatch[1]) : { components: [] }; + const components = spec.components ?? []; + const byName = new Map(components.map((c) => [c.nodeId, c])); + + const meshes = []; + group.traverse((o) => { + if (o.isMesh) meshes.push(o); + }); + // the ground shadow is deliberately wider than the object's contact point + const isShadow = (name) => /shadow/i.test(name); + const failures = []; + const counts = {}; + const size = new THREE.Box3().setFromObject(group).getSize(new THREE.Vector3()); + + console.log(`\n=== ${path.split('/').pop()} ===`); + console.log( + `size ${size.x.toFixed(2)} x ${size.y.toFixed(2)} x ${size.z.toFixed(2)} · ` + + `${components.length} authored components · ${meshes.length} meshes after repeats`, + ); + + // ---- 1. hairline geometry: a surface mark modelled as a solid + const hairline = components.filter((c) => { + const d = c.dimensions ?? []; + if (c.primitive === 'tube') return d[0] < HAIRLINE; + if (c.primitive === 'torus') return d[1] < HAIRLINE * 0.4; + if (c.primitive === 'box' || c.primitive === 'cylinder') { + return d.slice(0, 3).filter((v) => v < HAIRLINE * 0.75).length >= 2; + } + return false; + }); + report('hairline geometry (renders as a wire, not a feature)', hairline, (c) => + `${c.nodeId} — ${c.primitive} ${JSON.stringify(c.dimensions)} · partRef='${c.partRef}' · ${c.note ?? ''}`, + ); + + // ---- 2 & 3. the traced lathe profile is the object's true outer boundary, + // so it doubles as the test envelope for everything else + const lathe = components.find((c) => c.primitive === 'lathe'); + if (lathe) { + const env = []; + for (let i = 0; i + 1 < lathe.dimensions.length; i += 2) { + env.push({ half: lathe.dimensions[i], y: lathe.dimensions[i + 1] }); + } + const halfAt = (y) => { + if (y <= env[0].y) return env[0].half; + const last = env[env.length - 1]; + if (y >= last.y) return last.half; + for (let i = 1; i < env.length; i++) { + if (y <= env[i].y) { + const a = env[i - 1]; + const b = env[i]; + const t = (y - a.y) / (b.y - a.y || 1); + return a.half + t * (b.half - a.half); + } + } + return last.half; + }; + + const outside = []; + const above = []; + const top = env[env.length - 1].y; + for (const mesh of meshes) { + if (isShadow(mesh.name)) continue; + const box = new THREE.Box3().setFromObject(mesh); + const centerY = box.getCenter(new THREE.Vector3()).y; + const reach = Math.max(Math.abs(box.max.x), Math.abs(box.min.x)); + const allowed = halfAt(centerY) + OUTSIDE_TOLERANCE; + if (reach > allowed) { + outside.push( + `${mesh.name} — reaches x=${reach.toFixed(3)} where the body half-width at y=${centerY.toFixed(2)} is ${allowed.toFixed(3)}`, + ); + } + if (box.max.y > top + 0.005) { + above.push( + `${mesh.name} — top y=${box.max.y.toFixed(3)}, ${(box.max.y - top).toFixed(3)} above the body top`, + ); + } + } + report('outside the traced silhouette', outside, (s) => s); + report(`above the body top (y=${top})`, above, (s) => s); + } + + // ---- 4. radial repeats: the clones must land ON the declared circle, not + // at the original's offset PLUS the radius — and each must be the original + // CARRIED RIGIDLY around the ring, not respun about its own axes + const radial = []; + const splayed = []; + const axisVec = { x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1] }; + for (const c of components) { + let rep = c.repeat; + if (typeof rep === 'string') { + try { + rep = JSON.parse(rep); + } catch { + continue; + } + } + if (rep?.mode !== 'radial') continue; + const axis = rep.axis === 'x' ? 'x' : rep.axis === 'z' ? 'z' : 'y'; + const plane = { x: ['y', 'z'], y: ['x', 'z'], z: ['x', 'y'] }[axis]; + const host = c.attachTo ? group.getObjectByName(c.attachTo) : undefined; + const hostCenter = host + ? new THREE.Box3().setFromObject(host).getCenter(new THREE.Vector3()) + : new THREE.Vector3(); + const count = rep.count ?? 0; + // orientation of instance 0, the pose every other instance is a rotation of + let baseQuat = null; + for (let i = 0; i < count; i++) { + const clone = group.getObjectByName(i === 0 ? c.nodeId : `${c.nodeId}-${i}`); + if (!clone) continue; + const p = clone.getWorldPosition(new THREE.Vector3()); + const got = Math.hypot(p[plane[0]] - hostCenter[plane[0]], p[plane[1]] - hostCenter[plane[1]]); + const want = rep.radius ?? 0.5; + if (Math.abs(got - want) > Math.max(0.02, want * 0.25)) { + radial.push( + `${clone.name} — sits at radius ${got.toFixed(3)} from '${c.attachTo ?? 'origin'}' but the repeat declares ${want}`, + ); + break; // one line per repeat system is enough + } + // A ring carries its instances rigidly: instance i's orientation is + // instance 0's turned by exactly its own orbital angle about the ring + // axis, and nothing else. Composing that angle INSIDE the part's own + // Euler instead tilts each clone by its index — six barrels laid along z + // came out crossed like an asterisk rather than parallel. + const q = clone.getWorldQuaternion(new THREE.Quaternion()); + if (i === 0) { + baseQuat = q; + continue; + } + const angle = (i / count) * Math.PI * 2; + const expected = new THREE.Quaternion() + .setFromAxisAngle( + new THREE.Vector3(...axisVec[axis]), + axis === 'y' ? -angle : angle, + ) + .multiply(baseQuat); + const off = (q.angleTo(expected) * 180) / Math.PI; + if (off > 0.5) { + splayed.push( + `${clone.name} — orientation is ${off.toFixed(1)}° off the rigid ring pose (respun about its own axes instead of carried)`, + ); + break; + } + } + } + report('radial repeat clones off their declared circle', radial, (s) => s); + report('radial repeat clones splayed instead of carried', splayed, (s) => s); + + // ---- 5. whatever the builder itself noticed + const warnings = built.warnings ?? []; + report('assembly warnings from the builder', warnings, (s) => s); + + function report(title, items, format) { + const list = items ?? []; + counts[title] = list.length; + console.log(`\n[${title}] ${list.length}`); + for (const item of list.slice(0, 12)) console.log(` ${format(item)}`); + if (list.length > 12) console.log(` … ${list.length - 12} more`); + if (list.length) failures.push(`${title}: ${list.length}`); + } + + return { + failures, + metrics: { + objectName: spec.objectName ?? null, + objectClass: spec.objectClass ?? null, + inputKind: spec.inputKind ?? null, + components: components.length, + meshes: meshes.length, + size: [size.x, size.y, size.z].map((v) => +v.toFixed(3)), + findings: counts, + }, + }; +} + +// --flag value pairs anywhere in the args; everything else is a model path +const argv = process.argv.slice(2); +const flags = {}; +const paths = []; +for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) flags[argv[i].slice(2)] = argv[++i]; + else paths.push(argv[i]); +} +if (!paths.length) { + console.error( + 'usage: node util/audit-model.mjs [...] [--json out.json] [--baseline was.json]', + ); + process.exit(2); +} +let failed = 0; +const run = {}; +for (const path of paths) { + // key on the basename so a baseline survives being regenerated into a + // different directory + const key = path.split('/').pop(); + try { + const { failures, metrics } = auditFile(path); + failed += failures.length; + run[key] = metrics; + } catch (e) { + console.error(`\n=== ${path} ===\n could not build: ${e.message}`); + run[key] = { error: e.message }; + failed += 1; + } +} + +if (flags.baseline) { + const was = JSON.parse(fs.readFileSync(flags.baseline, 'utf8')); + console.log(`\n=== delta vs ${flags.baseline.split('/').pop()} ===`); + let moved = 0; + for (const key of new Set([...Object.keys(was), ...Object.keys(run)])) { + const before = was[key]; + const now = run[key]; + if (!before) { + console.log(` ${key}: new, no baseline`); + continue; + } + if (!now) { + console.log(` ${key}: missing from this run`); + continue; + } + const titles = new Set([ + ...Object.keys(before.findings ?? {}), + ...Object.keys(now.findings ?? {}), + ]); + const lines = []; + for (const title of titles) { + const a = before.findings?.[title] ?? 0; + const b = now.findings?.[title] ?? 0; + if (a !== b) lines.push(` ${b > a ? '↑' : '↓'} ${title}: ${a} → ${b}`); + } + if (before.meshes !== now.meshes) { + lines.push(` · meshes: ${before.meshes} → ${now.meshes}`); + } + if (lines.length) { + moved += 1; + console.log(` ${key}`); + for (const line of lines) console.log(line); + } + } + console.log(moved ? ` ${moved} model(s) moved` : ' no change'); +} + +if (flags.json) { + fs.writeFileSync(flags.json, JSON.stringify(run, null, 2) + '\n'); + console.log(`\nwrote ${flags.json}`); +} + +console.log(failed ? `\n${failed} check(s) reported findings` : '\nclean'); +process.exit(failed ? 1 : 0); diff --git a/4376bf-img-to-3d-generator/util/code-export.gts b/4376bf-img-to-3d-generator/util/code-export.gts new file mode 100644 index 00000000..14c35e37 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/code-export.gts @@ -0,0 +1,1588 @@ +// Spec → standalone three.js code generator. Translates a SculptSpecField +// into (a) a self-contained .js module defining buildSculpture(THREE) and +// (b) a self-contained .html viewer page (CDN three.js r147 + orbit harness) +// whose realm URL works directly as an iframe src. +// +// This is the single geometry builder: the emitted code IS what the studio +// viewport renders (via srcdoc) and what .glb export runs. Only the used +// primitives' geometry cases and the needed helpers are emitted, so the file +// stays readable. Known deliberate gaps, marked with comments in the output: +// meshAsset nodes (dormant feature) are skipped. + +import { + FINISH_PAINTER_SOURCES, + FINISH_RUNTIME_SOURCES, + type EmittableFn, +} from './finishes'; +import { expandRepeatInstances } from './repeat-expand'; +import { seatSurfaceParts } from './surface-seat'; + +// three.js UMD pins for the generated viewer harness +const THREE_CDN = + 'https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js'; +const ROUNDED_BOX_CDN = + 'https://cdn.jsdelivr.net/npm/three@0.147.0/examples/js/geometries/RoundedBoxGeometry.js'; +const GLTF_EXPORTER_CDN = + 'https://cdn.jsdelivr.net/npm/three@0.147.0/examples/js/exporters/GLTFExporter.js'; + +interface ExportNode { + nodeId: string; + parentId?: string | null; + primitive: string; + dimensions: number[]; + position: number[]; + rotation: number[]; + scale: number[]; + materialId?: string | null; + text?: string | null; + // analysis partPlan name this component belongs to — drives the measured + // proportion reconciliation (sizing), while textureRef drives artwork crops + partRef?: string | null; + // analysis partPlan name whose photo region carries this part's artwork + textureRef?: string | null; + // resolved crop URL (the studio crops the reference by the analysis bbox + // and writes it into the realm before generating code) + textureUrl?: string | null; + repeat?: any; + attachTo?: string | null; + note?: string | null; +} + +interface ExportMaterial { + materialId: string; + baseColor?: string | null; + roughness?: number | null; + metalness?: number | null; + opacity?: number | null; + emissive?: string | null; + emissiveIntensity?: number | null; + clearcoat?: number | null; + sheen?: number | null; + transmission?: number | null; + finish?: string | null; +} + +function parseNums(raw: any, fallback: number[]): number[] { + if (Array.isArray(raw)) return raw; + if (!raw || typeof raw !== 'string') return fallback; + try { + let parsed = JSON.parse(raw); + if ( + Array.isArray(parsed) && + parsed.every((n) => typeof n === 'number' && isFinite(n)) + ) { + return parsed; + } + } catch { + // fall through + } + return fallback; +} + +function specNodes(spec: any): ExportNode[] { + return (spec?.components ?? []) + .filter((c: any) => c?.nodeId) + .map((c: any) => ({ + nodeId: String(c.nodeId), + parentId: c.parentId ? String(c.parentId) : null, + primitive: c.primitive || 'group', + dimensions: parseNums(c.dimensions, []), + position: parseNums(c.position, [0, 0, 0]), + rotation: parseNums(c.rotation, [0, 0, 0]), + scale: parseNums(c.scale, [1, 1, 1]), + materialId: c.materialId || null, + text: c.text || null, + partRef: c.partRef || null, + textureRef: c.textureRef || null, + textureUrl: c.textureUrl || null, + repeat: safeRepeat(c.repeat), + attachTo: c.attachTo || null, + anchor: c.anchor && typeof c.anchor === 'object' ? c.anchor : null, + grounded: c.grounded === true ? true : null, + note: c.note || null, + })); +} + +function safeRepeat(raw: any): any { + if (!raw) return null; + if (typeof raw === 'object') return raw; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function specMaterials(spec: any): ExportMaterial[] { + return (spec?.materials ?? []) + .filter((m: any) => m?.materialId) + .map((m: any) => ({ + materialId: String(m.materialId), + baseColor: m.baseColor || null, + roughness: numOrNull(m.roughness), + metalness: numOrNull(m.metalness), + opacity: numOrNull(m.opacity), + emissive: m.emissive || null, + emissiveIntensity: numOrNull(m.emissiveIntensity), + clearcoat: numOrNull(m.clearcoat), + sheen: numOrNull(m.sheen), + transmission: numOrNull(m.transmission), + finish: m.finish || null, + })); +} + +function numOrNull(n: any): number | null { + return typeof n === 'number' && isFinite(n) ? n : null; +} + +// compact, deterministic number literal (4 decimals, trimmed) +function fmt(n: number): string { + if (!isFinite(n)) return '0'; + let s = Number(n.toFixed(4)).toString(); + return s === '-0' ? '0' : s; +} + +function fmtArr(a: number[]): string { + return '[' + a.map(fmt).join(', ') + ']'; +} + +// single-quoted JS string literal — newlines included (multi-line label +// text otherwise emits an unterminated string and breaks the whole file) +function q(s: string): string { + return ( + "'" + + String(s) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029') + + "'" + ); +} + +function clamp01(n: number | null | undefined, fallback: number): number { + if (typeof n !== 'number' || !isFinite(n)) return fallback; + return Math.min(1, Math.max(0, n)); +} + +// perceived brightness 0..1 of a #rrggbb colour +function hexLuminance(hex: string): number { + let m = /^#?([0-9a-f]{6})$/i.exec(String(hex ?? '').trim()); + if (!m) return 0.5; + let n = parseInt(m[1], 16); + return ( + (0.2126 * ((n >> 16) & 255) + + 0.7152 * ((n >> 8) & 255) + + 0.0722 * (n & 255)) / + 255 + ); +} + +// --------------------------------------------------------------------------- +// material emission — same parameter derivation as the interpreter +// --------------------------------------------------------------------------- + +function emitMaterials(materials: ExportMaterial[], unlit: boolean): string[] { + let lines: string[] = [' // ===== materials =====']; + lines.push(' var MATERIALS = {};'); + for (let m of materials) { + let opacity = clamp01(m.opacity, 1); + if (unlit) { + let params = [ + `color: new THREE.Color(${q(m.baseColor || '#8a8f9c')})`, + ...(opacity < 1 + ? ['transparent: true', `opacity: ${fmt(opacity)}`] + : []), + 'toneMapped: false', + ]; + lines.push( + ` MATERIALS[${q(m.materialId)}] = new THREE.MeshBasicMaterial({ ${params.join(', ')} });`, + ); + continue; + } + // glass safety clamp: a window declared with transmission but set LOW, or + // given a dark tint, renders as an opaque patch instead of see-through + // glass (the dump-truck windshield failure). A half-transmissive material + // is almost always a mistake — push real glass to clearly see-through and + // neutralise a dark tint so light passes. + let baseColor = m.baseColor || '#8a8f9c'; + let transmissionVal = + typeof m.transmission === 'number' + ? clamp01(m.transmission, 0) + : undefined; + if (transmissionVal !== undefined && transmissionVal > 0) { + if (transmissionVal < 0.6) transmissionVal = 0.9; + if (hexLuminance(baseColor) < 0.3) baseColor = '#dfeef5'; + } + let params: string[] = [ + `color: new THREE.Color(${q(baseColor)})`, + `roughness: ${fmt(clamp01(m.roughness, 0.55))}`, + `metalness: ${fmt(clamp01(m.metalness, 0.25))}`, + ]; + if (m.emissive && m.emissive !== '#000000') { + params.push(`emissive: new THREE.Color(${q(m.emissive)})`); + params.push( + `emissiveIntensity: ${fmt( + typeof m.emissiveIntensity === 'number' + ? Math.min(2, Math.max(0, m.emissiveIntensity)) + : 1, + )}`, + ); + } + if (opacity < 1) { + params.push('transparent: true', `opacity: ${fmt(opacity)}`); + } + let envIntensity = 0.35; + let usePhysical = + typeof m.clearcoat === 'number' || + typeof m.sheen === 'number' || + typeof m.transmission === 'number'; + if (usePhysical) { + if (transmissionVal !== undefined) { + params.push( + `transmission: ${fmt(transmissionVal)}`, + 'ior: 1.5', + 'thickness: 0.05', + ); + envIntensity = Math.max(envIntensity, 1); + } + if (typeof m.clearcoat === 'number') { + let clearcoat = clamp01(m.clearcoat, 0); + let roughness = clamp01(m.roughness, 0.55); + let glassy = clearcoat >= 0.8 && roughness <= 0.2; + params.push( + `clearcoat: ${fmt(clearcoat)}`, + `clearcoatRoughness: ${glassy ? '0.08' : '0.3'}`, + ); + if (glassy) envIntensity = 1.2; + } + if (typeof m.sheen === 'number') { + params.push(`sheen: ${fmt(clamp01(m.sheen, 0))}`); + } + } + // give plain opaque non-metal surfaces a hint of clearcoat, so plastic and + // painted parts read as clean toy-gloss instead of flat matte. Metals, + // glass, and anything the spec already made physical keep their authored + // response — only the otherwise-Standard matte case is nudged. + if (!usePhysical && opacity >= 1 && clamp01(m.metalness, 0.25) < 0.5) { + params.push('clearcoat: 0.15', 'clearcoatRoughness: 0.35'); + usePhysical = true; + } + params.push(`envMapIntensity: ${fmt(envIntensity)}`); + let ctor = usePhysical ? 'MeshPhysicalMaterial' : 'MeshStandardMaterial'; + lines.push( + ` MATERIALS[${q(m.materialId)}] = new THREE.${ctor}({ ${params.join(', ')} });`, + ); + // procedural finish, mirroring the studio interpreter exactly: the painted + // canvas serves as colour map AND roughnessMap, except for tread/knurl + // which are RELIEF and go to bumpMap so the material keeps its own colour. + // hazard/camo/louver carry their colours inside the map, so the material's + // colour is neutralised to white and the map is not tinted by it. + if (m.finish && FINISH_PAINTER_SOURCES[m.finish]) { + let id = q(m.materialId); + let finish = q(m.finish); + let seedColor = m.baseColor ? q(m.baseColor) : "'#7a7f5a'"; + lines.push( + ` (function () {`, + ` var tex = makeFinishTexture(THREE, ${finish}, ${id}, ${seedColor});`, + ` if (!tex) return;`, + ` var mat = MATERIALS[${id}];`, + m.finish === 'tread' || m.finish === 'knurl' + ? ` mat.bumpMap = tex; mat.bumpScale = 0.02;` + : ` mat.map = tex; mat.roughnessMap = tex;` + + (m.finish === 'hazard' || + m.finish === 'camo' || + m.finish === 'louver' + ? `\n mat.color = new THREE.Color('#ffffff');` + : ''), + ` mat.needsUpdate = true;`, + ` })();`, + ); + } + } + lines.push( + unlit + ? ' var FALLBACK_MATERIAL = new THREE.MeshBasicMaterial({ color: 0x8a8f9c, toneMapped: false });' + : ' var FALLBACK_MATERIAL = new THREE.MeshStandardMaterial({ color: 0x8a8f9c, roughness: 0.55, metalness: 0.25 });', + ); + return lines; +} + +// --------------------------------------------------------------------------- +// geometry cases — the interpreter's buildGeometry, emitted per used primitive +// --------------------------------------------------------------------------- + +// exported so a test can assert every solid primitive has a codegen case, +// guarding the interpreter/codegen drift risk (a primitive added to one but not +// the other renders differently in the live preview vs the saved .js file) +export const GEOMETRY_CASES: Record = { + box: ` case 'box': + return new THREE.BoxGeometry(d[0] ?? 1, d[1] ?? 1, d[2] ?? 1);`, + roundedBox: ` case 'roundedBox': { + // [w, h, d, cornerRadius?, bevel?] — needs the RoundedBoxGeometry + // add-on; falls back to an extruded rounded-rect slab without it + var w = d[0] ?? 1, h = d[1] ?? 0.3, dep = d[2] ?? 1; + var r = Math.min(Math.abs(d[3] ?? 0.1), Math.min(w, dep) / 2 - 0.001); + if (THREE.RoundedBoxGeometry) { + var radius = Math.min(r, Math.min(w, h, dep) / 2 - 0.001); + return new THREE.RoundedBoxGeometry(w, h, dep, 3, Math.max(0.01, radius)); + } + var bevel = Math.min(Math.abs(d[4] ?? 0.02), h / 3); + var geo = new THREE.ExtrudeGeometry(roundedRectShape(w, dep, r), { + depth: Math.max(h - bevel * 2, 0.001), bevelEnabled: bevel > 0, + bevelThickness: bevel, bevelSize: bevel, bevelSegments: 3, curveSegments: 24, + }); + geo.rotateX(-Math.PI / 2); + geo.translate(0, bevel - h / 2, 0); + return geo; + }`, + roundedPlate: ` case 'roundedPlate': { + // [w, h, depth, cornerRadius?] — rounded-rect plate facing +Z, centered + var w = d[0] ?? 2, h = d[1] ?? 2, dep = Math.abs(d[2] ?? 0.1); + var r = Math.min(Math.abs(d[3] ?? 0.2), Math.min(w, h) / 2 - 0.001); + var geo = new THREE.ExtrudeGeometry(roundedRectShape(w, h, r), { + depth: dep, bevelEnabled: false, curveSegments: 24, + }); + geo.translate(0, 0, -dep / 2); + return geo; + }`, + flatRing: ` case 'flatRing': { + // [outerRx, outerRy, ringWidth, depth] — flat elliptical ring, facing +Z + var orx = Math.abs(d[0] ?? 0.5), ory = Math.abs(d[1] ?? orx); + var width = Math.min(Math.abs(d[2] ?? 0.12), Math.min(orx, ory) - 0.01); + var depth = Math.abs(d[3] ?? 0.08); + var shape = new THREE.Shape(); + shape.absellipse(0, 0, orx, ory, 0, Math.PI * 2, false, 0); + var hole = new THREE.Path(); + hole.absellipse(0, 0, orx - width, ory - width, 0, Math.PI * 2, true, 0); + shape.holes.push(hole); + var geo = new THREE.ExtrudeGeometry(shape, { depth: depth, bevelEnabled: false, curveSegments: 32 }); + geo.translate(0, 0, -depth / 2); + return geo; + }`, + arch: ` case 'arch': { + // [outerR, ringWidth, depth, sweepDeg?] — partial ring spanning the top + var outer = Math.abs(d[0] ?? 0.6); + var width = Math.min(Math.abs(d[1] ?? 0.15), outer - 0.01); + var depth = Math.abs(d[2] ?? 0.3); + var sweep = (Math.min(340, Math.max(20, d[3] ?? 180)) * Math.PI) / 180; + var start = Math.PI / 2 + sweep / 2, end = Math.PI / 2 - sweep / 2; + var inner = outer - width; + var shape = new THREE.Shape(); + shape.absarc(0, 0, outer, start, end, true); + shape.lineTo(Math.cos(end) * inner, Math.sin(end) * inner); + shape.absarc(0, 0, inner, end, start, false); + shape.closePath(); + var geo = new THREE.ExtrudeGeometry(shape, { depth: depth, bevelEnabled: false, curveSegments: 32 }); + geo.translate(0, 0, -depth / 2); + return geo; + }`, + prism: ` case 'prism': { + // [lengthAlongRidge, span, height] — triangular prism, ridge along X + var length = Math.abs(d[0] ?? 1.5), span = Math.abs(d[1] ?? 1), height = Math.abs(d[2] ?? 0.6); + var shape = new THREE.Shape(); + shape.moveTo(-span / 2, 0); + shape.lineTo(span / 2, 0); + shape.lineTo(0, height); + shape.closePath(); + var geo = new THREE.ExtrudeGeometry(shape, { depth: length, bevelEnabled: false }); + geo.translate(0, -height / 2, -length / 2); + geo.rotateY(Math.PI / 2); + return geo; + }`, + extrudedSpline: ` case 'extrudedSpline': { + // [depth, x0,y0, x1,y1, ...] — smooth spline through the outline points + var depth = Math.abs(d[0] ?? 0.1); + var pts = []; + var flat = d.slice(1); + for (var i = 0; i + 1 < flat.length; i += 2) pts.push(new THREE.Vector2(flat[i], flat[i + 1])); + if (pts.length < 3) pts = [new THREE.Vector2(0, 0.5), new THREE.Vector2(-0.45, -0.35), new THREE.Vector2(0.45, -0.35)]; + var shape = new THREE.Shape(); + shape.moveTo(pts[0].x, pts[0].y); + shape.splineThru(pts.slice(1).concat([pts[0]])); + var geo = new THREE.ExtrudeGeometry(shape, { depth: depth, bevelEnabled: false, curveSegments: 24 }); + geo.translate(0, 0, -depth / 2); + return geo; + }`, + extrudedPolygon: ` case 'extrudedPolygon': { + // [depth, x0,y0, x1,y1, ...] — polygon in the XY plane, facing +Z + var depth = Math.abs(d[0] ?? 0.1); + var shape = new THREE.Shape(); + var pts = d.slice(1); + if (pts.length >= 6) { + shape.moveTo(pts[0], pts[1]); + for (var i = 2; i + 1 < pts.length; i += 2) shape.lineTo(pts[i], pts[i + 1]); + } else { + shape.moveTo(0, 0.5); + shape.lineTo(-0.45, -0.35); + shape.lineTo(0.45, -0.35); + } + var geo = new THREE.ExtrudeGeometry(shape, { depth: depth, bevelEnabled: false, curveSegments: 12 }); + geo.translate(0, 0, -depth / 2); + return geo; + }`, + capsule: ` case 'capsule': + // [radius, cylinderLength] + return new THREE.CapsuleGeometry(d[0] ?? 0.3, d[1] ?? 0.6, 12, Math.max(6, Math.round(d[2] ?? 32)));`, + hemisphere: ` case 'hemisphere': + // [radius] — dome opening downward + return new THREE.SphereGeometry( + d[0] ?? 0.5, Math.max(3, Math.round(d[1] ?? 32)), Math.max(2, Math.round(d[2] ?? 16)), + 0, Math.PI * 2, 0, Math.PI / 2);`, + cylinder: ` case 'cylinder': + return new THREE.CylinderGeometry( + d[0] ?? 0.5, d[1] ?? d[0] ?? 0.5, d[2] ?? 1, Math.max(3, Math.round(d[3] ?? 48)));`, + sphere: ` case 'sphere': + return new THREE.SphereGeometry( + d[0] ?? 0.5, Math.max(3, Math.round(d[1] ?? 48)), Math.max(2, Math.round(d[2] ?? 32)));`, + cone: ` case 'cone': { + var segments = Math.max(3, Math.round(d[2] ?? 24)); + var geo = new THREE.ConeGeometry(d[0] ?? 0.5, d[1] ?? 1, segments); + // 4 segments = hip-roof/spire pyramid: bake the 45° square-up into the + // geometry so node-level non-uniform scale doesn't shear it + if (segments === 4) geo.rotateY(Math.PI / 4); + return geo; + }`, + torus: ` case 'torus': + // [radius,tube] — NATIVE three.js orientation: the ring lies in the XY + // plane, axis along +Z, standing upright. A collar around an upright body + // is rotation [-1.5708, 0, 0]; a wheel hub facing sideways is [0, 1.5708, 0] + return new THREE.TorusGeometry( + d[0] ?? 0.5, d[1] ?? 0.15, Math.max(3, Math.round(d[2] ?? 16)), Math.max(3, Math.round(d[3] ?? 48)));`, + plane: ` case 'plane': + return new THREE.PlaneGeometry(d[0] ?? 1, d[1] ?? 1);`, + disc: ` case 'disc': + // [radius] — flat circle facing +Z + return new THREE.CircleGeometry(d[0] ?? 0.5, 48);`, + rock: ` case 'rock': + // [radius, detail?] — faceted low-poly blob + return new THREE.IcosahedronGeometry(d[0] ?? 0.5, Math.min(2, Math.max(0, Math.round(d[1] ?? 1))));`, + blob: ` case 'blob': { + // [radius, bumpiness, seed?, detail?] — sphere displaced radially by + // seeded pseudo-noise (sum of random 3D sinusoids) + var r = Math.abs(d[0] ?? 0.5); + var amp = Math.min(Math.abs(d[1] ?? 0.15) * r, r * 0.6); + var seed = Math.max(1, Math.round(Math.abs(d[2] ?? 1))); + var detail = Math.min(96, Math.max(16, Math.round(d[3] ?? 48))); + var geo = new THREE.SphereGeometry(r, detail, detail); + var rand = mulberry32(seed); + var waves = []; + for (var wi = 0; wi < 6; wi++) { + waves.push({ + fx: (0.8 + rand() * 2.2) / r, fy: (0.8 + rand() * 2.2) / r, fz: (0.8 + rand() * 2.2) / r, + px: rand() * Math.PI * 2, py: rand() * Math.PI * 2, pz: rand() * Math.PI * 2, + w: 0.4 + rand() * 0.6, + }); + } + var totalW = waves.reduce(function (s, v) { return s + v.w; }, 0); + var pos = geo.attributes.position; + for (var i = 0; i < pos.count; i++) { + var x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i); + var n = 0; + for (var vi = 0; vi < waves.length; vi++) { + var v = waves[vi]; + n += v.w * Math.sin(v.fx * x + v.px) * Math.sin(v.fy * y + v.py) * Math.sin(v.fz * z + v.pz); + } + var len = Math.sqrt(x * x + y * y + z * z) || 1; + var disp = (n / totalW) * amp; + pos.setXYZ(i, x + (x / len) * disp, y + (y / len) * disp, z + (z / len) * disp); + } + geo.computeVertexNormals(); + return geo; + }`, + tube: ` case 'tube': { + // [radius, x0,y0,z0, x1,y1,z1, ...] — tube swept along a 3D curve + var radius = Math.abs(d[0] ?? 0.05); + var pts = []; + for (var i = 1; i + 2 <= d.length; i += 3) pts.push(new THREE.Vector3(d[i], d[i + 1], d[i + 2])); + if (pts.length < 2) pts = [new THREE.Vector3(-0.5, 0, 0), new THREE.Vector3(0.5, 0, 0)]; + return new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 64, radius, 12, false); + }`, + bone: ` case 'bone': { + // [radius, x0,y0,z0, x1,y1,z1] — capsule spanning the two points, auto-oriented + var boneR = Math.abs(d[0] ?? 0.1); + var a = new THREE.Vector3(d[1] ?? 0, d[2] ?? 0, d[3] ?? 0); + var b = new THREE.Vector3(d[4] ?? 0, d[5] ?? 0, d[6] ?? 0); + var dir = b.clone().sub(a); var len = dir.length(); + var geo = new THREE.CapsuleGeometry(boneR, Math.max(0.01, len - boneR * 2), 12, 24); + if (len > 1e-6) geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize())); + geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2); + return geo; + }`, + lathe: ` case 'lathe': { + // dimensions is a flat [x0,y0, x1,y1, ...] profile polyline + var points = []; + for (var i = 0; i + 1 < d.length; i += 2) points.push(new THREE.Vector2(Math.max(0, d[i]), d[i + 1])); + if (points.length < 2) points = [new THREE.Vector2(0, -0.5), new THREE.Vector2(0.5, 0), new THREE.Vector2(0, 0.5)]; + return new THREE.LatheGeometry(points, 64); + }`, +}; + +// primitives whose geometry case relies on a shared helper +const NEEDS_ROUNDED_RECT = new Set(['roundedBox', 'roundedPlate']); +const NEEDS_MULBERRY = new Set(['blob']); + +const HELPER_ROUNDED_RECT = ` // rounded-rect outline used by rounded slabs/plates + function roundedRectShape(len, depth, r) { + var s = new THREE.Shape(); + var hx = Math.max(len / 2 - r, 0.001), hz = Math.max(depth / 2 - r, 0.001); + s.absarc(-hx, -hz, r, Math.PI, Math.PI * 1.5); + s.absarc(hx, -hz, r, Math.PI * 1.5, 0); + s.absarc(hx, hz, r, 0, Math.PI * 0.5); + s.absarc(-hx, hz, r, Math.PI * 0.5, Math.PI); + return s; + }`; + +const HELPER_MULBERRY = ` // deterministic PRNG — keeps blob shapes identical run to run + function mulberry32(seed) { + var a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0; + var t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + }`; + +const HELPER_LOAD_TEXTURE = ` // loads a cropped-artwork texture (a realm-hosted webp of the reference's + // own label/logo region) for decals that superimpose the REAL artwork + function loadDecalTexture(url) { + var loader = new THREE.TextureLoader(); + loader.setCrossOrigin('anonymous'); + var tex = loader.load(url); + tex.encoding = THREE.sRGBEncoding; + tex.anisotropy = 8; + return tex; + }`; + +const HELPER_TEXT_DECAL = ` // flat label plane: superimposes the cropped reference artwork when a + // texture is given, else canvas-paints the text + function buildTextDecal(d, text, color, textureUrl) { + var w = d[0] ?? 0.8, h = d[1] ?? 0.25; + if (textureUrl) { + var material = new THREE.MeshBasicMaterial({ + map: loadDecalTexture(textureUrl), + polygonOffset: true, polygonOffsetFactor: -4, toneMapped: false, + }); + return new THREE.Mesh(new THREE.PlaneGeometry(w, h), material); + } + var canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = Math.max(64, Math.round((512 * h) / Math.max(w, 0.001))); + var ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + var fontSize = Math.floor(canvas.height * 0.62); + ctx.font = '700 ' + fontSize + 'px Arial, sans-serif'; + while (fontSize > 8 && ctx.measureText(text).width > canvas.width * 0.94) { + fontSize -= 4; + ctx.font = '700 ' + fontSize + 'px Arial, sans-serif'; + } + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + var texture = new THREE.CanvasTexture(canvas); + texture.encoding = THREE.sRGBEncoding; + texture.anisotropy = 8; + var material = new THREE.MeshBasicMaterial({ + map: texture, transparent: true, depthWrite: false, + polygonOffset: true, polygonOffsetFactor: -4, toneMapped: false, + }); + return new THREE.Mesh(new THREE.PlaneGeometry(w, h), material); + }`; + +const HELPER_CURVED_DECAL = ` // label wrapped around a cylindrical body: superimposes the cropped + // reference artwork when a texture is given, else canvas-paints the text + function buildCurvedDecal(d, text, baseColor, textureUrl) { + var radius = Math.abs(d[0] ?? 0.5), height = Math.abs(d[1] ?? 0.6); + var arc = (Math.min(Math.max(d[2] ?? 120, 20), 350) * Math.PI) / 180; + if (textureUrl) { + var texMaterial = new THREE.MeshStandardMaterial({ + map: loadDecalTexture(textureUrl), + roughness: 0.6, metalness: 0, side: THREE.DoubleSide, + polygonOffset: true, polygonOffsetFactor: -2, + }); + var texGeometry = new THREE.CylinderGeometry(radius, radius, height, 48, 1, true, -arc / 2, arc); + return new THREE.Mesh(texGeometry, texMaterial); + } + var canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = Math.max(64, Math.round((512 * height) / Math.max(radius * arc, 0.001))); + var ctx = canvas.getContext('2d'); + ctx.fillStyle = baseColor || '#f4f1e8'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + if (text) { + var hex = (baseColor || '#f4f1e8').replace('#', ''); + var lum = hex.length >= 6 + ? (parseInt(hex.slice(0, 2), 16) * 0.299 + parseInt(hex.slice(2, 4), 16) * 0.587 + parseInt(hex.slice(4, 6), 16) * 0.114) / 255 + : 0.9; + ctx.fillStyle = lum > 0.5 ? '#20242c' : '#f2f2f2'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + var fontSize = Math.floor(canvas.height * 0.28); + ctx.font = '700 ' + fontSize + "px Georgia, 'Times New Roman', serif"; + while (fontSize > 8 && ctx.measureText(text).width > canvas.width * 0.82) { + fontSize -= 4; + ctx.font = '700 ' + fontSize + "px Georgia, 'Times New Roman', serif"; + } + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + } + var texture = new THREE.CanvasTexture(canvas); + texture.encoding = THREE.sRGBEncoding; + texture.anisotropy = 8; + var material = new THREE.MeshStandardMaterial({ + map: texture, roughness: 0.6, metalness: 0, side: THREE.DoubleSide, + polygonOffset: true, polygonOffsetFactor: -2, + }); + var geometry = new THREE.CylinderGeometry(radius, radius, height, 48, 1, true, -arc / 2, arc); + return new THREE.Mesh(geometry, material); + }`; + +// Procedural finish painters, emitted from their OWN source so the export +// paints the same weathering the studio does. `Function.prototype.toString()` +// hands back the real function text (TypeScript's annotations are already +// compiled away), which means there is no hand-copied duplicate here to drift +// out of sync — unlike the geometry and solver blocks in this file, which do +// mirror the interpreter by hand. +// +// Everything emitted is a function DECLARATION, so it hoists to the top of +// buildSculpture and is callable from the material block above it. +function emitFinishHelpers(finishes: Set): string { + let needed: EmittableFn[] = []; + let add = (fn: EmittableFn) => { + if (!needed.includes(fn)) needed.push(fn); + }; + for (let fn of FINISH_RUNTIME_SOURCES) add(fn); + for (let finish of finishes) { + for (let fn of FINISH_PAINTER_SOURCES[finish] ?? []) add(fn); + } + let dispatch = [...finishes] + .map((f) => { + let painters = FINISH_PAINTER_SOURCES[f] ?? []; + let painter = painters[painters.length - 1]; + let args = + f === 'camo' + ? "ctx, S, rand, baseColor || '#7a7f5a'" + : f === 'hazard' || f === 'louver' || f === 'knurl' + ? 'ctx, S' + : 'ctx, S, rand'; + return ` case '${f}': ${painter.name}(${args}); break;`; + }) + .join('\n'); + return [ + ' // procedural surface finishes — the painted canvas doubles as colour and', + ' // roughness map, which is what makes a surface read as weathered metal', + ' // instead of plastic. Painter sources are emitted from the studio module,', + ' // so this is the same paint the studio viewport shows.', + ...needed.map((fn) => indentSource(String(fn))), + ' function makeFinishTexture(THREE, finish, seedText, baseColor) {', + ' var S = 1024;', + " var cv = document.createElement('canvas');", + ' cv.width = cv.height = S;', + " var ctx = cv.getContext('2d');", + ' var rand = mulberry32(seedFrom(seedText));', + ' switch (finish) {', + dispatch, + ' default: return undefined;', + ' }', + ' var tex = new THREE.CanvasTexture(cv);', + ' tex.encoding = THREE.sRGBEncoding;', + ' tex.wrapS = tex.wrapT = THREE.RepeatWrapping;', + ' tex.anisotropy = 8;', + ' return tex;', + ' }', + ].join('\n'); +} + +// two-space indent so emitted sources sit inside buildSculpture like the rest +function indentSource(src: string): string { + return src + .split('\n') + .map((line) => (line.trim() ? ` ${line}` : line)) + .join('\n'); +} + +const HELPER_GLOW = ` // camera-facing additive glow sprite + function buildGlowSprite(d, color) { + var size = d[0] ?? 0.3; + var cv = document.createElement('canvas'); + cv.width = 128; cv.height = 128; + var ctx = cv.getContext('2d'); + var g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64); + g.addColorStop(0, 'rgba(255,255,255,1)'); + g.addColorStop(0.35, 'rgba(255,255,255,0.55)'); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 128, 128); + var tex = new THREE.CanvasTexture(cv); + var material = new THREE.SpriteMaterial({ + map: tex, color: new THREE.Color(color), transparent: true, + depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false, + }); + var sprite = new THREE.Sprite(material); + sprite.scale.set(size, size, 1); + return sprite; + }`; + +// assembly plumbing shared by every export: addPart, parent linking, +// attachTo pull, repeat expansion, contact backstop — the interpreter's +// post-build passes in the same order +const ASSEMBLY_RUNTIME = ` // ===== assembly plumbing (mirrors the studio interpreter) ===== + var objects = new Map(); + var meshes = []; + var parentIds = new Map(); + var attachments = []; + var repeats = []; + // true when the spec drives its own heights via "grounded"; the final + // stand-on-the-ground step defers to it + var groundedDeclared = false; + + function material(id) { + if (id && MATERIALS[id]) return MATERIALS[id]; + var first = Object.keys(MATERIALS)[0]; + return first ? MATERIALS[first] : FALLBACK_MATERIAL; + } + + function addPart(p) { + if (objects.has(p.id)) return; + var obj; + // decal/glow builders are emitted only when the spec uses them — the + // typeof guards keep this shared runtime valid either way + if (p.primitive === 'glow' && typeof buildGlowSprite === 'function') { + obj = buildGlowSprite(p.d || [], p.color || '#eeeeee'); + obj.name = p.id; + obj.position.set(p.pos[0] || 0, p.pos[1] || 0, p.pos[2] || 0); + objects.set(p.id, obj); + parentIds.set(p.id, p.parent || null); + return; + } + if (p.primitive === 'textDecal' && typeof buildTextDecal === 'function') { + obj = buildTextDecal(p.d || [], p.text || '', p.color || '#eeeeee', p.texture); + meshes.push(obj); + } else if ( + p.primitive === 'curvedDecal' && + typeof buildCurvedDecal === 'function' + ) { + obj = buildCurvedDecal(p.d || [], p.text || '', p.color || '#eeeeee', p.texture); + meshes.push(obj); + } else { + var geometry = buildGeometry(p.primitive, p.d || []); + if (geometry) { + obj = new THREE.Mesh(geometry, material(p.mat)); + obj.castShadow = obj.receiveShadow = true; + meshes.push(obj); + } else { + obj = new THREE.Group(); + } + } + obj.name = p.id; + obj.position.set(p.pos[0] || 0, p.pos[1] || 0, p.pos[2] || 0); + var rot = p.rot || [0, 0, 0]; + var ry = rot[1] || 0; + // double-correction guard: buildGeometry already squares up a 4-segment + // cone, so an author-supplied 45° Y rotation stacks to 90° and turns the + // hip roof back into an overhanging diamond + var coneSegs = p.primitive === 'cone' ? Math.max(3, Math.round((p.d || [])[2] != null ? (p.d || [])[2] : 24)) : 0; + if (coneSegs === 4 && ry) { + var quarter = Math.PI / 2; + // subtract the spurious 45°, never snap: 45° is exactly halfway to 90°, + // and resolving it upward would swap the roof's width and depth + if (Math.abs((((ry % quarter) + quarter) % quarter) - Math.PI / 4) < 0.09) { + ry -= Math.sign(ry) * (Math.PI / 4); + } + } + obj.rotation.set(rot[0] || 0, ry, rot[2] || 0); + var scl = p.scl || [1, 1, 1]; + obj.scale.set(scl[0] || 1, scl[1] || 1, scl[2] || 1); + objects.set(p.id, obj); + parentIds.set(p.id, p.parent || null); + if (p.attachTo) attachments.push({ id: p.id, to: p.attachTo, prim: p.primitive }); + if (p.repeat) repeats.push({ id: p.id, rep: p.repeat, to: p.attachTo }); + if (p.grounded) groundedDeclared = true; + } + + function assemble() { + // parent linkage — unknown/missing/self parents attach to root + objects.forEach(function (obj, id) { + var pid = parentIds.get(id); + var parent = pid && pid !== id ? objects.get(pid) : undefined; + (parent || root).add(obj); + }); + // cycle guard — anything orphaned by a parentId cycle reattaches to root + objects.forEach(function (obj) { + var ancestor = obj.parent; + while (ancestor && ancestor !== root) ancestor = ancestor.parent; + if (ancestor !== root) { + if (obj.removeFromParent) obj.removeFromParent(); + root.add(obj); + } + }); + // declared joints — pull each part into ~0.03 overlap with its support. + // The ceiling scales with the object (as the backstop's does below) so a + // solver can only ever close an authoring gap, never carry a part across + // the model to the wrong side of it. + root.updateWorldMatrix(true, true); + var jointSize = new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3()); + var maxPull = 0.15 * Math.max(jointSize.x, jointSize.y, jointSize.z, 0.001); + attachments.forEach(function (att) { + var obj = objects.get(att.id); + var target = objects.get(att.to); + if (!obj || !target || obj === target) return; + var ancestor = obj.parent; + while (ancestor) { + if (ancestor === target) return; // nested — contact guaranteed + ancestor = ancestor.parent; + } + var a = new THREE.Box3().setFromObject(obj); + var b = new THREE.Box3().setFromObject(target); + if (a.isEmpty() || b.isEmpty()) return; + var margin = 0.03; + var delta = new THREE.Vector3(); + ['x', 'y', 'z'].forEach(function (axis) { + if (a.min[axis] > b.max[axis]) delta[axis] = b.max[axis] - a.min[axis] + margin; + else if (a.max[axis] < b.min[axis]) delta[axis] = b.min[axis] - a.max[axis] + margin; + }); + if (delta.lengthSq() === 0 || delta.length() > maxPull) return; + var worldPos = obj.getWorldPosition(new THREE.Vector3()); + worldPos.add(delta); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + }); + // …then seat the features contact alone leaves wrong — buried inside the + // mass they mount on, or floating off it on one grazing rim + if (typeof seatSurfaceParts === 'function') { + seatSurfaceParts(THREE, objects, attachments.filter(function (att) { + return att.prim !== 'curvedDecal' && att.prim !== 'textDecal' + && att.prim !== 'glow' && att.prim !== 'tube'; + })); + } + // repeat expansion — one declared part clones into N placed copies. + // expandRepeatInstances is emitted from the studio's own module source, so + // this arrays parts exactly the way the viewport does. + repeats.forEach(function (req) { + var original = objects.get(req.id); + if (!original) return; + expandRepeatInstances( + THREE, + original, + req.rep, + req.to ? objects.get(req.to) : undefined, + root, + function (clone) { + clone.traverse(function (child) { if (child.isMesh) meshes.push(child); }); + } + ); + }); + // contact backstop — a part touching nothing is pulled into ~0.03 overlap + // with the support it DECLARED, via the minimal per-axis translation + // (closes gaps in ANY direction, not just straight down). A part with no + // declared attachTo stays exactly where it was authored: guessing the + // nearest neighbour used to drag parts the reference never contained onto + // whatever happened to be closest, welding them into a clump. maxSnap + // scales with the object (shared with the inset pass below) so a solver can + // only close an authoring gap, never relocate a part across the model. + root.updateWorldMatrix(true, true); + var solverSize = new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3()); + var maxSnap = 0.15 * Math.max(solverSize.x, solverSize.y, solverSize.z, 0.001); + if (meshes.length > 1) { + root.updateWorldMatrix(true, true); + var boxes = meshes.map(function (mesh) { + var b = new THREE.Box3().setFromObject(mesh); + b.expandByScalar(0.03); + return b; + }); + var floating = []; + for (var i = 0; i < meshes.length; i++) { + var touches = false; + for (var j = 0; j < meshes.length; j++) { + if (i !== j && boxes[i].intersectsBox(boxes[j])) { touches = true; break; } + } + if (!touches) floating.push(i); + } + var attachToByName = new Map(); + attachments.forEach(function (att) { attachToByName.set(att.id, att.to); }); + var contactDelta = function (a, b) { + var margin = 0.03; + var delta = new THREE.Vector3(); + ['x', 'y', 'z'].forEach(function (axis) { + if (a.min[axis] > b.max[axis]) delta[axis] = b.max[axis] - a.min[axis] + margin; + else if (a.max[axis] < b.min[axis]) delta[axis] = b.min[axis] - a.max[axis] + margin; + }); + return delta; + }; + floating.forEach(function (i) { + var name = meshes[i].name || ''; + var baseName = name.replace(/-\\d+$/, ''); + var targetBox; + // the declared joint is the only snap target + var attachTo = attachToByName.has(name) ? attachToByName.get(name) : attachToByName.get(baseName); + var targetObj = attachTo ? objects.get(attachTo) : undefined; + if (targetObj && targetObj !== meshes[i]) { + var tb = new THREE.Box3().setFromObject(targetObj); + if (!tb.isEmpty()) { targetBox = tb; } + } + if (!targetBox) return; + var delta = contactDelta(boxes[i], targetBox); + var dist = delta.length(); + if (dist === 0 || dist > maxSnap) return; + var worldPos = meshes[i].getWorldPosition(new THREE.Vector3()); + worldPos.add(delta); + meshes[i].position.copy(meshes[i].parent ? meshes[i].parent.worldToLocal(worldPos) : worldPos); + boxes[i].translate(delta); + }); + root.updateWorldMatrix(true, true); + } + // inset thin panels (windows / glass / signs) flush into their wall. The + // panel's thinnest world axis is its surface normal; using the WALL's + // thinnest axis moves side windows onto roofs whenever Y is the wall's + // smallest dimension. + // rings and bands never qualify: a torus/flatRing/arch encircles its host + // instead of sitting in one of its faces, and its thinnest axis is the one + // it wraps around — so insetting slides a collar or a cap rib to the end of + // the very part it should be banding. + root.updateWorldMatrix(true, true); + var NEVER_INSET = ['torus', 'flatRing', 'arch']; + attachments.forEach(function (att) { + if (NEVER_INSET.indexOf(att.prim) !== -1) return; + var obj = objects.get(att.id); + var wall = objects.get(att.to); + if (!obj || !wall || obj === wall) return; + var p = new THREE.Box3().setFromObject(obj); + var w = new THREE.Box3().setFromObject(wall); + if (p.isEmpty() || w.isEmpty()) return; + var pSize = p.getSize(new THREE.Vector3()); + var wSize = w.getSize(new THREE.Vector3()); + var axes = ['x', 'y', 'z']; + var normal = axes.reduce(function (a, b) { return pSize[b] < pSize[a] ? b : a; }); + var faceAxes = axes.filter(function (a) { return a !== normal; }); + // a panel must be a PLATE in its own right — thickness a small fraction + // of its own face. Comparing only against the wall let any small part + // qualify: a screwcap is "thinner" than a bottle on all three axes, so it + // was flush-mounted to the bottle's SIDE and sat off-axis beside the neck. + // A roughly square cross-section (caps, knobs, wheels) never qualifies. + var face = faceAxes.map(function (a) { return pSize[a]; }); + var plateLike = pSize[normal] < 0.3 * Math.min(face[0], face[1]); + var thin = pSize[normal] < wSize[normal] * 0.6; + var fits = faceAxes.every(function (a) { return pSize[a] <= wSize[a] * 1.1; }); + if (!plateLike || !thin || !fits) return; + var pCenter = p.getCenter(new THREE.Vector3()); + var wCenter = w.getCenter(new THREE.Vector3()); + var side = pCenter[normal] >= wCenter[normal] ? 1 : -1; + var d = side > 0 ? w.max[normal] - p.max[normal] : w.min[normal] - p.min[normal]; + if (Math.abs(d) < 0.001 || Math.abs(d) > maxSnap) return; + var worldPos = obj.getWorldPosition(new THREE.Vector3()); + worldPos[normal] += d; + obj.position.copy(obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos); + obj.updateWorldMatrix(true, true); + }); + root.updateWorldMatrix(true, true); + + // stand the object on the ground — LAST, once every solver above has + // finished, and only when the spec never used the "grounded" flag (which has + // its own drop). The model does not set that flag in practice, so without + // this an object sits wherever its coordinates landed: sunk into the ground, + // or floating above its own contact shadow. + if (!groundedDeclared) { + var standing = new THREE.Box3(); + for (var si = 0; si < meshes.length; si++) { + var sm = meshes[si]; + if (/shadow/i.test(sm.name) || sm.isSprite) continue; + standing.union(new THREE.Box3().setFromObject(sm)); + } + if (!standing.isEmpty()) { + var gdy = -standing.min.y; + var gh = standing.max.y - standing.min.y; + if (Math.abs(gdy) > 0.02 && gh > 0 && Math.abs(gdy) <= gh) { + root.position.y += gdy; + root.updateWorldMatrix(true, true); + } + } + } + }`; + +// --------------------------------------------------------------------------- +// public API +// --------------------------------------------------------------------------- + +export interface CodeExportMeta { + round?: number | null; + score?: number | null; +} + +// the standalone .js module: buildSculpture(THREE) → { group, meshes } +export function generateModelJs(spec: any, meta?: CodeExportMeta): string { + let nodes = specNodes(spec); + let materials = specMaterials(spec); + let unlit = spec?.inputKind === 'flat-graphic'; + let name = spec?.objectName || 'sculpture'; + + let usedPrimitives = new Set(nodes.map((n) => n.primitive)); + let colorById = new Map( + materials.map((m) => [m.materialId, m.baseColor || '#eeeeee']), + ); + + let lines: string[] = []; + // generated artifact — keep repo linters out of it + lines.push('/* eslint-disable */'); + lines.push(`// ${name} — procedural three.js model`); + let provenance = ['generated by Boxel Img-to-3D Studio from its sculpt spec']; + if (typeof meta?.round === 'number') provenance.push(`round ${meta.round}`); + if (typeof meta?.score === 'number') provenance.push(`score ${meta.score}`); + lines.push(`// ${provenance.join(' · ')}`); + lines.push('//'); + lines.push('// Usage (three.js r0.147):'); + lines.push('// var built = buildSculpture(THREE);'); + lines.push('// scene.add(built.group);'); + lines.push('//'); + lines.push( + '// Every part below is one addPart() call — dimensions, positions and', + ); + lines.push('// materials are plain numbers you can edit directly.'); + lines.push(''); + lines.push('function buildSculpture(THREE) {'); + lines.push(" 'use strict';"); + lines.push(' var root = new THREE.Group();'); + lines.push(` root.name = ${q(name)};`); + lines.push(''); + lines.push(...emitMaterials(materials, unlit)); + lines.push(''); + + // helpers, only the ones this spec needs + let helperBlocks: string[] = []; + if ([...usedPrimitives].some((p) => NEEDS_ROUNDED_RECT.has(p))) { + helperBlocks.push(HELPER_ROUNDED_RECT); + } + if ([...usedPrimitives].some((p) => NEEDS_MULBERRY.has(p))) { + helperBlocks.push(HELPER_MULBERRY); + } + let usesTexturedDecal = nodes.some( + (n) => + n.textureUrl && + (n.primitive === 'textDecal' || n.primitive === 'curvedDecal'), + ); + if (usesTexturedDecal) helperBlocks.push(HELPER_LOAD_TEXTURE); + if (usedPrimitives.has('textDecal')) helperBlocks.push(HELPER_TEXT_DECAL); + if (usedPrimitives.has('curvedDecal')) helperBlocks.push(HELPER_CURVED_DECAL); + if (usedPrimitives.has('glow')) helperBlocks.push(HELPER_GLOW); + let usedFinishes = new Set( + materials + .map((m: any) => String(m?.finish ?? '')) + .filter((f) => f && FINISH_PAINTER_SOURCES[f]), + ); + if (usedFinishes.size) helperBlocks.push(emitFinishHelpers(usedFinishes)); + // the repeat expander is emitted from its own module source rather than + // mirrored by hand — the two copies drifted once and every exported model + // arrayed its parts differently from the viewport + if (nodes.some((n) => n.repeat && typeof n.repeat === 'object')) { + helperBlocks.push(indentSource(String(expandRepeatInstances))); + } + // same reasoning for the surface seater: it is the difference between an eye + // sitting on a face and an eye hidden inside it, so the exported model must + // run the studio's own copy of it + if (nodes.some((n) => n.attachTo)) { + helperBlocks.push(indentSource(String(seatSurfaceParts))); + } + + // geometry builder with only the used primitive cases + let cases = [...usedPrimitives] + .filter((p) => GEOMETRY_CASES[p]) + .map((p) => GEOMETRY_CASES[p]); + helperBlocks.push( + [ + ' // geometry per primitive — same defaults as the studio interpreter', + ' function buildGeometry(primitive, d) {', + ' switch (primitive) {', + ...cases, + ' default:', + " return null; // 'group' carries no geometry", + ' }', + ' }', + ].join('\n'), + ); + lines.push(helperBlocks.join('\n\n')); + lines.push(''); + lines.push(ASSEMBLY_RUNTIME); + lines.push(''); + lines.push(' // ===== parts ====='); + + for (let node of nodes) { + if (node.primitive === 'meshAsset') { + lines.push( + ` // (skipped) meshAsset '${node.nodeId}' — external .glb assets are not exported`, + ); + continue; + } + let fields: string[] = [`id: ${q(node.nodeId)}`]; + if (node.parentId) fields.push(`parent: ${q(node.parentId)}`); + fields.push(`primitive: ${q(node.primitive)}`); + if (node.dimensions.length) fields.push(`d: ${fmtArr(node.dimensions)}`); + fields.push(`pos: ${fmtArr(node.position)}`); + if (node.rotation.some((n) => n !== 0)) + fields.push(`rot: ${fmtArr(node.rotation)}`); + if (node.scale.some((n) => n !== 1)) + fields.push(`scl: ${fmtArr(node.scale)}`); + if ( + node.primitive === 'glow' || + node.primitive === 'textDecal' || + node.primitive === 'curvedDecal' + ) { + fields.push( + `color: ${q(colorById.get(node.materialId ?? '') || '#eeeeee')}`, + ); + // textDecal falls back to the note as its label (interpreter parity); + // curvedDecal must NOT — a recipe note painted onto a wine label reads + // as gibberish text on the model + let label = + node.primitive === 'textDecal' ? node.text || node.note : node.text; + if (label) fields.push(`text: ${q(label)}`); + if (node.textureUrl) fields.push(`texture: ${q(node.textureUrl)}`); + } else if (node.materialId) { + fields.push(`mat: ${q(node.materialId)}`); + } + if (node.attachTo) fields.push(`attachTo: ${q(node.attachTo)}`); + if (node.repeat && typeof node.repeat === 'object') { + let rep = node.repeat; + let repFields: string[] = [`count: ${fmt(rep.count ?? 0)}`]; + if (rep.mode) repFields.push(`mode: ${q(rep.mode)}`); + if (typeof rep.radius === 'number') + repFields.push(`radius: ${fmt(rep.radius)}`); + if (rep.axis) repFields.push(`axis: ${q(rep.axis)}`); + if (Array.isArray(rep.offset)) + repFields.push(`offset: ${fmtArr(rep.offset)}`); + fields.push(`repeat: { ${repFields.join(', ')} }`); + } + let comment = node.note + ? ` // ${String(node.note).replace(/\n/g, ' ')}` + : ''; + lines.push(` addPart({ ${fields.join(', ')} });${comment}`); + } + + lines.push(''); + lines.push(' assemble();'); + lines.push(' return { group: root, meshes: meshes };'); + lines.push('}'); + lines.push(''); + lines.push( + '// machine-readable source spec — the studio reads this back to refine or', + '// regenerate; editing addPart() lines above without updating it is fine', + '// for one-off tweaks, but regeneration works from this data', + `var SCULPT_SPEC = ${JSON.stringify(plainSpec(spec))};`, + '', + "if (typeof module !== 'undefined') {", + ' module.exports = { buildSculpture: buildSculpture, SCULPT_SPEC: SCULPT_SPEC };', + '}', + ); + lines.push(''); + return lines.join('\n'); +} + +// the normalized, JSON-safe form of the spec that rides inside the .js file — +// card instances no longer persist the spec, so the file is the carrier +export function plainSpec(spec: any): any { + return { + objectName: spec?.objectName || 'sculpture', + inputKind: spec?.inputKind || 'object', + objectClass: spec?.objectClass || null, + complexity: spec?.complexity || null, + identityFeatures: Array.isArray(spec?.identityFeatures) + ? [...spec.identityFeatures] + : [], + materials: specMaterials(spec), + components: specNodes(spec).map((n) => ({ + nodeId: n.nodeId, + parentId: n.parentId, + primitive: n.primitive, + dimensions: n.dimensions, + position: n.position, + rotation: n.rotation, + scale: n.scale, + materialId: n.materialId, + text: n.text, + partRef: n.partRef, + textureRef: n.textureRef, + textureUrl: n.textureUrl, + repeat: n.repeat, + attachTo: n.attachTo, + note: n.note, + })), + }; +} + +// reads the embedded SCULPT_SPEC back out of a generated model .js file +export function specFromModelJs(code: string): any | null { + let match = code.match(/^var SCULPT_SPEC = (.*);$/m); + if (!match) return null; + try { + return JSON.parse(match[1]); + } catch { + return null; + } +} + +// the SHARED viewer page (one per generator, not per model): reads the model +// .js URL from its ?model= query param, fetches and executes it, and renders +// with the same scene/lights/orbit as the studio viewport. Its realm URL — +// viewer.html?model= — is the iframe src for every exported model. +// Same-origin embedders (the studio) can also call the window API it exposes: +// captureScreenshot() / captureViews(refCamera) / exportGlb(). +export function generateViewerHarnessHtml(bakedModelUrl?: string): string { + let harness = `(function () { + // model source, in priority order: inline code (draft builds — the studio + // measures proportions before persisting anything), a baked-in URL + // (srcdoc viewport embeds), or the ?model= query param (external iframes) + if (window.SCULPT_MODEL_INLINE) { + var inlineFactory = new Function( + window.SCULPT_MODEL_INLINE + '\\nreturn buildSculpture;', + ); + start(inlineFactory()); + return; + } + var modelUrl = + window.SCULPT_MODEL_URL || + new URLSearchParams(window.location.search).get('model'); + function fail(message) { + var p = document.createElement('p'); + p.style.cssText = 'color:#9aa0b2;font:14px ui-monospace,monospace;padding:1rem;'; + p.textContent = message; + document.body.appendChild(p); + } + if (!modelUrl) { + fail('no model — open as viewer.html?model='); + return; + } + + fetch(modelUrl, { headers: { Accept: '*/*' } }) + .then(function (response) { + if (!response.ok) throw new Error('could not load model (' + response.status + ')'); + return response.text(); + }) + .then(function (code) { + // the model file declares buildSculpture(THREE); execute it in a plain + // (non-module) scope and pull the function out + var factory = new Function(code + '\\nreturn buildSculpture;'); + start(factory()); + }) + .catch(function (e) { + fail(String((e && e.message) || e)); + }); + + function start(buildSculpture) { + var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputEncoding = THREE.sRGBEncoding; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.0; + if (THREE.ColorManagement) THREE.ColorManagement.legacyMode = false; + document.body.appendChild(renderer.domElement); + + var scene = new THREE.Scene(); + scene.background = new THREE.Color('#2b303d'); + scene.fog = new THREE.Fog('#2b303d', 14, 30); + + var camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100); + + // three-point + hemisphere rig: a STRONG key (so parts self-shadow and the + // form reads) over a MODEST fill/ambient. Bright enough to look like a studio + // shot, but the fill is deliberately low — too much fill floods the shadows + // and washes saturated colours flat under ACES tone mapping. + var key = new THREE.DirectionalLight('#fffaf2', 2.4); key.position.set(4, 6, 5); key.castShadow = true; + var fill = new THREE.DirectionalLight('#eaf1ff', 0.6); fill.position.set(-5, 2, -2); + var rim = new THREE.DirectionalLight('#dffbff', 0.5); rim.position.set(0, 4, -6); + var dome = new THREE.HemisphereLight('#aab4c6', '#2b303c', 0.9); + scene.add(key, fill, rim, dome); + + // minimal PMREM environment so metals and glass have something to reflect + var pmrem = new THREE.PMREMGenerator(renderer); + var envScene = new THREE.Scene(); + var room = new THREE.Mesh( + new THREE.BoxGeometry(10, 10, 10), + new THREE.MeshBasicMaterial({ color: '#444444', side: THREE.BackSide })); + envScene.add(room); + [[0, 4.9, 0, 6], [4, 2, 4, 3], [-4, 3, -2, 2]].forEach(function (p) { + var panel = new THREE.Mesh( + new THREE.PlaneGeometry(3, 3), + new THREE.MeshBasicMaterial({ color: new THREE.Color('#ffffff').multiplyScalar(p[3]) })); + panel.position.set(p[0], p[1], p[2]); + panel.lookAt(0, 0, 0); + envScene.add(panel); + }); + scene.environment = pmrem.fromScene(envScene, 0.04).texture; + + var built = buildSculpture(THREE); + // normalize into view — same framing as the studio viewport + var box = new THREE.Box3().setFromObject(built.group); + var size = box.getSize(new THREE.Vector3()); + var center = box.getCenter(new THREE.Vector3()); + var maxDim = Math.max(size.x, size.y, size.z) || 1; + var scaleFactor = 2.6 / maxDim; + built.group.scale.setScalar(scaleFactor); + built.group.position.set( + -center.x * scaleFactor, -center.y * scaleFactor, -center.z * scaleFactor); + scene.add(built.group); + + // staggered pop-in: parts appear one after another, each springing up to + // its real size (same feel as the studio's original canvas viewport) + var popStart = performance.now(); + var popDone = false; + var popParts = built.meshes.map(function (mesh, i) { + var base = mesh.scale.clone(); + mesh.scale.setScalar(0.0001); + return { mesh: mesh, base: base, at: popStart + i * 55 }; + }); + function easeOutBack(t) { + var c1 = 1.70158, c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + } + function animatePopIn(now) { + if (popDone) return; + var allDone = true; + for (var i = 0; i < popParts.length; i++) { + var p = popParts[i]; + var t = (now - p.at) / 320; + if (t < 1) allDone = false; + if (t < 0) t = 0; + if (t > 1) t = 1; + var s = t === 1 ? 1 : easeOutBack(t); + p.mesh.scale.set(p.base.x * s, p.base.y * s, p.base.z * s); + } + popDone = allDone; + } + // screenshots must show the finished model, never a mid-animation frame + function finishPopIn() { + for (var i = 0; i < popParts.length; i++) { + popParts[i].mesh.scale.copy(popParts[i].base); + } + popDone = true; + } + + // drag-to-orbit / wheel-to-zoom with idle auto-rotate + var orbit = { theta: 0.85, phi: 1.12, radius: 6 }; + var dragging = false, lastX = 0, lastY = 0, lastInteraction = 0; + var el = renderer.domElement; + el.style.touchAction = 'none'; + el.addEventListener('pointerdown', function (e) { + dragging = true; lastX = e.clientX; lastY = e.clientY; + el.setPointerCapture(e.pointerId); + }); + el.addEventListener('pointermove', function (e) { + if (!dragging) return; + orbit.theta -= (e.clientX - lastX) * 0.008; + orbit.phi = Math.min(2.6, Math.max(0.35, orbit.phi - (e.clientY - lastY) * 0.006)); + lastX = e.clientX; lastY = e.clientY; + lastInteraction = performance.now(); + }); + el.addEventListener('pointerup', function () { dragging = false; }); + el.addEventListener('wheel', function (e) { + e.preventDefault(); + orbit.radius = Math.min(22, Math.max(2, orbit.radius + e.deltaY * 0.01)); + lastInteraction = performance.now(); + }, { passive: false }); + + function resize() { + var w = window.innerWidth, h = window.innerHeight; + renderer.setSize(w, h); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + } + window.addEventListener('resize', resize); + resize(); + + function frame(now) { + animatePopIn(now); + if (!dragging && now - lastInteraction > 2500) orbit.theta += 0.0032; + camera.position.set( + orbit.radius * Math.sin(orbit.phi) * Math.sin(orbit.theta), + orbit.radius * Math.cos(orbit.phi), + orbit.radius * Math.sin(orbit.phi) * Math.cos(orbit.theta)); + camera.lookAt(0, 0, 0); + renderer.render(scene, camera); + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + + function renderAt(theta, phi) { + camera.position.set( + orbit.radius * Math.sin(phi) * Math.sin(theta), + orbit.radius * Math.cos(phi), + orbit.radius * Math.sin(phi) * Math.cos(theta)); + camera.lookAt(0, 0, 0); + renderer.render(scene, camera); + } + + // same-origin embedder API (the studio calls these through contentWindow) + window.captureScreenshot = function () { + finishPopIn(); + renderer.render(scene, camera); + return renderer.domElement.toDataURL('image/webp', 0.9); + }; + // per-part world bounding boxes — the studio compares these against the + // analysis bbox targets to auto-correct proportions (no vision call). + // The root transform below is presentation-only framing; neutralize it + // while measuring so returned positions use the original spec's units. + window.measureParts = function () { + finishPopIn(); + var framedPosition = built.group.position.clone(); + var framedScale = built.group.scale.clone(); + built.group.position.set(0, 0, 0); + built.group.scale.set(1, 1, 1); + built.group.updateWorldMatrix(true, true); + var box = function (object) { + var b = new THREE.Box3().setFromObject(object); + return { min: [b.min.x, b.min.y, b.min.z], max: [b.max.x, b.max.y, b.max.z] }; + }; + var measurement = { + whole: box(built.group), + parts: built.meshes.map(function (mesh) { + var b = box(mesh); + return { name: mesh.name, min: b.min, max: b.max }; + }), + }; + built.group.position.copy(framedPosition); + built.group.scale.copy(framedScale); + built.group.updateWorldMatrix(true, true); + return measurement; + }; + window.captureViews = function (refCamera) { + finishPopIn(); + var views = [ + { label: 'front', theta: 0, phi: 1.35 }, + { label: 'side', theta: Math.PI / 2, phi: 1.35 }, + { label: 'three-quarter', theta: 0.85, phi: 1.12 }, + ]; + if (refCamera && typeof refCamera.azimuthDeg === 'number') { + views.unshift({ + label: 'reference angle', + theta: (refCamera.azimuthDeg * Math.PI) / 180, + phi: Math.PI / 2 - (((refCamera.elevationDeg || 0) * Math.PI) / 180), + }); + } + var shots = views.map(function (view) { + renderAt(view.theta, view.phi); + return { label: view.label, dataUrl: renderer.domElement.toDataURL('image/webp', 0.9) }; + }); + renderAt(orbit.theta, orbit.phi); + return shots; + }; + window.exportGlb = function () { + return new Promise(function (resolve, reject) { + function run() { + new THREE.GLTFExporter().parse(built.group, resolve, reject, { binary: true }); + } + if (THREE.GLTFExporter) return run(); + var s = document.createElement('script'); + s.src = '${GLTF_EXPORTER_CDN}'; + s.onload = run; + s.onerror = function () { reject(new Error('could not load GLTFExporter')); }; + document.body.appendChild(s); + }); + }; + // lasso hit-test: given a screen-space polygon (pixels, viewport coords), + // raycast a grid of points INSIDE the polygon and take the front-most hit at + // each — so only the parts actually visible under the lasso are returned + // (an occluded body behind a lassoed cap is never picked). Drives the + // studio's inpaint/lasso targeted-edit feature. + window.pickInRegion = function (poly) { + finishPopIn(); + built.group.updateWorldMatrix(true, true); + var w = renderer.domElement.clientWidth || window.innerWidth; + var h = renderer.domElement.clientHeight || window.innerHeight; + function inside(px, py) { + var c = false; + for (var i = 0, j = poly.length - 1; i < poly.length; j = i++) { + var xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y; + if (((yi > py) !== (yj > py)) && + (px < (xj - xi) * (py - yi) / (yj - yi) + xi)) c = !c; + } + return c; + } + var minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + for (var p = 0; p < poly.length; p++) { + if (poly[p].x < minx) minx = poly[p].x; + if (poly[p].x > maxx) maxx = poly[p].x; + if (poly[p].y < miny) miny = poly[p].y; + if (poly[p].y > maxy) maxy = poly[p].y; + } + var raycaster = new THREE.Raycaster(); + var ndc = new THREE.Vector2(); + // ~14 samples across the lasso's shorter side, min 4px spacing + var step = Math.max(4, Math.min(maxx - minx, maxy - miny) / 14); + var seen = {}, hits = []; + for (var y = miny; y <= maxy; y += step) { + for (var x = minx; x <= maxx; x += step) { + if (!inside(x, y)) continue; + ndc.set((x / w) * 2 - 1, -(y / h) * 2 + 1); + raycaster.setFromCamera(ndc, camera); + var is = raycaster.intersectObjects(built.meshes, true); + if (!is.length) continue; + var obj = is[0].object; + while (obj && !obj.name && obj.parent) obj = obj.parent; + if (obj && obj.name && !seen[obj.name]) { + seen[obj.name] = 1; + hits.push(obj.name); + } + } + } + return hits; + }; + window.sculptViewerReady = true; + try { window.parent.postMessage({ type: 'sculpt-viewer-ready' }, '*'); } catch (e) { /* sandboxed */ } + } +})();`; + + return [ + '', + '', + '', + '', + '', + 'Sculpture Viewer', + '', + '', + '', + ...(bakedModelUrl + ? [ + ``, + ] + : []), + ``, + ``, + '', + '', + '', + '', + ].join('\n'); +} + +// the same harness with the model URL baked in, for use as an iframe's +// `srcdoc` attribute — the browser never requests an .html from the realm, +// so this works regardless of how the realm routes text/html navigations. +// A srcdoc document inherits the embedding page's origin, so the studio can +// still call the harness's window API and the model .js fetch stays +// same-origin. +export function generateViewerSrcdoc(modelUrl: string): string { + return generateViewerHarnessHtml(modelUrl); +} + +// harness with the model CODE embedded directly (no realm file involved) — +// used for draft builds: the studio measures the draft's proportions and +// corrects the spec before any file is written. `token` stands in for the +// model URL so readiness checks work the same as for persisted models. +export function generateViewerSrcdocInline( + code: string, + token: string, +): string { + let html = generateViewerHarnessHtml(token); + return html.replace( + '', + [ + '', + ``, + ].join('\n'), + ); +} diff --git a/4376bf-img-to-3d-generator/util/comparison-sheet.gts b/4376bf-img-to-3d-generator/util/comparison-sheet.gts new file mode 100644 index 00000000..5a37554a --- /dev/null +++ b/4376bf-img-to-3d-generator/util/comparison-sheet.gts @@ -0,0 +1,55 @@ +// The contact sheet the refine pass looks at: the reference photo beside the +// current render from several angles, packed into one image so the model can +// compare them in a single glance. + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + let img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('could not load comparison image')); + img.src = src; + }); +} + +// packs the reference beside one or more labeled render angles into a +// single sheet — the model reviews exactly one image per round, but sees the +// build from every side (3D errors hide from single angles) +export async function composeComparison( + referenceDataUrl: string, + renderDataUrls: string | string[], + opts?: { firstIsReferenceAngle?: boolean }, +): Promise { + let renders = Array.isArray(renderDataUrls) + ? renderDataUrls + : [renderDataUrls]; + let images = await Promise.all([referenceDataUrl, ...renders].map(loadImage)); + const H = renders.length > 1 ? 384 : 512; + const LABEL = 22; + const GAP = 6; + let angleLabels = opts?.firstIsReferenceAngle + ? ['RENDER @ REF ANGLE', 'RENDER FRONT', 'RENDER SIDE', 'RENDER 3/4'] + : ['RENDER FRONT', 'RENDER SIDE', 'RENDER 3/4']; + let labels = [ + 'REFERENCE', + ...(renders.length > 1 ? angleLabels.slice(0, renders.length) : ['RENDER']), + ]; + let widths = images.map((img) => + Math.max(1, Math.round((img.width / img.height) * H)), + ); + let canvas = document.createElement('canvas'); + canvas.width = widths.reduce((a, b) => a + b, 0) + GAP * (images.length - 1); + canvas.height = H + LABEL; + let ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + let x = 0; + for (let i = 0; i < images.length; i++) { + ctx.fillStyle = '#111111'; + ctx.font = '700 13px Arial'; + ctx.textBaseline = 'top'; + ctx.fillText(labels[i] ?? '', x + 4, 4); + ctx.drawImage(images[i], x, LABEL, widths[i], H); + x += widths[i] + GAP; + } + return canvas.toDataURL('image/jpeg', 0.85); +} diff --git a/4376bf-img-to-3d-generator/util/finishes.gts b/4376bf-img-to-3d-generator/util/finishes.gts new file mode 100644 index 00000000..1b42e6a5 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/finishes.gts @@ -0,0 +1,330 @@ +// Procedural surface-finish painters, ported from the img2threejs showcase +// War-Hauler demo: every texture is PAINTED on a canvas with a seeded RNG — +// no image files. The same canvas doubles as color map and roughnessMap, +// which is what makes surfaces read as weathered/tactile instead of plastic. +// +// The LLM only ever names a finish ('worn' | 'brushed' | 'hazard' | 'tread'); +// all painting happens here, deterministically per material id. + +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function seedFrom(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function newCanvas(size: number): { + cv: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; +} { + let cv = document.createElement('canvas'); + cv.width = size; + cv.height = size; + return { cv, ctx: cv.getContext('2d')! }; +} + +// grime blotches + soot streaks + brushed scratches over a bright base, so +// the material's own baseColor shows through and weathering stays subtle +function paintWorn( + ctx: CanvasRenderingContext2D, + S: number, + rand: () => number, +) { + ctx.fillStyle = '#efefef'; + ctx.fillRect(0, 0, S, S); + for (let i = 0; i < 130; i++) { + let x = rand() * S; + let y = rand() * S; + let r = 30 + rand() * 90; + let g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, `rgba(60,50,30,${0.04 + rand() * 0.1})`); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } + for (let i = 0; i < 60; i++) { + ctx.strokeStyle = `rgba(50,40,26,${0.05 + rand() * 0.1})`; + ctx.lineWidth = 1 + rand() * 3; + let x = rand() * S; + let y0 = rand() * S; + ctx.beginPath(); + ctx.moveTo(x, y0); + ctx.bezierCurveTo( + x + (rand() - 0.5) * 24, + y0 + 60, + x + (rand() - 0.5) * 24, + y0 + 140, + x + (rand() - 0.5) * 18, + y0 + 210, + ); + ctx.stroke(); + } + paintScratches(ctx, S, rand, 420); +} + +function paintScratches( + ctx: CanvasRenderingContext2D, + S: number, + rand: () => number, + count: number, +) { + for (let i = 0; i < count; i++) { + ctx.strokeStyle = + rand() > 0.5 + ? `rgba(255,255,255,${0.06 + rand() * 0.12})` + : `rgba(120,100,60,${0.05 + rand() * 0.1})`; + ctx.lineWidth = rand() * 1.2; + let x = rand() * S; + let y = rand() * S; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + (rand() - 0.5) * 36, y + (rand() - 0.5) * 6); + ctx.stroke(); + } +} + +function paintBrushed( + ctx: CanvasRenderingContext2D, + S: number, + rand: () => number, +) { + ctx.fillStyle = '#e8e8e8'; + ctx.fillRect(0, 0, S, S); + paintScratches(ctx, S, rand, 1200); +} + +// diagonal caution stripes — the map carries the colors, so the material's +// baseColor should stay near-white for this finish +function paintHazard(ctx: CanvasRenderingContext2D, S: number) { + ctx.fillStyle = '#151515'; + ctx.fillRect(0, 0, S, S); + ctx.save(); + ctx.translate(S / 2, S / 2); + ctx.rotate(-Math.PI / 4); + ctx.fillStyle = '#f7c948'; + for (let x = -S; x < S; x += 52) { + ctx.fillRect(x, -S, 26, S * 2); + } + ctx.restore(); +} + +// parallel tread grooves that MULTIPLY with the material's own baseColor — +// a black tire stays black, with slightly lighter ribs catching the light +function paintTread( + ctx: CanvasRenderingContext2D, + S: number, + rand: () => number, +) { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, S, S); + let rows = 14; + let rowH = S / rows; + for (let r = 0; r < rows; r++) { + // dark groove line per row + ctx.fillStyle = '#5f5f5f'; + ctx.fillRect(0, r * rowH + rowH * 0.62, S, rowH * 0.3); + // staggered lug notches + ctx.fillStyle = '#7a7a7a'; + let offset = (r % 2) * (S / 12); + for (let x = -S / 12; x < S; x += S / 6) { + ctx.fillRect(x + offset, r * rowH + rowH * 0.1, S / 24, rowH * 0.45); + } + if (rand() > 2) break; // keep rand consumed signature-compatible + } +} + +function shade(hex: string, factor: number): string { + let n = parseInt(hex.replace('#', ''), 16); + if (isNaN(n)) n = 0x8a8f9c; + let ch = (v: number) => Math.min(255, Math.max(0, Math.round(v * factor))); + let r = ch((n >> 16) & 255); + let g = ch((n >> 8) & 255); + let b = ch(n & 255); + return `rgb(${r},${g},${b})`; +} + +// organic camouflage blotches in tones derived from the material's own +// baseColor (War-Hauler oxidized-panel technique, parameterized) +function paintCamo( + ctx: CanvasRenderingContext2D, + S: number, + rand: () => number, + baseColor: string, +) { + ctx.fillStyle = shade(baseColor, 1); + ctx.fillRect(0, 0, S, S); + let tones = [shade(baseColor, 0.62), shade(baseColor, 1.35)]; + for (let t = 0; t < tones.length; t++) { + ctx.fillStyle = tones[t]; + for (let i = 0; i < 26; i++) { + let x = rand() * S; + let y = rand() * S; + ctx.beginPath(); + ctx.moveTo(x, y); + // lumpy blob: a closed run of arcs at varying radii + let lobes = 5 + Math.floor(rand() * 4); + for (let l = 0; l <= lobes; l++) { + let ang = (l / lobes) * Math.PI * 2; + let r = 40 + rand() * 90; + ctx.lineTo(x + Math.cos(ang) * r, y + Math.sin(ang) * r); + } + ctx.closePath(); + ctx.fill(); + } + } + paintScratches(ctx, S, rand, 200); +} + +// horizontal ribbed vent slats (dark panel louvers) +function paintLouver(ctx: CanvasRenderingContext2D, S: number) { + ctx.fillStyle = '#3a3d40'; + ctx.fillRect(0, 0, S, S); + let rows = 12; + let rowH = S / rows; + for (let r = 0; r < rows; r++) { + ctx.fillStyle = '#15171a'; + ctx.fillRect(0, r * rowH + rowH * 0.45, S, rowH * 0.4); + ctx.fillStyle = '#6a6e72'; + ctx.fillRect(0, r * rowH + rowH * 0.05, S, rowH * 0.12); + } +} + +// height-driven oxidation: the top of the surface blooms into a teal-green +// patina over the material's own baseColor (weathered-brass technique) +function paintPatina( + ctx: CanvasRenderingContext2D, + S: number, + rand: () => number, +) { + ctx.fillStyle = '#f0f0f0'; + ctx.fillRect(0, 0, S, S); + let grad = ctx.createLinearGradient(0, 0, 0, S); + grad.addColorStop(0, 'rgba(63, 111, 76, 0.85)'); + grad.addColorStop(0.35, 'rgba(63, 111, 76, 0.3)'); + grad.addColorStop(0.7, 'rgba(63, 111, 76, 0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, S, S); + for (let i = 0; i < 40; i++) { + let x = rand() * S; + let y = rand() * S * 0.5; + let r = 20 + rand() * 60; + let g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, `rgba(63, 111, 76, ${0.15 + rand() * 0.25})`); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } + paintScratches(ctx, S, rand, 260); +} + +// diamond cross-hatch grip relief (knife handles, tool grips). Routed to a +// bumpMap by the interpreter: mid-gray base = flat, dark diagonal grooves = +// recessed, so the diamonds read as raised knurling. +function paintKnurl(ctx: CanvasRenderingContext2D, S: number) { + ctx.fillStyle = '#9a9a9a'; + ctx.fillRect(0, 0, S, S); + ctx.strokeStyle = '#2c2c2c'; + ctx.lineWidth = S / 128; + let step = S / 24; + for (let k = -S; k < S * 2; k += step) { + ctx.beginPath(); + ctx.moveTo(k, 0); + ctx.lineTo(k + S, S); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(k + S, 0); + ctx.lineTo(k, S); + ctx.stroke(); + } +} + +// returns a CanvasTexture for the named finish, deterministic per seedText +// The standalone .js export needs these painters too — a model whose materials +// ask for 'worn' or 'hazard' rendered as FLAT COLOUR once exported, which is +// most of why exported vehicles and machines looked like untextured toys. The +// exporter emits each painter's own source via Function.prototype.toString() +// rather than carrying a hand-copied duplicate, so the two can never drift: +// these are plain functions over a canvas context with no closure state, and +// TypeScript's annotations are gone by the time toString() sees them. +// +// Keyed by finish name; each entry lists every function that finish needs, in +// dependency order, so only the painters a spec actually uses get emitted. +// the painters are emitted by source text, so the only shape that matters is +// "something with a .name and a .toString()" — spelling that out avoids the +// bare `Function` type the lint config rejects +export type EmittableFn = { name: string; toString(): string }; + +export const FINISH_PAINTER_SOURCES: Record = { + worn: [paintScratches, paintWorn], + brushed: [paintScratches, paintBrushed], + hazard: [paintHazard], + tread: [paintTread], + camo: [shade, paintScratches, paintCamo], + louver: [paintLouver], + patina: [paintScratches, paintPatina], + knurl: [paintKnurl], +}; + +// the shared plumbing every finish needs regardless of which painter runs +export const FINISH_RUNTIME_SOURCES: EmittableFn[] = [mulberry32, seedFrom]; + +export function makeFinishTexture( + THREE: any, + finish: string, + seedText: string, + baseColor?: string, +): any { + const S = 1024; + let { cv, ctx } = newCanvas(S); + let rand = mulberry32(seedFrom(seedText)); + switch (finish) { + case 'worn': + paintWorn(ctx, S, rand); + break; + case 'brushed': + paintBrushed(ctx, S, rand); + break; + case 'hazard': + paintHazard(ctx, S); + break; + case 'tread': + paintTread(ctx, S, rand); + break; + case 'camo': + paintCamo(ctx, S, rand, baseColor || '#7a7f5a'); + break; + case 'louver': + paintLouver(ctx, S); + break; + case 'patina': + paintPatina(ctx, S, rand); + break; + case 'knurl': + paintKnurl(ctx, S); + break; + default: + return undefined; + } + let tex = new THREE.CanvasTexture(cv); + tex.encoding = THREE.sRGBEncoding; + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.anisotropy = 8; + return tex; +} diff --git a/4376bf-img-to-3d-generator/util/llm-request.gts b/4376bf-img-to-3d-generator/util/llm-request.gts new file mode 100644 index 00000000..954a5dbb --- /dev/null +++ b/4376bf-img-to-3d-generator/util/llm-request.gts @@ -0,0 +1,275 @@ +// The one place this pipeline talks to a model. +// +// Every stage — analyse, spec, refine, targeted edit — is the same shape of +// call: a system prompt, some images, a JSON reply that has to parse. What +// differs between them is only the prompt and the parser, so the transport, +// the retry policy and the determinism controls live here once. Keeping them +// together also keeps them honest: the retry budget and the temperature are +// the same decision seen from two sides, and splitting them is how one gets +// tuned without the other. + +import SendRequestViaProxyCommand from '@cardstack/boxel-host/tools/send-request-via-proxy'; + +import { parseSpecJson } from './spec-io'; + +export const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; +export const VISION_MODEL = 'anthropic/claude-sonnet-5'; +// the model for the conversational "Refine with AI" room. Deliberately +// NON-Anthropic: the ai-bot injects skill instructions as an inline system +// message, which Anthropic's tightened API rejects ("role 'system' must follow +// a 'user' message …"). Gemini Flash sidesteps that rule and is vision-strong +// for the reference-vs-render diagnosis the assistant does in the room. +// the conversational Refine room's model — shown in the chat UI. Non-Anthropic +// (sidesteps the inline-system-message API error) and vision-capable for the +// reference-vs-render diagnosis. Kept in step with REFINE_MODEL so the visible +// room model and the edit model are the same. +export const ASSISTANT_MODEL = 'google/gemini-3.5-flash'; +// the model the Refine Model command uses to turn ONE agreed instruction into a +// spec change set (no image — the assistant already diagnosed visually). A fast +// Gemini Flash is plenty for that structured-JSON edit and keeps refines snappy. +export const REFINE_MODEL = 'google/gemini-3.5-flash'; +// the analysis stage is the gate for the whole pipeline — it classifies the +// object, measures per-part bboxes, and decides which parts are 'revolved' +// (which drives deterministic silhouette tracing). It is almost pure PERCEPTION +// (image → bboxes/camera), where a fast Gemini Flash grounds bounding boxes at +// least as well as a much slower/pricier reasoning model — so the gate is +// pinned to Flash for speed/cost, independent of the spec model the user picks. +export const ANALYSIS_MODEL = 'google/gemini-3.6-flash'; +// the vision models offered anywhere a model can be picked (studio) or +// recorded (each SculptedModel round, the analyze command) — one shared list +// so every "model" field enumerates the same options. VISION_MODEL must stay +// a member so the programmatic default is always a valid enum value. +export const VISION_MODEL_OPTIONS = [ + 'anthropic/claude-sonnet-5', + 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-opus-4.8', + 'google/gemini-3.5-flash', + 'google/gemini-3.6-flash', +]; + +// Every stage of this pipeline is a MEASUREMENT, not a creative act: the +// analysis reads bboxes off a photo, the spec derives coordinates from those +// bboxes, the refine pass corrects placement. Sampling has nothing to +// contribute to any of them — it only makes the same photo produce a +// different object type, a different part count and differently placed parts +// on every run. Left unset, the request inherits the provider's default +// (~1.0), which is why two Generates on one reference never matched. Pin it +// at 0 so a reference maps to one reconstruction. +export const SPEC_TEMPERATURE = 0; + +// temperature 0 alone is not bit-reproducible — it picks the argmax token, +// and ties/batching still drift. `seed` closes the rest of the gap on +// providers that honour it (Gemini and OpenAI-family; Anthropic ignores it +// harmlessly), so both are sent. Derived from the reference URLs rather than +// random: same photos in, same seed, same model — while a new photo set gets +// its own seed instead of inheriting the previous object's sampling path. +export function seedFromStrings(parts: string[]): number { + // FNV-1a, 32-bit — tiny, dependency-free, well distributed over short + // strings. Kept below 2^31 because some providers reject larger seeds. + let hash = 0x811c9dc5; + for (let part of parts) { + for (let i = 0; i < part.length; i++) { + hash ^= part.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + // separator so ['ab','c'] and ['a','bc'] do not collide + hash ^= 0x2f; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash % 0x7fffffff; +} + +// one vision round-trip through the Boxel proxy, returning the parsed spec. +// Retries transient failures (rate limit / upstream error / dropped +// connection / invalid JSON / truncation) with backoff, appending a +// corrective nudge on content failures. Network drops get extra attempts: +// the browser kills every in-flight fetch when the machine's network +// changes (ERR_NETWORK_CHANGED — Wi-Fi hop, VPN reconnect), and these +// vision calls run for minutes, so a single flap mid-request is common. +// This pipeline's calls are INPUT-heavy, not output-heavy: the spec stage sends +// roughly 7k tokens of system prompt, 1.7k of analysis and up to 11k of images to +// get back about 2.3k tokens of JSON. Marking the system prompt cacheable lets +// the provider skip re-processing it — cheaper and quicker to first token. +// +// A caller may pass several blocks. Only the FIRST carries the cache marker, +// because only the first is guaranteed byte-identical across objects: the spec +// stage sends its invariant contract as block 0 and this object's selected +// build directives after it, so the expensive half still caches while the +// per-object half varies freely. Order matters — a prefix cache is only a hit +// up to the first byte that differs. +// +// Only Anthropic models are given the marker: the field is an Anthropic +// extension, and a provider that does not understand a structured system message +// is better off receiving the plain string it has always received. +function systemMessage(llmModel: string, systemPrompt: string | string[]): any { + let blocks = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt]; + if (!/^anthropic\//.test(llmModel)) return blocks.join('\n\n'); + return blocks.map((text, i) => ({ + type: 'text', + text, + ...(i === 0 ? { cache_control: { type: 'ephemeral' } } : {}), + })); +} + +export async function requestSpec( + commandContext: any, + llmModel: string, + systemPrompt: string | string[], + userContent: any[], + onLog?: (line: string) => void, + parser: (raw: string) => any = parseSpecJson, + // seed: pass seedFromStrings(referenceUrls) so one reference set always + // takes the same sampling path. temperature defaults to SPEC_TEMPERATURE + // (0) and should only be raised deliberately. + // + // validate: inspect a well-formed reply and return a correction to send back, + // or null to accept it. The retry loop below already knows how to re-ask with a + // nudge when a reply is truncated or is not JSON; a reply that PARSES but left + // out half the object is the same kind of failure and deserves the same + // treatment. Being told exactly which parts are missing is far more likely to + // work than re-rolling and hoping. + options?: { + temperature?: number; + seed?: number; + validate?: (parsed: any) => string | null; + // OpenRouter reasoning control. The analysis stage is pure perception, so + // passing { enabled: false } stops a "thinking" model (e.g. Gemini flash) + // from spending seconds emitting reasoning tokens before the JSON. + reasoning?: { enabled?: boolean; max_tokens?: number; effort?: string }; + }, +) { + let temperature = options?.temperature ?? SPEC_TEMPERATURE; + let seed = options?.seed; + let attempt = async (extraNudge?: string) => { + let content = extraNudge + ? [...userContent, { type: 'text', text: extraNudge }] + : userContent; + let proxy = new SendRequestViaProxyCommand(commandContext); + let result = await proxy.execute({ + url: OPENROUTER_URL, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + requestBody: JSON.stringify({ + model: llmModel, + max_tokens: 24000, + temperature, + ...(typeof seed === 'number' ? { seed } : {}), + ...(options?.reasoning ? { reasoning: options.reasoning } : {}), + messages: [ + { role: 'system', content: systemMessage(llmModel, systemPrompt) }, + { role: 'user', content }, + ], + }), + }); + let response = result?.response; + if (!response) { + throw new Error('proxy returned no response'); + } + if (response.status === 403) { + throw new Error( + 'AI request rejected (403) — you may be out of AI credits.', + ); + } + if (response.status >= 400) { + let body = ''; + try { + body = (await response.text()).slice(0, 120); + } catch { + // body unavailable + } + let err: any = new Error( + `vision request failed: ${response.status}${body ? ` — ${body}` : ''}`, + ); + err.status = response.status; + throw err; + } + let payload = await response.json(); + let choice = payload?.choices?.[0]; + if (choice?.finish_reason === 'length') { + let err: any = new Error( + 'the spec got too long and was truncated — retrying with shorter notes', + ); + err.truncated = true; + throw err; + } + let parsed = parser(choice?.message?.content ?? ''); + let complaint = options?.validate?.(parsed) ?? null; + if (complaint) { + // a parseable but incomplete reply: throw so the loop retries, and carry + // both the complaint (to send back) and the reply (so the last attempt can + // still be used rather than failing the whole generation) + let err: any = new Error(complaint); + err.incomplete = true; + err.correction = complaint; + err.parsed = parsed; + throw err; + } + return parsed; + }; + + // A dropped connection deserves more patience than a bad reply: nothing about + // the request was wrong, it just never finished, and re-sending is free of the + // risk that a retry makes things worse. A malformed or truncated reply is the + // model's doing, so hammering it rarely helps — the corrective nudge does. The + // comment here used to claim network drops already got extra attempts; they + // did not, every failure shared one budget. + const MAX_ATTEMPTS = 4; + const MAX_NETWORK_ATTEMPTS = 7; + const RETRY_DELAYS_MS = [2000, 5000, 10000]; + // longer, gentler backoff for a flapping connection — a Wi-Fi hop or a dev + // server restart takes tens of seconds to settle, and retrying into it just + // burns an attempt + const NETWORK_DELAYS_MS = [2000, 5000, 10000, 20000, 30000, 30000]; + let attemptsAllowed = MAX_ATTEMPTS; + let lastError: any; + for (let i = 0; i < attemptsAllowed; i++) { + let nudge: string | undefined; + if (lastError?.truncated) { + nudge = + 'Your previous reply was truncated. Reply with ONLY the JSON object and keep every "note" under 8 words.'; + } else if (lastError?.correction) { + nudge = lastError.correction; + } else if (lastError?.contentFailure) { + nudge = + 'Your previous reply was not valid JSON. Reply with ONLY the JSON object — no prose, no markdown fences.'; + } + try { + return await attempt(nudge); + } catch (e: any) { + let transientHttp = + e?.status === 429 || (typeof e?.status === 'number' && e.status >= 500); + let networkDrop = /Failed to fetch|NetworkError|network changed/i.test( + e?.message ?? '', + ); + let contentFailure = + e?.truncated || + e?.incomplete || + /did not return JSON|no components/i.test(e?.message ?? ''); + if (!transientHttp && !networkDrop && !contentFailure) throw e; + e.contentFailure = contentFailure; + lastError = e; + if (networkDrop) attemptsAllowed = MAX_NETWORK_ATTEMPTS; + // an incomplete reply is only worth ONE corrective re-ask: if naming the + // missing parts did not produce them, a third identical request will not + // either, and the reply we already hold is usable — better an object with a + // part missing than no object at all + if (e?.incomplete && i >= 1) { + onLog?.( + `> still incomplete after a correction — building anyway (${e.correction})`, + ); + return e.parsed; + } + if (i === attemptsAllowed - 1) break; + let delay = networkDrop + ? (NETWORK_DELAYS_MS[i] ?? 30000) + : (RETRY_DELAYS_MS[i] ?? 10000); + onLog?.( + `> retrying ${i + 1}/${attemptsAllowed - 1} in ${Math.round(delay / 1000)}s (${ + networkDrop ? 'connection dropped' : (e?.status ?? 'invalid response') + })…`, + ); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw lastError; +} diff --git a/4376bf-img-to-3d-generator/util/pipeline-config.gts b/4376bf-img-to-3d-generator/util/pipeline-config.gts new file mode 100644 index 00000000..434dee34 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/pipeline-config.gts @@ -0,0 +1,16 @@ +// Loop policy for the studio's generate/refine cycle. Separate from the +// transport because these are the knobs worth turning when the pipeline is +// too slow or not accurate enough, and they should be findable without +// reading the request code. + +// auto refine passes after the initial generation (each costs one vision +// call and several minutes). Default 0: one generate = one model file; set +// >0 to re-enable the render-vs-reference correction loop. +export const AUTO_REFINE_ROUNDS = 0; +export const REFINE_TARGET_SCORE = 85; + +// How many render-vs-reference "refine" passes the FIRST generation may run +// after the deterministic build + completeness audit. Each is a slow vision +// call, so this is deliberately small. Together with the completeness pass it +// bounds first generation to 2 AI correction passes total. +export const AUTO_VERIFY_ROUNDS = 1; diff --git a/4376bf-img-to-3d-generator/util/realm-image.gts b/4376bf-img-to-3d-generator/util/realm-image.gts new file mode 100644 index 00000000..553d6407 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/realm-image.gts @@ -0,0 +1,165 @@ +// Shared image plumbing for the studio: encoding, naming, and persisting +// binary images into the realm as file-backed ImageDef cards. + +import { ImageDef } from 'https://cardstack.com/base/card-api'; +import WriteBinaryFileCommand from '@cardstack/boxel-host/tools/write-binary-file'; +import SendRequestViaProxyCommand from '@cardstack/boxel-host/tools/send-request-via-proxy'; + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('could not encode image')); + reader.readAsDataURL(blob); + }); +} + +// Vision models resize anything past roughly this on its long edge before they +// look at it, so pixels beyond it are thrown away by the provider — after being +// uploaded. A 2000px concept sheet is several megabytes, base64 adds another +// third, and the analysis stage sends up to SIX of them in one request that then +// runs for minutes. That is the request most likely to die halfway with "Failed +// to fetch": the browser abandons every in-flight fetch the moment the network +// blinks, and a bigger, slower request is simply exposed to more blinks. +const MAX_VISION_EDGE = 1568; + +// The analysis stage only does structural perception — count the parts, read +// each part's normalized bbox, judge the camera. None of that needs full detail +// (the bboxes are resolution-independent fractions, and the real label pixels +// are cropped from the full-res image later, in the build/silhouette stage), so +// analysis sends a smaller image: fewer input vision tokens, faster time-to- +// first-token on the stage that gates everything downstream. The BUILD stage +// keeps MAX_VISION_EDGE so printed artwork still reproduces faithfully. +export const ANALYZE_MAX_EDGE = 1024; + +// The BUILD stage sends the primary view at full MAX_VISION_EDGE so printed +// artwork reproduces faithfully, but the additional views only inform depth / +// thickness — a smaller cap on those trims the build request's input vision +// tokens (and time-to-first-token) without touching label fidelity. +export const BUILD_SECONDARY_EDGE = 1024; + +// A pasted image URL points at somebody else's CDN, and a browser fetch to a +// third-party host fails with "Failed to fetch" unless that host sends +// Access-Control-Allow-Origin — which product-image CDNs essentially never do. +// The confusing part is that the thumbnail still appears: renders a +// cross-origin image happily, it just refuses to let script READ the bytes. So +// a URL reference looks fine and then dies the moment the pipeline needs pixels +// to send to the model or to trace a silhouette from. +// +// The bytes are reachable server-side, where CORS does not apply, and this app +// already has a server-side hop: the proxy that carries the model calls. Try the +// direct read first — same-realm uploads are same-origin and need no detour — +// and fall back to the proxy only when the browser refuses. +async function readAsDataUrl( + url: string, + commandContext?: any, +): Promise { + try { + let response = await fetch(url); + if (!response.ok) { + throw new Error(`could not read image (${response.status})`); + } + return await blobToDataUrl(await response.blob()); + } catch (directError) { + if (!commandContext) throw directError; + let result = await new SendRequestViaProxyCommand(commandContext).execute({ + url, + method: 'GET', + } as any); + let proxied = (result as any)?.response; + if (!proxied || proxied.status >= 400) { + throw new Error( + `could not read that image URL (${proxied?.status ?? 'no response'}) — the host blocks direct reads; download it and add it with Link Image instead`, + ); + } + let blob = await proxied.blob(); + if (!blob?.size) { + throw new Error( + 'that image URL returned no data — download it and add it with Link Image instead', + ); + } + return await blobToDataUrl(blob); + } +} + +function loadImageEl(src: string): Promise { + return new Promise((resolve, reject) => { + let img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('could not decode image')); + img.src = src; + }); +} + +// Re-encodes to WebP, which keeps alpha — a JPEG would turn a transparent +// product-shot background black. If the browser cannot produce WebP, toDataURL +// silently returns a PNG, which is still correct, just larger. +export async function fetchAsDataUrl( + url: string, + opts: { maxEdge?: number; commandContext?: any } = {}, +): Promise { + let maxEdge = opts.maxEdge ?? MAX_VISION_EDGE; + let original = await readAsDataUrl(url, opts.commandContext); + if (!(maxEdge > 0)) return original; + try { + let img = await loadImageEl(original); + let longEdge = Math.max(img.naturalWidth, img.naturalHeight); + // already small enough — return the untouched bytes rather than re-encoding + // and losing quality for nothing + if (!longEdge || longEdge <= maxEdge) return original; + let scale = maxEdge / longEdge; + let canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(img.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(img.naturalHeight * scale)); + let ctx = canvas.getContext('2d'); + if (!ctx) return original; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + let shrunk = canvas.toDataURL('image/webp', 0.92); + // a failed encode can come back tiny or as the wrong type; keep the original + // rather than send something degraded + if (!shrunk || shrunk.length < 512) return original; + return shrunk.length < original.length ? shrunk : original; + } catch { + return original; // decoding is best-effort; never block a generation on it + } +} + +export function slugify(name: string, fallback = 'image'): string { + return ( + name + .toLowerCase() + .replace(/\.[^.]+$/, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || fallback + ); +} + +// writes base64 image bytes into the realm and returns a file-backed +// ImageDef (the file URL is the card id — no separate instance json) +export async function writeRealmImage( + commandContext: any, + options: { + realm: string | undefined; + path: string; + base64: string; + contentType: string; + }, +): Promise { + let result = await new WriteBinaryFileCommand(commandContext).execute({ + path: options.path, + realm: options.realm, + base64Content: options.base64, + contentType: options.contentType, + useNonConflictingFilename: true, + }); + let url = (result as any)?.fileIdentifier; + if (!url) return undefined; + return new ImageDef({ + id: url, + url, + sourceUrl: url, + name: url.split('/').pop() ?? 'image', + contentType: options.contentType, + } as any); +} diff --git a/4376bf-img-to-3d-generator/util/repeat-expand.gts b/4376bf-img-to-3d-generator/util/repeat-expand.gts new file mode 100644 index 00000000..956be71c --- /dev/null +++ b/4376bf-img-to-3d-generator/util/repeat-expand.gts @@ -0,0 +1,106 @@ +// Repetition systems: one declared part becomes N placed copies. +// +// This is the ONE implementation. The studio interpreter imports it directly +// and the code exporter emits its own source via `Function.prototype.toString()` +// (the same trick the finish painters use), so a standalone exported model +// arrays its parts exactly the way the viewport does. Both used to carry a +// hand-copied twin of this logic and they drifted: a fix to the interpreter's +// copy left every exported model still wrong, which is the only copy the +// viewport actually renders. +// +// Written as a hoisting function DECLARATION with no module-level references — +// emitted into `buildSculpture`, it has to stand alone. It also sticks to +// syntax that survives compilation untouched (no `?.` / `??`), so the emitted +// text can never reach for a transpiler helper that is not there. +export function expandRepeatInstances( + THREE: any, + original: any, + rep: any, + host: any, + root: any, + onClone: (clone: any) => void, +): void { + if (!rep || typeof rep !== 'object') return; + let count = Math.min(48, Math.max(0, Math.round(rep.count || 0))); + if (count < 2) return; + let parent = original.parent || root; + let axis = rep.axis === 'x' ? 'x' : rep.axis === 'z' ? 'z' : 'y'; + let basePos = original.position.clone(); + let baseQuat = original.quaternion.clone(); + // RING CENTER for a radial array. The part's own in-plane position is + // already a point ON the intended circle, so adding the radius to it put + // every clone at twice the radius — 20 knurl ridges orbited at 0.29 around a + // 0.16 cap, reading as a spiked collar floating off the cap. The circle + // belongs to the part this array wraps around, so its axis supplies the + // in-plane center; the position ALONG the axis stays where the part was + // authored. With no declared host there is nothing to centre on, so the + // part's own position is kept as the centre. + let center = basePos.clone(); + if (rep.mode === 'radial' && host && host !== original) { + let hostBox = new THREE.Box3().setFromObject(host); + if (!hostBox.isEmpty()) { + let hostCenter = hostBox.getCenter(new THREE.Vector3()); + parent.worldToLocal(hostCenter); + if (axis === 'y') { + center.x = hostCenter.x; + center.z = hostCenter.z; + } else if (axis === 'x') { + center.y = hostCenter.y; + center.z = hostCenter.z; + } else { + center.x = hostCenter.x; + center.y = hostCenter.y; + } + } + } + // place instance i — index 0 is the original itself, so a radial array is one + // coherent ring instead of the original sitting off the circle its own clones + // orbit on + let place = (obj: any, i: number) => { + if (rep.mode === 'radial') { + let radius = rep.radius != null ? rep.radius : 0.5; + let angle = (i / count) * Math.PI * 2; + let ca = Math.cos(angle) * radius; + let sa = Math.sin(angle) * radius; + // The instance is carried around the ring RIGIDLY: the orbital angle + // composes OUTSIDE the part's own orientation, so a part already aimed + // along the ring axis keeps that aim. Adding the angle to the matching + // Euler component instead composes it inside that orientation, and since + // Euler order is XYZ that tilts every clone by its own angle — six + // minigun barrels laid along z came out crossed like an asterisk rather + // than parallel. Positions sweep +x→+z about y, which is a rotation + // about −y, so that one axis spins the opposite way to stay in step. + let spin = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3( + axis === 'x' ? 1 : 0, + axis === 'y' ? 1 : 0, + axis === 'z' ? 1 : 0, + ), + axis === 'y' ? -angle : angle, + ); + obj.quaternion.copy(baseQuat).premultiply(spin); + if (axis === 'y') { + obj.position.set(center.x + ca, center.y, center.z + sa); + } else if (axis === 'x') { + obj.position.set(center.x, center.y + ca, center.z + sa); + } else { + obj.position.set(center.x + ca, center.y + sa, center.z); + } + } else { + let offset = Array.isArray(rep.offset) ? rep.offset : [0.2, 0, 0]; + obj.position.set( + basePos.x + offset[0] * i, + basePos.y + offset[1] * i, + basePos.z + offset[2] * i, + ); + } + }; + place(original, 0); + for (let i = 1; i < count; i++) { + let clone = original.clone(true); + clone.name = `${original.name || 'part'}-${i}`; + place(clone, i); + parent.add(clone); + onClone(clone); + } +} diff --git a/4376bf-img-to-3d-generator/util/silhouette.gts b/4376bf-img-to-3d-generator/util/silhouette.gts new file mode 100644 index 00000000..bab72e2d --- /dev/null +++ b/4376bf-img-to-3d-generator/util/silhouette.gts @@ -0,0 +1,670 @@ +// Silhouette tracing: extract a lathe profile for a rotationally symmetric +// part straight from the reference photo's pixels, instead of trusting the +// vision model to invent one. The traced half-width-per-height sequence IS +// the part's real outline — cone-shaped bottles and invented bulges cannot +// survive it. + +export interface SilhouetteBbox { + left: number; + top: number; + width: number; + height: number; +} + +interface Segmentation { + w: number; + h: number; + outside: Uint8Array; +} + +// Shared segmentation: downsample the crop and split foreground from background +// with a direct per-pixel color threshold against the corner-sampled ground. +// (Deliberately NOT a border flood-fill — see the note at the threshold below; +// on glossy bottles a flood leaks along bright edge highlights and eats the +// body.) Interior gaps a threshold leaves — a pale label, a highlight — are +// repaired later per-row. Returns the mask (`outside[k]===0` ⇒ foreground), or +// null (with a reason pushed to `diag`) when the crop can't segment cleanly. +function segmentCrop( + image: HTMLImageElement, + bbox: SilhouetteBbox, + diag?: string[], +): Segmentation | null { + let fail = (reason: string): null => { + diag?.push(reason); + return null; + }; + if (!(bbox?.width > 0) || !(bbox?.height > 0)) + return fail('bbox has no area'); + let sx = Math.round(bbox.left * image.width); + let sy = Math.round(bbox.top * image.height); + let sw = Math.max(4, Math.round(bbox.width * image.width)); + let sh = Math.max(4, Math.round(bbox.height * image.height)); + + // downsample the crop — silhouette scanning needs shape, not resolution + let maxDim = 220; + let scale = Math.min(1, maxDim / Math.max(sw, sh)); + let w = Math.max(4, Math.round(sw * scale)); + let h = Math.max(4, Math.round(sh * scale)); + let canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + let ctx = canvas.getContext('2d')!; + ctx.drawImage(image, sx, sy, sw, sh, 0, 0, w, h); + let data: Uint8ClampedArray; + try { + data = ctx.getImageData(0, 0, w, h).data; + } catch { + return fail('tainted canvas — reference pixels unreadable (CORS)'); + } + + // background = the average of the four corner patches (product shots have + // clean grounds) + let corner = (cx: number, cy: number) => { + let r = 0, + g = 0, + b = 0, + n = 0; + for (let y = cy; y < cy + 3 && y < h; y++) { + for (let x = cx; x < cx + 3 && x < w; x++) { + let i = (y * w + x) * 4; + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + n++; + } + } + return [r / n, g / n, b / n]; + }; + let corners = [ + corner(0, 0), + corner(w - 3, 0), + corner(0, h - 3), + corner(w - 3, h - 3), + ]; + let bg = [0, 1, 2].map( + (c) => corners.reduce((s, k) => s + k[c], 0) / corners.length, + ); + // DIRECT-THRESHOLD segmentation (NOT a border flood-fill): a pixel is + // foreground when it is NOT near-white background. On a clean product shot + // EVERYTHING that isn't the white ground is the object — dark glass, the cream + // label, and the lighter curved-glass edges alike — so the threshold is LOW, + // just above pure white. A high threshold was the bug: it excluded the pale + // label and the light glass edges, leaving only the dark centre, so the body + // traced as a thin strip (its width collapsed to the label's dark text). A + // per-pixel test also can't "flow" like a flood-fill, which on a glossy bottle + // leaks inward along the bright rim highlights and eats both whole sides. + let fgTol = 26 * 26; // squared distance from white that still counts as body + let outside = new Uint8Array(w * h); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let i = (y * w + x) * 4; + let dr = data[i] - bg[0]; + let dg = data[i + 1] - bg[1]; + let db = data[i + 2] - bg[2]; + let fg = data[i + 3] >= 32 && dr * dr + dg * dg + db * db > fgTol; + outside[y * w + x] = fg ? 0 : 1; + } + } + + // sanity: the mask must be mostly-but-not-entirely foreground + let fgCount = 0; + for (let y = 0; y < h; y += 2) { + for (let x = 0; x < w; x += 2) { + if (outside[y * w + x] === 0) fgCount++; + } + } + let fgFraction = fgCount / ((w / 2) * (h / 2)); + if (fgFraction < 0.03 || fgFraction > 0.98) { + return fail( + `foreground fraction ${fgFraction.toFixed(2)} out of range — busy background or crop fills the frame`, + ); + } + return { w, h, outside }; +} + +// Fill a spans array's null gaps by linear-interpolating between the nearest +// non-null rows above and below (leading/trailing nulls stay null). +function interpolateSpans(vals: (number | null)[]): (number | null)[] { + let out = vals.slice(); + let prev = -1; + for (let i = 0; i < out.length; i++) { + if (out[i] == null) continue; + if (prev >= 0 && i - prev > 1) { + let a = out[prev] as number; + let b = out[i] as number; + let gap = i - prev; + for (let k = 1; k < gap; k++) out[prev + k] = a + ((b - a) * k) / gap; + } + prev = i; + } + return out; +} + +// Median-smooth a spans array — robust to single-row spikes from label text, +// reflections, or antialiasing (a moving average would round shoulders and +// still be dragged toward an outlier). +function medianSmoothSpans( + vals: (number | null)[], + radius: number, +): (number | null)[] { + return vals.map((v, i) => { + if (v == null) return null; + let s: number[] = []; + for ( + let j = Math.max(0, i - radius); + j <= Math.min(vals.length - 1, i + radius); + j++ + ) { + let x = vals[j]; + if (x != null) s.push(x); + } + s.sort((a, b) => a - b); + return s.length ? s[Math.floor(s.length / 2)] : v; + }); +} + +// Repair a revolved-object mask so a white/pale label, transparent glass, or a +// bright reflection can't punch a hole (or cut an inward spike) into the +// silhouette. For each row take the OUTER foreground span [left,right] (the true +// bottle edges survive even when the label between them reads as background), +// drop the drop-shadow flare at the base, interpolate rows the label ate +// entirely, median-smooth both boundaries, then fill every valid row solid +// left→right. The result is one continuous silhouette that BOTH the lathe +// profile and the SVG outline consume, keeping preview, body, and clamp +// envelope in sync. Only sound for lathe-like bodies (the per-row fill would +// erase intentional holes) — callers use it exclusively on revolved parts. +function repairLatheSilhouetteMask( + outside: Uint8Array, + w: number, + h: number, + diag?: string[], +): Uint8Array { + let left: (number | null)[] = new Array(h).fill(null); + let right: (number | null)[] = new Array(h).fill(null); + let minSpan = Math.max(2, Math.round(w * 0.01)); + let widths: number[] = []; + for (let y = 0; y < h; y++) { + let minX = w; + let maxX = -1; + for (let x = 0; x < w; x++) { + if (outside[y * w + x] === 0) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + } + if (maxX >= minX && maxX - minX >= minSpan) { + left[y] = minX; + right[y] = maxX; + widths.push(maxX - minX); + } + } + if (widths.length < 3) return outside; // too little to repair — keep raw + // drop the drop-shadow flare: trailing (base) rows much wider than the median + let sortedW = widths.slice().sort((a, b) => a - b); + let medW = sortedW[Math.floor(sortedW.length / 2)] || 1; + for (let y = h - 1; y >= 0; y--) { + if (left[y] == null) continue; + if ((right[y] as number) - (left[y] as number) > medW * 1.35) { + left[y] = null; + right[y] = null; + } else break; + } + let sl = medianSmoothSpans(interpolateSpans(left), 3); + let sr = medianSmoothSpans(interpolateSpans(right), 3); + let repaired = new Uint8Array(w * h).fill(1); + let filled = 0; + let interpolated = 0; + for (let y = 0; y < h; y++) { + let l = sl[y]; + let r = sr[y]; + if (l == null || r == null || r < l) continue; + if (left[y] == null) interpolated++; + let s = Math.max(0, Math.floor(l)); + let e = Math.min(w - 1, Math.ceil(r)); + for (let x = s; x <= e; x++) repaired[y * w + x] = 0; + filled++; + } + if (!filled) return outside; + if (interpolated > 0) { + diag?.push(`repaired lathe silhouette across ${interpolated} missing rows`); + } + return repaired; +} + +// Average colour of a small patch, used to sample backdrop corners. +function patchColor( + data: Uint8ClampedArray, + w: number, + x0: number, + y0: number, + size: number, +): [number, number, number] { + let r = 0, + g = 0, + b = 0, + n = 0; + for (let y = y0; y < y0 + size; y++) { + for (let x = x0; x < x0 + size; x++) { + let i = (y * w + x) * 4; + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + n++; + } + } + return [r / n, g / n, b / n]; +} + +function colorDistanceSq(a: number[], b: number[]): number { + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; +} + +// Squared RGB distance from the backdrop colour. Below CUT a pixel IS the +// backdrop and goes fully transparent; above KEEP it is object and stays +// opaque; between the two it is the antialiased rim and fades. Both are +// deliberately tight — a product shot's ground is near-uniform, so a wide +// tolerance starts eating pale parts of the object (a cream label, a chrome +// bezel) rather than the ground. +const CUT_TOLERANCE = 30 * 30; +const KEEP_TOLERANCE = 55 * 55; + +// Surface-print keying on a NEUTRAL surface (a logo on a black/white/grey mug, +// phone, or metal) keys by CHROMA, not colour distance: the surface is neutral +// at every brightness across its glossy gradient (near-black shadow to grey +// highlight all have chroma ~0), while the ink is chromatic. A single-colour +// key can't span that whole gradient, but "drop the low-chroma pixels" removes +// all of it at once and keeps only the ink. chroma = max(r,g,b) - min(r,g,b). +const NEUTRAL_SURFACE_CHROMA = 26; // border colour below this ⇒ neutral surface +const CUT_CHROMA = 26; // at/below ⇒ surface, fully transparent +const KEEP_CHROMA = 68; // at/above ⇒ ink, fully opaque + +function unionBox(boxes: SilhouetteBbox[]): SilhouetteBbox { + let left = Math.min(...boxes.map((b) => b.left)); + let top = Math.min(...boxes.map((b) => b.top)); + return { + left, + top, + width: Math.max(...boxes.map((b) => b.left + b.width)) - left, + height: Math.max(...boxes.map((b) => b.top + b.height)) - top, + }; +} + +// Which region of the reference to trace as THE object's revolved silhouette, +// or a reason not to trace at all. +// +// Analysis usually splits one revolved body into stacked parts — a bottle's +// body / shoulder / neck / lip — which share an axis, so the region to trace +// is their union. Two conditions have to hold for that union to mean anything, +// and a machine with a round component satisfies neither: +// +// · ONE AXIS. Parts at opposite ends of an object are not a stack. A +// Thompson's drum magazine and barrel unioned to a box around the whole +// weapon, whose traced outline revolved into a wooden spinning top. +// · IT IS THE BODY. A single revolved part passes the axis test trivially — +// nothing disagrees with it — so a drum magazine alone would otherwise +// hand its own outline to the entire gun. +// +// The second condition matters more than the profile it rejects: the traced +// outline also becomes the envelope every solid part is clamped into, so a +// wrong trace does not just add one bad part, it displaces all the good ones. +export function revolvedSilhouetteBbox(plan: any[]): { + bbox?: SilhouetteBbox; + skipped?: string; +} { + let sized = (plan ?? []).filter((p: any) => p?.bbox?.width > 0); + let revolved = sized.filter((p: any) => p?.approach === 'revolved'); + if (!revolved.length) return { skipped: 'no revolved parts' }; + + let bbox = unionBox(revolved.map((p: any) => p.bbox)); + // PAIRWISE, not each-part-against-the-union: every part is inside the union + // by construction, so measuring against it always returns the part's own + // width and passes everything. + let overlap = (a: SilhouetteBbox, b: SilhouetteBbox) => + Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left); + for (let i = 0; i < revolved.length; i++) { + for (let j = i + 1; j < revolved.length; j++) { + let a = revolved[i].bbox; + let b = revolved[j].bbox; + if (overlap(a, b) < 0.6 * Math.min(a.width, b.width)) { + return { skipped: 'revolved parts are not on one axis' }; + } + } + } + + let objectBox = unionBox(sized.map((p: any) => p.bbox)); + if ( + bbox.height < 0.6 * objectBox.height || + bbox.width < 0.5 * objectBox.width + ) { + return { skipped: 'revolved parts are a detail, not the body' }; + } + return { bbox }; +} + +// The photo's backdrop colour, sampled from the four image corners — which +// are backdrop by construction on the product shots this pipeline is fed. +// Returns null when those corners disagree, i.e. the reference has no clean +// ground to key against and nothing should be cut. +function referenceBackdrop( + image: HTMLImageElement, + diag?: string[], +): [number, number, number] | null { + let side = 64; + let canvas = document.createElement('canvas'); + canvas.width = side; + canvas.height = side; + let ctx = canvas.getContext('2d')!; + ctx.drawImage(image, 0, 0, side, side); + let data: Uint8ClampedArray; + try { + data = ctx.getImageData(0, 0, side, side).data; + } catch { + diag?.push('tainted canvas — backdrop unreadable (CORS)'); + return null; + } + let patch = 4; + let far = side - patch; + let corners = [ + patchColor(data, side, 0, 0, patch), + patchColor(data, side, far, 0, patch), + patchColor(data, side, 0, far, patch), + patchColor(data, side, far, far, patch), + ]; + let backdrop = [0, 1, 2].map( + (c) => corners.reduce((s, k) => s + k[c], 0) / corners.length, + ) as [number, number, number]; + if (corners.some((corner) => colorDistanceSq(corner, backdrop) >= 30 * 30)) { + diag?.push('reference has no uniform backdrop — crop left opaque'); + return null; + } + return backdrop; +} + +// Average colour of a crop's one-pixel border ring, from its already-decoded +// pixel data. For a graphic printed directly on a part, that ring is the +// surface the ink sits on — the colour surface-print keying removes. +function cropBorderColor( + pixels: Uint8ClampedArray, + sw: number, + sh: number, +): [number, number, number] { + let r = 0; + let g = 0; + let b = 0; + let n = 0; + let add = (x: number, y: number) => { + let i = (y * sw + x) * 4; + r += pixels[i]; + g += pixels[i + 1]; + b += pixels[i + 2]; + n++; + }; + for (let x = 0; x < sw; x++) { + add(x, 0); + add(x, sh - 1); + } + for (let y = 1; y < sh - 1; y++) { + add(0, y); + add(sw - 1, y); + } + n = Math.max(1, n); + return [r / n, g / n, b / n]; +} + +// Crops `bbox` out of the image with the BACKDROP knocked out to transparent, +// for artwork that gets pasted onto the model as a decal. +// +// A bbox is a rectangle and the thing inside it rarely is: the region around a +// bottle's foil capsule, or a character's face, is mostly the photo's ground. +// Pasted as an opaque decal, that ground lands on the model as a pale slab +// around the artwork — the capsule arrived wearing two white wings. +// +// Keyed against the WHOLE IMAGE's backdrop colour, never against the crop's +// own corners. A crop's corners are only the backdrop when the crop happens to +// straddle the object's edge; inside a wine label they are the label's cream +// ground, and cutting on that answer keeps the lettering and throws the label +// away. It also survives a crop that clips a neighbouring part — the capsule's +// bbox catches the top of the bottle body, and that body is object, not ground. +// +// The crop keeps the bbox's full extent rather than trimming to what survived: +// the decal's authored width/height were chosen for this rectangle, so +// shrinking it would stretch the graphic across a plane it no longer matches. +// Transparent margin costs nothing to render. +// +// Returns null — caller falls back to a plain opaque crop — when the reference +// has no clean ground, when the crop lies entirely on the object (the common +// and correct case for a label), or when the cut would take nearly everything. +// +// surfacePrint mode is the OPPOSITE keying target, for graphics that are +// printed / laser-etched / silk-screened directly onto the part's own surface +// (a logo on a mug, a print on a shirt) rather than applied as a separate +// label patch. There the thing to remove is the SURFACE the ink sits on — the +// crop's own border colour — leaving only the ink, so the part's real material +// shows through instead of an opaque rectangle of surface. Keyed against the +// crop's border ring, and the "is there ground" bail is skipped (the border IS +// the surface we mean to cut). +export function cropWithBackgroundRemoved( + image: HTMLImageElement, + bbox: SilhouetteBbox, + diag?: string[], + opts?: { surfacePrint?: boolean }, +): HTMLCanvasElement | null { + let surfacePrint = opts?.surfacePrint ?? false; + + let sx = Math.round(bbox.left * image.width); + let sy = Math.round(bbox.top * image.height); + let sw = Math.max(1, Math.round(bbox.width * image.width)); + let sh = Math.max(1, Math.round(bbox.height * image.height)); + let canvas = document.createElement('canvas'); + canvas.width = sw; + canvas.height = sh; + let ctx = canvas.getContext('2d')!; + ctx.drawImage(image, sx, sy, sw, sh, 0, 0, sw, sh); + let frame; + try { + frame = ctx.getImageData(0, 0, sw, sh); + } catch { + diag?.push('tainted canvas — artwork pixels unreadable (CORS)'); + return null; + } + let pixels = frame.data; + + // label mode keys the whole-image backdrop; surface-print keys the crop's + // OWN border ring — the surface colour the ink is printed on. + let backdrop = surfacePrint + ? cropBorderColor(pixels, sw, sh) + : referenceBackdrop(image, diag); + if (!backdrop) return null; + if (surfacePrint) { + diag?.push( + `surface-print: keying out the surface colour rgb(${backdrop + .map((n) => Math.round(n)) + .join(',')})`, + ); + } + let isBackdrop = (i: number) => + colorDistanceSq([pixels[i], pixels[i + 1], pixels[i + 2]], backdrop); + + // a neutral surface (black/white/grey) can't be keyed by a single colour + // across its glossy gradient — key it by chroma instead (drop the neutral + // pixels at every brightness, keep the chromatic ink). + let surfaceChroma = + Math.max(backdrop[0], backdrop[1], backdrop[2]) - + Math.min(backdrop[0], backdrop[1], backdrop[2]); + let chromaKey = surfacePrint && surfaceChroma < NEUTRAL_SURFACE_CHROMA; + if (chromaKey) { + diag?.push('surface-print: neutral surface — keying by ink chroma'); + } + let pixelChroma = (i: number) => + Math.max(pixels[i], pixels[i + 1], pixels[i + 2]) - + Math.min(pixels[i], pixels[i + 1], pixels[i + 2]); + + // Is there any ground in this rectangle at all? Measured on the border ring + // rather than the four corners: a bbox drawn tight around a part is a few + // pixels of margin at most, so corner patches land on the part itself and + // report "no backdrop" for exactly the crops that need cutting. The ring is + // a far larger sample and degrades gracefully — a part flush against one + // edge of its own bbox still leaves the other three. Skipped for surface + // print: the border ring is the surface, so it is 100% "backdrop" by design. + if (!surfacePrint) { + let border = 0; + let onBackdrop = 0; + let sample = (x: number, y: number) => { + border++; + if (isBackdrop((y * sw + x) * 4) < CUT_TOLERANCE) onBackdrop++; + }; + for (let x = 0; x < sw; x++) { + sample(x, 0); + sample(x, sh - 1); + } + for (let y = 1; y < sh - 1; y++) { + sample(0, y); + sample(sw - 1, y); + } + if (onBackdrop / Math.max(1, border) < 0.12) { + diag?.push('crop lies on the object — left opaque'); + return null; + } + } + + // Ramp rather than threshold: a pixel well clear of the ground keeps its + // alpha, one indistinguishable from it goes fully transparent, and the + // antialiased rim in between fades. A hard cut leaves a jagged edge and a + // halo of ground colour one pixel wide all the way round the artwork. + let kept = 0; + for (let i = 0; i < pixels.length; i += 4) { + let opacity; + if (chromaKey) { + let c = pixelChroma(i); + opacity = + c <= CUT_CHROMA + ? 0 + : c >= KEEP_CHROMA + ? 1 + : (c - CUT_CHROMA) / (KEEP_CHROMA - CUT_CHROMA); + } else { + let d = isBackdrop(i); + opacity = + d <= CUT_TOLERANCE + ? 0 + : d >= KEEP_TOLERANCE + ? 1 + : (d - CUT_TOLERANCE) / (KEEP_TOLERANCE - CUT_TOLERANCE); + } + pixels[i + 3] = Math.round(pixels[i + 3] * opacity); + if (opacity > 0.5) kept++; + } + let keptFraction = kept / (pixels.length / 4); + if (keptFraction < 0.03) { + diag?.push('cut would remove the whole crop — left opaque'); + return null; + } + diag?.push( + `cut backdrop, kept ${Math.round(keptFraction * 100)}% of the crop`, + ); + ctx.putImageData(frame, 0, 0); + return canvas; +} + +// Traces the part inside `bbox` (normalized to the image) and returns a +// lathe dimensions array [x0,y0, x1,y1, ...] bottom→top, where x is the +// half-width and y the height, both normalized so the profile spans +// height 0..1 with half-widths as fractions of that height. Returns null +// (with a reason pushed to `diag`) when the crop can't segment cleanly. +export function traceLatheProfile( + image: HTMLImageElement, + bbox: SilhouetteBbox, + samples = 12, + diag?: string[], +): number[] | null { + let fail = (reason: string): null => { + diag?.push(reason); + return null; + }; + let seg = segmentCrop(image, bbox, diag); + if (!seg) return null; + let { w, h } = seg; + // repair white-label / reflection gaps so the profile follows the true bottle + let outside = repairLatheSilhouetteMask(seg.outside, w, h, diag); + let isForeground = (x: number, y: number) => outside[y * w + x] === 0; + + // per-sample row: the silhouette half-width is (rightmost − leftmost) / 2 of + // the segmented object on that row. The filled mask means interior label / + // highlight pixels no longer break the span, so a clean outline falls out — no + // symmetry assumption and no center-line scan needed. + let rows: { y: number; half: number }[] = []; + for (let s = 0; s < samples; s++) { + // bottom→top, sampling row centers so the lip and base are included + let y = Math.round(((samples - 1 - s + 0.5) / samples) * (h - 1)); + let minX = -1; + let maxX = -1; + for (let x = 0; x < w; x++) { + if (isForeground(x, y)) { + if (minX < 0) minX = x; + maxX = x; + } + } + rows.push({ + y: s / (samples - 1), + half: minX < 0 ? 0 : (maxX - minX + 1) / 2 / h, + }); + } + + // drop leading/trailing empty rows (crop padding), keep at least 3 rows + let first = rows.findIndex((r) => r.half > 0); + let last = rows.length - 1 - [...rows].reverse().findIndex((r) => r.half > 0); + if (first < 0 || last - first < 2) { + return fail('fewer than 3 solid rows — silhouette too sparse to trace'); + } + let kept = rows.slice(first, last + 1); + + // physical shape prior for revolved containers: the true outline is + // UNIMODAL — it never narrows on the way up to its widest point, and never + // widens again above it (a small lip flare at the very top excepted). + // Every violation is segmentation noise: light labels reading as + // background, glass reflections eroding an edge row. Clamp to the + // envelope instead of trusting wavy rows. + let smoothed = kept.map((r, i) => { + let window = [kept[i - 1]?.half, r.half, kept[i + 1]?.half].filter( + (v): v is number => typeof v === 'number', + ); + window.sort((a, b) => a - b); + return { y: r.y, half: window[Math.floor(window.length / 2)] }; + }); + let peak = smoothed.reduce( + (best, r, i) => (r.half > smoothed[best].half ? i : best), + 0, + ); + for (let i = 1; i <= peak; i++) { + // base → widest point: non-decreasing + smoothed[i].half = Math.max(smoothed[i].half, smoothed[i - 1].half); + } + for (let i = peak + 1; i < smoothed.length; i++) { + // widest point → top: non-increasing, but let the last rows flare a + // little (bottle lips / rolled rims) + let flare = i >= smoothed.length - 2 ? 1.2 : 1; + smoothed[i].half = Math.min(smoothed[i].half, smoothed[i - 1].half * flare); + } + kept = smoothed; + + // the traced widths are the measurement of record (the threshold already + // keeps shadows out); the bbox aspect only CAPS them — analysis bboxes run + // loose, so scaling UP to the bbox width fattens slender objects + let maxHalf = Math.max(...kept.map((r) => r.half)); + if (!(maxHalf > 0)) return fail('traced width is zero'); + let capHalf = (w / h / 2) * 1.15; + let renorm = maxHalf > capHalf ? capHalf / maxHalf : 1; + + let span = kept[kept.length - 1].y - kept[0].y || 1; + let y0 = kept[0].y; + let profile: number[] = []; + // start closed on the axis so the lathe caps its base + profile.push(0, 0); + for (let row of kept) { + profile.push( + Number((row.half * renorm).toFixed(4)), + Number(((row.y - y0) / span).toFixed(4)), + ); + } + return profile; +} diff --git a/4376bf-img-to-3d-generator/util/spec-diff.gts b/4376bf-img-to-3d-generator/util/spec-diff.gts new file mode 100644 index 00000000..5a837dcb --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-diff.gts @@ -0,0 +1,173 @@ +// Applying a model-authored change set to an existing spec. +// +// The refine and targeted-edit stages reply with a diff, not a whole spec, +// which is the only reason a scoped edit stays scoped. What may change is +// deliberately narrow — a part's primitive is frozen on both paths, because +// turning a box into a sphere is never what an edit meant. + +import { serializeSpecForPrompt } from './spec-io'; + +// Which lasso-selected parts a "remove X" instruction actually means. +// +// The lasso reports every mesh under its polygon, and a surface part — a +// sticker, a label, a printed mark — always sits ON a solid, so the pick ray +// hits that solid too: lassoing a blade sticker returns [sticker, blade]. The +// deterministic delete used to remove EVERY selected part, so "remove sticker" +// took the blade with it. When the instruction NAMES what to remove, that noun +// is the authority over the lasso's spillover — narrow the selection to the +// parts whose id / partRef / note match the noun (and, for sticker/label/decal +// words, to the decal primitives, since the noun can never name the solid it +// rides on). Returns the narrowed set only when it is a real, non-empty subset; +// otherwise the original selection stands (a generic "remove this", or a lasso +// that already matches the words), so nothing here can DELETE MORE than before. +const REMOVE_VERB = /^(remove|delete|erase|drop|get rid of)\b/i; +const DECAL_WORD = + /\b(sticker|label|decal|logo|print|graphic|wordmark|badge|marking|text)s?\b/i; +const STOPWORD = + /^(the|a|an|this|that|these|those|part|parts|one|it|please|from|on|off|of|my|selected|whole|entire)$/i; + +export function isRemovalInstruction(instruction: string): boolean { + return REMOVE_VERB.test(String(instruction ?? '').trim()); +} + +export function narrowRemovalTargets( + components: any[], + lassoTargets: string[], + instruction: string, +): string[] { + let text = String(instruction ?? '').trim(); + if (!REMOVE_VERB.test(text) || lassoTargets.length < 2) return lassoTargets; + let nouns = text + .replace(REMOVE_VERB, '') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((w) => w && !STOPWORD.test(w)); + if (!nouns.length) return lassoTargets; // "remove this" — the lasso is all we have + let wantsDecal = DECAL_WORD.test(text); + let byId = new Map(components.map((c: any) => [String(c?.nodeId), c])); + let nameOf = (c: any) => + `${c?.nodeId ?? ''} ${c?.partRef ?? ''} ${c?.note ?? ''}`.toLowerCase(); + let isDecal = (c: any) => + c?.primitive === 'textDecal' || c?.primitive === 'curvedDecal'; + let named = lassoTargets.filter((id) => { + let c = byId.get(String(id)); + if (!c) return false; + let name = nameOf(c); + if (nouns.some((n) => name.includes(n))) return true; + // a sticker/label word can only mean the decal — never the solid under it + return wantsDecal && isDecal(c); + }); + return named.length && named.length < lassoTargets.length + ? named + : lassoTargets; +} + +export function applySpecDiff( + currentSpec: any, + diff: any, + opts: { + allowRemoval?: boolean; + // A user-directed edit may RESIZE a part and ADD new ones; the automatic + // refine pass may not. Refine judges a render against a photo and re-places + // what it sees, and letting it resize or invent parts turns a correction + // loop into an unsupervised rebuild. A person asking for a taller label or + // a missing one restored is a different situation: they are looking at the + // result and they asked. + allowReshape?: boolean; + allowAdditions?: boolean; + } = {}, +) { + let base = serializeSpecForPrompt(currentSpec) as any; + let components = new Map( + (base.components ?? []).map((c: any) => [c.nodeId, c]), + ); + // `primitive` stays frozen on BOTH paths. Dimensions and placement describe a + // shape's size and where it sits; the primitive is what kind of thing it is, + // and silently turning a box into a blob is never what an edit instruction + // meant. Unknown nodeIds are ignored. + for (let c of diff.changed) { + if (!c?.nodeId) continue; + let existing = components.get(String(c.nodeId)); + if (!existing) continue; + components.set(String(c.nodeId), { + ...existing, + ...(c.position !== undefined ? { position: c.position } : {}), + ...(c.rotation !== undefined ? { rotation: c.rotation } : {}), + ...(c.scale !== undefined ? { scale: c.scale } : {}), + ...(opts.allowReshape && c.dimensions !== undefined + ? { dimensions: c.dimensions } + : {}), + ...(c.grounded !== undefined ? { grounded: c.grounded } : {}), + ...(c.materialId !== undefined + ? { materialId: String(c.materialId) } + : {}), + }); + } + // new parts, for restoring something the build left out. A duplicate nodeId + // would silently replace an existing part, and a component with no primitive + // cannot be built, so both are refused rather than merged. + if (opts.allowAdditions && Array.isArray(diff.added)) { + for (let c of diff.added) { + let id = c?.nodeId ? String(c.nodeId) : ''; + if (!id || !c?.primitive || components.has(id)) continue; + components.set(id, { + nodeId: id, + parentId: c.parentId ?? null, + primitive: String(c.primitive), + dimensions: c.dimensions ?? [], + position: c.position ?? [0, 0, 0], + rotation: c.rotation ?? [0, 0, 0], + scale: c.scale ?? [1, 1, 1], + materialId: c.materialId ?? null, + text: c.text ?? null, + partRef: c.partRef ?? null, + textureRef: c.textureRef ?? null, + textureUrl: c.textureUrl ?? null, + repeat: c.repeat ?? null, + attachTo: c.attachTo ?? null, + grounded: c.grounded ?? null, + note: c.note ?? null, + }); + } + } + // lasso targeted edit only: drop removed parts AND anything parented to them + if (opts.allowRemoval && Array.isArray(diff.removedNodeIds)) { + let dead = new Set(diff.removedNodeIds.map((x: any) => String(x))); + let changed = true; + while (changed) { + changed = false; + for (let c of components.values()) { + if ( + c.parentId != null && + dead.has(String(c.parentId)) && + !dead.has(String(c.nodeId)) + ) { + dead.add(String(c.nodeId)); + changed = true; + } + } + } + for (let id of dead) components.delete(id); + } + let materials = new Map( + (base.materials ?? []).map((m: any) => [m.materialId, m]), + ); + for (let m of diff.materialsChanged) { + if (m?.materialId) materials.set(String(m.materialId), m); + } + return { + objectName: diff.objectName ?? base.objectName, + inputKind: diff.inputKind ?? base.inputKind, + objectClass: diff.objectClass ?? base.objectClass, + buildBackend: diff.buildBackend ?? base.buildBackend, + complexity: diff.complexity ?? base.complexity, + identityFeatures: Array.isArray(diff.identityFeatures) + ? diff.identityFeatures + : (base.identityFeatures ?? []), + critique: diff.critique, + score: diff.score, + featureCheck: diff.featureCheck, + materials: [...materials.values()], + components: [...components.values()], + }; +} diff --git a/4376bf-img-to-3d-generator/util/spec-geometry.gts b/4376bf-img-to-3d-generator/util/spec-geometry.gts new file mode 100644 index 00000000..eeb4b2b7 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-geometry.gts @@ -0,0 +1,256 @@ +// Where a component actually sits and how big it actually is. +// +// The spec authors a primitive plus a position, but "how big is this part" +// depends on the primitive's own dimension semantics, and "where does it sit" +// depends on whether its geometry is centred on its origin. Every repair pass +// needs both answers, and needs them to agree — two passes disagreeing about a +// part's box is how a part gets pushed twice in opposite directions. So the +// answers live here, once, as pure functions with no LLM or realm dependency. + +// Every spec-side geometry pass in this file reads a component's authored +// position as a WORLD coordinate. That holds for the flat hierarchies these +// specs use — almost everything hangs off the root — but not for a part under a +// transformed parent: a balcony baluster parented to a slab at y 0.35 has a +// local y of 0.15, and treating that as world puts it underground. Reading a +// wrong box is bad enough when a pass only reports; a pass that MOVES parts on +// that basis will drag a correctly-placed part somewhere wrong. +// +// So a component only qualifies if every ancestor up to the root is neutral. +// Anything under a real transform is skipped and left to the interpreter's own +// solvers, which work in true world space. +export function hasNeutralAncestry(c: any, byId: Map): boolean { + let seen = new Set(); + let cur = c; + while (cur?.parentId != null) { + let id = String(cur.parentId); + if (seen.has(id)) return false; // parentId cycle — not analysable + seen.add(id); + let parent = byId.get(id); + if (!parent) return true; // unknown parent resolves to root in the builder + let pos = Array.isArray(parent.position) ? parent.position : [0, 0, 0]; + let scl = Array.isArray(parent.scale) ? parent.scale : [1, 1, 1]; + let rot = Array.isArray(parent.rotation) ? parent.rotation : [0, 0, 0]; + let neutral = + pos.every((n: any) => Math.abs(Number(n) || 0) < 0.001) && + scl.every((n: any) => Math.abs((Number(n) || 1) - 1) < 0.001) && + rot.every((n: any) => Math.abs(Number(n) || 0) < 0.001); + if (!neutral) return false; + cur = parent; + } + return true; +} + +// Half extents [hx, hy, hz] of one component from its authored dimensions, +// scale AND rotation — the spec-side equivalent of a Box3, used by the +// constraint checks below. Only the primitives whose extents are unambiguous +// are described; anything else returns undefined and is left alone rather than +// guessed at. +// +// Rotation matters more than it looks. A wheel is a cylinder turned 90° about Z +// so its axle runs across the vehicle, which swaps its height and its radius: +// measured unrotated it is 0.45 tall, measured properly it is 1.0. Reading the +// unrotated box made every check downstream wrong for rotated parts — the +// attachment solver "seated" a wheel using a box less than half its real height +// and pushed it 0.26 further up than it belonged. +export function halfExtents(c: any): [number, number, number] | undefined { + let local = localHalfExtents(c); + if (!local) return undefined; + let rot = Array.isArray(c?.rotation) ? c.rotation.map(Number) : []; + if (!rot.length || rot.every((r: number) => Math.abs(r || 0) < 0.01)) { + return local; + } + // rotate the box's eight corners and take the widest reach on each axis — + // exact for any Euler triple, not just the axis-aligned quarter turns + let [rx, ry, rz] = [rot[0] || 0, rot[1] || 0, rot[2] || 0]; + let cx = Math.cos(rx); + let sx = Math.sin(rx); + let cy = Math.cos(ry); + let sy = Math.sin(ry); + let cz = Math.cos(rz); + let sz = Math.sin(rz); + let out: [number, number, number] = [0, 0, 0]; + for (let ix of [-1, 1]) { + for (let iy of [-1, 1]) { + for (let iz of [-1, 1]) { + let x = ix * local[0]; + let y = iy * local[1]; + let z = iz * local[2]; + // three.js default Euler order XYZ + let y1 = y * cx - z * sx; + let z1 = y * sx + z * cx; + let x2 = x * cy + z1 * sy; + let z2 = -x * sy + z1 * cy; + let x3 = x2 * cz - y1 * sz; + let y3 = x2 * sz + y1 * cz; + out[0] = Math.max(out[0], Math.abs(x3)); + out[1] = Math.max(out[1], Math.abs(y3)); + out[2] = Math.max(out[2], Math.abs(z2)); + } + } + } + return out; +} + +// Where a primitive's geometry sits relative to its node origin. Almost +// everything is centred, but the extruded shapes are centred in Z only — their +// outline keeps whatever X/Y the author wrote — so a traced hull's box is offset +// from its position. Ignoring that made every check skip or mis-measure exactly +// the part that matters most on a traced vehicle: its body. +export function localCentreOffset(c: any): [number, number, number] { + if (c?.primitive !== 'extrudedPolygon' && c?.primitive !== 'extrudedSpline') { + return [0, 0, 0]; + } + let raw = Array.isArray(c.dimensions) + ? c.dimensions + : (() => { + try { + let v = JSON.parse(String(c.dimensions ?? '[]')); + return Array.isArray(v) ? v : []; + } catch { + return []; + } + })(); + let pts = raw + .slice(1) + .map(Number) + .filter((n: number) => !isNaN(n)); + if (pts.length < 6) return [0, 0, 0]; + let xs: number[] = []; + let ys: number[] = []; + for (let i = 0; i + 1 < pts.length; i += 2) { + xs.push(pts[i]); + ys.push(pts[i + 1]); + } + let s = Array.isArray(c.scale) ? c.scale.map(Number) : [1, 1, 1]; + return [ + ((Math.min(...xs) + Math.max(...xs)) / 2) * (s[0] || 1), + ((Math.min(...ys) + Math.max(...ys)) / 2) * (s[1] || 1), + 0, + ]; +} + +// The component's world-ish axis-aligned box: position, plus the geometry's own +// offset from its origin, plus its rotated half extents. Every pass that reasons +// about where a part IS should use this rather than position ± halfExtents. +export function specBox(c: any): { min: number[]; max: number[] } | undefined { + let h = halfExtents(c); + if (!h) return undefined; + let p = Array.isArray(c?.position) ? c.position.map(Number) : [0, 0, 0]; + let o = localCentreOffset(c); + // the offset is expressed in the node's own frame, so a rotated part carries it + // round with the geometry + let rot = Array.isArray(c?.rotation) ? c.rotation.map(Number) : []; + if (rot.length && rot.some((r: number) => Math.abs(r || 0) > 0.01)) { + let [rx, ry, rz] = [rot[0] || 0, rot[1] || 0, rot[2] || 0]; + let cx = Math.cos(rx); + let sx = Math.sin(rx); + let cy = Math.cos(ry); + let sy = Math.sin(ry); + let cz = Math.cos(rz); + let sz = Math.sin(rz); + let y1 = o[1] * cx - o[2] * sx; + let z1 = o[1] * sx + o[2] * cx; + let x2 = o[0] * cy + z1 * sy; + let z2 = -o[0] * sy + z1 * cy; + o = [x2 * cz - y1 * sz, x2 * sz + y1 * cz, z2]; + } + let centre = [0, 1, 2].map((a) => (p[a] || 0) + o[a]); + return { + min: [0, 1, 2].map((a) => centre[a] - h[a]), + max: [0, 1, 2].map((a) => centre[a] + h[a]), + }; +} + +export function localHalfExtents(c: any): [number, number, number] | undefined { + let d = Array.isArray(c?.dimensions) + ? c.dimensions.map(Number) + : (() => { + try { + let v = JSON.parse(String(c?.dimensions ?? '[]')); + return Array.isArray(v) ? v.map(Number) : []; + } catch { + return []; + } + })(); + let s = Array.isArray(c?.scale) ? c.scale.map(Number) : [1, 1, 1]; + let [sx, sy, sz] = [ + Math.abs(s[0] || 1), + Math.abs(s[1] || 1), + Math.abs(s[2] || 1), + ]; + let abs = (n: any) => Math.abs(Number(n) || 0); + switch (c?.primitive) { + case 'box': + case 'roundedBox': + return [(abs(d[0]) / 2) * sx, (abs(d[1]) / 2) * sy, (abs(d[2]) / 2) * sz]; + case 'prism': + // [lengthAlongRidge, span, height] — ridge along X + return [(abs(d[0]) / 2) * sx, (abs(d[2]) / 2) * sy, (abs(d[1]) / 2) * sz]; + case 'cylinder': + return [ + Math.max(abs(d[0]), abs(d[1])) * sx, + (abs(d[2]) / 2) * sy, + Math.max(abs(d[0]), abs(d[1])) * sz, + ]; + case 'cone': { + // the engine squares up a 4-segment cone, so its footprint is the + // face-to-face width (radius x sqrt2), not the corner-to-corner radius + let segments = Math.max(3, Math.round(abs(d[2]) || 24)); + let r = abs(d[0]) * (segments === 4 ? Math.SQRT1_2 * Math.SQRT2 : 1); + let half = segments === 4 ? abs(d[0]) * Math.SQRT1_2 : r; + return [half * sx, (abs(d[1]) / 2) * sy, half * sz]; + } + case 'sphere': + case 'rock': + case 'blob': + return [abs(d[0]) * sx, abs(d[0]) * sy, abs(d[0]) * sz]; + case 'capsule': + // [radius, cylinderLength] — length along Y plus a cap at each end + return [abs(d[0]) * sx, (abs(d[1]) / 2 + abs(d[0])) * sy, abs(d[0]) * sz]; + case 'torus': + // [radius, tube] — lying flat, so the hole faces up + return [ + (abs(d[0]) + abs(d[1])) * sx, + abs(d[1]) * sy, + (abs(d[0]) + abs(d[1])) * sz, + ]; + case 'disc': + return [abs(d[0]) * sx, abs(d[0]) * sy, 0]; + case 'plane': + return [(abs(d[0]) / 2) * sx, (abs(d[1]) / 2) * sy, 0]; + case 'roundedPlate': + return [(abs(d[0]) / 2) * sx, (abs(d[1]) / 2) * sy, (abs(d[2]) / 2) * sz]; + case 'flatRing': + // [outerRx, outerRy, ringWidth, depth] + return [abs(d[0]) * sx, abs(d[1]) * sy, (abs(d[3]) / 2) * sz]; + case 'extrudedPolygon': + case 'extrudedSpline': { + // [depth, x0,y0, x1,y1, …] — the interpreter centres these in Z only, so + // the outline keeps the author's own X/Y coordinates and the geometry is + // NOT centred on its origin. specBox() carries the offset; the half + // extents here are the outline's own half width and height. + let pts = d + .slice(1) + .map(Number) + .filter((n: number) => !isNaN(n)); + if (pts.length < 6) return undefined; + let xs: number[] = []; + let ys: number[] = []; + for (let i = 0; i + 1 < pts.length; i += 2) { + xs.push(pts[i]); + ys.push(pts[i + 1]); + } + return [ + ((Math.max(...xs) - Math.min(...xs)) / 2) * sx, + ((Math.max(...ys) - Math.min(...ys)) / 2) * sy, + (abs(d[0]) / 2) * sz, + ]; + } + default: + // lathe / hemisphere / arch / tube / extruded* are NOT centred on their + // origin — their geometry sits wherever their profile or point list puts + // it — so a half-extent triple cannot describe them and callers must treat + // them as unmeasurable rather than guess. + return undefined; + } +} diff --git a/4376bf-img-to-3d-generator/util/spec-io.gts b/4376bf-img-to-3d-generator/util/spec-io.gts new file mode 100644 index 00000000..78855803 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-io.gts @@ -0,0 +1,230 @@ +// The boundary between a model's text and the spec the builders consume: +// parsing each stage's JSON reply, serializing an existing spec back into +// something a model can read, and materializing a parsed reply into a +// SculptSpecField. + +import { + SculptSpecField, + MaterialSpecField, + ComponentNodeField, +} from '../fields/sculpt-spec'; + +// compact plain-JSON view of the current spec for the refine prompt +export function serializeSpecForPrompt(spec: any) { + if (!spec) return {}; + let arr = (value: unknown) => { + if (Array.isArray(value)) return [...value]; + try { + return JSON.parse(typeof value === 'string' ? value : '[]'); + } catch { + return value; + } + }; + return { + objectName: spec.objectName, + inputKind: spec.inputKind, + identityFeatures: spec.identityFeatures ?? [], + objectClass: spec.objectClass, + buildBackend: spec.buildBackend, + complexity: spec.complexity, + materials: (spec.materials ?? []).map((m: any) => ({ + materialId: m.materialId, + baseColor: m.baseColor, + roughness: m.roughness, + metalness: m.metalness, + opacity: m.opacity, + emissive: m.emissive, + ...(typeof m.emissiveIntensity === 'number' + ? { emissiveIntensity: m.emissiveIntensity } + : {}), + ...(typeof m.clearcoat === 'number' ? { clearcoat: m.clearcoat } : {}), + ...(typeof m.sheen === 'number' ? { sheen: m.sheen } : {}), + ...(typeof m.transmission === 'number' + ? { transmission: m.transmission } + : {}), + ...(m.finish ? { finish: m.finish } : {}), + })), + components: (spec.components ?? []).map((c: any) => ({ + nodeId: c.nodeId, + parentId: c.parentId, + primitive: c.primitive, + dimensions: arr(c.dimensions), + position: arr(c.position), + rotation: arr(c.rotation), + scale: arr(c.scale), + materialId: c.materialId, + ...(c.text ? { text: c.text } : {}), + ...(c.partRef ? { partRef: c.partRef } : {}), + ...(c.textureRef ? { textureRef: c.textureRef } : {}), + ...(c.textureUrl ? { textureUrl: c.textureUrl } : {}), + // repeat is a {count, mode, offset|radius, axis} OBJECT — pass it + // through verbatim. Running it through arr() (a coordinate-array + // normalizer) coerced the object to [], silently destroying every + // repeat/mirror system on each refine round (applySpecDiff serializes + // through here), collapsing 6 wheels to one, blade rows to one, etc. + ...(c.repeat ? { repeat: c.repeat } : {}), + ...(c.assetUrl ? { assetUrl: c.assetUrl } : {}), + ...(c.attachTo ? { attachTo: c.attachTo } : {}), + ...(c.anchor && typeof c.anchor === 'object' ? { anchor: c.anchor } : {}), + ...(c.grounded === true ? { grounded: true } : {}), + note: c.note, + })), + }; +} + +// extracts and parses the spec JSON out of a model response, tolerating +// markdown fences and trailing commas +export function parseSpecJson(raw: string) { + let text = raw.trim(); + let fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fence) text = fence[1].trim(); + let start = text.indexOf('{'); + let end = text.lastIndexOf('}'); + if (start === -1 || end <= start) { + throw new Error('the model did not return JSON — try again'); + } + let body = text.slice(start, end + 1); + let parsed; + try { + parsed = JSON.parse(body); + } catch { + // second chance: strip trailing commas, a common LLM slip + parsed = JSON.parse(body.replace(/,\s*([}\]])/g, '$1')); + } + if (!Array.isArray(parsed.components) || parsed.components.length === 0) { + throw new Error('spec has no components — try again'); + } + parsed.materials = Array.isArray(parsed.materials) ? parsed.materials : []; + return parsed; +} + +// parses the analysis-stage reply (objectType/partPlan/buildRecipe) — stage 1 +// of the v2 pipeline; its recipe is injected into the build request +export function parseAnalysisJson(raw: string) { + let text = raw.trim(); + let fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fence) text = fence[1].trim(); + let start = text.indexOf('{'); + let end = text.lastIndexOf('}'); + if (start === -1 || end <= start) { + throw new Error('the model did not return JSON — try again'); + } + let body = text.slice(start, end + 1); + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = JSON.parse(body.replace(/,\s*([}\]])/g, '$1')); + } + if (!Array.isArray(parsed.partPlan) || parsed.partPlan.length === 0) { + throw new Error('analysis has no part plan — try again'); + } + parsed.buildRecipe = Array.isArray(parsed.buildRecipe) + ? parsed.buildRecipe + : []; + parsed.identityFeatures = Array.isArray(parsed.identityFeatures) + ? parsed.identityFeatures + : []; + parsed.attachments = Array.isArray(parsed.attachments) + ? parsed.attachments + : []; + // the build directives stage 1 nominates; absent on analyses cached before + // the field existed, where the plan's own structure supplies them instead + parsed.directives = Array.isArray(parsed.directives) ? parsed.directives : []; + if ( + typeof parsed.camera?.azimuthDeg !== 'number' || + typeof parsed.camera?.elevationDeg !== 'number' + ) { + parsed.camera = undefined; + } + return parsed; +} + +// parses a refine change-set reply (critique/score/featureCheck + changed/ +// removed arrays; all arrays may legitimately be empty) +export function parseDiffJson(raw: string) { + let text = raw.trim(); + let fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fence) text = fence[1].trim(); + let start = text.indexOf('{'); + let end = text.lastIndexOf('}'); + if (start === -1 || end <= start) { + throw new Error('the model did not return JSON — try again'); + } + let body = text.slice(start, end + 1); + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = JSON.parse(body.replace(/,\s*([}\]])/g, '$1')); + } + parsed.changed = Array.isArray(parsed.changed) ? parsed.changed : []; + parsed.removedNodeIds = Array.isArray(parsed.removedNodeIds) + ? parsed.removedNodeIds + : []; + parsed.materialsChanged = Array.isArray(parsed.materialsChanged) + ? parsed.materialsChanged + : []; + parsed.added = Array.isArray(parsed.added) ? parsed.added : []; + return parsed; +} + +// materializes a parsed response into a SculptSpecField +export function specFieldFromParsed(parsed: any): SculptSpecField { + return new SculptSpecField({ + objectName: parsed.objectName || 'Untitled object', + inputKind: parsed.inputKind === 'flat-graphic' ? 'flat-graphic' : 'object', + identityFeatures: Array.isArray(parsed.identityFeatures) + ? parsed.identityFeatures.map((f: any) => String(f)).slice(0, 6) + : [], + objectClass: parsed.objectClass, + buildBackend: parsed.buildBackend, + complexity: parsed.complexity, + materials: parsed.materials.map( + (m: any) => + new MaterialSpecField({ + materialId: String(m.materialId ?? ''), + baseColor: m.baseColor, + roughness: m.roughness, + metalness: m.metalness, + opacity: m.opacity, + emissive: m.emissive, + emissiveIntensity: + typeof m.emissiveIntensity === 'number' + ? m.emissiveIntensity + : null, + clearcoat: typeof m.clearcoat === 'number' ? m.clearcoat : null, + sheen: typeof m.sheen === 'number' ? m.sheen : null, + transmission: + typeof m.transmission === 'number' ? m.transmission : null, + finish: m.finish ? String(m.finish) : null, + }), + ), + components: parsed.components.map( + (c: any) => + new ComponentNodeField({ + nodeId: String(c.nodeId ?? ''), + parentId: String(c.parentId ?? ''), + primitive: c.primitive, + dimensions: JSON.stringify(c.dimensions ?? []), + position: JSON.stringify(c.position ?? [0, 0, 0]), + rotation: JSON.stringify(c.rotation ?? [0, 0, 0]), + scale: JSON.stringify(c.scale ?? [1, 1, 1]), + materialId: String(c.materialId ?? ''), + text: c.text ? String(c.text) : null, + partRef: c.partRef ? String(c.partRef) : null, + textureRef: c.textureRef ? String(c.textureRef) : null, + textureUrl: c.textureUrl ? String(c.textureUrl) : null, + repeat: + c.repeat && typeof c.repeat === 'object' + ? JSON.stringify(c.repeat) + : typeof c.repeat === 'string' + ? c.repeat + : null, + assetUrl: c.assetUrl ? String(c.assetUrl) : null, + attachTo: c.attachTo ? String(c.attachTo) : null, + note: String(c.note ?? ''), + }), + ), + }); +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/attachments.gts b/4376bf-img-to-3d-generator/util/spec-passes/attachments.gts new file mode 100644 index 00000000..bddee254 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/attachments.gts @@ -0,0 +1,683 @@ +// Making the assembly graph true. +// +// The analysis states which part mounts on which, and that list is the only +// thing standing between a model and a pile of parts floating near each other. +// These passes enforce it numerically rather than trusting the spec to have +// honoured it, repair joints that point at the wrong sibling, and seat decals +// against the curved surfaces they are printed on. + +import { hasNeutralAncestry, halfExtents, specBox } from '../spec-geometry'; +import { SUPPORT_NAME } from './placement'; + +// The analysis writes an explicit attachments list — "lower hipped roof +// centered-above ground floor block", "balcony railing rests-on balcony +// platform" — and the spec prompt calls each line a hard joint that "must be +// numerically true in the final coordinates". Nothing checked that. The +// interpreter pulls declared attachTo joints into CONTACT, but contact is not +// the constraint: a roof touching the corner of the storey below satisfies +// contact while sitting nowhere near centred above it, which is how a house +// arrives as a stack of offset slabs. +// +// This is the projection-free half of that problem, and the important half for +// a tilted reference: it needs no camera model at all, only the spec's own +// coordinates. Two constraints are REPAIRED because their correction is +// unambiguous — centered-above (align horizontally, sit on top) and rests-on +// (sit on top). The rest are reported only: "attached-right" and "inset-into" +// depend on which way the object faces, and a wrong guess there moves a part +// further from the truth than leaving it. +export function enforceAttachments(parsed: any, analysis: any): string[] { + let logs: string[] = []; + let lines: any[] = analysis?.attachments ?? []; + let components: any[] = parsed?.components ?? []; + if (!lines.length || !components.length) return logs; + const CONSTRAINTS = [ + 'centered-above', + 'flush-top', + 'attached-left', + 'attached-right', + 'attached-front', + 'attached-back', + 'inset-into', + 'rests-on', + ]; + let key = (s: any) => + String(s ?? '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + // every component realizing a given plan part, so a multi-piece part moves + // together and keeps its internal arrangement + let byRef = new Map(); + for (let c of components) { + if (!c?.nodeId || c.primitive === 'group') continue; + let k = key(c.partRef); + if (!k) continue; + if (!byRef.has(k)) byRef.set(k, []); + byRef.get(k)!.push(c); + } + // world-ish AABB of a part group. Like clampToEnvelope, this reads authored + // positions as world coordinates — true for the flat hierarchies these specs + // use, and any part under a transformed parent is skipped below. + let boxOf = (group: any[]) => { + let min = [Infinity, Infinity, Infinity]; + let max = [-Infinity, -Infinity, -Infinity]; + for (let c of group) { + let box = specBox(c); + if (!box) return undefined; + for (let a = 0; a < 3; a++) { + min[a] = Math.min(min[a], box.min[a]); + max[a] = Math.max(max[a], box.max[a]); + } + } + return min[0] === Infinity ? undefined : { min, max }; + }; + let byId = new Map(components.map((c: any) => [c.nodeId, c])); + let parentIsNeutral = (group: any[]) => + group.every((c) => hasNeutralAncestry(c, byId)); + const OVERLAP = 0.03; + + for (let raw of lines) { + let line = String(raw ?? '').trim(); + let constraint = CONSTRAINTS.find((c) => line.includes(c)); + if (!constraint) continue; + let [subjectName, targetName] = line.split(constraint).map((s) => s.trim()); + let subject = byRef.get(key(subjectName)); + let target = byRef.get(key(targetName)); + if (!subject?.length || !target?.length || subject === target) continue; + let sBox = boxOf(subject); + let tBox = boxOf(target); + if (!sBox || !tBox) continue; + + if (constraint !== 'centered-above' && constraint !== 'rests-on') { + // report-only, and the test has to match the constraint: an inset window + // is SUPPOSED to sit inside its wall, so measuring its vertical gap would + // report every correct window as broken. Noise here would bury the real + // findings, so each constraint is judged on its own terms. + if (constraint === 'inset-into') { + let protrusion = Math.max( + ...[0, 1, 2].map((a) => + Math.max(tBox.min[a] - sBox.min[a], sBox.max[a] - tBox.max[a]), + ), + ); + if (protrusion > 0.02) { + logs.push( + `'${subjectName}' should be inset-into '${targetName}' but protrudes ${protrusion.toFixed(2)} out of it`, + ); + } + } else if (constraint === 'flush-top') { + let off = Math.abs(sBox.max[1] - tBox.max[1]); + if (off > 0.05) { + logs.push( + `'${subjectName}' should be flush-top with '${targetName}' but their tops differ by ${off.toFixed(2)}`, + ); + } + } else { + // attached-left/right/front/back: the two must at least touch + let separation = Math.max( + ...[0, 1, 2].map((a) => + Math.max(sBox.min[a] - tBox.max[a], tBox.min[a] - sBox.max[a]), + ), + ); + if (separation > 0.05) { + logs.push( + `'${subjectName}' should be ${constraint} '${targetName}' but they are ${separation.toFixed(2)} apart`, + ); + } + } + continue; + } + if (!parentIsNeutral(subject)) continue; + + // DIRECTION SANITY. "rests-on" and "centered-above" both mean the subject + // sits on TOP of the target, so enforcing one on a part the spec placed + // BELOW its target does not correct a gap — it turns the object upside + // down. The analysis wrote "wheels rests-on main hull body" for a truck, + // inverting the real relationship (a hull rests on its wheels), and the + // wheels were duly lifted onto the roof. + // + // When the plan's direction and the authored coordinates disagree, the + // coordinates win: they were derived from measured bboxes, while the + // attachment line is prose about which part holds which. Report the + // conflict instead of acting on it. + // A SUPPORT never rests on the thing it carries. "wheels rests-on main hull + // body" is the inversion of the truth, and seating the wheels on the hull's + // roof is a far bigger error than leaving the line unenforced — so when the + // subject is a wheel/track/foot and the target is the larger body, the line + // is refused outright. groundSupports then puts the supports underneath + // from the coordinates the spec actually authored, which is why this must + // not "correct" them first. + let subjectVol = subject.reduce((sum, c) => { + let h = halfExtents(c); + return sum + (h ? 8 * h[0] * h[1] * h[2] : 0); + }, 0); + let targetVol = target.reduce((sum, c) => { + let h = halfExtents(c); + return sum + (h ? 8 * h[0] * h[1] * h[2] : 0); + }, 0); + let subjectIsSupport = subject.some( + (c) => + SUPPORT_NAME.test(String(c.partRef ?? '')) || + SUPPORT_NAME.test(String(c.nodeId ?? '')), + ); + if (subjectIsSupport && targetVol > subjectVol) { + logs.push( + `refused '${subjectName}' ${constraint} '${targetName}' — a support does not sit on the body it carries`, + ); + continue; + } + + let sCenterY = (sBox.min[1] + sBox.max[1]) / 2; + let tCenterY = (tBox.min[1] + tBox.max[1]) / 2; + if (sCenterY < tCenterY) { + logs.push( + `'${subjectName}' is ${constraint} '${targetName}' in the plan but sits BELOW it in the spec — left alone (the plan line looks inverted)`, + ); + continue; + } + + // sit on top of the target with a small overlap + let dy = tBox.max[1] - OVERLAP - sBox.min[1]; + let span = Math.max( + tBox.max[0] - tBox.min[0], + tBox.max[1] - tBox.min[1], + tBox.max[2] - tBox.min[2], + 0.001, + ); + // and, for centered-above, share its horizontal centre — but only when the + // part is ALREADY roughly centred. "centered-above" is the analysis' + // shorthand for "this storey stands on that one", and plenty of real houses + // set the upper floor back over an L-shaped plan. Snapping a deliberately + // offset wing to dead centre would enforce the plan's wording against the + // photograph's shape, so a large existing offset is taken as intentional + // and only the seating is corrected. + let dx = 0; + let dz = 0; + if (constraint === 'centered-above') { + let offX = + (tBox.min[0] + tBox.max[0]) / 2 - (sBox.min[0] + sBox.max[0]) / 2; + let offZ = + (tBox.min[2] + tBox.max[2]) / 2 - (sBox.min[2] + sBox.max[2]) / 2; + if (Math.hypot(offX, offZ) <= span * 0.25) { + dx = offX; + dz = offZ; + } else { + logs.push( + `kept '${subjectName}' off-centre over '${targetName}' (offset ${Math.hypot(offX, offZ).toFixed(2)} looks deliberate) — seated it only`, + ); + } + } + // a correction larger than the target itself means the two parts were never + // the pair the line describes — leave it and say so + if (Math.hypot(dx, dy, dz) > span) { + logs.push( + `'${subjectName}' is too far from '${targetName}' to be ${constraint} it — left in place`, + ); + continue; + } + if (Math.hypot(dx, dy, dz) < 0.005) continue; + for (let c of subject) { + let p = Array.isArray(c.position) ? c.position.map(Number) : [0, 0, 0]; + c.position = [ + Number((p[0] + dx).toFixed(4)), + Number((p[1] + dy).toFixed(4)), + Number((p[2] + dz).toFixed(4)), + ]; + } + logs.push( + `${constraint}: moved '${subjectName}' onto '${targetName}' ` + + `(Δ ${dx.toFixed(2)}, ${dy.toFixed(2)}, ${dz.toFixed(2)})`, + ); + } + return logs; +} + +// `attachTo` names the part that HOLDS this one up, and the builder's joint +// solver takes it literally: it pulls the subject until it overlaps that target +// by ~0.03. So a wrong attachTo does not merely fail to help, it actively drags +// geometry across the model. +// +// The failure that made this necessary: a spec set `wheel-right attachTo +// wheel-left`. Those are the two flanks of a six-wheeler, 1.30 apart in X, and +// the solver dutifully hauled the entire right-hand row over to touch the left — +// all six wheels ended up on one flank, at x -0.65 and -0.32, with the hull +// spanning -0.52 to 0.52 above them. The prompt is partly to blame: it requires +// every non-group part to declare an attachTo, so a model that has made one wheel +// the first part hangs everything off it, the opposite wheel row included. +// +// Two mirrored instances of one feature do not support each other — the body +// between them does. Recognising them needs no naming convention: they have the +// same primitive and the same dimensions (the family test the collision check +// already uses) and they sit on opposite sides of the object's centre. Where the +// target itself declares a support, the attachment is re-pointed at that instead +// of just dropped, so the part keeps a joint to hold on to. +export function repairMirroredAttachments(parsed: any): string[] { + let logs: string[] = []; + let components: any[] = (parsed?.components ?? []).filter( + (c: any) => c?.nodeId && c.primitive !== 'group', + ); + if (components.length < 2) return logs; + let byId = new Map(components.map((c: any) => [c.nodeId, c])); + let sig = (c: any) => { + let h = halfExtents(c); + return h ? `${c.primitive}|${h.map((n) => n.toFixed(3)).join(',')}` : ''; + }; + let boxes = new Map(); + for (let c of components) { + let b = specBox(c); + if (b) boxes.set(c.nodeId, b); + } + if (!boxes.size) return logs; + let centre = [0, 1, 2].map((a) => { + let lo = Math.min(...[...boxes.values()].map((b) => b.min[a])); + let hi = Math.max(...[...boxes.values()].map((b) => b.max[a])); + return (lo + hi) / 2; + }); + + for (let c of components) { + if (!c.attachTo) continue; + let target = byId.get(String(c.attachTo)); + if (!target) continue; + let mySig = sig(c); + if (!mySig || mySig !== sig(target)) continue; // not the same feature + let mine = boxes.get(c.nodeId); + let theirs = boxes.get(target.nodeId); + if (!mine || !theirs) continue; + // opposite sides of the object on some axis, and not touching: a mirrored + // pair. Two copies that already touch (a stacked rib, a chain link) are a + // real joint and must be left alone. + let opposed = [0, 1, 2].some((a) => { + let mc = (mine.min[a] + mine.max[a]) / 2; + let tc = (theirs.min[a] + theirs.max[a]) / 2; + let apart = mine.min[a] > theirs.max[a] || theirs.min[a] > mine.max[a]; + return apart && (mc - centre[a]) * (tc - centre[a]) < 0; + }); + if (!opposed) continue; + let inherited = target.attachTo ? String(target.attachTo) : null; + if (inherited && inherited !== c.nodeId) { + c.attachTo = inherited; + logs.push( + `re-pointed '${c.nodeId}' from its mirror '${target.nodeId}' to '${inherited}' — opposite instances of one part do not hold each other up`, + ); + } else { + delete c.attachTo; + logs.push( + `dropped '${c.nodeId}' attachTo '${target.nodeId}' — they are the same part on opposite sides, and the joint solver would drag one across to the other`, + ); + } + } + return logs; +} + +// specFieldFromParsed consumes — refine rounds rebuild only what changed +// deterministic decal fitting (the lateral cousin of gravity snap): a +// curvedDecal must hug the body it attaches to, but the model's authored +// radius is only probabilistically right — a too-large radius reads as a +// label floating beside the bottle. Snap each curvedDecal's radius to its +// attachTo host's real radius (+0.01 skin gap) and keep its height inside +// the host. Mutates the parsed spec; returns log lines for the studio. +export function fitCurvedDecals(parsed: any): string[] { + let logs: string[] = []; + let components: any[] = parsed?.components ?? []; + let byId = new Map(components.map((c: any) => [c.nodeId, c])); + let nums = (raw: any): number[] => + Array.isArray(raw) ? raw : typeof raw === 'string' ? JSON.parse(raw) : []; + for (let decal of components) { + if (decal?.primitive !== 'curvedDecal' || !decal.attachTo) continue; + let host: any = byId.get(decal.attachTo); + // a decal may attachTo ANOTHER decal (e.g. an illustration wrapped on a + // label) — walk up the attachTo chain to the first solid body so the wrap + // is fitted against real geometry instead of a zero-thickness decal that + // has no radius of its own (which leaves it floating at its authored size) + let hostGuard = 0; + while ( + host && + (host.primitive === 'curvedDecal' || host.primitive === 'textDecal') && + host.attachTo && + hostGuard++ < 8 + ) { + host = byId.get(host.attachTo); + } + if (!host) continue; + let d: number[]; + let hostDims: number[]; + let hostScale: number[]; + let decalPos: number[]; + let hostPos: number[]; + try { + d = nums(decal.dimensions); + hostDims = nums(host.dimensions); + hostScale = nums(host.scale); + decalPos = nums(decal.position); + hostPos = nums(host.position); + } catch { + continue; + } + let sx = Math.abs(hostScale?.[0] || 1); + let sz = Math.abs(hostScale?.[2] || 1); + let sy = Math.abs(hostScale?.[1] || 1); + + // a wrap-around label lives ON the body's axis — models often author it + // offset forward like a flat sticker, which floats the whole shell in + // front of the bottle. Snap the decal's x/z onto the host axis. + let hx = hostPos[0] ?? 0; + let hz = hostPos[2] ?? 0; + if ( + Math.abs((decalPos[0] ?? 0) - hx) > 0.001 || + Math.abs((decalPos[2] ?? 0) - hz) > 0.001 + ) { + decalPos[0] = hx; + decalPos[2] = hz; + decal.position = decalPos; + logs.push(`centered '${decal.nodeId}' on its body axis`); + } + + // radius at the DECAL'S OWN HEIGHT — a bottle is thinner at the label + // band than at its widest bulge, so a global max over-sizes the wrap + let decalH = Math.abs(d[1] ?? 0.6); + let localY = ((decalPos[1] ?? 0) - (hostPos[1] ?? 0)) / (sy || 1); + let bandLo = localY - decalH / 2 / (sy || 1); + let bandHi = localY + decalH / 2 / (sy || 1); + let radius: number | undefined; + let height: number | undefined; + switch (host.primitive) { + case 'cylinder': { + let rTop = Math.abs(hostDims[0] ?? 0.5); + let rBottom = Math.abs(hostDims[1] ?? hostDims[0] ?? 0.5); + radius = Math.max(rTop, rBottom); + height = Math.abs(hostDims[2] ?? 1) * sy; + break; + } + case 'capsule': + radius = Math.abs(hostDims[0] ?? 0.3); + height = (Math.abs(hostDims[1] ?? 0.6) + 2 * radius) * sy; + break; + case 'sphere': + case 'hemisphere': + case 'blob': + radius = Math.abs(hostDims[0] ?? 0.5); + break; + case 'lathe': { + // profile is [x0,y0, x1,y1, ...] — sample the wall's half-width + // where the decal actually sits, interpolating along each segment + let pts: { x: number; y: number }[] = []; + for (let i = 0; i + 1 < hostDims.length; i += 2) { + pts.push({ x: Math.max(0, hostDims[i]), y: hostDims[i + 1] }); + } + if (pts.length >= 2) { + let bandMax = 0; + for (let i = 0; i + 1 < pts.length; i++) { + let a = pts[i]; + let b = pts[i + 1]; + let lo = Math.min(a.y, b.y); + let hi = Math.max(a.y, b.y); + if (hi < bandLo || lo > bandHi) continue; + let xAt = (y: number) => + hi === lo + ? Math.max(a.x, b.x) + : a.x + ((b.x - a.x) * (y - a.y)) / (b.y - a.y); + let edgeLo = Math.max(lo, bandLo); + let edgeHi = Math.min(hi, bandHi); + bandMax = Math.max(bandMax, xAt(edgeLo), xAt(edgeHi)); + for (let p of [a, b]) { + if (p.y >= bandLo && p.y <= bandHi) + bandMax = Math.max(bandMax, p.x); + } + } + // decal band outside the profile → fall back to the global max + radius = bandMax > 0 ? bandMax : Math.max(...pts.map((p) => p.x)); + let ys = pts.map((p) => p.y); + height = (Math.max(...ys) - Math.min(...ys)) * sy; + } + break; + } + default: + continue; // box-like hosts: a curved label on a flat body is the + // model's own mistake — leave it visible so refine reports it + } + if (!radius || !isFinite(radius)) continue; + let fitted = radius * Math.max(sx, sz) + 0.01; + let authored = Math.abs(d[0] ?? 0.5); + if (Math.abs(authored - fitted) > 0.005) { + d[0] = Number(fitted.toFixed(4)); + logs.push( + `fitted '${decal.nodeId}' radius to its ${host.primitive} body (${authored} → ${d[0]})`, + ); + } + if (height && isFinite(height)) { + let maxH = height * 0.85; + if (Math.abs(d[1] ?? 0.6) > maxH) { + d[1] = Number(maxH.toFixed(4)); + logs.push(`clamped '${decal.nodeId}' height inside its body`); + } + // a wrap label centered beyond its host's vertical span floats above + // or below the body — clamp its center into the host band + let hostLoY = (hostPos[1] ?? 0) - height / 2; + let hostHiY = (hostPos[1] ?? 0) + height / 2; + if (host.primitive === 'lathe') { + let ys: number[] = []; + for (let i = 1; i < hostDims.length; i += 2) ys.push(hostDims[i]); + if (ys.length) { + hostLoY = (hostPos[1] ?? 0) + Math.min(...ys) * sy; + hostHiY = (hostPos[1] ?? 0) + Math.max(...ys) * sy; + } + } + let half = Math.abs(d[1] ?? 0.6) / 2; + let minC = hostLoY + half; + let maxC = hostHiY - half; + if (minC <= maxC) { + let clamped = Math.min(maxC, Math.max(minC, decalPos[1] ?? 0)); + if (Math.abs(clamped - (decalPos[1] ?? 0)) > 0.005) { + decalPos[1] = Number(clamped.toFixed(4)); + decal.position = decalPos; + logs.push(`pulled '${decal.nodeId}' into its body's band`); + } + } + } + decal.dimensions = d; + } + + // cylinders riding a lathe body (foil capsules, caps, collars) must stay + // proportionate to the wall they sit on — analysis bboxes overestimate + // them, which grows a monster cap on a slender neck + for (let part of components) { + if (part?.primitive !== 'cylinder' || !part.attachTo) continue; + let host: any = byId.get(part.attachTo); + // follow one hop: capsule → lower capsule → lathe + if (host?.primitive === 'cylinder' && host.attachTo) { + host = byId.get(host.attachTo) ?? host; + } + if (host?.primitive !== 'lathe') continue; + let d: number[]; + let hostDims: number[]; + let partPos: number[]; + let hostPos: number[]; + try { + d = nums(part.dimensions); + hostDims = nums(host.dimensions); + partPos = nums(part.position); + hostPos = nums(host.position); + } catch { + continue; + } + let pts: { x: number; y: number }[] = []; + for (let i = 0; i + 1 < hostDims.length; i += 2) { + pts.push({ x: Math.max(0, hostDims[i]), y: hostDims[i + 1] }); + } + if (pts.length < 2) continue; + let cylH = Math.abs(d[2] ?? 1); + let localY = (partPos[1] ?? 0) - (hostPos[1] ?? 0); + let bandLo = localY - cylH / 2; + let bandHi = localY + cylH / 2; + let wallR = 0; + for (let i = 0; i + 1 < pts.length; i++) { + let a = pts[i]; + let b = pts[i + 1]; + let lo = Math.min(a.y, b.y); + let hi = Math.max(a.y, b.y); + if (hi < bandLo || lo > bandHi) continue; + wallR = Math.max(wallR, a.x, b.x); + } + // band above the profile top (a cap ON the mouth): size to the topmost + // profile radius instead + if (!(wallR > 0)) wallR = pts[pts.length - 1].x || pts[pts.length - 2].x; + if (!(wallR > 0)) continue; + let maxR = wallR * 1.3; + let r0 = Math.abs(d[0] ?? 0.5); + let r1 = Math.abs(d[1] ?? d[0] ?? 0.5); + let biggest = Math.max(r0, r1); + if (biggest > maxR) { + let shrink = maxR / biggest; + d[0] = Number((r0 * shrink).toFixed(4)); + d[1] = Number((r1 * shrink).toFixed(4)); + part.dimensions = d; + logs.push( + `slimmed '${part.nodeId}' to its neck wall (×${shrink.toFixed(2)})`, + ); + } + } + return logs; +} + +// A ring that WRAPS a barrel — a gun's muzzle collar, a barrel clamp, a hose +// ferrule — is a torus/flatRing/arch whose hole faces ALONG the barrel's axis +// and whose centre sits on the barrel's centreline. Models routinely author it +// as an upright hoop standing beside the barrel instead: the general torus +// orientation prose in spec-shape.gts asks for the right rotation, but "asks" +// is not "guarantees" (a minigun came back with six hoops dangling under its +// barrels). This is the ring cousin of fitCurvedDecals — the axis and the +// centre come from the host, so nothing is left to a probabilistic guess. +// +// The host barrel's axis is its local +Y rotated by its own Euler triple (the +// same XYZ-order corner math halfExtents uses). The ring is then snapped to the +// NEAREST canonical axis so its hole wraps the barrel — axis ±Z → [0,0,0], +// ±X → [0,π/2,0], ±Y → [-π/2,0,0] — which covers every real barrel orientation +// without composing rotations (these passes carry no THREE), and a rare diagonal +// barrel snaps to its dominant axis, still far better than a perpendicular hoop. +export function seatRingCollars(parsed: any): string[] { + let logs: string[] = []; + let components: any[] = parsed?.components ?? []; + let byId = new Map(components.map((c: any) => [c.nodeId, c])); + let nums = (raw: any): number[] => + Array.isArray(raw) ? raw : typeof raw === 'string' ? JSON.parse(raw) : []; + const HALF_PI = Math.PI / 2; + + for (let ring of components) { + if ( + ring?.primitive !== 'torus' && + ring?.primitive !== 'flatRing' && + ring?.primitive !== 'arch' + ) { + continue; + } + if (!ring.attachTo) continue; + let host: any = byId.get(ring.attachTo); + if (host?.primitive !== 'cylinder' && host?.primitive !== 'capsule') { + continue; + } + let d: number[]; + let hostDims: number[]; + let hostScale: number[]; + let hostRot: number[]; + let ringPos: number[]; + let hostPos: number[]; + try { + d = nums(ring.dimensions); + hostDims = nums(host.dimensions); + hostScale = nums(host.scale); + hostRot = nums(host.rotation); + ringPos = nums(ring.position); + hostPos = nums(host.position); + } catch { + continue; + } + + // host barrel axis = local +Y rotated by the host's Euler XYZ triple + let [rx, ry, rz] = [hostRot[0] || 0, hostRot[1] || 0, hostRot[2] || 0]; + let cx = Math.cos(rx); + let sx = Math.sin(rx); + let cy = Math.cos(ry); + let sy = Math.sin(ry); + let cz = Math.cos(rz); + let sz = Math.sin(rz); + let axis = [sx * sy * cz - cx * sz, sx * sy * sz + cx * cz, sx * cy]; + // the world axis (0=x,1=y,2=z) the barrel most points along + let ax = 0; + for (let i = 1; i < 3; i++) { + if (Math.abs(axis[i]) > Math.abs(axis[ax])) ax = i; + } + + // ---- orient: lay the ring's hole along the barrel ---- + let target = + ax === 1 ? [-HALF_PI, 0, 0] : ax === 0 ? [0, HALF_PI, 0] : [0, 0, 0]; + let ringRot = nums(ring.rotation); + let already = + Math.abs((ringRot[0] || 0) - target[0]) < 0.02 && + Math.abs((ringRot[1] || 0) - target[1]) < 0.02 && + Math.abs((ringRot[2] || 0) - target[2]) < 0.02; + if (!already) { + ring.rotation = target; + logs.push( + `oriented '${ring.nodeId}' to wrap the ${'xyz'[ax]}-axis barrel '${host.nodeId}'`, + ); + } + + // ---- centre: share the host's centreline on the two off-axis axes, + // keep the ring's own position ALONG the barrel (it sits at the muzzle) ---- + let moved = false; + for (let i = 0; i < 3; i++) { + if (i === ax) continue; + if (Math.abs((ringPos[i] ?? 0) - (hostPos[i] ?? 0)) > 0.001) { + ringPos[i] = Number((hostPos[i] ?? 0).toFixed(4)); + moved = true; + } + } + if (moved) { + ring.position = ringPos; + logs.push(`centred '${ring.nodeId}' on the barrel axis`); + } + + // ---- radius: the hole hugs the barrel (+ a thin skin gap) ---- + let sScale = [ + Math.abs(hostScale[0] || 1), + Math.abs(hostScale[1] || 1), + Math.abs(hostScale[2] || 1), + ]; + let hostR: number; + if (host.primitive === 'cylinder') { + hostR = + Math.max(Math.abs(hostDims[0] ?? 0.5), Math.abs(hostDims[1] ?? 0.5)) * + Math.max(sScale[0], sScale[2]); + } else { + hostR = Math.abs(hostDims[0] ?? 0.3) * Math.max(sScale[0], sScale[2]); + } + if (!(hostR > 0)) continue; + const SKIN = 0.01; + if (ring.primitive === 'torus') { + // [radius, tube] — hole radius = radius - tube, so wrap ⇒ radius = R + tube + let tube = Math.abs(d[1] ?? 0.05); + let wrapR = hostR + tube + SKIN; + if (Math.abs(Math.abs(d[0] ?? 0) - wrapR) > 0.005) { + let before = d[0]; + d[0] = Number(wrapR.toFixed(4)); + ring.dimensions = d; + logs.push( + `fitted '${ring.nodeId}' around barrel radius (${before} → ${d[0]})`, + ); + } + } else if (ring.primitive === 'flatRing') { + // [outerRx, outerRy, ringWidth, depth] — hole = outer - width + let width = Math.abs(d[2] ?? 0.05); + let wrapR = hostR + width + SKIN; + if (Math.abs(Math.abs(d[0] ?? 0) - wrapR) > 0.005) { + let before = d[0]; + d[0] = Number(wrapR.toFixed(4)); + d[1] = Number(wrapR.toFixed(4)); + ring.dimensions = d; + logs.push( + `fitted '${ring.nodeId}' around barrel radius (${before} → ${d[0]})`, + ); + } + } + // arch: a partial ring — orient + centre only; its sweep/size is intentional + } + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/face.gts b/4376bf-img-to-3d-generator/util/spec-passes/face.gts new file mode 100644 index 00000000..ffde15df --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/face.gts @@ -0,0 +1,519 @@ +// Making the face sit where a face sits. +// +// A character is judged almost entirely on its face, and the face is the one +// assembly the other passes cannot help with: every feature can be planned, +// attached, unburied and inside the silhouette while the whole cluster sits on +// the side of the head. The canonical world frame fixes the front at -Z, so +// where the face belongs is not a judgement call — it is a direction the code +// can enforce. + +import { hasNeutralAncestry, halfExtents, specBox } from '../spec-geometry'; + +// the features that make up a face, minus ears — ears belong on the sides +export const FACE_FEATURE = + /\b(eye|eyes|eyeball|pupil|iris|brow|eyebrow|nose|nostril|mouth|lip|lips|tongue|tooth|teeth)\b/i; +// masses that hug the skull rather than sit proud of it: a muzzle, a snout, a +// bird's bill/beak, a colour patch. These are DESIGNED to have their centre +// near the head and sit on the facial midline. +export const FACE_MASS = + /\b(muzzle|snout|bill|beak|jaw|maw|face|mask|cheek|patch)\b/i; +// a beak/bill/snout/jaw IS an animal's nose AND mouth — so a face that has one +// already has both, and the backstop must not add a redundant nose or mouth. +const BEAK = /\b(bill|beak|snout|jaw|maw)\b/i; +const HEAD = /\b(head|skull)\b/i; +const EAR = /\bears?\b/i; + +function nameOf(c: any): string { + return `${c?.nodeId ?? ''} ${c?.partRef ?? ''} ${c?.note ?? ''}`; +} + +function centreOf(box: { min: number[]; max: number[] }): number[] { + return [0, 1, 2].map((a) => (box.min[a] + box.max[a]) / 2); +} + +// Aligns a character's facial features with the head, in three steps: +// +// 1. SIDE — the feature cluster's mean offset from the head centre says which +// way the authored face points. The canonical frame says it points -Z, so +// a cluster pointing anywhere else is rotated about the head's vertical +// axis until it does. The whole cluster turns together, so eyes, nose and +// mouth keep their arrangement — only the side of the head changes. +// 2. LANDMARKS — when the analysis measured face landmarks off the reference +// (normalized [x,y] inside the head part's bbox), each feature's height +// comes from its landmark and its depth is snapped onto the head's front +// surface. Heights survive any camera angle, which is why only y and z are +// snapped — a three-quarter view makes the landmark x unreliable. +// 3. EMBED — a muzzle / face patch bites INTO the skull (centre inside the +// head volume) or it renders as a separate lump floating in front of the +// face. Any face mass whose centre is outside is pulled in along its own +// centre-line. +// +// A vertical-order violation (nose above the eyes) is reported, not repaired: +// which feature is mislabeled is a question about the photograph. +export function alignFaceFeatures(parsed: any, analysis?: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let solid = (c: any) => + c?.nodeId && + c.primitive !== 'group' && + c.primitive !== 'glow' && + !c.repeat && + hasNeutralAncestry(c, byId); + + // the host: the largest head-named solid that is not itself a feature + let heads = all.filter( + (c: any) => + solid(c) && HEAD.test(nameOf(c)) && !FACE_FEATURE.test(nameOf(c)), + ); + let volume = (c: any) => { + let h = halfExtents(c); + return h ? h[0] * h[1] * h[2] : 0; + }; + let head = heads.sort((a: any, b: any) => volume(b) - volume(a))[0]; + if (!head) return logs; + let headBox = specBox(head); + let headHalf = halfExtents(head); + if (!headBox || !headHalf) return logs; + let headC = centreOf(headBox); + + let features = all.filter( + (c: any) => + solid(c) && + c !== head && + !EAR.test(nameOf(c)) && + (FACE_FEATURE.test(nameOf(c)) || FACE_MASS.test(nameOf(c))), + ); + if (!features.length) return logs; + + let boxOf = (c: any) => specBox(c); + let moveTo = (c: any, target: number[], axes: number[]) => { + let box = boxOf(c); + if (!box) return; + let cur = centreOf(box); + let p = Array.isArray(c.position) ? c.position.map(Number) : [0, 0, 0]; + for (let a of axes) { + p[a] = Number(((p[a] || 0) + target[a] - cur[a]).toFixed(4)); + } + c.position = p; + }; + + // ---- 1. side: rotate the cluster onto the front (-Z) ------------------- + let meanX = 0; + let meanZ = 0; + let measured = 0; + for (let c of features) { + let box = boxOf(c); + if (!box) continue; + let ctr = centreOf(box); + meanX += ctr[0] - headC[0]; + meanZ += ctr[2] - headC[2]; + measured++; + } + if (!measured) return logs; + meanX /= measured; + meanZ /= measured; + let mag = Math.hypot(meanX, meanZ); + // a cluster hugging the head's vertical axis has no direction to read + if (mag > 0.15 * Math.max(headHalf[0], headHalf[2])) { + // angle of the cluster off the -Z axis, positive toward +X + let theta = Math.atan2(meanX, -meanZ); + if (Math.abs(theta) > (20 * Math.PI) / 180) { + let cos = Math.cos(theta); + let sin = Math.sin(theta); + for (let c of features) { + let box = boxOf(c); + if (!box) continue; + let ctr = centreOf(box); + let dx = ctr[0] - headC[0]; + let dz = ctr[2] - headC[2]; + moveTo( + c, + [headC[0] + dx * cos + dz * sin, 0, headC[2] - dx * sin + dz * cos], + [0, 2], + ); + // keep the part facing the way it now points; approximate for parts + // that already carry pitch/roll, exact for the usual flat rotations + let r = Array.isArray(c.rotation) ? c.rotation.map(Number) : [0, 0, 0]; + if (r[0] || r[1] || r[2]) { + r[1] = Number(((r[1] || 0) + theta).toFixed(4)); + c.rotation = r; + } + } + logs.push( + `rotated the face cluster ${Math.round((theta * 180) / Math.PI)}° onto the front of '${head.nodeId}' — the face belongs at -Z`, + ); + } + } + + // ---- 2. landmarks: measured heights + front-surface depth -------------- + let planFace = (analysis?.partPlan ?? []).find( + (p: any) => + p?.face?.landmarks && + String(p?.part ?? '') + .trim() + .toLowerCase() === + String(head.partRef ?? '') + .trim() + .toLowerCase(), + )?.face; + let landmarks = planFace?.landmarks; + if (landmarks) { + let headH = headBox.max[1] - headBox.min[1]; + let frontZ = (x: number, y: number, ownHalfZ: number, bite: number) => { + let rel = + ((x - headC[0]) / (headHalf[0] || 1)) ** 2 + + ((y - headC[1]) / (headHalf[1] || 1)) ** 2; + let root = Math.sqrt(Math.max(0, 1 - Math.min(rel, 0.96))); + // centre on the surface (half proud), or bitten in for a face mass + return headC[2] - headHalf[2] * root + bite * ownHalfZ; + }; + let snap = ( + pattern: RegExp, + landmark: number[] | undefined, + label: string, + ) => { + if (!Array.isArray(landmark) || landmark.length < 2) return; + let group = features + .filter((c: any) => pattern.test(nameOf(c))) + .sort((a: any, b: any) => volume(b) - volume(a)); + let primary = group[0]; + if (!primary) return; + let box = boxOf(primary); + let h = halfExtents(primary); + if (!box || !h) return; + let ctr = centreOf(box); + let y = headBox.max[1] - Number(landmark[1]) * headH; + let target = [ctr[0], y, frontZ(ctr[0], y, h[2], 0)]; + let before = [...ctr]; + moveTo(primary, target, [1, 2]); + // whatever rides this feature moves with it (a pupil on its eye) + let delta = [0, y - before[1], target[2] - before[2]]; + for (let c of all) { + if (c === primary || !solid(c)) continue; + if ( + String(c.attachTo ?? '') === String(primary.nodeId) || + String(c.parentId ?? '') === String(primary.nodeId) + ) { + let p = Array.isArray(c.position) + ? c.position.map(Number) + : [0, 0, 0]; + c.position = [ + p[0], + Number((p[1] + delta[1]).toFixed(4)), + Number((p[2] + delta[2]).toFixed(4)), + ]; + } + } + logs.push( + `snapped '${primary.nodeId}' to the measured ${label} landmark`, + ); + }; + // eyes share one measured height; each keeps its own side of the axis + let eyeMarks = [landmarks.leftEye, landmarks.rightEye].filter( + (m: any) => Array.isArray(m) && m.length >= 2, + ); + if (eyeMarks.length) { + let v = + eyeMarks.reduce((sum: number, m: number[]) => sum + Number(m[1]), 0) / + eyeMarks.length; + let eyes = features.filter((c: any) => + /\beyes?\b|\beyeball\b/i.test(nameOf(c)), + ); + for (let eye of eyes) { + let box = boxOf(eye); + let h = halfExtents(eye); + if (!box || !h) continue; + let ctr = centreOf(box); + let y = headBox.max[1] - v * headH; + moveTo(eye, [ctr[0], y, frontZ(ctr[0], y, h[2], 0)], [1, 2]); + } + if (eyes.length) logs.push('snapped the eyes to the measured eye line'); + } + snap(/\bnose|nostril\b/i, landmarks.nose, 'nose'); + snap(/\bmouth|lips?\b/i, landmarks.mouth, 'mouth'); + } else { + // No measured landmarks — but a nose/mouth the LLM authored too DEEP still + // reads as buried (and resolveBuriedParts used to shove it down to the + // chin). Seat it just proud of the muzzle front at a sane height, and only + // when it is not already clearly proud, so a good placement is left alone. + let muzzle = all + .filter( + (c: any) => + solid(c) && + FACE_MASS.test(nameOf(c)) && + !FACE_FEATURE.test(nameOf(c)), + ) + .sort((a: any, b: any) => volume(b) - volume(a))[0]; + let anchorBox = (muzzle ? boxOf(muzzle) : headBox) ?? headBox; + let ac = centreOf(anchorBox); + let frontZ = anchorBox.min[2]; + let anchorH = anchorBox.max[1] - anchorBox.min[1] || 1; + let seatFront = (pattern: RegExp, yFrac: number, label: string) => { + let primary = features + .filter((c: any) => pattern.test(nameOf(c))) + .sort((a: any, b: any) => volume(b) - volume(a))[0]; + if (!primary) return; + let box = boxOf(primary); + let h = halfExtents(primary); + if (!box || !h) return; + let ctr = centreOf(box); + // front is -Z: a proud feature already sits in front of the muzzle face + if (ctr[2] <= frontZ - 0.4 * h[2]) return; + let y = anchorBox.max[1] - yFrac * anchorH; + moveTo(primary, [ac[0], y, frontZ - 0.6 * h[2]], [1, 2]); + logs.push( + `seated '${primary.nodeId}' on the muzzle front (no ${label} landmark)`, + ); + }; + seatFront(/\bnose|nostril\b/i, 0.45, 'nose'); + seatFront(/\bmouth|lips?\b/i, 0.72, 'mouth'); + } + + // ---- 3. embed: a face mass bites into the skull ------------------------- + for (let c of features) { + if (!FACE_MASS.test(nameOf(c)) || FACE_FEATURE.test(nameOf(c))) continue; + let box = boxOf(c); + let h = halfExtents(c); + if (!box || !h) continue; + let ctr = centreOf(box); + let off = [0, 1, 2].map((a) => ctr[a] - headC[a]); + let norm = Math.sqrt( + [0, 1, 2].reduce((sum, a) => sum + (off[a] / (headHalf[a] || 1)) ** 2, 0), + ); + let dist = Math.hypot(...off); + if (norm <= 1 || !(dist > 0)) continue; // already biting in + // put the centre on the head surface, then pull it in by 40% of the + // mass's own smallest half-extent so the two volumes read as one head + let bite = 0.4 * Math.min(...h); + let surface = off.map((o, a) => headC[a] + o / norm); + let unit = off.map((o) => o / dist); + let target = [0, 1, 2].map((a) => surface[a] - unit[a] * bite); + // a muzzle / bill / snout sits on the facial MIDLINE — snap its x to the + // head-centre x so a bill the model authored off to one side is pulled onto + // the face axis instead of floating beside the head + target[0] = headC[0]; + moveTo(c, target, [0, 1, 2]); + logs.push( + `embedded '${c.nodeId}' into '${head.nodeId}' on the facial midline`, + ); + } + + // ---- 4. vertical order: report only ------------------------------------- + let meanY = (pattern: RegExp) => { + let ys = features + .filter((c: any) => pattern.test(nameOf(c))) + .map((c: any) => boxOf(c)) + .filter(Boolean) + .map((b: any) => centreOf(b)[1]); + return ys.length + ? ys.reduce((s: number, y: number) => s + y, 0) / ys.length + : undefined; + }; + let eyeY = meanY(/\beyes?\b|\beyeball\b/i); + let noseY = meanY(/\bnose|nostril\b/i); + let mouthY = meanY(/\bmouth|lips?\b/i); + if (eyeY !== undefined && noseY !== undefined && noseY > eyeY) { + logs.push( + `the nose sits ABOVE the eyes (${noseY.toFixed(2)} > ${eyeY.toFixed(2)}) — a face is a stack: eyes, then nose, then mouth`, + ); + } + if (noseY !== undefined && mouthY !== undefined && mouthY > noseY) { + logs.push( + `the mouth sits ABOVE the nose (${mouthY.toFixed(2)} > ${noseY.toFixed(2)}) — a face is a stack: eyes, then nose, then mouth`, + ); + } + return logs; +} + +// Guaranteeing a face HAS a face. +// +// alignFaceFeatures can only move parts that exist; it cannot conjure an eye +// the plan never listed. But the LLM routinely ships a character with no eyes, +// no mouth, or a muzzle collapsed to one flat disc — and dropUnplannedParts +// then guarantees nothing downstream can add them back. So for anything that +// reads as a face, this synthesises the missing core features (two eyes, two +// pupils, a nose, a mouth) directly from the head/muzzle geometry, the same way +// the hand-authored reference model hard-codes them. Additive and idempotent: +// a feature that already exists (under any of its names) is left alone, so a +// re-run or a well-built face is untouched. Placed at the front (-Z) with no +// attachTo, so alignFaceFeatures/landmarks can still refine them afterwards. +function luminance(hex: string): number { + let m = /^#?([0-9a-f]{6})$/i.exec(String(hex ?? '').trim()); + if (!m) return 0.5; + let n = parseInt(m[1], 16); + return ( + (0.2126 * ((n >> 16) & 255) + + 0.7152 * ((n >> 8) & 255) + + 0.0722 * (n & 255)) / + 255 + ); +} + +export function ensureFaceParts(parsed: any, analysis?: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + if (!all.length) return logs; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let solid = (c: any) => + c?.nodeId && + c.primitive !== 'group' && + c.primitive !== 'glow' && + hasNeutralAncestry(c, byId); + + // only run on something that reads as a face: a head plus either ears, a + // muzzle/face mass, or a character directive from stage 1 + let heads = all.filter( + (c: any) => + solid(c) && HEAD.test(nameOf(c)) && !FACE_FEATURE.test(nameOf(c)), + ); + let volume = (c: any) => { + let h = halfExtents(c); + return h ? h[0] * h[1] * h[2] : 0; + }; + let head = heads.sort((a: any, b: any) => volume(b) - volume(a))[0]; + if (!head) return logs; + let hasEar = all.some((c: any) => solid(c) && EAR.test(nameOf(c))); + let hasMass = all.some( + (c: any) => + solid(c) && FACE_MASS.test(nameOf(c)) && !FACE_FEATURE.test(nameOf(c)), + ); + let isCharacter = + Array.isArray(analysis?.directives) && + analysis.directives.map(String).includes('character'); + if (!hasEar && !hasMass && !isCharacter) return logs; + + let headBox = specBox(head); + let headHalf = halfExtents(head); + if (!headBox || !headHalf) return logs; + let hc = centreOf(headBox); + let u = Math.max(headHalf[0], headHalf[1], headHalf[2]) || 0.3; + + // anchor nose/mouth to the muzzle front when there is one, else the head + let mass = all + .filter( + (c: any) => + solid(c) && FACE_MASS.test(nameOf(c)) && !FACE_FEATURE.test(nameOf(c)), + ) + .sort((a: any, b: any) => volume(b) - volume(a))[0]; + let massBox = mass ? specBox(mass) : undefined; + let anchor = massBox ?? headBox; + let ac = centreOf(anchor); + let frontZ = anchor.min[2]; // most-forward (-Z) face of the anchor + + let has = (re: RegExp) => + all.some((c: any) => solid(c) && re.test(nameOf(c))); + + // resolve (or create) a material near a target colour + let materials: any[] = (parsed.materials = parsed.materials ?? []); + let materialFor = (want: 'white' | 'black' | 'red', id: string) => { + let hit = materials.find((m: any) => { + let l = luminance(m?.baseColor); + if (want === 'white') return l > 0.75; + if (want === 'black') return l < 0.14; + let hex = String(m?.baseColor ?? ''); + return /^#?[cdef][0-9a-f]/i.test(hex.replace('#', '')) && l < 0.5; // reddish + }); + if (hit) return hit.materialId; + let color = + want === 'white' ? '#ffffff' : want === 'black' ? '#0a0a0a' : '#7a1f1f'; + materials.push({ + materialId: id, + baseColor: color, + roughness: want === 'black' ? 0.2 : 0.35, + metalness: 0, + }); + return id; + }; + + let add = ( + nodeId: string, + dims: number[], + pos: number[], + scale: number[], + mat: string, + partRef: string, + ) => { + all.push({ + nodeId, + parentId: 'root', + primitive: 'sphere', + dimensions: dims, + position: pos.map((n) => Number(n.toFixed(4))), + rotation: [0, 0, 0], + scale, + materialId: mat, + partRef, + note: partRef, + }); + logs.push(`added a missing '${partRef}' — a face must have one`); + }; + + // eyes: tall white ellipsoids on the upper front, close to the axis + if (!has(/\beyes?\b|\beyeball\b/i)) { + let white = materialFor('white', 'm-eyewhite'); + let black = materialFor('black', 'm-pupil'); + let eyeY = hc[1] + 0.32 * headHalf[1]; + let eyeZ = hc[2] - 0.82 * headHalf[2]; + let eyeX = 0.34 * headHalf[0]; + let r = 0.34 * u; + for (let s of [-1, 1]) { + add( + s < 0 ? 'left-eye' : 'right-eye', + [r], + [hc[0] + s * eyeX, eyeY, eyeZ], + [0.62, 1.2, 0.5], + white, + s < 0 ? 'left eye' : 'right eye', + ); + } + // pupils ride the eyes, a hair further out and up + if (!has(/\bpupils?\b|\biris\b/i)) { + let pr = 0.15 * u; + for (let s of [-1, 1]) { + add( + s < 0 ? 'left-pupil' : 'right-pupil', + [pr], + [hc[0] + s * eyeX * 0.9, eyeY + 0.02 * u, eyeZ - 0.06 * u], + [0.7, 1.35, 0.7], + black, + s < 0 ? 'left pupil' : 'right pupil', + ); + } + } + } + + // nose: black button at the muzzle front — but a beak/bill/snout already IS + // the nose, so skip when the face has one (a duck must not grow a second nose) + if (!has(/\bnose|nostril\b/i) && !has(BEAK)) { + let black = materialFor('black', 'm-nose'); + add( + 'nose', + [0.22 * u], + [ac[0], ac[1] + 0.05 * u, frontZ - 0.12 * u], + [1.3, 1.0, 1.2], + black, + 'nose', + ); + } + + // mouth: a wide dark smile below the nose — again, a beak IS the mouth, so + // skip it when a beak/bill/snout is present + if (!has(/\bmouth|lips?\b/i) && !has(BEAK)) { + let red = materialFor('red', 'm-mouth'); + add( + 'mouth', + [0.16 * u], + [ + ac[0], + ac[1] - 0.45 * (massBox ? halfExtents(mass)![1] : headHalf[1]), + frontZ - 0.02 * u, + ], + [2.0, 0.55, 0.7], + red, + 'mouth', + ); + } + + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/index.gts b/4376bf-img-to-3d-generator/util/spec-passes/index.gts new file mode 100644 index 00000000..a4180fb5 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/index.gts @@ -0,0 +1,12 @@ +// The repair passes the studio runs over a parsed spec, grouped by what they +// do to it. Order matters at the call site, not here — this only spares the +// caller seven import lines. + +export * from './prune'; +export * from './placement'; +export * from './overlap'; +export * from './attachments'; +export * from './face'; +export * from './shape'; +export * from './materials'; +export * from './proportions'; diff --git a/4376bf-img-to-3d-generator/util/spec-passes/materials.gts b/4376bf-img-to-3d-generator/util/spec-passes/materials.gts new file mode 100644 index 00000000..d088f7bf --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/materials.gts @@ -0,0 +1,129 @@ +// Whether the palette says anything. +// +// A reply that gives every part the same mid-grey has technically satisfied +// the schema and produced a model nobody can read. This reports a palette that +// has collapsed, so the caller can ask again rather than ship it. + +// A reference of brass-gold plates, dark teal panels, black rubber, red hub +// lights and bare steel blades came back as twelve materials of which nine sat +// inside one 40° olive band — a uniformly muddy object. The cause is sampling: +// the analysis is told to take colours from the flattest-lit panel and instead +// read them off the warm-lit beauty render, where everything is the same colour +// of gold. +// +// Judging a palette by its hue spread would be wrong — plenty of objects really +// are monochrome, and a wine bottle's glass, paper and cap legitimately live near +// each other. What IS object-agnostic is near-duplicates: declaring two materials +// that nobody could tell apart means the spec spent two slots to describe one +// surface, and whatever distinction the reference drew between those parts has +// been lost. Report only — which of the two should change is a judgement about +// the photo, not the numbers. +export function flagFlatPalette(parsed: any): string[] { + let logs: string[] = []; + let materials: any[] = parsed?.materials ?? []; + if (materials.length < 3) return logs; + let rgb = (hex: any): [number, number, number] | undefined => { + let s = String(hex ?? '').trim(); + if (!/^#[0-9a-fA-F]{6}$/.test(s)) return undefined; + return [ + parseInt(s.slice(1, 3), 16), + parseInt(s.slice(3, 5), 16), + parseInt(s.slice(5, 7), 16), + ]; + }; + let entries = materials + .map((m: any) => ({ + id: m?.materialId, + c: rgb(m?.baseColor), + finish: m?.finish, + })) + .filter((e) => e.id && e.c) as { + id: string; + c: [number, number, number]; + finish?: string; + }[]; + if (entries.length < 3) return logs; + // a rough perceptual distance: weight green most, blue least, the way the eye + // does. ~12 is about where two swatches stop being distinguishable side by side + const INDISTINGUISHABLE = 12; + let distance = (a: [number, number, number], b: [number, number, number]) => { + let dr = a[0] - b[0]; + let dg = a[1] - b[1]; + let db = a[2] - b[2]; + return Math.sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db) / 3; + }; + // hazard, camo and louver carry their colours INSIDE the painted map — the + // interpreter neutralises the material's own colour to white for them — so two + // such materials say nothing about each other's baseColor. worn, brushed, + // patina, tread and knurl only modulate the paint underneath, so their base + // colours are still what you see and remain comparable. + let colourReplaced = (f: any) => + f === 'hazard' || f === 'camo' || f === 'louver'; + let pairs: string[] = []; + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + if ( + colourReplaced(entries[i].finish) || + colourReplaced(entries[j].finish) + ) { + continue; + } + if (distance(entries[i].c, entries[j].c) < INDISTINGUISHABLE) { + pairs.push(`'${entries[i].id}' and '${entries[j].id}'`); + } + } + } + if (pairs.length) { + logs.push( + `${pairs.length} material pair(s) are visually the same colour — ${pairs.slice(0, 3).join(', ')}${pairs.length > 3 ? ', …' : ''}: the reference likely draws a distinction here that the sampling lost`, + ); + } + + // And the case a duplicate check structurally cannot catch: not two materials + // being identical, but ALL of them living in one hue family. That is what turns + // a brass-and-teal machine into uniform mud, and it comes from sampling a + // warm-lit render instead of a flat panel. Whether it is WRONG depends on the + // object — plenty of things really are monochrome — so this reports the + // measurement and lets a human or the refine round judge it. + let hue = ([r, g, b]: [number, number, number]) => { + let max = Math.max(r, g, b); + let min = Math.min(r, g, b); + if (max === min) return undefined; // grey has no hue + let d = max - min; + let h = + max === r + ? ((g - b) / d) % 6 + : max === g + ? (b - r) / d + 2 + : (r - g) / d + 4; + return (((h * 60) % 360) + 360) % 360; + }; + let hues = entries + .filter((e) => !colourReplaced(e.finish)) + .map((e) => hue(e.c)) + .filter((h): h is number => h !== undefined); + // below half a dozen hues the observation is not evidence of anything: a wine + // bottle's olive glass, cream label, dark cap and gold band genuinely share the + // warm band, and 3-of-4 would "prove" it was sampled badly when it was not + if (hues.length >= 6) { + // widest band containing the most hues, sliding a 40° window + let best = 0; + let bestAt = 0; + for (let start = 0; start < 360; start += 5) { + let inBand = hues.filter((h) => { + let rel = (h - start + 360) % 360; + return rel <= 40; + }).length; + if (inBand > best) { + best = inBand; + bestAt = start; + } + } + if (best / hues.length >= 0.7) { + logs.push( + `${best} of ${hues.length} colours sit inside a single 40° hue band (${bestAt}°-${bestAt + 40}°) — if the reference is more varied than that, the palette was sampled from a lit render rather than a flat view`, + ); + } + } + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/overlap.gts b/4376bf-img-to-3d-generator/util/spec-passes/overlap.gts new file mode 100644 index 00000000..83f61f77 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/overlap.gts @@ -0,0 +1,213 @@ +// Two parts trying to occupy one volume. +// +// Interpenetration and coplanarity look similar in the spec and completely +// different on screen: solids that overlap render as one lump, while flat +// parts sharing a plane z-fight and flicker as the camera moves. They are +// separated here because the fix differs — one is a placement error, the +// other needs a depth stagger the spec never thought to author. + +import { hasNeutralAncestry, halfExtents, specBox } from '../spec-geometry'; + +// Copies of one feature that overlap each other fuse into a single lump, and +// because the overlapping faces are coincident they z-fight into a dark smear. +// A six-wheeled truck came out with a black streak where its wheels should be: +// each wheel was 1.10 across on an 0.85 axle spacing, so every neighbour ate a +// quarter of the one beside it. +// +// Two ways that happens, both measured off the part's own extents so no naming +// convention is involved: +// 1. a linear repeat whose step is shorter than the part it is repeating +// 2. copies authored INDIVIDUALLY (front / mid / rear wheel as three separate +// components), which no repeat check would ever see — they are recognised +// by having the same primitive and the same dimensions +// A linear repeat whose step is too short is REPAIRED (widen the step — copies +// must not overlap, unambiguous), while the individually-authored case is +// report-only, since collapsing three hand-placed wheels is not this pass's call. +export function flagInstanceCollisions(parsed: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let components: any[] = all.filter( + (c: any) => c?.nodeId && c.primitive !== 'group', + ); + if (!components.length) return logs; + // a repeat step is a LOCAL offset, so that half of the check is valid under + // any parent; only the sibling-overlap half compares world boxes + let worldOk = (c: any) => hasNeutralAncestry(c, byId); + // parts are told to overlap their support by ~0.03, so a little contact + // between copies is normal; a fifth of the part is not + const TOLERATED = 0.2; + + // ---- 1. repeat steps shorter than the part + for (let c of components) { + let rep = c.repeat; + if (typeof rep === 'string') { + try { + rep = JSON.parse(rep); + } catch { + continue; + } + } + let count = Math.round(rep?.count ?? 0); + if (!rep || count < 2) continue; + let h = halfExtents(c); + if (!h) continue; + if (rep.mode === 'radial') { + let radius = Math.abs(Number(rep.radius) || 0); + let axis = rep.axis === 'x' ? 0 : rep.axis === 'z' ? 2 : 1; + let inPlane = [0, 1, 2].filter((a) => a !== axis); + // adjacent clones sit a chord apart; compare against the part's smaller + // in-plane width, which is the one facing its neighbour + let chord = 2 * radius * Math.sin(Math.PI / count); + let width = 2 * Math.min(h[inPlane[0]], h[inPlane[1]]); + if (width > 0 && chord < width * (1 - TOLERATED)) { + logs.push( + `'${c.nodeId}' repeats ${count}× around radius ${radius} but each is ${width.toFixed(2)} wide and only ${chord.toFixed(2)} apart — the ring will fuse`, + ); + } + continue; + } + let offset = Array.isArray(rep.offset) ? rep.offset.map(Number) : []; + if (!offset.length) continue; + let axis = offset.reduce( + (best: number, v: number, i: number) => + Math.abs(v) > Math.abs(offset[best]) ? i : best, + 0, + ); + let step = Math.abs(offset[axis] ?? 0); + let extent = 2 * h[axis]; + if (extent <= 0) continue; + if (step < 0.001) { + logs.push( + `'${c.nodeId}' repeats ${count}× with no offset — every copy lands on the same spot`, + ); + } else if (step < extent * (1 - TOLERATED)) { + // widen the step to the part's own size along the repeat axis, so the + // copies stop fusing into a continuous band — a truck's 8 tires melting + // into one tank track is the classic case. Direction is kept; only the + // length grows. Repairable because the fix is unambiguous: copies must + // not overlap. + let sign = (offset[axis] ?? 0) < 0 ? -1 : 1; + offset[axis] = Number((sign * extent).toFixed(4)); + rep.offset = offset; + c.repeat = rep; + logs.push( + `widened '${c.nodeId}' repeat step ${step.toFixed(2)} → ${extent.toFixed(2)} so its ${count} copies stop fusing`, + ); + } + } + + // ---- 2. individually authored copies of the same feature + let sig = (c: any) => { + let h = halfExtents(c); + if (!h) return undefined; + return `${c.primitive}|${h.map((n) => n.toFixed(3)).join(',')}`; + }; + let families = new Map(); + for (let c of components) { + if (!worldOk(c)) continue; // its position is local to a transformed parent + let s = sig(c); + if (!s) continue; + if (!families.has(s)) families.set(s, []); + families.get(s)!.push(c); + } + for (let family of families.values()) { + if (family.length < 2) continue; + let boxes = family.map((c) => { + let h = halfExtents(c)!; + let box = specBox(c)!; + return { c, min: box.min, max: box.max, vol: 8 * h[0] * h[1] * h[2] }; + }); + let reported = 0; + for (let i = 0; i < boxes.length && reported < 2; i++) { + for (let j = i + 1; j < boxes.length && reported < 2; j++) { + let a = boxes[i]; + let b = boxes[j]; + let overlap = [0, 1, 2].map((ax) => + Math.max( + 0, + Math.min(a.max[ax], b.max[ax]) - Math.max(a.min[ax], b.min[ax]), + ), + ); + let volume = overlap[0] * overlap[1] * overlap[2]; + let share = volume / Math.max(0.000001, Math.min(a.vol, b.vol)); + if (share > TOLERATED / 2) { + let axis = overlap.indexOf(Math.min(...overlap.filter((v) => v > 0))); + logs.push( + `'${a.c.nodeId}' and '${b.c.nodeId}' are the same part overlapping by ${overlap[axis].toFixed(2)} (${Math.round(share * 100)}%) — they will read as one lump`, + ); + reported++; + } + } + } + } + return logs; +} + +// Coplanar overlapping faces have no depth order, so the renderer picks a winner +// per pixel from floating-point noise and the choice changes as the camera moves +// — the surface strobes. A starburst icon put all twelve rays at exactly +// z = 0.105; they converge at the hub, so the middle of the icon flickered in a +// checkerboard while everything else looked fine. +// +// Separating them by a hair gives the depth buffer something to sort by. The step +// is far below anything visible at these scales, so this cannot change how the +// object reads — it only removes the ambiguity. Flat artwork is where this bites, +// because that is where shapes deliberately share a plane; a solid assembly +// rarely has two faces at an identical depth by accident. +export function separateCoplanarLayers(parsed: any): string[] { + let logs: string[] = []; + let components: any[] = parsed?.components ?? []; + if (components.length < 3) return logs; + const STEP = 0.0005; + // group by the z they sit at, to 4dp — parts an author placed on "the same + // layer" rather than parts that merely happen to be close + let layers = new Map(); + for (let c of components) { + if (!c?.nodeId || c.primitive === 'group') continue; + // Only genuinely FLAT parts can be coplanar. A bottle's lathe body, its cap + // and its shadow disc all sit at z = 0 because they are solids of revolution + // centred on the axis — they share a centre, not a plane, and nudging them + // apart would be meaningless. Require the part to be thin in z against its + // own face: that is what a layer of artwork is and a stacked solid is not. + let h = halfExtents(c); + if (!h) continue; + let face = Math.min(h[0], h[1]); + if (!(face > 0) || h[2] > face * 0.25) continue; + let p = Array.isArray(c.position) ? c.position.map(Number) : [0, 0, 0]; + let key = (p[2] || 0).toFixed(4); + if (!layers.has(key)) layers.set(key, []); + layers.get(key)!.push(c); + } + for (let [, members] of layers) { + if (members.length < 2) continue; + // only worth doing when they actually overlap in the plane; a row of + // separate icons sharing a z is not fighting with anything + let boxes = members.map((c) => specBox(c)); + let overlaps = false; + for (let i = 0; i < boxes.length && !overlaps; i++) { + for (let j = i + 1; j < boxes.length && !overlaps; j++) { + let a = boxes[i]; + let b = boxes[j]; + if (!a || !b) continue; + overlaps = [0, 1].every( + (ax) => a.min[ax] < b.max[ax] && b.min[ax] < a.max[ax], + ); + } + } + if (!overlaps) continue; + members.forEach((c, i) => { + if (i === 0) return; + let p = Array.isArray(c.position) ? c.position.map(Number) : [0, 0, 0]; + c.position = [ + p[0] || 0, + p[1] || 0, + Number(((p[2] || 0) + STEP * i).toFixed(5)), + ]; + }); + logs.push( + `separated ${members.length} overlapping parts sharing one depth — they would have flickered against each other`, + ); + } + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/placement.gts b/4376bf-img-to-3d-generator/util/spec-passes/placement.gts new file mode 100644 index 00000000..db0c7b1d --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/placement.gts @@ -0,0 +1,728 @@ +// Putting parts WHERE they belong. +// +// These passes move things: a part buried inside its neighbour, a wheel +// floating above the hull it should carry, a model hovering over or sunk +// through the ground plane, a part outside the envelope its plan measured. +// They read boxes through spec-geometry so they all agree about where a part +// currently is — two movers disagreeing about that is how a part gets pushed +// twice in opposite directions. + +import { hasNeutralAncestry, halfExtents, specBox } from '../spec-geometry'; +import { FACE_FEATURE } from './face'; + +// The last line of defence, for when the plan and the spec are wrong the SAME +// way. An analysis wrote "wheels rests-on main hull body" — inverting which part +// carries which — the spec obeyed it, and every check passed: the wheels touched +// their host, sat inside the silhouette, were planned, were not hairline, were +// not buried. The truck simply had its tyres on the roof. +// +// What no plan can overrule is that a thing stands on its supports. A wheel, a +// track, a foot or a leg above the body's own midline is never a stylistic +// choice, so this is the one place where the parts' NAMES are worth reading: +// naming is exactly what the plan exists to carry, and the geometry alone cannot +// tell a wheel from a barrel. +// +// The repair mirrors the support to the other side of its host rather than +// dropping it to some invented height: the spec placed it the right DISTANCE +// from the body and only got the side wrong, so reflecting preserves the overlap +// the model intended (a wheel half-sunk into the flank stays half-sunk, just +// underneath). +export const SUPPORT_NAME = + /\b(wheel|wheels|tyre|tire|track|tracks|caster|roller|foot|feet|leg|legs|skid|outrigger|castor)\b/i; + +// A feature can PROTRUDE (a bumper, a mirror) or be RECESSED (a window well, a +// grille cavity, a sunken door panel, an air intake). The rest of the pipeline +// assumes protrusion — resolveBuriedParts pushes any buried part back OUT — so +// a recess needs its own opt-in: a component either flags "inset": true or its +// name says so, and then it is seated INTO the surface instead of ejected. +export const RECESS_NAME = + /\b(recess|recessed|sunken|inset|intake|vent|grille|grill|interior|cavity|well|window|windshield|windscreen|windscreens|windows)\b/i; +export function isRecessed(c: any): boolean { + return ( + c?.inset === true || + RECESS_NAME.test(`${c?.nodeId ?? ''} ${c?.partRef ?? ''} ${c?.note ?? ''}`) + ); +} + +export function groundSupports(parsed: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let components = all.filter( + (c: any) => + c?.nodeId && c.primitive !== 'group' && hasNeutralAncestry(c, byId), + ); + if (components.length < 2) return logs; + let boxed = components + .map((c) => { + let h = halfExtents(c); + let box = specBox(c); + if (!h || !box) return undefined; + return { c, min: box.min, max: box.max, vol: 8 * h[0] * h[1] * h[2] }; + }) + .filter(Boolean) as { + c: any; + min: number[]; + max: number[]; + vol: number; + }[]; + if (boxed.length < 2) return logs; + let isSupport = (c: any) => + SUPPORT_NAME.test(String(c.partRef ?? '')) || + SUPPORT_NAME.test(String(c.nodeId ?? '')); + let supports = boxed.filter( + (b) => isSupport(b.c) && !/glow/i.test(b.c.nodeId), + ); + if (!supports.length) return logs; + // the body is the biggest non-support volume — what the supports carry + let body = boxed + .filter((b) => !isSupport(b.c) && !/shadow/i.test(b.c.nodeId)) + .sort((a, b) => b.vol - a.vol)[0]; + if (!body) return logs; + let bodyCenterY = (body.min[1] + body.max[1]) / 2; + + for (let s of supports) { + let sCenterY = (s.min[1] + s.max[1]) / 2; + if (sCenterY <= bodyCenterY) continue; + let mirrored = bodyCenterY - (sCenterY - bodyCenterY); + let dy = mirrored - sCenterY; + let p = Array.isArray(s.c.position) ? s.c.position.map(Number) : [0, 0, 0]; + p[1] = Number(((p[1] || 0) + dy).toFixed(4)); + s.c.position = p; + s.min[1] += dy; + s.max[1] += dy; + logs.push( + `mirrored '${s.c.nodeId}' below '${body.c.nodeId}' (Δ ${dy.toFixed(2)} on y) — a support cannot sit above the body it carries`, + ); + } + + // whatever the supports ended up as, they should be what the object stands on + let lowestSupport = Math.min(...supports.map((s) => s.min[1])); + for (let b of boxed) { + if (isSupport(b.c) || /shadow/i.test(b.c.nodeId)) continue; + if (b.min[1] < lowestSupport - 0.02) { + logs.push( + `'${b.c.nodeId}' hangs ${(lowestSupport - b.min[1]).toFixed(2)} below the supports — it will scrape the ground`, + ); + } + } + return logs; +} + +// The render seats a part ONLY towards the support it DECLARED: the joint solver +// and the contact backstop both read `attachTo`, and a part that has none is +// left exactly where it was authored — deliberately, so a blind nearest snap +// cannot weld the whole model into a clump. But the spec prompt does not always +// emit an attachTo for every part, and a face feature, a completeness-added +// part, or any detail the model forgot to wire hangs in mid-air as a result — +// the very floaters that today only an AI refine round fixes. +// +// This closes that gap without vision: every ORPHAN (a non-group, non-support +// part with no attachTo, and not the anchor body) is GIVEN the joint it lacks, +// pointed at the mass it is physically nearest to. It only ASSIGNS attachTo — it +// moves nothing — so the render's own solvers do the seating, and both are +// capped at 15% of the object, so a part genuinely far from everything is still +// left in place rather than dragged across the model. The clump risk stays shut: +// assignment is scoped to orphans (a part that already declared a joint keeps +// it), and ties break toward the LARGER neighbour, so a detail hangs off a body, +// not off another detail. +export function attachOrphans(parsed: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let parts = all.filter( + (c: any) => + c?.nodeId && c.primitive !== 'group' && hasNeutralAncestry(c, byId), + ); + if (parts.length < 2) return logs; + const SKIP = /shadow|glow/i; + let boxed = parts + .map((c) => { + let box = specBox(c); + let h = halfExtents(c); + if (!box || !h) return undefined; + return { c, min: box.min, max: box.max, vol: 8 * h[0] * h[1] * h[2] }; + }) + .filter(Boolean) as { + c: any; + min: number[]; + max: number[]; + vol: number; + }[]; + if (boxed.length < 2) return logs; + let isSupport = (c: any) => + SUPPORT_NAME.test(String(c.partRef ?? '')) || + SUPPORT_NAME.test(String(c.nodeId ?? '')); + // the biggest non-support mass is the anchor everything ultimately hangs on; + // nothing carries the body, so it never gets an attachTo of its own + let anchor = boxed + .filter((b) => !isSupport(b.c) && !SKIP.test(String(b.c.nodeId))) + .sort((a, b) => b.vol - a.vol)[0]; + // separation between two boxes on their farthest-apart axis: >0 is a real gap, + // <=0 means they already overlap (not floating) + let gapOf = (a: (typeof boxed)[number], b: (typeof boxed)[number]) => + Math.max( + ...[0, 1, 2].map((ax) => + Math.max(a.min[ax] - b.max[ax], b.min[ax] - a.max[ax]), + ), + ); + + for (let o of boxed) { + let c = o.c; + if (c.attachTo) continue; // already wired — leave it + if (anchor && c === anchor.c) continue; // the body hangs on nothing + if (isSupport(c)) continue; // supports are grounded, not hung + if (SKIP.test(String(c.nodeId))) continue; + let best: { t: (typeof boxed)[number]; g: number } | undefined; + for (let t of boxed) { + if (t === o || SKIP.test(String(t.c.nodeId))) continue; + let g = gapOf(o, t); + if ( + !best || + g < best.g - 0.001 || + (Math.abs(g - best.g) <= 0.001 && t.vol > best.t.vol) + ) { + best = { t, g }; + } + } + if (!best) continue; + c.attachTo = best.t.c.nodeId; + logs.push( + `gave '${c.nodeId}' an attachTo '${best.t.c.nodeId}' (had none — nearest mass, gap ${best.g.toFixed(2)}) so the builder seats it instead of leaving it floating`, + ); + } + return logs; +} + +// A part can be perfectly assembled and still be invisible: swallowed whole by +// the part next to it. That is what happened to a house's lower hip roof, where +// the analysis said both "lower hipped roof centered-above ground floor block" +// AND "second floor block centered-above ground floor block", so the two were +// seated at the same height and the roof ended up 90% inside the storey box — +// the render simply had no lower roof on it. Nothing caught this: the part +// touches its support, sits inside the silhouette, is planned, is not hairline. +// +// Being inside another part is not always wrong, so this only looks at parts +// that have real volume. A window, door, badge or decal is MEANT to sit flush in +// its wall, and a plate is exactly what those are — so plate-like parts and +// decals are exempt. What remains is the case worth reporting: one solid mass +// hidden inside another. +// +// Repaired where the correction is unambiguous. The buried part is the smaller +// one, and the direction to move it is the one that costs least: push it out +// through the nearest face of its host until it protrudes, keeping the ~0.03 +// overlap the assembly rules want so it stays attached. That turns an invisible +// part into a visible mounted one, which is what a louvre panel sunk into a +// superstructure or a headlight sunk into a ram plate should have been. +// +// The move is capped at 15% of the object's size, the same limit the contact +// solvers use. A part deeper in than that is not a mounting mistake — it was +// modelled in the wrong place entirely, and shoving it across the object would +// invent a shape nobody asked for. Those stay reported. +export function resolveBuriedParts(parsed: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let components: any[] = all.filter( + (c: any) => + c?.nodeId && + c.primitive !== 'group' && + c.primitive !== 'curvedDecal' && + c.primitive !== 'textDecal' && + c.primitive !== 'glow' && + !/shadow/i.test(String(c.nodeId)) && + // facial features (eye / pupil / nose / mouth …) are MEANT to sit proud + // on the muzzle: their centre is legitimately inside it, and this pass's + // "leave by the nearest face" heuristic pushes them the wrong way — an + // eye out the back of the skull, a mouth down onto the chin. alignFace- + // Features already seats them; leave them to it. + !FACE_FEATURE.test(`${c.nodeId} ${c.partRef ?? ''} ${c.note ?? ''}`) && + // a RECESSED feature (window well, grille cavity, sunken panel, interior) + // is DESIGNED to sit inside its host — ejecting it is the opposite of what + // it is for. seatRecesses / clampInteriorCavities place these instead. + !isRecessed(c) && + hasNeutralAncestry(c, byId), + ); + if (components.length < 2) return logs; + // the overlap the assembly rules want a mounted part to keep with its host + const OVERLAP_KEPT = 0.03; + // a move this small changes nothing anyone can see, and reporting it as a fix + // would be a lie — a baluster sharing a face with its platform produced a + // "pushed by -0.00" line before this + const MIN_MOVE = 0.005; + let measure = () => { + let out: { c: any; min: number[]; max: number[]; vol: number }[] = []; + for (let c of components) { + let h = halfExtents(c); + let box = specBox(c); + if (!h || !box) continue; + // a plate is a surface feature — being inset in its host is the point + let sorted = [...h].sort((a, b) => a - b); + if (sorted[0] < 0.3 * sorted[1]) continue; + let vol = h[0] * h[1] * h[2] * 8; + if (vol <= 0) continue; + out.push({ c, min: box.min, max: box.max, vol }); + } + return out; + }; + let boxes = measure(); + if (boxes.length < 2) return logs; + // the same ceiling the contact solvers use: a solver closes a gap, it does not + // relocate a part across the object + let extent = [0, 1, 2].map( + (ax) => + Math.max(...boxes.map((x) => x.max[ax])) - + Math.min(...boxes.map((x) => x.min[ax])), + ); + let maxMove = 0.15 * Math.max(...extent, 0.001); + // freeing one part can bury another — lifting a balcony platform out of a wall + // pushed its own balusters inside the platform — so re-measure and settle, + // with a hard pass limit because two parts can always be in each other's way + let unresolved = new Map(); + // one move per part, ever: a part buried in TWO hosts otherwise ping-pongs + // between their exits (a storey was shoved +0.96 out of the lower roof, then + // -0.90 out of the upper roof, then back again) + let movedOnce = new Set(); + for (let pass = 0; pass < 3; pass++) { + let moved = resolvePass(); + if (!moved) break; + boxes = measure(); + } + for (let line of unresolved.values()) logs.push(line); + return logs; + + function resolvePass(): number { + let moved = 0; + for (let i = 0; i < boxes.length; i++) { + for (let j = 0; j < boxes.length; j++) { + if (i === j) continue; + let a = boxes[i]; + let b = boxes[j]; + if (a.vol > b.vol) continue; // only report the smaller as the buried one + // The assembly rules REQUIRE every part to overlap its support by about + // 0.03, and for a thin part — a railing bar, a trim strip — that mandated + // overlap is already most of its own volume. Volume share alone therefore + // condemns correctly seated details. A part is only buried if its CENTRE + // has gone inside the other part: a bar resting on a platform keeps its + // centre above the platform's top face, while a roof swallowed by a + // storey does not. + let center = [0, 1, 2].map((ax) => (a.min[ax] + a.max[ax]) / 2); + let centerInside = [0, 1, 2].every( + (ax) => + center[ax] > b.min[ax] + 0.001 && center[ax] < b.max[ax] - 0.001, + ); + if (!centerInside) continue; + let overlap = [0, 1, 2].reduce( + (acc, ax) => + acc * + Math.max( + 0, + Math.min(a.max[ax], b.max[ax]) - Math.max(a.min[ax], b.min[ax]), + ), + 1, + ); + let share = overlap / a.vol; + if (share <= 0.5) continue; + + // a repeat prototype sits ON its host's surface by design — its clones + // ring the host after expansion, and nudging the prototype breaks the + // whole ring (a cap's knurl ridges were shoved 0.02 INTO the cap here) + if (a.c.repeat) continue; + // A part buried in the very host it DECLARED a joint with is the + // surface seater's case, not this one, and the two disagree about + // which way is out. This pass leaves by the nearest box face, which is + // the cheapest exit and not always a real one: an eye sunk in a muzzle + // is nearest to the muzzle's BACK face, so the cheap exit surfaces it + // inside the skull, facing away from every camera. The seater knows + // the host is itself mounted on something and pushes out the exposed + // side instead. Burial in a part the spec never claimed to mount on is + // still this pass's problem — there is no joint there to reason from. + if (a.c.attachTo && String(a.c.attachTo) === String(b.c.nodeId)) { + continue; + } + // two revolved bodies sharing an axis are concentric shells — a cap + // skirt around a cap body, a collar around a neck. Their AABBs nest + // completely, but the geometry is a ring AROUND the host; shoving one + // sideways only breaks its symmetry. + let revolved = (p: string) => p === 'cylinder' || p === 'lathe'; + if ( + revolved(a.c.primitive) && + revolved(b.c.primitive) && + Math.abs((a.min[0] + a.max[0]) / 2 - (b.min[0] + b.max[0]) / 2) < + 0.05 && + Math.abs((a.min[2] + a.max[2]) / 2 - (b.min[2] + b.max[2]) / 2) < 0.05 + ) { + continue; + } + // and a part comparable in size to its host is not a mounted detail in + // the wrong place — it is major structure whose interpenetration is + // either deliberate (a storey rising through its skirt roof) or a + // modelling error no translation can fix. Only a genuine detail (a + // fraction of its host's bulk) is safe to relocate. + if (a.vol > 0.25 * b.vol || movedOnce.has(a.c.nodeId)) { + unresolved.set( + a.c.nodeId, + `'${a.c.nodeId}' is ${Math.round(share * 100)}% inside '${b.c.nodeId}' — not moved (${ + movedOnce.has(a.c.nodeId) + ? 'already moved once' + : 'the parts are comparable in size' + })`, + ); + break; + } + + // cheapest way out: through whichever face of the host is nearest, in + // whichever direction moves the part least + let best: { axis: number; delta: number } | undefined; + for (let ax = 0; ax < 3; ax++) { + for (let delta of [ + b.max[ax] - OVERLAP_KEPT - a.min[ax], // out through the + face + b.min[ax] + OVERLAP_KEPT - a.max[ax], // out through the - face + ]) { + if (!best || Math.abs(delta) < Math.abs(best.delta)) { + best = { axis: ax, delta }; + } + } + } + if ( + !best || + Math.abs(best.delta) > maxMove || + Math.abs(best.delta) < MIN_MOVE + ) { + unresolved.set( + a.c.nodeId, + `'${a.c.nodeId}' is ${Math.round(share * 100)}% inside '${b.c.nodeId}' — too deep in to move automatically`, + ); + break; + } + let p = Array.isArray(a.c.position) + ? a.c.position.map(Number) + : [0, 0, 0]; + p[best.axis] = Number(((p[best.axis] || 0) + best.delta).toFixed(4)); + a.c.position = p; + // keep the local boxes in step so a later pair is judged on the new + // position rather than the old one + a.min[best.axis] += best.delta; + a.max[best.axis] += best.delta; + unresolved.delete(a.c.nodeId); + movedOnce.add(a.c.nodeId); + moved++; + logs.push( + `pushed '${a.c.nodeId}' out of '${b.c.nodeId}' by ${best.delta.toFixed(2)} on ${'xyz'[best.axis]} (it was ${Math.round(share * 100)}% inside)`, + ); + break; + } + } + return moved; + } +} + +// The traced silhouette is the object's true outer boundary. Every solid part +// must reconcile WITHIN it: a capsule / gold ring / collar wider than the neck, +// or a part that slid off-axis, pokes past the outline and reads as floating. +// Given the world-space envelope (half-width per height Y, from the traced +// lathe profile), pull each solid back inside — scale an on-axis part down to +// the local half-width, or slide an off-axis part toward the axis. Front-plane +// only: clamps X always, and Z too for a revolved body (its silhouette is +// symmetric about the axis). Decals are left to fitCurvedDecals. Positions are +// treated as world-ish — valid for the centered, shallow hierarchies revolved +// objects use. Mutates parsed; returns log lines. +export function clampToEnvelope( + parsed: any, + envelope: { y: number; half: number }[], + revolved: boolean, +): string[] { + let logs: string[] = []; + let components: any[] = parsed?.components ?? []; + if (!envelope?.length || !components.length) return logs; + let pts = [...envelope].sort((a, b) => a.y - b.y); + let loY = pts[0].y; + let hiY = pts[pts.length - 1].y; + // widest half-width across the neck (top quarter of the profile) — caps and + // foils that sit at or above the lip clamp to THIS, not to zero, so they are + // pulled onto the neck instead of left floating past the outline + let neckHalf = Math.max( + ...pts.slice(Math.floor(pts.length * 0.75)).map((p) => p.half), + 0, + ); + // half-width of the silhouette at world height y + let envAt = (y: number): number => { + if (y <= loY) return pts[0].half; + if (y >= hiY) return neckHalf; + for (let i = 0; i + 1 < pts.length; i++) { + let a = pts[i]; + let b = pts[i + 1]; + if (y >= a.y && y <= b.y) { + let t = b.y === a.y ? 0 : (y - a.y) / (b.y - a.y); + return a.half + (b.half - a.half) * t; + } + } + return pts[pts.length - 1].half; + }; + let nums = (raw: any, fb: number[]): number[] => + Array.isArray(raw) ? raw : fb; + // lathe/tube/extrusions/mesh have footprints we can't bound from + // dimensions[0], so leave them alone entirely. Decals ARE processed — their + // radius is fitted elsewhere (fitCurvedDecals), but a decal floating above + // the silhouette still needs the vertical pull-down, or a cap band left + // hovering at the old height stays detached after its host cap is pulled in. + let skip = new Set([ + 'group', + 'glow', + 'lathe', + 'meshAsset', + 'tube', + 'extrudedPolygon', + 'extrudedSpline', + ]); + let isDecal = (p: string) => p === 'curvedDecal' || p === 'textDecal'; + let halfExtentX = (c: any): number => { + let d = nums(c.dimensions, []); + let sx = Math.abs(nums(c.scale, [1, 1, 1])[0] ?? 1); + switch (c.primitive) { + case 'cylinder': + return Math.max(Math.abs(d[0] ?? 0), Math.abs(d[1] ?? 0)) * sx; + case 'torus': + return (Math.abs(d[0] ?? 0) + Math.abs(d[1] ?? 0)) * sx; + case 'box': + case 'roundedBox': + case 'plane': + case 'roundedPlate': + return (Math.abs(d[0] ?? 0) / 2) * sx; + default: + // disc/sphere/hemisphere/cone/capsule/rock/blob/flatRing/arch: radius + return Math.abs(d[0] ?? 0) * sx; + } + }; + let skinFactor = 1.06; // a hair of overhang is fine (foil lips sit just proud) + for (let c of components) { + if (!c?.primitive || skip.has(c.primitive) || c.parentId == null) continue; + let pos = nums(c.position, [0, 0, 0]).slice(); + let cx = pos[0] ?? 0; + let cy = pos[1] ?? 0; + let posChanged = false; + // vertical: a part whose center floats ABOVE the silhouette top is outside + // the outline entirely — drop it onto the top edge so a cap sits on the + // neck instead of hovering above it + if (cy > hiY) { + pos[1] = Number(hiY.toFixed(4)); + cy = pos[1]; + posChanged = true; + logs.push(`lowered '${c.nodeId}' onto the silhouette top`); + } + // lateral: keep the part's front-plane extent inside the outline half-width + // (solids only — a decal's radius is owned by fitCurvedDecals) + let ext = isDecal(c.primitive) ? 0 : halfExtentX(c); + let limit = envAt(cy); + if (ext > 0 && limit > 0 && Math.abs(cx) + ext > limit * skinFactor) { + if (Math.abs(cx) < 1e-3) { + // on-axis and too wide: scale it down to the local half-width + let f = (limit * skinFactor) / ext; + let sc = nums(c.scale, [1, 1, 1]).slice(); + sc[0] = Number(((sc[0] ?? 1) * f).toFixed(4)); + if (revolved) sc[2] = Number(((sc[2] ?? 1) * f).toFixed(4)); + c.scale = sc; + logs.push( + `clamped '${c.nodeId}' into the traced silhouette (×${f.toFixed(2)})`, + ); + } else { + // off-axis: slide toward the axis until the outer edge fits + pos[0] = Number( + (Math.sign(cx) * Math.max(0, limit * skinFactor - ext)).toFixed(4), + ); + posChanged = true; + logs.push(`pulled '${c.nodeId}' inside the traced silhouette`); + } + } + if (posChanged) c.position = pos; + } + return logs; +} + +// Keeping a hollow body's interior cavity INSIDE its shell. +// +// The hollow-body recipe adds a dark "interior" box behind the windows so a cab +// reads as a cabin you can see into. But the model sizes it by eye, and an +// interior as big as (or bigger than) the shell pokes through the walls — the +// dark box eats the yellow cab and the shell looks gone. The fix is unambiguous: +// an interior belongs inside its shell, smaller on every axis and centred in it. +// Shrinks via scale and recentres; never grows anything. +export function clampInteriorCavities(parsed: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let isInterior = (c: any) => + /\b(interior|cavity)\b/i.test( + `${c?.nodeId} ${c?.partRef ?? ''} ${c?.note ?? ''}`, + ); + // how much smaller than its shell an interior must stay, per axis + const FIT = 0.85; + + for (let interior of all) { + if (interior.primitive === 'group' || !isInterior(interior)) continue; + let ih = halfExtents(interior); + let ibox = specBox(interior); + if (!ih || !ibox) continue; + let ic = [0, 1, 2].map((a) => (ibox.min[a] + ibox.max[a]) / 2); + + // the shell: the part it attachTo, else the smallest solid box that + // contains the interior's centre (excluding other interiors and decals) + let host: any = interior.attachTo ? byId.get(interior.attachTo) : undefined; + if (!host || isInterior(host)) { + let best: { c: any; vol: number } | undefined; + for (let c of all) { + if ( + c === interior || + c.primitive === 'group' || + isInterior(c) || + c.primitive === 'glow' || + c.primitive === 'textDecal' || + c.primitive === 'curvedDecal' + ) { + continue; + } + let b = specBox(c); + let hh = halfExtents(c); + if (!b || !hh) continue; + if ( + ic[0] > b.min[0] && + ic[0] < b.max[0] && + ic[1] > b.min[1] && + ic[1] < b.max[1] && + ic[2] > b.min[2] && + ic[2] < b.max[2] + ) { + let vol = hh[0] * hh[1] * hh[2]; + if (!best || vol < best.vol) best = { c, vol }; + } + } + host = best?.c; + } + if (!host) continue; + let hh = halfExtents(host); + let hbox = specBox(host); + if (!hh || !hbox) continue; + let hc = [0, 1, 2].map((a) => (hbox.min[a] + hbox.max[a]) / 2); + + // shrink any axis where the interior reaches past FIT of the shell + let scale = Array.isArray(interior.scale) + ? interior.scale.map(Number) + : [1, 1, 1]; + let shrank = false; + for (let a = 0; a < 3; a++) { + let limit = hh[a] * FIT; + if (ih[a] > limit && ih[a] > 0) { + scale[a] = Number((scale[a] * (limit / ih[a])).toFixed(4)); + shrank = true; + } + } + if (shrank) interior.scale = scale; + + // recentre on the shell so it sits fully within it + if ([0, 1, 2].some((a) => Math.abs(ic[a] - hc[a]) > 0.001)) { + let p = Array.isArray(interior.position) + ? interior.position.map(Number) + : [0, 0, 0]; + interior.position = [0, 1, 2].map((a) => + Number((p[a] + hc[a] - ic[a]).toFixed(4)), + ); + shrank = true; + } + if (shrank) { + logs.push( + `fitted '${interior.nodeId}' inside '${host.nodeId}' — an interior stays within its shell`, + ); + } + } + return logs; +} + +// The mirror of resolveBuriedParts: seating a RECESSED feature INTO its host. +// +// resolveBuriedParts pushes a buried part outward because the pipeline assumes +// every feature protrudes. But a window well, a grille cavity, a sunken door +// panel or an air intake goes the other way — its outer face sits just BELOW +// the surrounding surface, and the shadow in that dip is what reads as depth. +// This pushes such a part along the host face it sits on until its outer face +// is RECESS_DEPTH under the surface. Interior/cavity boxes are left to +// clampInteriorCavities (they are centred inside, not sunk into one face). +export function seatRecesses(parsed: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let isInterior = (c: any) => + /\b(interior|cavity)\b/i.test( + `${c?.nodeId} ${c?.partRef ?? ''} ${c?.note ?? ''}`, + ); + const RECESS_DEPTH = 0.03; + + let enclosingHost = (part: any, pc: number[]) => { + let host: any = part.attachTo ? byId.get(part.attachTo) : undefined; + if (host && !isRecessed(host)) return host; + let best: { c: any; vol: number } | undefined; + for (let c of all) { + if ( + c === part || + c.primitive === 'group' || + isRecessed(c) || + c.primitive === 'glow' || + c.primitive === 'textDecal' || + c.primitive === 'curvedDecal' + ) { + continue; + } + let b = specBox(c); + let h = halfExtents(c); + if (!b || !h) continue; + if ( + pc[0] > b.min[0] && + pc[0] < b.max[0] && + pc[1] > b.min[1] && + pc[1] < b.max[1] && + pc[2] > b.min[2] && + pc[2] < b.max[2] + ) { + let vol = h[0] * h[1] * h[2]; + if (!best || vol < best.vol) best = { c, vol }; + } + } + return best?.c; + }; + + for (let part of all) { + if (part.primitive === 'group' || !isRecessed(part) || isInterior(part)) { + continue; + } + let pbox = specBox(part); + if (!pbox) continue; + let pc = [0, 1, 2].map((a) => (pbox.min[a] + pbox.max[a]) / 2); + let host = enclosingHost(part, pc); + if (!host) continue; + let hbox = specBox(host); + if (!hbox) continue; + let hc = [0, 1, 2].map((a) => (hbox.min[a] + hbox.max[a]) / 2); + + // the host face this feature sits on = the axis it is most offset along + let off = [0, 1, 2].map((a) => pc[a] - hc[a]); + let axis = off.reduce( + (best, v, i) => (Math.abs(v) > Math.abs(off[best]) ? i : best), + 0, + ); + if (Math.abs(off[axis]) < 1e-4) continue; + let dir = off[axis] > 0 ? 1 : -1; + let hostSurface = dir > 0 ? hbox.max[axis] : hbox.min[axis]; + let partOuter = dir > 0 ? pbox.max[axis] : pbox.min[axis]; + // sink the outer face RECESS_DEPTH below the host surface + let delta = hostSurface - dir * RECESS_DEPTH - partOuter; + if (Math.abs(delta) < 0.005) continue; + let p = Array.isArray(part.position) + ? part.position.map(Number) + : [0, 0, 0]; + p[axis] = Number(((p[axis] || 0) + delta).toFixed(4)); + part.position = p; + logs.push( + `recessed '${part.nodeId}' into the '${host.nodeId}' surface (sunk ${RECESS_DEPTH})`, + ); + } + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/proportions.gts b/4376bf-img-to-3d-generator/util/spec-passes/proportions.gts new file mode 100644 index 00000000..6ed10287 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/proportions.gts @@ -0,0 +1,346 @@ +// Reconciling built proportions against the measured plan. +// +// The analysis measured each part's bounding box in the reference image; the +// spec then authored dimensions from scratch. Where the two disagree this +// scales the built part toward the measurement — but only along the axes the +// camera could actually see, and only within a band, because a correction +// applied on top of a correction is how a part ends up at 0.4^5 of its size. + +// Measured proportion reconciliation — the heart of "LLM names structure, +// math writes numbers". The draft build is measured in the viewer (real +// world-space boxes, transforms and all), each partRef group is compared +// against its analysis bbox target, and size/placement corrections are +// written back into the spec deterministically. Zero vision calls. +export interface MeasuredBox { + name: string; + min: number[]; + max: number[]; +} + +export interface MeasuredModel { + whole: { min: number[]; max: number[] }; + parts: MeasuredBox[]; +} + +export interface ReconcileResult { + logs: string[]; + // mean deviation of every measured part group from its analysis target + // (0 = perfect match) — the machine metric the auto-verify loop gates on + residual: number | null; +} + +export function reconcileProportions( + parsed: any, + analysis: any, + measured: MeasuredModel, + // part names already embodied by a traced silhouette — their geometry is + // ground truth, rescaling them piecewise would tear the outline apart + skipRefs: string[] = [], +): ReconcileResult { + let logs: string[] = []; + let deviations: number[] = []; + // how far this pass may ever stretch or squash a part, measured on the part's + // FINAL scale rather than on one pass's factor — see the cumulative clamp + // below. A part outside the band was authored that way deliberately and is + // left alone; only reconcile's own contribution is bounded. + const SCALE_FLOOR = 0.4; + const SCALE_CEILING = 2.5; + let clamped: string[] = []; + let result = (): ReconcileResult => ({ + logs, + residual: deviations.length + ? deviations.reduce((s, d) => s + d, 0) / deviations.length + : null, + }); + let plan: any[] = analysis?.partPlan ?? []; + let components: any[] = parsed?.components ?? []; + if (!plan.length || !components.length || !measured?.parts?.length) { + return result(); + } + let norm = (s: any) => + String(s ?? '') + .trim() + .toLowerCase(); + let skip = new Set(skipRefs.map(norm)); + let nums = (raw: any, fallback: number[]): number[] => { + if (Array.isArray(raw)) return raw; + try { + let v = JSON.parse(raw ?? 'null'); + return Array.isArray(v) ? v : fallback; + } catch { + return fallback; + } + }; + + // ---- target side: analysis bboxes, normalized to the OBJECT's bounds + // (the union of all part bboxes), not to the whole photo + let boxes = plan.filter((p) => p?.bbox?.width > 0 && p?.bbox?.height > 0); + if (!boxes.length) return result(); + let uL = Math.min(...boxes.map((p) => p.bbox.left)); + let uT = Math.min(...boxes.map((p) => p.bbox.top)); + let uR = Math.max(...boxes.map((p) => p.bbox.left + p.bbox.width)); + let uB = Math.max(...boxes.map((p) => p.bbox.top + p.bbox.height)); + let uW = uR - uL; + let uH = uB - uT; + if (!(uW > 0) || !(uH > 0)) return result(); + let targets = new Map< + string, + { w: number; h: number; cx: number; cyFromTop: number } + >(); + for (let p of boxes) { + targets.set(norm(p.part), { + w: p.bbox.width / uW, + h: p.bbox.height / uH, + cx: (p.bbox.left + p.bbox.width / 2 - uL) / uW, + cyFromTop: (p.bbox.top + p.bbox.height / 2 - uT) / uH, + }); + } + + // frontal-view assumption: image x ↔ world x only holds near the front + let az = Math.abs(analysis?.camera?.azimuthDeg ?? 0); + let xTrustworthy = az <= 30; + if (!xTrustworthy) { + logs.push(`camera ${az}° off-front — not reconciling widths`); + } + // ...and image y ↔ world y only holds near eye level. Raise the camera and + // the image's vertical axis starts carrying DEPTH: a part further back simply + // appears higher up, which is the same warning the spec prompt gives the + // model about ground placement. Recentering a part vertically from an aerial + // bbox therefore pushes it up by however far back it sits — on a 40° + // elevation house reference that spread the storeys, fence and hedges into a + // stack of floating slabs. Sizes stay reconcilable (a bbox's extent survives + // the tilt far better than its position), so only the vertical MOVE is + // withheld; the declared joints and the ground drop own placement instead. + let el = Math.abs(analysis?.camera?.elevationDeg ?? 0); + let yPlacementTrustworthy = el <= 25; + if (!yPlacementTrustworthy) { + logs.push( + `camera ${el}° above eye level — sizing only, vertical placement left to the declared joints`, + ); + } + + // ---- built side: world boxes grouped by the owning component's partRef. + // Repeat clones are named '-' — fold them back onto their original. + let byId = new Map(components.map((c: any) => [c.nodeId, c])); + let componentFor = (name: string): any => { + if (byId.has(name)) return byId.get(name); + let base = name.replace(/-\d+$/, ''); + return byId.get(base); + }; + interface Group { + minX: number; + maxX: number; + minY: number; + maxY: number; + comps: Set; + boxes: { min: number[]; max: number[] }[]; + } + let groups = new Map(); + for (let part of measured.parts) { + let comp = componentFor(part.name); + let ref = norm(comp?.partRef); + if (!comp || !ref || !targets.has(ref) || skip.has(ref)) continue; + let g = groups.get(ref) ?? { + minX: Infinity, + maxX: -Infinity, + minY: Infinity, + maxY: -Infinity, + comps: new Set(), + boxes: [], + }; + g.minX = Math.min(g.minX, part.min[0]); + g.maxX = Math.max(g.maxX, part.max[0]); + g.minY = Math.min(g.minY, part.min[1]); + g.maxY = Math.max(g.maxY, part.max[1]); + g.boxes.push({ min: part.min, max: part.max }); + g.comps.add(comp); + groups.set(ref, g); + } + if (!groups.size) return result(); + let whole = measured.whole; + let wholeW = whole.max[0] - whole.min[0]; + let wholeH = whole.max[1] - whole.min[1]; + if (!(wholeW > 0) || !(wholeH > 0)) return result(); + + for (let [ref, g] of groups) { + let target = targets.get(ref)!; + let builtW = (g.maxX - g.minX) / wholeW; + let builtH = (g.maxY - g.minY) / wholeH; + if (!(builtW > 0) || !(builtH > 0)) continue; + let wS = xTrustworthy + ? Math.min(SCALE_CEILING, Math.max(SCALE_FLOOR, target.w / builtW)) + : 1; + let hS = Math.min(SCALE_CEILING, Math.max(SCALE_FLOOR, target.h / builtH)); + // world-space target centers (image top ↔ world +Y top) + let builtCx = (g.minX + g.maxX) / 2; + let builtCy = (g.minY + g.maxY) / 2; + let targetCx = whole.min[0] + target.cx * wholeW; + let targetCy = whole.max[1] - target.cyFromTop * wholeH; + // A SPLIT GROUP cannot be positioned from one image bbox. "ground floor + // windows" is one plan part but two components on two different faces of the + // house; their union has a centre that corresponds to nothing in the photo, + // and moving both by that union's error drags them onto the same spot — a + // front window and a side window ended up at identical coordinates, sunk to + // y 0 and half underground. Sizes still mean something (a window is that + // fraction of the object either way), so only the MOVE is withheld. + // ...UNLESS the pieces are a mirrored set on ONE host. Two eyes on a + // muzzle are separate boxes, but they are separate the way the photo's + // "eyes" bbox is: it spans both of them, so their union centre is exactly + // what it measures. The house-windows case this rule was written for is + // different in a way the spec already records — those pieces name + // DIFFERENT hosts (front-wall, side-wall), and no single image bbox can + // place two faces at once. + // + // Getting this wrong is worse than not correcting at all, because it is + // selective: 'nose' is one component and gets recentred from its bbox + // while 'eyes' is two and keeps whatever y the model first guessed. Half + // the face measured and half of it invented is how the eyes ended up + // below the nose on a model whose every individual part passed its check. + let sharedHost = (() => { + let hosts = new Set( + [...g.comps].map((c: any) => String(c.attachTo ?? '').trim()), + ); + return hosts.size === 1 && !hosts.has(''); + })(); + let connected = + sharedHost || + (() => { + if (g.boxes.length < 2) return true; + let joined = [0]; + let touches = (a: any, b: any) => + [0, 1, 2].every( + (ax) => + a.min[ax] <= b.max[ax] + 0.05 && b.min[ax] <= a.max[ax] + 0.05, + ); + let grew = true; + while (grew) { + grew = false; + for (let i = 0; i < g.boxes.length; i++) { + if (joined.includes(i)) continue; + if (joined.some((j) => touches(g.boxes[i], g.boxes[j]))) { + joined.push(i); + grew = true; + } + } + } + return joined.length === g.boxes.length; + })(); + if (!connected) { + logs.push( + `'${ref}' is built as ${g.boxes.length} separate pieces — sizing only, they cannot share one image position`, + ); + } + let dx = xTrustworthy && connected ? targetCx - builtCx : 0; + let dy = yPlacementTrustworthy && connected ? targetCy - builtCy : 0; + // decal-only groups are placement-corrected but never resized here, so + // their size mismatch must not drive the auto-verify metric — the loop + // would chase a number this pass refuses to change + let decalOnly = [...g.comps].every( + (c: any) => + c.primitive === 'curvedDecal' || + c.primitive === 'textDecal' || + c.primitive === 'glow', + ); + deviations.push( + decalOnly + ? Math.max(Math.abs(dx) / wholeW, Math.abs(dy) / wholeH) + : Math.max( + Math.abs(wS - 1), + Math.abs(hS - 1), + Math.abs(dx) / wholeW, + Math.abs(dy) / wholeH, + ), + ); + let sizeOff = Math.abs(wS - 1) > 0.03 || Math.abs(hS - 1) > 0.03; + let posOff = Math.abs(dx) > wholeW * 0.02 || Math.abs(dy) > wholeH * 0.02; + if (!sizeOff && !posOff) continue; + + let applied = 0; + for (let comp of g.comps) { + // decal fitting owns wrap-around geometry (radius/arc) — decals get + // their PLACEMENT corrected here (a label must ride its own bbox + // band even when its host body was rescaled), never their size + let isDecal = + comp.primitive === 'curvedDecal' || + comp.primitive === 'textDecal' || + comp.primitive === 'glow'; + // A tube's dimensions ARE its curve, in coordinates that already say + // where the part runs, so its node transform is not its placement: + // writing a position onto it slides the curve off the body, and scaling + // it moves the curve away from a node origin that sits at 0,0,0 while the + // geometry is metres away. This pass kept re-offsetting a truck's exhaust + // run every round (y -1.26, then 0.09, then -0.50) no matter how often it + // was zeroed upstream, which is the brown arc floating across the hull. + if (comp.primitive === 'tube') continue; + let rotation = nums(comp.rotation, [0, 0, 0]); + let rotated = rotation.some((r) => Math.abs(r) > 0.01); + let parent = comp.parentId ? byId.get(comp.parentId) : undefined; + let parentNeutral = + !parent || + (nums(parent.position, [0, 0, 0]).every((n) => Math.abs(n) < 0.001) && + nums(parent.scale, [1, 1, 1]).every((n) => Math.abs(n - 1) < 0.001) && + nums(parent.rotation, [0, 0, 0]).every((n) => Math.abs(n) < 0.001)); + if (!parentNeutral) continue; // local ≠ world — leave for refine + let pos = nums(comp.position, [0, 0, 0]); + let sxApply = rotated || comp.repeat || isDecal ? 1 : wS; + let syApply = rotated || comp.repeat || isDecal ? 1 : hS; + // The clamp on wS/hS bounds ONE pass, but the passes MULTIPLY: three + // rounds pinned at the 0.4 floor land at 0.064. That is how a balcony + // railing arrived at scale 0.1137 — a wafer 0.045 units tall, buried + // inside its own platform, which had itself been stretched to 2.7989. So + // bound the scale the part ENDS UP with, not the per-pass factor: the + // factor actually applied is whatever brings the part to the edge of the + // allowed band, which still lets a pass correct a proportion but can + // never drive a part degenerate no matter how many rounds run. + let currentScale = nums(comp.scale, [1, 1, 1]); + let limited = (factor: number, axis: number) => { + if (factor === 1) return 1; + let at = Math.abs(currentScale[axis] ?? 1) || 1; + return Math.min(SCALE_CEILING, Math.max(SCALE_FLOOR, at * factor)) / at; + }; + let sxLimited = limited(sxApply, 0); + let syLimited = limited(syApply, 1); + let szLimited = limited(sxApply, 2); + if ( + (Math.abs(sxLimited - sxApply) > 0.001 || + Math.abs(syLimited - syApply) > 0.001) && + clamped.length < 5 + ) { + clamped.push(comp.nodeId); + } + sxApply = sxLimited; + syApply = syLimited; + if (sizeOff && (sxApply !== 1 || syApply !== 1 || szLimited !== 1)) { + let scl = currentScale; + scl[0] = Number((scl[0] * sxApply).toFixed(4)); + scl[2] = Number((scl[2] * szLimited).toFixed(4)); + scl[1] = Number((scl[1] * syApply).toFixed(4)); + comp.scale = scl; + } + // node scaling happens about the node's own origin — place the origin + // so the group's center lands on the target center + pos[0] = Number( + (pos[0] + dx + (builtCx - pos[0]) * (1 - sxApply)).toFixed(4), + ); + pos[1] = Number( + (pos[1] + dy + (builtCy - pos[1]) * (1 - syApply)).toFixed(4), + ); + comp.position = pos; + applied++; + } + if (applied) { + let facts: string[] = []; + if (sizeOff) { + facts.push(`size ×${wS.toFixed(2)}/${hS.toFixed(2)}`); + } + if (posOff) facts.push('recentered'); + logs.push(`reconciled '${ref}' (${facts.join(', ')})`); + } + } + if (clamped.length) { + logs.push( + `held ${clamped.join(', ')} at the ${SCALE_FLOOR}–${SCALE_CEILING}× limit (rounds were compounding)`, + ); + } + return result(); +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/prune.gts b/4376bf-img-to-3d-generator/util/spec-passes/prune.gts new file mode 100644 index 00000000..c9e4cc70 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/prune.gts @@ -0,0 +1,333 @@ +// Taking parts OUT of a reply. +// +// The spec stage over-produces in predictable ways: it invents parts the plan +// never asked for, splits one part into a dozen slivers, and models surface +// marks as hairline geometry that renders as wire hanging in space. Removing a +// part is the one repair that cannot be undone later, so each pass here has to +// be sure — and each removal cascades, because a dropped parent would +// otherwise leave its children floating. + +// The analysis partPlan is the agreed inventory of what the photo actually +// contains, and every visible component is told to name the plan entry it +// realizes in "partRef". Nothing enforced that, so the spec stage could invent +// parts the reference has none of — glass mould seams, extra cap ribs, neck +// collars — and the assembly solver would then glue each unsupported orphan +// onto its nearest neighbour rather than question it, which is what produced +// the clump of spare parts around a bottle neck. This is the gate: a component +// whose partRef names no planned part is deleted, along with anything parented +// to it. It only fires when there IS a plan to check against, so a +// plan-less spec passes through untouched. +// Delete a set of nodeIds and everything parented to them. A surviving child of +// a deleted parent inherits root coordinates and flies off on its own, so the +// cascade is not optional. +function pruneComponents(parsed: any, dead: Set, logs: string[]): void { + if (!dead.size) return; + let components: any[] = parsed?.components ?? []; + let changed = true; + while (changed) { + changed = false; + for (let c of components) { + let id = String(c?.nodeId ?? ''); + if (!id || dead.has(id)) continue; + if (c.parentId != null && dead.has(String(c.parentId))) { + dead.add(id); + logs.push(`dropped '${id}' — its parent was dropped`); + changed = true; + } + } + } + parsed.components = components.filter( + (c: any) => !dead.has(String(c?.nodeId ?? '')), + ); +} + +// Hairline solids: geometry so thin it can only render as a wire hanging in +// space. A mould seam, panel gap, perforation line or printed hairline is a mark +// ON a surface, and the moment it is modelled as a 0.008-radius tube it becomes +// the most visually obtrusive thing in the picture — a long dark line crossing +// the object it was meant to decorate. +// +// This gate is deliberately NAME-BLIND. The partRef gate can be evaded by +// relabelling: the same two mould-seam tubes were rejected in one round as +// partRef 'mold seam' and accepted in the next as partRef 'bottle body'. A +// measurement cannot be argued with, so the test is purely dimensional. +export function dropHairlineParts(parsed: any): string[] { + let logs: string[] = []; + let components: any[] = parsed?.components ?? []; + if (!components.length) return logs; + let nums = (raw: any): number[] => { + if (Array.isArray(raw)) return raw.map(Number).filter((n) => !isNaN(n)); + try { + let v = JSON.parse(String(raw ?? '[]')); + return Array.isArray(v) ? v.map(Number).filter((n) => !isNaN(n)) : []; + } catch { + return []; + } + }; + // thresholds are in world units against the standard 2-3 unit object, where + // 0.02 is about a millimetre of real bottle — below that there is no shape + // left to read, only a line + const MIN_TUBE_RADIUS = 0.02; + const MIN_TORUS_TUBE = 0.008; + const MIN_SOLID_EXTENT = 0.015; + let dead = new Set(); + for (let c of components) { + if (!c?.nodeId || c.primitive === 'group') continue; + let d = nums(c.dimensions); + let reason = ''; + if (c.primitive === 'tube' && d.length && d[0] < MIN_TUBE_RADIUS) { + reason = `tube radius ${d[0]}`; + } else if ( + c.primitive === 'torus' && + d.length > 1 && + d[1] < MIN_TORUS_TUBE + ) { + reason = `torus tube ${d[1]}`; + } else if ( + (c.primitive === 'box' || + c.primitive === 'roundedBox' || + c.primitive === 'cylinder') && + d.slice(0, 3).filter((v) => v > 0 && v < MIN_SOLID_EXTENT).length >= 2 + ) { + reason = `two extents under ${MIN_SOLID_EXTENT}`; + } + if (!reason) continue; + dead.add(String(c.nodeId)); + logs.push( + `dropped '${c.nodeId}' — ${reason}: a surface mark, not geometry (${c.note ?? c.primitive})`, + ); + } + pruneComponents(parsed, dead, logs); + return logs; +} + +export function dropUnplannedParts(parsed: any, analysis: any): string[] { + let logs: string[] = []; + let plan: any[] = analysis?.partPlan ?? []; + let components: any[] = parsed?.components ?? []; + if (!plan.length || !components.length) return logs; + // compare on alphanumerics only: the plan says 'screwcap' and the spec may + // say 'screw cap' or 'screw-cap' for the same part, and dropping a REAL part + // over punctuation would be worse than keeping a fake one + let key = (s: any) => + String(s ?? '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + let plannedKeys = plan.map((p: any) => key(p?.part)).filter(Boolean); + if (!plannedKeys.length) return logs; + // a containment match still counts ('left handle loop' realizes 'handle + // loop'), but only for stems long enough to be meaningful + let planned = (ref: string) => + plannedKeys.some( + (p) => + p === ref || + (ref.length >= 4 && p.includes(ref)) || + (p.length >= 4 && ref.includes(p)), + ); + + let dead = new Set(); + for (let c of components) { + if (!c?.nodeId) continue; + // groups carry hierarchy rather than geometry, and the prompt explicitly + // allows one ground shadow disc that no plan entry covers + if (c.primitive === 'group') continue; + if (/shadow/i.test(String(c.nodeId))) continue; + let ref = key(c.partRef); + if (ref && planned(ref)) continue; + dead.add(String(c.nodeId)); + logs.push( + ref + ? `dropped '${c.nodeId}' — partRef '${c.partRef}' is not a planned part` + : `dropped '${c.nodeId}' — no partRef, so no planned part backs it`, + ); + } + pruneComponents(parsed, dead, logs); + return logs; +} + +// The mirror of dropUnplannedParts, and the more common failure. That gate +// catches a component the plan never asked for; nothing caught a plan entry that +// no component realizes — so a part the reference plainly has just quietly is not +// there. +// +// The case that exposed it: a Cloudy Bay bottle whose plan listed "front label" +// with approach wrap-decal AND an artwork bbox pointing at the label's pixels, +// whose identityFeatures named the label twice, and whose spec even declared an +// m-label material — and then authored four components: body, cap, shadow, root. +// No label. Every existing check passed, because they all ask "is this component +// allowed?" and none asks "is every planned part present?". +// +// A declared material that no component uses is the same story from the other +// end: the model planned the part, gave it paint, and never built it. Worth +// saying out loud because it is often the only trace left. +// +// Report only — geometry cannot be invented from a part name. +export function flagUnrealizedParts(parsed: any, analysis: any): string[] { + let logs: string[] = []; + let plan: any[] = analysis?.partPlan ?? []; + let components: any[] = parsed?.components ?? []; + if (!plan.length || !components.length) return logs; + let key = (s: any) => + String(s ?? '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + let built = new Set( + components + .filter((c: any) => c?.nodeId && c.primitive !== 'group') + .map((c: any) => key(c.partRef)) + .filter(Boolean), + ); + // a spec that names no parts at all cannot be compared against a plan — every + // entry would look missing. In the live pipeline dropUnplannedParts has already + // deleted anything without a valid partRef by this point, so this only guards + // against older specs written before partRef was required. + if (!built.size) return logs; + // the same tolerant match dropUnplannedParts uses, so 'screwcap' and + // 'screw cap' are not reported as two different things + let realized = (planned: string) => + [...built].some( + (b) => + b === planned || + (planned.length >= 4 && b.includes(planned)) || + (b.length >= 4 && planned.includes(b)), + ); + // A "revolved" part is SUPPOSED to disappear into someone else's profile: the + // approach directives tell the model to trace body, shoulder, neck and lip as + // ONE lathe and explicitly forbid adding separate stacked pieces for them. So a + // bottle's planned "neck" with no component of its own is the rule being obeyed, + // not a part going missing — as long as a lathe exists to have absorbed it. + let hasLathe = components.some((c: any) => c?.primitive === 'lathe'); + let missing = plan + .map((p: any) => ({ + name: p?.part, + k: key(p?.part), + absorbable: hasLathe && String(p?.approach ?? '') === 'revolved', + })) + .filter((p) => p.k && !p.absorbable && !realized(p.k)); + for (let part of missing) { + logs.push( + `'${part.name}' is in the plan but NO component realizes it — that part of the reference will simply be absent`, + ); + } + + let usedMaterials = new Set( + components.map((c: any) => key(c?.materialId)).filter(Boolean), + ); + for (let m of parsed?.materials ?? []) { + let k = key(m?.materialId); + if (k && !usedMaterials.has(k)) { + logs.push( + `material '${m.materialId}' is declared but no component uses it — usually the paint for a part that was never built`, + ); + } + } + return logs; +} + +// The partRef gate above catches parts the plan never mentions, but not the +// other half of padding: burying many invented components under ONE legitimate +// part name. A screwcap the plan calls a single part, realized as a body + top +// + skirt + perforation ring + rib band + 20 radial knurl blocks, passes every +// name check while producing the pile of spare geometry around the cap. +// +// How many components a part honestly needs depends on how it is built, and the +// plan already says: a curved-chain or freeform part IS a chain of overlapping +// volumes, while a boxy / revolved / decal part is one or two pieces. So the +// allowance comes from the part's own approach. This only reports — which of a +// part's components are the invented ones cannot be known from counting, and +// deleting the wrong one would break the part. The log makes the padding +// visible so the refine round or a human can act on it. +export function flagOverbuiltParts(parsed: any, analysis: any): string[] { + let logs: string[] = []; + let plan: any[] = analysis?.partPlan ?? []; + let components: any[] = parsed?.components ?? []; + if (!plan.length || !components.length) return logs; + let key = (s: any) => + String(s ?? '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + // chains and freeform masses are MADE of many overlapping volumes; the rest + // of the approaches describe one shape, so a handful of pieces is the ceiling + let allowance = (approach: string): number => + approach === 'curved-chain' || approach === 'freeform-mesh' ? 10 : 4; + let allowanceByPart = new Map(); + for (let p of plan) { + let k = key(p?.part); + if (k) allowanceByPart.set(k, allowance(String(p?.approach ?? ''))); + } + let counts = new Map(); + for (let c of components) { + if (!c?.nodeId || c.primitive === 'group') continue; + if (/shadow/i.test(String(c.nodeId))) continue; + let k = key(c.partRef); + if (!k || !allowanceByPart.has(k)) continue; + counts.set(k, (counts.get(k) ?? 0) + 1); + } + for (let [k, count] of counts) { + let limit = allowanceByPart.get(k) ?? 4; + if (count <= limit) continue; + let name = plan.find((p: any) => key(p?.part) === k)?.part ?? k; + logs.push( + `'${name}' is one planned part but was built from ${count} components (about ${limit} expected) — likely padded`, + ); + } + return logs; +} + +// merges a change set over the current spec, returning the same plain shape +// A part that carries a real cropped-artwork image (textureRef) IS the whole +// printed label — a photo of an inkjet sticker with its text, wordmarks, +// crests, borders and illustrations already baked in. When the model ALSO +// stacks re-typed textDecals, a crest disc, or border boxes ON TOP of it, they +// double-print the same content (blurred, offset) and opaque shapes cover the +// real artwork. Drop every flat-graphic part whose attachTo chain leads to a +// textured decal — the image already contains it. Mutates parsed; returns logs. +export function stripRedundantLabelParts(parsed: any): string[] { + let logs: string[] = []; + let components: any[] = parsed?.components ?? []; + let byId = new Map(components.map((c: any) => [c.nodeId, c])); + // decals that carry a real photo crop — the self-contained printed graphics + let textured = new Set( + components.filter((c: any) => c?.textureRef).map((c: any) => c.nodeId), + ); + if (!textured.size) return logs; + // flat-graphic primitives are the only ones that re-create printed content; + // never drop a genuine solid part that happens to touch the label + let flatGraphic = new Set([ + 'textDecal', + 'curvedDecal', + 'disc', + 'plane', + 'roundedPlate', + 'box', + 'extrudedPolygon', + 'extrudedSpline', + ]); + let onTextured = (c: any): boolean => { + let seen = new Set(); + let cur: any = c; + while (cur?.attachTo && !seen.has(cur.attachTo)) { + if (textured.has(cur.attachTo)) return true; + seen.add(cur.attachTo); + cur = byId.get(cur.attachTo); + } + return false; + }; + let kept: any[] = []; + for (let c of components) { + if ( + c?.nodeId && + c.textureRef == null && + flatGraphic.has(c.primitive) && + onTextured(c) + ) { + logs.push( + `dropped '${c.nodeId}' — already printed in the '${c.attachTo}' label image`, + ); + continue; + } + kept.push(c); + } + parsed.components = kept; + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/run-all.gts b/4376bf-img-to-3d-generator/util/spec-passes/run-all.gts new file mode 100644 index 00000000..420b419b --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/run-all.gts @@ -0,0 +1,101 @@ +// The one ordered run of the deterministic structure passes. +// +// Every pass here is pure — (parsed[, analysis]) => string[] log lines, mutating +// the spec in place — and the ORDER between them is load-bearing: passes read +// each other's output, and two in the wrong order fight (a burial push undoing a +// face seat, a ground snap overruling an inverted attachment). That ordering +// used to live inline in the studio's generate() as a dozen separate loops, +// where it was invisible and untested. Centralising it here makes the sequence +// explicit, keeps the "why this order" notes next to the calls, and lets a test +// run the WHOLE chain over a spec instead of only each pass in isolation. + +import { + dropHairlineParts, + dropUnplannedParts, + flagUnrealizedParts, + flagOverbuiltParts, + repairMirroredAttachments, + enforceAttachments, + groundSupports, + attachOrphans, + ensureFaceParts, + alignFaceFeatures, + seatRingCollars, + resolveBuriedParts, + clampInteriorCavities, + seatRecesses, + flagInstanceCollisions, + flagFlatPalette, + flagSilhouetteNotch, + repairPrimitiveConventions, + separateCoplanarLayers, +} from './index'; + +// Runs the full deterministic structure/geometry repair chain over a freshly +// built spec and returns every log line, in order. Used by the studio's +// generate() (which prefixes each line with "> " for its console) and by the +// integration test. +export function runStructurePasses(parsed: any, analysis: any): string[] { + let logs: string[] = []; + let run = (lines: string[]) => { + for (let line of lines) logs.push(line); + }; + + // The analysis owns the part INVENTORY: a hairline solid is a surface mark + // whatever it is called, and anything the build invented on top of the plan + // goes now, before later passes spend work on it. + run(dropHairlineParts(parsed)); + run(dropUnplannedParts(parsed, analysis)); + // the other direction: a planned part that nothing realized (report only) + run(flagUnrealizedParts(parsed, analysis)); + run(flagOverbuiltParts(parsed, analysis)); + + // Attachments are hard joints, sanity-checked and enforced in the spec's own + // coordinates before anything camera-derived reasons about them. + run(repairMirroredAttachments(parsed)); + run(enforceAttachments(parsed, analysis)); + // runs AFTER the attachment lines — those can be inverted (a plan once said + // the wheels rest on the hull), and grounding is the check no plan overrules. + run(groundSupports(parsed)); + + // Faces: FIRST guarantee the core features exist (the build routinely omits + // eyes/mouth, and dropUnplannedParts above removed anything unplanned), THEN + // align them onto the front and bite the muzzle in. Both run BEFORE + // resolveBuriedParts, which would otherwise surface an embedded muzzle out the + // skull's back face (its cheapest exit) and undo the bite. + run(ensureFaceParts(parsed, analysis)); + run(alignFaceFeatures(parsed, analysis)); + + // a ring that wraps a barrel (muzzle collar, clamp, ferrule) is seated on the + // barrel's axis before burial resolution, so it is not read as a floating part. + run(seatRingCollars(parsed)); + + // seating two parts on the same support can bury one inside the other — check + // after the joints are enforced and the face is placed, not before. + run(resolveBuriedParts(parsed)); + + // a hollow body's dark interior box must stay inside its shell — runs after + // burial (which is told to leave interiors alone) so the shell is already + // placed when the cavity is fitted to it. + run(clampInteriorCavities(parsed)); + // and the other direction from protrusion: a recessed feature (window well, + // grille cavity, sunken panel) is sunk INTO the host surface rather than + // ejected — the mirror of resolveBuriedParts. + run(seatRecesses(parsed)); + + // LAST placement step: after every mover has settled positions, give any part + // still lacking an attachTo the nearest one, so the render's joint solver and + // contact backstop seat it instead of leaving it floating. Runs after the + // movers (so "nearest mass" is judged on final coordinates) and after the face + // passes (so a synthesized eye/nose hangs off the face mass it was just placed + // against, not off some unrelated part). + run(attachOrphans(parsed)); + + run(flagInstanceCollisions(parsed)); + run(flagFlatPalette(parsed)); + run(flagSilhouetteNotch(parsed)); + run(repairPrimitiveConventions(parsed)); + run(separateCoplanarLayers(parsed)); + + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/spec-passes/shape.gts b/4376bf-img-to-3d-generator/util/spec-passes/shape.gts new file mode 100644 index 00000000..1c8fbb35 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/spec-passes/shape.gts @@ -0,0 +1,127 @@ +// Whether each part is the shape it claims to be. +// +// Some primitives carry conventions the spec routinely gets wrong — a tube +// positions itself from its point list, a four-segment cone is a pyramid at +// 45 degrees. And a silhouette with a notch bitten out of it is the signature +// of a body modelled as several blocks that failed to meet. + +import { hasNeutralAncestry, specBox } from '../spec-geometry'; + +// A few primitives carry their own geometry in "dimensions" rather than being +// placed by "position", and mixing the two double-offsets them. A tube's +// dimensions ARE its curve — [radius, x0,y0,z0, x1,y1,z1, …] in the node's own +// frame — so a tube given both a curve and a position lands nowhere near where +// its points say: a truck's exhaust run was authored along the hull's flank and +// then offset to y -1.26, ending up as a brown hook floating across the body. +// Repaired rather than reported, because the points are unambiguous about where +// the part goes. +export function repairPrimitiveConventions(parsed: any): string[] { + let logs: string[] = []; + for (let c of parsed?.components ?? []) { + // tube and bone both carry their placement in "dimensions" (a tube's swept + // points, a bone's two endpoints), so a non-zero "position" double-offsets + // them off where their points say they go + if (c?.primitive !== 'tube' && c?.primitive !== 'bone') continue; + let p = Array.isArray(c.position) ? c.position.map(Number) : []; + if (!p.length || p.every((n: number) => Math.abs(n || 0) < 0.001)) continue; + logs.push( + `zeroed the position of '${c.nodeId}' — a ${c.primitive}'s points already place it`, + ); + c.position = [0, 0, 0]; + } + return logs; +} + +// A part that was never built leaves a NOTCH: the object's height profile along +// its own length reads tower, valley, tower. A truck came back with a 1.03-tall +// cab at the front, a 0.95-tall engine at the back, and 1.1 units of nothing but +// 0.53-tall chassis between them, because the plan split one continuous body into +// three parts and the traced outline only covered the lowest of them. +// +// This is the shape-level counterpart of the buried-part check: there, a part +// exists but cannot be seen; here, the silhouette says a part should exist and +// none does. Real objects are full of steps and slopes, so a dip alone proves +// nothing — what does not happen by design is a LONG dip with taller structure on +// BOTH sides, which is why the test needs flanks rather than just a low spot. +// +// Report only. Which part is missing is a question about the photograph. +export function flagSilhouetteNotch(parsed: any): string[] { + let logs: string[] = []; + let all: any[] = parsed?.components ?? []; + let byId = new Map(all.map((c: any) => [c?.nodeId, c])); + let boxes: { min: number[]; max: number[] }[] = []; + for (let c of all) { + if (!c?.nodeId || c.primitive === 'group') continue; + if (/shadow/i.test(String(c.nodeId))) continue; + if ( + c.primitive === 'glow' || + c.primitive === 'textDecal' || + c.primitive === 'curvedDecal' + ) { + continue; + } + if (!hasNeutralAncestry(c, byId)) continue; + let box = specBox(c); + if (!box) return logs; // an unmeasurable part means the profile is incomplete + boxes.push(box); + } + if (boxes.length < 4) return logs; + let lo = [0, 1, 2].map((a) => Math.min(...boxes.map((b) => b.min[a]))); + let hi = [0, 1, 2].map((a) => Math.max(...boxes.map((b) => b.max[a]))); + // walk the LONGEST horizontal axis — a vehicle's length, a building's frontage + let axis = hi[0] - lo[0] >= hi[2] - lo[2] ? 0 : 2; + let span = hi[axis] - lo[axis]; + if (!(span > 0.5)) return logs; + // thin side parts (fenders, skirts, railings) run the whole length and would + // paper over the gap, so only the central slab of the object is sampled + let cross = axis === 0 ? 2 : 0; + let mid = (lo[cross] + hi[cross]) / 2; + let halfWidth = (hi[cross] - lo[cross]) / 2; + const STEPS = 24; + let profile: number[] = []; + for (let i = 0; i < STEPS; i++) { + let at = lo[axis] + (span * (i + 0.5)) / STEPS; + let top = lo[1]; + for (let b of boxes) { + if (at < b.min[axis] || at > b.max[axis]) continue; + if (b.max[cross] < mid - halfWidth * 0.5) continue; + if (b.min[cross] > mid + halfWidth * 0.5) continue; + if (b.max[1] > top) top = b.max[1]; + } + profile.push(top - lo[1]); + } + let tallest = Math.max(...profile); + if (!(tallest > 0)) return logs; + // the longest run that sits well under full height, with taller ground on both + // sides — a dip at either END is a nose or a tail, not a hole + const LOW = 0.65; + let best: { from: number; to: number } | undefined; + let i = 0; + while (i < STEPS) { + if (profile[i] >= tallest * LOW) { + i++; + continue; + } + let j = i; + while (j < STEPS && profile[j] < tallest * LOW) j++; + let flankedBefore = i > 0 && profile[i - 1] >= tallest * LOW; + let flankedAfter = j < STEPS && profile[j] >= tallest * LOW; + if ( + flankedBefore && + flankedAfter && + (!best || j - i > best.to - best.from) + ) { + best = { from: i, to: j }; + } + i = j; + } + if (!best) return logs; + let runLength = ((best.to - best.from) / STEPS) * span; + // a short dip is a styling step between two masses; a long one is a hole + if (runLength < span * 0.15) return logs; + let dipHeight = Math.min(...profile.slice(best.from, best.to)); + logs.push( + `the silhouette dips to ${dipHeight.toFixed(2)} for ${runLength.toFixed(2)} of its ${span.toFixed(2)} length, with ${tallest.toFixed(2)}-tall structure on both sides — that stretch of the body looks like a part nobody built`, + ); + return logs; +} diff --git a/4376bf-img-to-3d-generator/util/surface-seat.gts b/4376bf-img-to-3d-generator/util/surface-seat.gts new file mode 100644 index 00000000..cc00d418 --- /dev/null +++ b/4376bf-img-to-3d-generator/util/surface-seat.gts @@ -0,0 +1,227 @@ +// Seating surface features on the mass they are mounted on. +// +// The declared-joint solver next door closes GAPS: if a part's box does not +// reach its support's box it translates the part until they overlap by a +// hair. That is the only placement error it can see, and it is the less +// common one. A feature mounted on a rounded mass fails two other ways the +// box test calls correct: +// +// · BURIED — the eye's box sits well inside the muzzle's box, so contact is +// satisfied while nothing of the eye is visible from any angle. +// · DETACHED-BUT-TOUCHING — the muzzle's centre sits outside the skull and +// only its back rim grazes it, so contact is satisfied while the face +// reads as a separate lump floating in front of the head. +// +// Both are the same error measured against the right surface: a feature is +// seated when its centre sits just INSIDE its host's surface, along the +// direction it was authored to face. Boxes cannot express that for a rounded +// host — an ellipsoid can, and every mass this matters for (skull, muzzle, +// eyeball, cushion, fruit) is an ellipsoid to within the accuracy this pass +// needs. +// +// Deliberately narrow. It acts only on the two unambiguous failures above: +// a feature already straddling its host's surface is left exactly where the +// spec put it, because that is what a correct part looks like and any nudge +// would be this pass inventing a placement of its own. +// +// Written as a hoisting function DECLARATION with no module-level references +// and no `?.` / `??`, for the same reason as expandRepeatInstances: the code +// exporter emits its source verbatim via `Function.prototype.toString()`, so +// the studio viewport and a standalone exported model seat parts identically. +export function seatSurfaceParts( + THREE: any, + objects: any, + joints: any[], +): string[] { + let logs: string[] = []; + if (!joints || !joints.length) return logs; + + // ellipsoid radius along a unit direction, from the half extents of a box + function radiusAlong(half: any, unit: any): number { + let hx = Math.max(half.x, 1e-6); + let hy = Math.max(half.y, 1e-6); + let hz = Math.max(half.z, 1e-6); + let q = + (unit.x / hx) * (unit.x / hx) + + (unit.y / hy) * (unit.y / hy) + + (unit.z / hz) * (unit.z / hz); + if (!(q > 0)) return 0; + return 1 / Math.sqrt(q); + } + + function measure(obj: any): any { + let box = new THREE.Box3().setFromObject(obj); + if (box.isEmpty()) return undefined; + let size = box.getSize(new THREE.Vector3()); + return { + center: box.getCenter(new THREE.Vector3()), + half: size.multiplyScalar(0.5), + mean: (size.x + size.y + size.z) / 3, + }; + } + + let hostOf = new Map(); + for (let i = 0; i < joints.length; i++) { + let joint = joints[i]; + if (joint && joint.id && joint.to) hostOf.set(joint.id, joint.to); + } + + // Hosts settle before the features on them: a pupil is seated on an eyeball + // that may itself be moving onto the muzzle this same pass, and reading a + // stale eyeball position would seat the pupil against a surface that is no + // longer there. Depth in the joint chain is that order. + function depthOf(id: string): number { + let depth = 0; + let at = id; + let seen: any = {}; + while (hostOf.has(at) && !seen[at]) { + seen[at] = true; + at = hostOf.get(at); + depth++; + if (depth > 32) break; + } + return depth; + } + let ordered = joints.slice().sort(function (a: any, b: any) { + return depthOf(a.id) - depthOf(b.id); + }); + + // What this pass has already moved, so a feature declared on a moved host + // travels with it. A pupil is authored against its eyeball's position; once + // the eyeball slides round to the front of the face, the pupil's offset is + // stale rather than wrong, and re-seating it from where it was left would + // read the wrong direction and then reject the correction as too large. + let carried = new Map(); + + for (let n = 0; n < ordered.length; n++) { + let joint = ordered[n]; + let obj = objects.get(joint.id); + let host = objects.get(joint.to); + if (!obj || !host || obj === host) continue; + let inherited = carried.get(joint.to); + if (inherited) { + let world = obj.getWorldPosition(new THREE.Vector3()).add(inherited); + obj.position.copy(obj.parent ? obj.parent.worldToLocal(world) : world); + obj.updateWorldMatrix(true, true); + carried.set(joint.id, inherited.clone()); + } + // a nested part's box lives inside its parent's by construction, so + // "buried" is what it is supposed to be + let ancestor = obj.parent; + let nested = false; + while (ancestor) { + if (ancestor === host) { + nested = true; + break; + } + ancestor = ancestor.parent; + } + if (nested) continue; + + let c = measure(obj); + let h = measure(host); + if (!c || !h || !(h.mean > 0) || !(c.mean > 0)) continue; + // Only a FEATURE gets seated. Two parts of comparable size are a + // structural stack (torso on shorts, roof on storey) whose placement the + // declared joint already owns, and pulling one to the other's surface + // would be this pass overruling the assembly graph. + if (c.mean > 0.85 * h.mean) continue; + // ...and only a COMPACT one. An ellipsoid centred on the part is a fair + // model of an eye, a nose or a wheel hub, and a useless model of an arm: + // a limb's centre is half its length away from the joint it hangs off, so + // seating that centre on the shoulder buries the whole arm in the torso. + // The elongated parts are exactly the ones whose placement the declared + // joint already handles well, so requiring roundness costs nothing. + let cH = [c.half.x, c.half.y, c.half.z]; + let cMin = Math.min(cH[0], cH[1], cH[2]); + let cMax = Math.max(cH[0], cH[1], cH[2]); + if (!(cMin > 0) || cMax > 2 * cMin) continue; + + let dir = c.center.clone().sub(h.center); + if (dir.lengthSq() < 1e-8) continue; + let dist = dir.length(); + let unit = dir.clone().divideScalar(dist); + let hostR = radiusAlong(h.half, unit); + let childR = radiusAlong(c.half, unit); + if (!(hostR > 0) || !(childR > 0)) continue; + + // WHICH WAY IS OUT — the error that survives every other check, because + // depth alone cannot see it. For a feature on a free-standing mass, "out" + // is simply away from that mass's centre. But when the host is ITSELF a + // feature bolted to something bigger — a muzzle on a skull — the exposed + // side of the muzzle is the side pointing away from the skull, and a + // feature whose authored offset points the other way is behind the face. + // It can be perfectly seated on the muzzle's surface and still be inside + // the head, visible from nowhere: this Mickey's eyes sat 0.24 behind the + // muzzle's centre and every box test called them attached. + // + // The offset's tangential part carries the feature's left/right and + // up/down placement on the face and is kept as authored; only the + // through-the-face component is rebuilt, positive by construction. + let flipped = false; + let grandId = hostOf.get(joint.to); + let grand = grandId ? objects.get(grandId) : undefined; + let g = grand && grand !== host ? measure(grand) : undefined; + if (g && h.mean <= 0.8 * g.mean) { + let outward = h.center.clone().sub(g.center); + if (outward.lengthSq() > 1e-8) { + outward.normalize(); + let along = dir.dot(outward); + if (along < 0.15 * dist) { + let rebuilt = dir + .clone() + .addScaledVector(outward, -along) + .addScaledVector(outward, 0.6 * dist); + if (rebuilt.lengthSq() > 1e-8) { + dir = rebuilt; + dist = dir.length(); + unit = dir.clone().divideScalar(dist); + hostR = radiusAlong(h.half, unit); + childR = radiusAlong(c.half, unit); + flipped = true; + } + } + } + } + if (!(hostR > 0) || !(childR > 0)) continue; + + // Depth. A seated feature bites into its host by about a quarter of its + // own radius: enough that the join reads as one form, little enough that + // the feature is still mostly proud of the surface. + let seated = hostR + 0.75 * childR; + // Only the two errors the box solver next door CANNOT see. A part merely + // sitting in mid-air short of its host is that solver's case and it + // handles it well; this pass reaching for it too meant an upper arm being + // "seated" 0.34 into the torso, because an ellipsoid centred on a limb is + // nowhere near the shoulder the limb actually hangs from. + let buried = dist + childR <= hostR; + if (!flipped && !buried) continue; + let move = seated - dist; + if (Math.abs(move) < 1e-4) continue; + // never relocate a part across the model: a correction larger than the + // host itself means the spec is wrong somewhere this pass cannot see + if (Math.abs(move) > 1.2 * hostR) { + logs.push( + `'${joint.id}' sits more than its own host's radius out of place on '${joint.to}' — left for refine`, + ); + continue; + } + let why = flipped ? 'was on the hidden side' : 'was buried'; + + let shift = h.center.clone().addScaledVector(unit, seated).sub(c.center); + let previous = carried.get(joint.id); + carried.set( + joint.id, + previous ? previous.add(shift.clone()) : shift.clone(), + ); + let worldPos = obj.getWorldPosition(new THREE.Vector3()).add(shift); + obj.position.copy( + obj.parent ? obj.parent.worldToLocal(worldPos) : worldPos, + ); + obj.updateWorldMatrix(true, true); + logs.push( + `seated '${joint.id}' on '${joint.to}' (${why}, moved ${Math.abs(move).toFixed(2)})`, + ); + } + return logs; +} diff --git a/index.json b/index.json index 64cd2e48..cd73a264 100644 --- a/index.json +++ b/index.json @@ -3,7 +3,7 @@ "meta": { "adoptsFrom": { "name": "Catalog", - "module": "./catalog-app/catalog" + "module": "@cardstack/catalog/catalog-app/catalog" } }, "type": "card", @@ -38,4 +38,4 @@ } } } -} +} \ No newline at end of file