Mike0021 commited on
Commit
39ff632
·
verified ·
1 Parent(s): 62fdf85

Deploy verified img2threejs Docker Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +28 -0
  2. .gitignore +13 -0
  3. Dockerfile +81 -0
  4. LICENSE +21 -0
  5. README.md +165 -5
  6. SKILL.md +136 -0
  7. app/__init__.py +1 -0
  8. app/config.py +202 -0
  9. app/exemplar_spec.json +485 -0
  10. app/forge_bridge.py +314 -0
  11. app/image_guard.py +184 -0
  12. app/llm.py +322 -0
  13. app/main.py +478 -0
  14. app/pipeline.py +530 -0
  15. app/prompt.py +140 -0
  16. app/ratelimit.py +53 -0
  17. app/static/app.js +367 -0
  18. app/static/index.html +109 -0
  19. app/static/logo.svg +33 -0
  20. app/static/styles.css +135 -0
  21. app/static/viewer-core.js +152 -0
  22. app/static/viewer.html +90 -0
  23. docs/SECURITY.md +116 -0
  24. forge/_shared/feature_acceptance_policy.py +113 -0
  25. forge/requirements.txt +6 -0
  26. forge/stage1_intake/build_detail_inventory.py +336 -0
  27. forge/stage1_intake/delight_albedo.py +343 -0
  28. forge/stage1_intake/extract_landmarks.py +447 -0
  29. forge/stage1_intake/extract_pbr_evidence.py +834 -0
  30. forge/stage1_intake/probe_image.py +168 -0
  31. forge/stage1_intake/solve_camera_pose.py +202 -0
  32. forge/stage2_spec/new_pre_spec_assessment.py +113 -0
  33. forge/stage2_spec/new_sculpt_spec.py +1129 -0
  34. forge/stage2_spec/validate_sculpt_spec.py +1730 -0
  35. forge/stage3_build/bake_projected_texture.py +163 -0
  36. forge/stage3_build/generate_threejs_factory.py +986 -0
  37. forge/stage3_build/orchestrate_passes.py +543 -0
  38. forge/stage4_review/append_review.py +384 -0
  39. forge/stage4_review/make_comparison_sheet.py +279 -0
  40. forge/tests/test_pipeline.py +277 -0
  41. grimoire/build/geometry_patterns.md +120 -0
  42. grimoire/character/likeness_maximization.md +74 -0
  43. grimoire/character/reconstruction.md +63 -0
  44. grimoire/feedback/render_capture.md +81 -0
  45. grimoire/feedback/shading_realism.md +80 -0
  46. grimoire/glossary/3d_vocabulary.md +84 -0
  47. grimoire/intake/detail_inventory.md +131 -0
  48. grimoire/intake/quality_contract.md +68 -0
  49. grimoire/intake/validation_rubric.md +66 -0
  50. grimoire/readiness/action_rigging.md +53 -0
.dockerignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VCS / local state
2
+ .git/
3
+ .gitignore
4
+ .workflow/
5
+ upstream-src/
6
+ verification/
7
+ rollout*.jsonl
8
+ .env
9
+ .env.*
10
+ .venv/
11
+
12
+ # Python
13
+ **/__pycache__/
14
+ **/*.py[cod]
15
+ .pytest_cache/
16
+ .coverage
17
+ htmlcov/
18
+
19
+ # Node (installed inside the image via npm ci)
20
+ node_modules/
21
+
22
+ # Test/build artifacts
23
+ forge/tests/
24
+ tests/fixtures/factory_fixture.ts
25
+ *.log
26
+
27
+ # OS cruft
28
+ .DS_Store
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **/__pycache__/
2
+ **/*.py[cod]
3
+ .pytest_cache/
4
+ .coverage
5
+ htmlcov/
6
+ .venv/
7
+ node_modules/
8
+ .env
9
+ .env.*
10
+ *.log
11
+ rollout*.jsonl
12
+ tests/fixtures/factory_fixture.ts
13
+ verification/live-artifacts/
Dockerfile ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ # img2threejs — production Dockerfile for a Hugging Face Docker Space.
3
+ #
4
+ # Build stages:
5
+ # 1. fixture — regenerate the test fixture factory through the real
6
+ # vendored pipeline (strict gate -> explicit unreviewed
7
+ # hosted-preview manifest -> generation). A broken forge
8
+ # copy fails here.
9
+ # 2. nodesmoke — npm ci (three + esbuild), bundle the fixture exactly as
10
+ # the runtime does, and execute it headlessly in node
11
+ # (scene-graph smoke: Group, meshes, finite bbox,
12
+ # sculptRuntime). A non-executing factory fails the build.
13
+ # 3. runtime — python:3.12-slim, non-root UID 1000, tini init,
14
+ # uvicorn on 0.0.0.0:7860, /health HEALTHCHECK.
15
+ # (esbuild is a statically-linked native binary, so the
16
+ # runtime needs no node; node exists only in stage 2.)
17
+
18
+ # ---------------------------------------------------------------------------
19
+ FROM python:3.12-slim-bookworm AS fixture
20
+ WORKDIR /build
21
+ COPY forge/ forge/
22
+ COPY app/__init__.py app/__init__.py
23
+ COPY app/forge_bridge.py app/forge_bridge.py
24
+ COPY scripts/build_fixture_factory.py scripts/build_fixture_factory.py
25
+ COPY tests/fixtures/canned_spec.json tests/fixtures/canned_spec.json
26
+ RUN python3 scripts/build_fixture_factory.py /build/factory_fixture.ts
27
+
28
+ # ---------------------------------------------------------------------------
29
+ FROM node:22-bookworm-slim AS nodesmoke
30
+ WORKDIR /build
31
+ COPY package.json package-lock.json ./
32
+ RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
33
+ COPY --from=fixture /build/factory_fixture.ts /build/factory.ts
34
+ COPY app/static/viewer-core.js app/static/viewer-core.js
35
+ COPY scripts/node_smoke.mjs scripts/node_smoke.mjs
36
+ RUN set -e; \
37
+ factory_export=$(grep -oE 'export function create[A-Za-z0-9]+Model' factory.ts | head -1 | awk '{print $3}'); \
38
+ pascal=$(echo "$factory_export" | sed -E 's/^create//; s/Model$//'); \
39
+ printf 'export { %s as makeModel, create%sLookDevLights as makeLights } from "./factory.ts";\nexport { mountViewer } from "%s";\n' \
40
+ "$factory_export" "$pascal" "/build/app/static/viewer-core.js" > entry.js; \
41
+ node_modules/.bin/esbuild entry.js \
42
+ --bundle --format=esm --target=es2022 --minify --outfile=model.bundle.js; \
43
+ node scripts/node_smoke.mjs /build/model.bundle.js
44
+
45
+ # ---------------------------------------------------------------------------
46
+ FROM python:3.12-slim-bookworm AS runtime
47
+
48
+ ENV PYTHONUNBUFFERED=1 \
49
+ PYTHONDONTWRITEBYTECODE=1 \
50
+ PIP_NO_CACHE_DIR=1 \
51
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
52
+ HOME=/home/user
53
+
54
+ RUN apt-get update \
55
+ && apt-get install -y --no-install-recommends tini curl ca-certificates \
56
+ && rm -rf /var/lib/apt/lists/* \
57
+ && useradd -m -u 1000 user
58
+
59
+ USER user
60
+ WORKDIR /home/user/app
61
+
62
+ COPY --chown=user requirements.txt ./
63
+ RUN pip install --no-cache-dir -r requirements.txt
64
+
65
+ COPY --chown=user package.json package-lock.json ./
66
+ COPY --from=nodesmoke --chown=user /build/node_modules ./node_modules
67
+
68
+ # Runtime source allowlist. Build/test/deploy helpers, fixtures, local
69
+ # verification evidence and workspace state never enter the final image.
70
+ COPY --chown=user:user app/ ./app/
71
+ COPY --chown=user:user forge/ ./forge/
72
+ COPY --chown=user:user LICENSE ./LICENSE
73
+
74
+ EXPOSE 7860
75
+
76
+ # Local-docker convenience only; the HF runner probes app_port over HTTP.
77
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
78
+ CMD curl -fsS http://localhost:7860/health || exit 1
79
+
80
+ ENTRYPOINT ["/usr/bin/tini", "--"]
81
+ CMD ["python", "-m", "app.main"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hoainho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,170 @@
1
  ---
2
- title: Img2threejs
3
- emoji: 📚
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: img2threejs
3
+ emoji: 🧊
4
+ colorFrom: indigo
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
+ short_description: Strict-validated image to procedural Three.js code
11
+ tags:
12
+ - threejs
13
+ - image-to-3d
14
+ - procedural-generation
15
+ - code-generation
16
+ - llm
17
+ startup_duration_timeout: 30m
18
  ---
19
 
20
+ # img2threejs image to procedural Three.js
21
+
22
+ Upload one object reference image. A vision-capable LLM authors an
23
+ `ObjectSculptSpec`; deterministic validators enforce the upstream structural
24
+ and strict-quality rules; and the vendored generator emits a TypeScript
25
+ `THREE.Group` factory. The result renders in a sandboxed browser viewer and is
26
+ downloadable as the original spec, TypeScript, an ESM bundle, and standalone
27
+ HTML.
28
+
29
+ This Docker Space adapts [hoainho/img2threejs](https://github.com/hoainho/img2threejs)
30
+ (MIT). It is reconstruction-by-code, not photogrammetry, mesh extraction, or a
31
+ downloaded asset pack.
32
+
33
+ ## What the hosted workflow guarantees
34
+
35
+ ```text
36
+ image ──▶ byte/type/decompression guard + deterministic probe
37
+ ──▶ vision LLM authors ObjectSculptSpec
38
+ ──▶ strict validator (errors return to the LLM, up to 3 repair rounds)
39
+ ──▶ original spec saved locked and unreviewed
40
+ ──▶ separate hosted-preview compile manifest (all supported parts)
41
+ ──▶ vendored generator + esbuild ──▶ sandboxed viewer/downloads
42
+ ```
43
+
44
+ The hosted preview does **not** fabricate upstream review evidence. The
45
+ downloaded `spec.json` retains an empty `reviewHistory` and its original locked
46
+ pass order. A separate compile copy marks itself
47
+ `hosted-unreviewed-preview` and combines the declared components into one
48
+ preview pass. It contains no screenshot comparison, AI-vision score, reviewer,
49
+ or `continue` decision.
50
+
51
+ The result is an approximate, stylized procedural reconstruction from a single
52
+ image. Hidden sides are inferred rather than observed or measured. Structural
53
+ strict validation is not visual approval; production approval requires running
54
+ the upstream pass-by-pass render, comparison, and review loop outside this
55
+ hosted preview. Unsupported geometry families and parent cycles are rejected
56
+ instead of being replaced by placeholder boxes.
57
+
58
+ ## Required Space Secrets
59
+
60
+ Conversion needs a vision-capable Anthropic Messages or OpenAI-compatible chat
61
+ endpoint. Add these under **Settings → Secrets**:
62
+
63
+ | Secret | Required | Example | Purpose |
64
+ | --- | --- | --- | --- |
65
+ | `LLM_API_KEY` | yes | provider API key | Credential; never logged or returned to the browser. |
66
+ | `LLM_MODEL` | yes | `claude-sonnet-4-5`, `moonshotai/kimi-k3` | Provider model identifier; it must accept images. |
67
+ | `LLM_BASE_URL` | no | `https://api.anthropic.com` | Provider base URL; defaults to Anthropic. |
68
+ | `LLM_API_STYLE` | no | `auto`, `anthropic`, or `openai` | `auto` tries Messages then falls back on HTTP 404. |
69
+ | `LLM_MAX_TOKENS` | no | `16384` | Response budget for the structured spec. |
70
+ | `LLM_TIMEOUT_S` | no | `180` | Per-request timeout in seconds. |
71
+ | `LLM_MAX_RETRIES` | no | `2` | Transient provider retries. |
72
+ | `LLM_REFERER`, `LLM_TITLE` | no | provider-specific | Optional attribution headers. |
73
+
74
+ `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, and
75
+ `ANTHROPIC_MODEL` are accepted as server-side aliases. Without a key and model,
76
+ the app still starts, `/health` reports `llm_configured: false`, and job creation
77
+ returns `503 llm_not_configured`; no model or placeholder is emitted.
78
+
79
+ ## HTTP API
80
+
81
+ ```bash
82
+ curl https://<space-host>/health
83
+ curl -F "file=@object.png" https://<space-host>/api/jobs
84
+ curl -N https://<space-host>/api/jobs/<job-id>/events
85
+ curl https://<space-host>/api/jobs/<job-id>/artifacts/spec.json
86
+ curl https://<space-host>/api/jobs/<job-id>/artifacts/factory.ts
87
+ curl https://<space-host>/api/jobs/<job-id>/artifacts/model.bundle.js
88
+ curl https://<space-host>/api/jobs/<job-id>/artifacts/standalone.html
89
+ ```
90
+
91
+ Uploads are limited to PNG, JPEG, WebP, GIF, or BMP, 10 MiB, and 40 MP by
92
+ default. SVG is rejected. Images are orientation-normalized, metadata-stripped,
93
+ and downscaled to at most 1024 px before processing. Defaults permit 10 jobs per
94
+ hour per client, 2 active conversions, and 8 queued/running jobs total.
95
+
96
+ ## Run and test locally
97
+
98
+ Python 3.12 and Node 22 are the tested versions.
99
+
100
+ ```bash
101
+ python3 -m pip install -r requirements.txt
102
+ npm ci
103
+ pytest -q
104
+ python3 forge/tests/test_pipeline.py
105
+
106
+ LLM_API_KEY=... LLM_MODEL=... LLM_BASE_URL=... python3 -m app.main
107
+ # http://127.0.0.1:7860
108
+ ```
109
+
110
+ Build and verify the same target architecture used by Hugging Face:
111
+
112
+ ```bash
113
+ docker build --platform=linux/amd64 -t img2threejs .
114
+ docker run --rm -p 7860:7860 \
115
+ -e LLM_API_KEY -e LLM_MODEL -e LLM_BASE_URL img2threejs
116
+
117
+ scripts/verify_docker.sh
118
+ ```
119
+
120
+ The Docker build regenerates a fixture factory through the real strict gate
121
+ and hosted-preview compiler, bundles it with esbuild, and executes the bundle
122
+ headlessly in Node. The final runtime image contains only runtime dependencies,
123
+ `app/`, the required `forge/` source, and the license—not tests, scripts,
124
+ rollouts, virtual environments, caches, or the upstream comparison clone.
125
+
126
+ ## Deploy and verify
127
+
128
+ The helper uses the currently authenticated local `hf` CLI account; an
129
+ `HF_TOKEN` environment variable is not required. `--dry-run` performs no Hub
130
+ mutation and prints the exact deterministic upload allowlist.
131
+
132
+ ```bash
133
+ hf auth whoami
134
+ python3 scripts/deploy_space.py --dry-run
135
+ python3 scripts/deploy_space.py # sets present LLM_* aliases as Secrets
136
+ # or: python3 scripts/deploy_space.py --no-secrets
137
+
138
+ hf spaces logs Mike0021/img2threejs --build --follow
139
+ hf spaces logs Mike0021/img2threejs --follow
140
+
141
+ python3 scripts/verify_space.py # wait/info/logs + GET-only probes
142
+ python3 scripts/verify_space.py \
143
+ --e2e tests/fixtures/mug_photo.png # real SSE job + artifact/node smoke
144
+ ```
145
+
146
+ The deploy helper stages only its allowlist and issues one sanitized
147
+ `hf upload`. Secret values are passed in a temporary mode-0600 secrets file;
148
+ only secret names are printed. The live verifier never submits a job unless an
149
+ explicit `--e2e IMAGE` is supplied.
150
+
151
+ ## Repository layout
152
+
153
+ | Path | Purpose |
154
+ | --- | --- |
155
+ | `app/` | FastAPI service, LLM client, pipeline adapter, SPA, and sandboxed viewer |
156
+ | `forge/` | Vendored upstream pipeline with targeted runtime generator/safety fixes |
157
+ | `grimoire/`, `SKILL.md` | Upstream rubrics and complete agent workflow |
158
+ | `tests/` | Unit, HTTP, pipeline, manifest, and render smoke tests |
159
+ | `scripts/` | Fixture builder, Node smoke, Docker verifier, deployer, live verifier |
160
+ | `docs/SECURITY.md` | Threat model, controls, and accepted limitations |
161
+
162
+ The targeted `forge/` changes preserve declared component dimensions, avoid
163
+ misinterpreting non-axis attachments, and reject unsupported hosted-preview
164
+ geometry rather than emitting TODO placeholders. They are not represented as
165
+ a byte-for-byte upstream tree.
166
+
167
+ ## License and attribution
168
+
169
+ MIT — © 2026 hoainho (upstream) and this Space's contributors. See
170
+ `LICENSE`. three.js is MIT © the three.js authors.
SKILL.md ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: img2threejs
3
+ description: Turn an object or character reference image into a quality-gated, animation-ready procedural Three.js model built in code. Use for image-to-3D reconstruction, detail-accurate object rebuilds, stylized/likeness-maximized human characters, sculpt specs, and staged code generation.
4
+ license: MIT
5
+ version: 1.2.0
6
+ ---
7
+
8
+ # img2threejs — Image to procedural Three.js
9
+
10
+ Rebuild the object visible in a reference image as a **code-only** procedural Three.js model,
11
+ gated by a staged sculpting pipeline and an AI-vision self-correction loop. This is
12
+ reconstruction-by-code, **not** photogrammetry, mesh extraction, or downloaded art packs.
13
+
14
+ Agent-agnostic: works under Claude Code, Codex, or OpenCode. Wherever this doc says "agent
15
+ vision" or "agent browser tool", use whatever the host provides — native image reading, a
16
+ browser MCP (playwright/chrome-devtools), the project preview, or a user-supplied screenshot.
17
+
18
+ ## When To Use
19
+
20
+ The user attaches/points to an object image and wants a procedural Three.js model, a
21
+ reconstruction/animation/destruction plan, a sculpt spec, or code. Also for material studies,
22
+ action-ready props, game objects, botanical/mechanical parts, and stylized reconstructions.
23
+
24
+ ## Core Promise
25
+
26
+ Sculpt from a photo, in order — never one-shot a mesh:
27
+ 1. **Validate** the image is a suitable 3D target (`grimoire/intake/validation_rubric.md`).
28
+ 2. **Assess** object class + complexity, then write a `qualityContract` before any code.
29
+ 3. **Spec** it: component hierarchy, materials, lighting, pivots, sockets, action anchors.
30
+ 4. **Build pass-by-pass** from blockout → structure → form → material → lighting → interaction → optimization.
31
+ 5. **Verify** each pass with a screenshot compared against the reference; fail a pass if an identity-defining feature is wrong even when the global score looks fine.
32
+
33
+ State explicitly when output is approximate/stylized/low-poly. A single image cannot reveal
34
+ hidden sides or guarantee exact geometry — say so instead of faking confidence.
35
+
36
+ ## Required Inputs
37
+
38
+ - one image path / screenshot / URL / attached image (if missing or unreadable, ask)
39
+ - intended use: prop, game object, hero render, playable/destructible object, animation rig
40
+ (default: real-time browser prop with interactive performance)
41
+
42
+ ## The Loop (scripts do enforcement; agent vision does judgment)
43
+
44
+ Run scripts from the skill root (`forge/...`). Pure Python 3.10+ stdlib, no pip installs.
45
+ Full flags: `grimoire/scripts.md`. Never let a script *score* visuals — that is the agent's job.
46
+
47
+ 1. Probe local images: `forge/stage1_intake/probe_image.py <image>` (metadata only, not a visual check).
48
+ 2. **Pre-Spec Assessment Gate** — classify + score complexity + write the quality contract:
49
+ `forge/stage2_spec/new_pre_spec_assessment.py "Name" --image <img> --complexity <simple|moderate|complex|ultra-complex> --out assessment.json`. Rules: `grimoire/intake/quality_contract.md`.
50
+ Set `objectClass.primaryDomain` (`object` | `character` | `hybrid`) and fill the seeded
51
+ `detailInventory` (its `targetMinDetails` scales with complexity).
52
+ 2b. **Detail inventory** (do not skip for detailed subjects) — scan zones and enumerate every
53
+ identity-defining small detail (gloss, bevel, fasteners, linework, contours, stains):
54
+ `forge/stage1_intake/build_detail_inventory.py <image> --mode grid-3x3 --out-dir <dir> --out di.json`.
55
+ Each detail MUST map to a `component.localFeatures` or `material.localOverrides` entry — never
56
+ prose only. Taxonomy + 3D-term recipes: `grimoire/intake/detail_inventory.md`.
57
+ 2c. **Character/hybrid subjects** — capture head-unit proportions + facial/body landmarks:
58
+ `forge/stage1_intake/extract_landmarks.py <image> --out anatomy.json --overlay overlay.png`, then
59
+ fill `preSpecAssessment.anatomy`. Route: `grimoire/character/reconstruction.md`. For maximum
60
+ likeness use the projection-first path (`grimoire/character/likeness_maximization.md`): solve the camera
61
+ (`stage1_intake/solve_camera_pose.py`), de-light the photo (`stage1_intake/delight_albedo.py`), and project it onto
62
+ the fitted mesh (`stage3_build/bake_projected_texture.py`). A single image cannot guarantee 100% likeness —
63
+ report per-region confidence and request more views for a real person.
64
+ 3. Author the spec from the assessment:
65
+ `forge/stage2_spec/new_sculpt_spec.py "Name" --image <img> --assessment assessment.json --out object-sculpt-spec.json`.
66
+ Replace generic starter `featureReviewTargets` with the object's real identity-defining
67
+ systems (≤5 critical, ≤3 important per pass); for characters add `anatomy-proportion`,
68
+ `face-landmark-placement`, `pose-silhouette`, `outfit-and-palette`. Use 3D-graphics terms only
69
+ (`grimoire/glossary/3d_vocabulary.md`), never "nice/smooth/shiny".
70
+ 4. When material fidelity matters and a source image exists, extract reference PBR evidence per crop:
71
+ `forge/stage1_intake/extract_pbr_evidence.py <crop> --out-dir <dir> --material-id <id> --target-threshold 0.7`.
72
+ Confidence < 0.7 is a stop/refine-input signal, not a pass. It is inference, not inverse rendering.
73
+ 5. Validate, then strict-validate before generating code:
74
+ `forge/stage2_spec/validate_sculpt_spec.py object-sculpt-spec.json` then `--strict-quality`.
75
+ Strict blocks shallow specs (a complex object with one root, no repetition systems, no
76
+ local overrides, no micro groups is NOT implementation-ready even if JSON validates).
77
+ 6. **Locked build passes** — only touch the currently unlocked pass:
78
+ `forge/stage3_build/orchestrate_passes.py status object-sculpt-spec.json`
79
+ `forge/stage3_build/orchestrate_passes.py check object-sculpt-spec.json --pass-id <pass>`
80
+ `forge/stage3_build/generate_threejs_factory.py object-sculpt-spec.json --out src/createObjectModel.ts`
81
+ (generator is pass-gated: a future `--pass-id` fails until prior passes are reviewed `continue`).
82
+ 7. Render the current pass in a browser/preview, capture a screenshot at a review viewpoint.
83
+ 8. Package one side-by-side sheet, then inspect it with agent vision:
84
+ `forge/stage4_review/make_comparison_sheet.py --reference <img> --render <shot> --out cmp.png --json`.
85
+ 9. Record the review (overall + per-layer + per-feature scores + decision):
86
+ `forge/stage4_review/append_review.py object-sculpt-spec.json --pass-id <pass> --fidelity <0-1> --action <continue|refine-spec|refine-code|request-input|stop> --summary "..." --render-screenshot <shot> --comparison-image cmp.png --ai-vision-score <0-1> --layer-scores-json '{...}' --feature-reviews-json <f.json> --in-place`.
87
+ 10. Sync pipeline state after manual review edits:
88
+ `forge/stage3_build/orchestrate_passes.py sync object-sculpt-spec.json --in-place`.
89
+
90
+ ## Gates (do not skip)
91
+
92
+ - **Suitability**: pass / conditional / reject before any planning. `grimoire/intake/validation_rubric.md`.
93
+ - **Pre-spec / strict-quality**: blocks code gen until the spec is deep enough for its contract.
94
+ - **Screenshot feedback**: `continue` is allowed only with a render + comparison sheet + global
95
+ AI-vision score ≥ threshold (default 0.7) AND every critical feature ≥ its own threshold.
96
+ Details + per-layer scorecard: `grimoire/feedback/render_capture.md`.
97
+ - **Action-ready**: build a runtime hierarchy (pivots, sockets, colliders, destruction groups),
98
+ never an inert lump; expose `root.userData.sculptRuntime`. `grimoire/readiness/action_rigging.md`.
99
+ - **Attachment**: child appendages (branches/limbs/handles/tubes) need `attachment.parentSocket`,
100
+ `localStart`, `localEnd`, `contactType`, `embedDepth`/`overlap`, `gapTolerance` — no mid-air parts.
101
+ `grimoire/readiness/joint_attachment.md`.
102
+ - **Material/lighting**: `grimoire/feedback/shading_realism.md` — independent PBR channels
103
+ (never alias albedo into roughness/normal/AO), macro/meso/micro frequency bands, real lights.
104
+ - **Detail inventory**: for `moderate`+ subjects strict-quality blocks code gen until the
105
+ `detailInventory` reaches `targetMinDetails` and every detail maps to a real component/material
106
+ entry (gloss needs low-roughness/clearcoat; fasteners need instancing/micro parts).
107
+ - **Character track**: when `primaryDomain` is `character`/`hybrid` (or `--character`), the spec
108
+ author auto-builds a stylized humanoid template (head/neck/torso/arms + hair, glasses,
109
+ headphones, face features), flattened to world space under a hidden root, with per-part
110
+ character materials and character build passes (`proportion-lock`, `feature-placement`).
111
+ strict-quality requires a filled `anatomy` block (head-units, proportions, face landmarks) and
112
+ character feature targets. Suitability routing for humans: `grimoire/intake/validation_rubric.md`
113
+ (stylized vs maximum-likeness). Stylized bust, not a face-copy; refine positions per reference.
114
+
115
+ ## Self-Correction
116
+
117
+ After every pass, decide exactly one: `continue | refine-spec | refine-code | request-input | stop`.
118
+ `refine-spec` fixes a wrong/missing/shallow spec (re-validate, don't patch code around it);
119
+ `refine-code` fixes geometry/material/lighting that doesn't match a sound spec. Full root-cause
120
+ guide + fidelity scale: `grimoire/review/self_correction.md`.
121
+
122
+ ## Implementation Rules (brief)
123
+
124
+ TypeScript + plain Three.js unless the project uses a wrapper. `Group` factory
125
+ `createObjectNameModel(spec, options)`, reconstruction data kept separate from renderer objects,
126
+ deterministic seeds for all procedural noise. Prefer primitives / `Shape` extrude / curve+tube /
127
+ instancing / displacement / generated canvas textures before any external art. Full geometry &
128
+ material recipes + hard-won failure patterns: `grimoire/build/geometry_patterns.md`.
129
+
130
+ ## Output
131
+
132
+ - **Analysis-only**: suitability verdict + scores, object extraction, macro→micro hierarchy,
133
+ geometry strategy, material/lighting recipe, animation/destruction feasibility, plan + risks.
134
+ - **Implementation**: the above briefly, then edit code; verify with typecheck/build + a screenshot.
135
+ - **Not feasible**: name the blocker, ask for more views / cleaner image / accepted stylization /
136
+ a narrower target. "This cannot reach the requested fidelity from this image" is a valid result.
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """img2threejs Hugging Face Space application package."""
app/config.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime configuration for the img2threejs Space.
2
+
3
+ All values come from environment variables. On Hugging Face Spaces the
4
+ ``LLM_*`` variables are meant to be set as *Space Secrets* (Settings ->
5
+ Secrets); they are injected into the process environment at runtime.
6
+
7
+ The conventional ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_BASE_URL`` /
8
+ ``ANTHROPIC_MODEL`` names are honoured as fallbacks so the Space also works
9
+ when a deployer only sets those.
10
+
11
+ Nothing in this module may ever log or return the API key value.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import os
18
+ from dataclasses import dataclass, field
19
+
20
+
21
+ def _first(*names: str) -> str | None:
22
+ for name in names:
23
+ value = os.environ.get(name)
24
+ if value and value.strip():
25
+ return value.strip()
26
+ return None
27
+
28
+
29
+ def _int(
30
+ name: str,
31
+ default: int,
32
+ *,
33
+ minimum: int | None = None,
34
+ maximum: int | None = None,
35
+ ) -> int:
36
+ """Read an integer setting without allowing malformed or extreme input.
37
+
38
+ Environment variables are an operational boundary, not trusted Python
39
+ values. Falling back on parse failure keeps the app bootable; clamping
40
+ keeps an accidental value such as ``MAX_CONCURRENT_JOBS=-1`` from
41
+ disabling a guard or allocating an unreasonable amount of work.
42
+ """
43
+ raw = os.environ.get(name)
44
+ value = default
45
+ if raw is not None:
46
+ try:
47
+ value = int(raw.strip())
48
+ except (TypeError, ValueError):
49
+ value = default
50
+ if minimum is not None:
51
+ value = max(minimum, value)
52
+ if maximum is not None:
53
+ value = min(maximum, value)
54
+ return value
55
+
56
+
57
+ def _float(
58
+ name: str,
59
+ default: float,
60
+ *,
61
+ minimum: float | None = None,
62
+ maximum: float | None = None,
63
+ ) -> float:
64
+ """Read a finite, optionally clamped floating-point setting."""
65
+ raw = os.environ.get(name)
66
+ value = default
67
+ if raw is not None:
68
+ try:
69
+ parsed = float(raw.strip())
70
+ value = parsed if math.isfinite(parsed) else default
71
+ except (TypeError, ValueError):
72
+ value = default
73
+ if minimum is not None:
74
+ value = max(minimum, value)
75
+ if maximum is not None:
76
+ value = min(maximum, value)
77
+ return value
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Settings:
82
+ """Immutable runtime settings snapshot."""
83
+
84
+ # --- LLM provider (Space Secrets) -------------------------------------
85
+ llm_api_key: str | None = field(
86
+ default_factory=lambda: _first("LLM_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
87
+ )
88
+ llm_base_url: str = field(
89
+ default_factory=lambda: _first("LLM_BASE_URL", "ANTHROPIC_BASE_URL")
90
+ or "https://api.anthropic.com"
91
+ )
92
+ llm_model: str | None = field(
93
+ default_factory=lambda: _first("LLM_MODEL", "ANTHROPIC_MODEL")
94
+ )
95
+ # "anthropic" = Messages API only, "openai" = chat/completions only,
96
+ # "auto" = anthropic first, fall back to openai on HTTP 404.
97
+ llm_api_style: str = field(
98
+ default_factory=lambda: (os.environ.get("LLM_API_STYLE") or "auto").strip().lower()
99
+ )
100
+ # Reasoning models (e.g. kimi-k3) spend thinking tokens inside this
101
+ # budget; 8192 truncates real specs. 16384 verified end-to-end.
102
+ llm_max_tokens: int = field(
103
+ default_factory=lambda: _int(
104
+ "LLM_MAX_TOKENS", 16384, minimum=256, maximum=131_072
105
+ )
106
+ )
107
+ llm_timeout_s: float = field(
108
+ default_factory=lambda: _float(
109
+ "LLM_TIMEOUT_S", 180.0, minimum=1.0, maximum=1800.0
110
+ )
111
+ )
112
+ llm_max_retries: int = field(
113
+ default_factory=lambda: _int("LLM_MAX_RETRIES", 2, minimum=0, maximum=10)
114
+ )
115
+ llm_referer: str | None = field(default_factory=lambda: _first("LLM_REFERER"))
116
+ llm_title: str | None = field(default_factory=lambda: _first("LLM_TITLE"))
117
+
118
+ # --- pipeline behaviour ------------------------------------------------
119
+ spec_repair_rounds: int = field(
120
+ default_factory=lambda: _int("SPEC_REPAIR_ROUNDS", 3, minimum=0, maximum=10)
121
+ )
122
+
123
+ # --- HTTP / server ------------------------------------------------------
124
+ port: int = field(
125
+ default_factory=lambda: _int("PORT", 7860, minimum=1, maximum=65_535)
126
+ )
127
+ runs_dir: str = field(default_factory=lambda: os.environ.get("RUNS_DIR", "/tmp/i2t-runs"))
128
+ max_upload_bytes: int = field(
129
+ default_factory=lambda: _int(
130
+ "MAX_UPLOAD_BYTES", 10 * 1024 * 1024,
131
+ minimum=64 * 1024, maximum=50 * 1024 * 1024,
132
+ )
133
+ )
134
+ max_image_pixels: int = field(
135
+ default_factory=lambda: _int(
136
+ "MAX_IMAGE_PIXELS", 40_000_000, minimum=4096, maximum=100_000_000
137
+ )
138
+ )
139
+ # Longest-side pixel cap for the normalised image handed to the forge
140
+ # scripts (pure-Python per-pixel readers) and to the LLM.
141
+ normalize_max_side: int = field(
142
+ default_factory=lambda: _int(
143
+ "NORMALIZE_MAX_SIDE", 1024, minimum=64, maximum=8192
144
+ )
145
+ )
146
+ job_ttl_s: int = field(
147
+ default_factory=lambda: _int(
148
+ "JOB_TTL_S", 2 * 60 * 60, minimum=60, maximum=7 * 24 * 60 * 60
149
+ )
150
+ )
151
+ # Wall-clock bound for a queued/running pipeline. ``run_job`` owns the
152
+ # timeout transition so every timeout produces the same terminal event as
153
+ # other pipeline failures.
154
+ job_timeout_s: float = field(
155
+ default_factory=lambda: _float(
156
+ "JOB_TIMEOUT_S", 900.0, minimum=30.0, maximum=3600.0
157
+ )
158
+ )
159
+ max_concurrent_jobs: int = field(
160
+ default_factory=lambda: _int(
161
+ "MAX_CONCURRENT_JOBS", 2, minimum=1, maximum=16
162
+ )
163
+ )
164
+ # Hard cap on queued+running jobs (each pins its upload bytes in memory).
165
+ max_in_flight_jobs: int = field(
166
+ default_factory=lambda: _int(
167
+ "MAX_IN_FLIGHT_JOBS", 8, minimum=1, maximum=64
168
+ )
169
+ )
170
+ rate_limit_jobs_per_hour: int = field(
171
+ default_factory=lambda: _int(
172
+ "RATE_LIMIT_JOBS_PER_HOUR", 10, minimum=1, maximum=10_000
173
+ )
174
+ )
175
+
176
+ # --- tooling ------------------------------------------------------------
177
+ # esbuild 0.25+ ships a statically-linked native binary (no node needed
178
+ # at runtime); the .bin path is an npm-managed symlink to it.
179
+ esbuild_entry: str = field(
180
+ default_factory=lambda: os.environ.get("ESBUILD_ENTRY", "node_modules/.bin/esbuild")
181
+ )
182
+
183
+ # --- informational -------------------------------------------------------
184
+ space_id: str | None = field(default_factory=lambda: _first("SPACE_ID"))
185
+ space_host: str | None = field(default_factory=lambda: _first("SPACE_HOST"))
186
+
187
+ @property
188
+ def llm_configured(self) -> bool:
189
+ return bool(self.llm_api_key and self.llm_model)
190
+
191
+ @property
192
+ def missing_llm_vars(self) -> list[str]:
193
+ missing: list[str] = []
194
+ if not self.llm_api_key:
195
+ missing.append("LLM_API_KEY")
196
+ if not self.llm_model:
197
+ missing.append("LLM_MODEL")
198
+ return missing
199
+
200
+
201
+ def load_settings() -> Settings:
202
+ return Settings()
app/exemplar_spec.json ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "targetName": "Mug",
3
+ "targetId": "mug",
4
+ "schemaVersion": "2.0",
5
+ "suitability": "pass",
6
+ "sourceImage": "ref.png",
7
+ "coordinateFrame": {
8
+ "up": "+Y",
9
+ "forward": "+Z",
10
+ "units": "meters",
11
+ "origin": "ground-center"
12
+ },
13
+ "silhouette": {
14
+ "primaryAxis": "y",
15
+ "dominantCurves": [
16
+ "cylindrical body"
17
+ ],
18
+ "negativeSpaces": [
19
+ "handle loop"
20
+ ]
21
+ },
22
+ "scores": {
23
+ "object_isolation": 3,
24
+ "silhouette_readability": 3
25
+ },
26
+ "terminologyProfile": {
27
+ "geometryTerms": [
28
+ "cylinder",
29
+ "torus",
30
+ "bevel"
31
+ ],
32
+ "materialTerms": [
33
+ "albedo",
34
+ "roughness",
35
+ "clearcoat"
36
+ ],
37
+ "lightingTerms": [
38
+ "key light",
39
+ "fill light",
40
+ "rim light"
41
+ ],
42
+ "descriptionRule": "measurable 3D terms only"
43
+ },
44
+ "preSpecAssessment": {
45
+ "objectClass": {
46
+ "primaryType": "vessel",
47
+ "primaryDomain": "object",
48
+ "formLanguage": [
49
+ "cylindrical",
50
+ "hard-surface"
51
+ ],
52
+ "structureKind": [
53
+ "single-body-with-appendage"
54
+ ],
55
+ "motionPotential": [
56
+ "static"
57
+ ],
58
+ "materialFamilies": [
59
+ "glazed-ceramic"
60
+ ]
61
+ },
62
+ "complexity": {
63
+ "tier": "simple",
64
+ "scores": {
65
+ "silhouetteComplexity": 1,
66
+ "componentCount": 1
67
+ },
68
+ "estimatedCounts": {
69
+ "macroComponents": 1,
70
+ "mesoComponents": 1,
71
+ "microFeatureGroups": 1,
72
+ "materialLayers": 1,
73
+ "repetitionSystems": 0
74
+ },
75
+ "reasoning": [
76
+ "single body plus handle"
77
+ ]
78
+ },
79
+ "specDepthDecision": {
80
+ "requiredDepth": "simple",
81
+ "minimumComponentLevels": [
82
+ "macro"
83
+ ],
84
+ "needsRepetitionSystems": false,
85
+ "needsMaterialLocalOverrides": true,
86
+ "needsMultipleReviewViews": true,
87
+ "needsActionReadyHierarchy": true
88
+ },
89
+ "unknownsToResolveBeforeImplementation": [],
90
+ "detailInventory": {
91
+ "scanMethod": "component-zones",
92
+ "targetMinDetails": 3,
93
+ "details": [
94
+ {
95
+ "id": "d1",
96
+ "kind": "gloss",
97
+ "zone": "body",
98
+ "mapsTo": {
99
+ "ref": "ceramic/glaze-gloss"
100
+ }
101
+ },
102
+ {
103
+ "id": "d2",
104
+ "kind": "bevel",
105
+ "zone": "rim",
106
+ "mapsTo": {
107
+ "ref": "body/rim-bevel"
108
+ }
109
+ },
110
+ {
111
+ "id": "d3",
112
+ "kind": "contour",
113
+ "zone": "handle",
114
+ "mapsTo": {
115
+ "ref": "handle"
116
+ }
117
+ }
118
+ ]
119
+ }
120
+ },
121
+ "qualityContract": {
122
+ "qualityBar": "simple",
123
+ "definitionOfDone": [
124
+ "render matches silhouette, glaze gloss, handle attachment"
125
+ ],
126
+ "minimumSpecDepth": {
127
+ "macroComponents": 1,
128
+ "mesoComponents": 0,
129
+ "microFeatureGroups": 0,
130
+ "materialLayers": 1,
131
+ "repetitionSystems": 0,
132
+ "reviewViewpoints": 2
133
+ },
134
+ "featureGroups": [
135
+ {
136
+ "id": "overall-silhouette",
137
+ "name": "Silhouette",
138
+ "required": true,
139
+ "qualityCriteria": [
140
+ "cylindrical body and handle loop read correctly"
141
+ ]
142
+ },
143
+ {
144
+ "id": "primary-structure",
145
+ "name": "Structure",
146
+ "required": true,
147
+ "qualityCriteria": [
148
+ "body/handle hierarchy named"
149
+ ]
150
+ },
151
+ {
152
+ "id": "surface-material-response",
153
+ "name": "Material",
154
+ "required": true,
155
+ "qualityCriteria": [
156
+ "glaze gloss and roughness variation specified"
157
+ ]
158
+ }
159
+ ],
160
+ "visualDeltaChecks": [
161
+ "silhouette delta"
162
+ ],
163
+ "antiShallowSpecRules": [
164
+ "no single-root compound objects"
165
+ ]
166
+ },
167
+ "qualityTargets": {
168
+ "targetFidelity": 0.7,
169
+ "mustMatch": [
170
+ "silhouette"
171
+ ],
172
+ "niceToHave": [],
173
+ "reviewViewpoints": [
174
+ "front",
175
+ "three-quarter"
176
+ ]
177
+ },
178
+ "actionReadiness": {
179
+ "contract": "runtime hierarchy with pivots and sockets",
180
+ "defaultRigType": "static-prop",
181
+ "rootMotionNode": "root",
182
+ "requiredComponentFields": [
183
+ "actionProfile"
184
+ ],
185
+ "transformChannels": [
186
+ "translate",
187
+ "rotate"
188
+ ],
189
+ "authoringRules": [
190
+ "no floating parts"
191
+ ]
192
+ },
193
+ "selfCorrectLoop": {
194
+ "enabled": true,
195
+ "reviewAfterPasses": [
196
+ "blockout"
197
+ ],
198
+ "allowedActions": [
199
+ "continue",
200
+ "refine-spec",
201
+ "refine-code",
202
+ "request-input",
203
+ "stop"
204
+ ],
205
+ "visualAcceptance": {
206
+ "reviewer": "ai-vision",
207
+ "threshold": 0.7,
208
+ "comparisonArtifactRequired": true,
209
+ "layerScoresRequired": true,
210
+ "requiredLayerScores": [
211
+ "silhouetteProportion"
212
+ ],
213
+ "featureReviewPolicy": {
214
+ "enabled": true,
215
+ "maxCriticalFeaturesPerPass": 5,
216
+ "maxImportantFeaturesPerPass": 3,
217
+ "criticalDefaultThreshold": 0.8,
218
+ "importantAverageThreshold": 0.65,
219
+ "adaptiveEscalation": true,
220
+ "singleImagePairOnly": true,
221
+ "reviewUnit": "semantic-subsystem",
222
+ "selectionRule": "most salient systems"
223
+ }
224
+ },
225
+ "screenshotPolicy": {
226
+ "requiredForPasses": [
227
+ "blockout"
228
+ ],
229
+ "preferredCapture": "in-app"
230
+ }
231
+ },
232
+ "featureReviewTargets": [
233
+ {
234
+ "id": "overall-silhouette",
235
+ "name": "Body silhouette",
236
+ "tier": "critical",
237
+ "passIds": [
238
+ "blockout"
239
+ ],
240
+ "minimumScore": 0.8,
241
+ "mustPass": true,
242
+ "componentRefs": [
243
+ "body"
244
+ ],
245
+ "evidenceRefs": [
246
+ "full-object"
247
+ ]
248
+ }
249
+ ],
250
+ "buildPasses": [
251
+ {
252
+ "id": "blockout",
253
+ "goal": "macro silhouette",
254
+ "componentRefs": [
255
+ "body"
256
+ ],
257
+ "acceptance": [
258
+ "silhouette reads"
259
+ ]
260
+ },
261
+ {
262
+ "id": "structural-pass",
263
+ "goal": "add handle",
264
+ "componentRefs": [
265
+ "handle"
266
+ ],
267
+ "acceptance": [
268
+ "handle attached"
269
+ ]
270
+ },
271
+ {
272
+ "id": "material-pass",
273
+ "goal": "glaze",
274
+ "componentRefs": [
275
+ "body"
276
+ ],
277
+ "acceptance": [
278
+ "glaze reads"
279
+ ]
280
+ }
281
+ ],
282
+ "sculptPipeline": {
283
+ "passGateMode": "locked-sequential",
284
+ "passOrder": [
285
+ "blockout",
286
+ "structural-pass",
287
+ "material-pass"
288
+ ],
289
+ "currentPass": "blockout",
290
+ "completedPasses": [],
291
+ "nextRequiredEvidence": [
292
+ "render screenshot"
293
+ ]
294
+ },
295
+ "lookDevTargets": {
296
+ "qualityPriority": "balanced"
297
+ },
298
+ "lightingFromPhoto": [
299
+ "key light upper left, soft directional",
300
+ "fill light from front, low intensity ambient",
301
+ "rim light from behind right for edge separation",
302
+ "exposure neutral with ACES tone mapping",
303
+ "contact shadow under base on ground plane"
304
+ ],
305
+ "componentTree": [
306
+ {
307
+ "id": "body",
308
+ "name": "Mug body",
309
+ "level": "macro",
310
+ "role": "shell",
311
+ "primitive": "cylinder",
312
+ "material": "ceramic",
313
+ "importance": 1.0,
314
+ "confidence": 0.9,
315
+ "dimensions": {
316
+ "width": 0.08,
317
+ "height": 0.1,
318
+ "depth": 0.08
319
+ },
320
+ "transform": {
321
+ "position": [
322
+ 0,
323
+ 0.05,
324
+ 0
325
+ ],
326
+ "rotation": [
327
+ 0,
328
+ 0,
329
+ 0
330
+ ],
331
+ "scale": [
332
+ 1,
333
+ 1,
334
+ 1
335
+ ]
336
+ },
337
+ "actionProfile": {
338
+ "animationRole": "static",
339
+ "pivot": {
340
+ "mode": "center",
341
+ "localPosition": [
342
+ 0,
343
+ 0,
344
+ 0
345
+ ],
346
+ "axis": [
347
+ 0,
348
+ 1,
349
+ 0
350
+ ]
351
+ },
352
+ "sockets": [
353
+ {
354
+ "id": "handle-socket",
355
+ "localPosition": [
356
+ 0.045,
357
+ 0.0,
358
+ 0.0
359
+ ]
360
+ }
361
+ ]
362
+ },
363
+ "localFeatures": [
364
+ {
365
+ "id": "rim-bevel",
366
+ "kind": "bevel",
367
+ "note": "rounded lip"
368
+ }
369
+ ],
370
+ "evidenceRefs": [
371
+ "full-object"
372
+ ]
373
+ },
374
+ {
375
+ "id": "handle",
376
+ "name": "Handle",
377
+ "level": "meso",
378
+ "role": "handle",
379
+ "primitive": "torus",
380
+ "material": "ceramic",
381
+ "parent": "body",
382
+ "importance": 0.8,
383
+ "confidence": 0.85,
384
+ "dimensions": {
385
+ "width": 0.04,
386
+ "height": 0.06,
387
+ "depth": 0.015
388
+ },
389
+ "transform": {
390
+ "position": [
391
+ 0.055,
392
+ 0.0,
393
+ 0
394
+ ],
395
+ "rotation": [
396
+ 0,
397
+ 0,
398
+ 0
399
+ ],
400
+ "scale": [
401
+ 1,
402
+ 1,
403
+ 1
404
+ ]
405
+ },
406
+ "attachment": {
407
+ "parentId": "body",
408
+ "parentSocket": "handle-socket",
409
+ "localStart": [
410
+ 0.04,
411
+ 0.07,
412
+ 0
413
+ ],
414
+ "localEnd": [
415
+ 0.04,
416
+ 0.03,
417
+ 0
418
+ ],
419
+ "contactType": "embed",
420
+ "embedDepth": 0.004,
421
+ "gapTolerance": 0.001
422
+ },
423
+ "actionProfile": {
424
+ "animationRole": "static"
425
+ },
426
+ "evidenceRefs": [
427
+ "full-object"
428
+ ]
429
+ }
430
+ ],
431
+ "materials": [
432
+ {
433
+ "id": "ceramic",
434
+ "name": "Glazed ceramic",
435
+ "baseColor": "#3f6f9f",
436
+ "roughness": {
437
+ "base": 0.22,
438
+ "variation": 0.1
439
+ },
440
+ "metalness": {
441
+ "base": 0.0
442
+ },
443
+ "clearcoat": {
444
+ "base": 0.6
445
+ },
446
+ "colorVariation": {
447
+ "palette": [
448
+ "#3f6f9f",
449
+ "#35618c"
450
+ ],
451
+ "amplitude": 0.08
452
+ },
453
+ "localOverrides": [
454
+ {
455
+ "id": "glaze-gloss",
456
+ "region": "upper body",
457
+ "roughness": 0.12
458
+ }
459
+ ],
460
+ "ambientOcclusion": {
461
+ "cavityStrength": 0.3
462
+ }
463
+ }
464
+ ],
465
+ "repetitionSystems": [],
466
+ "proceduralStrategy": [
467
+ "blockout cylinder",
468
+ "attach handle torus",
469
+ "apply glaze"
470
+ ],
471
+ "reviewHistory": [],
472
+ "viewEvidence": [
473
+ {
474
+ "id": "full-object",
475
+ "imageRegion": {
476
+ "x": 0,
477
+ "y": 0,
478
+ "width": 1,
479
+ "height": 1
480
+ },
481
+ "confidence": 0.8
482
+ }
483
+ ],
484
+ "risks": []
485
+ }
app/forge_bridge.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Subprocess bridge to the vendored upstream ``forge/`` pipeline scripts.
2
+
3
+ This module is intentionally *standard-library only* so it can also run
4
+ inside the minimal Docker build stage that regenerates the test fixture
5
+ factory (``scripts/build_fixture_factory.py``).
6
+
7
+ Safety contract:
8
+ * every invocation is a list-argv ``subprocess.run`` (never shell=True);
9
+ * child processes get a scrubbed environment (no ``LLM_*``/``ANTHROPIC_*``
10
+ variables can leak into them);
11
+ * every call has a hard timeout and bounded captured output;
12
+ * all file paths passed to the scripts live inside a per-job directory
13
+ owned by the caller -- the scripts resolve and write whatever path they
14
+ are given, so containment is enforced here by construction.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import json
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parents[1]
29
+ FORGE = REPO_ROOT / "forge"
30
+
31
+ PROBE = FORGE / "stage1_intake" / "probe_image.py"
32
+ VALIDATE = FORGE / "stage2_spec" / "validate_sculpt_spec.py"
33
+ ORCHESTRATE = FORGE / "stage3_build" / "orchestrate_passes.py"
34
+ GENERATE = FORGE / "stage3_build" / "generate_threejs_factory.py"
35
+
36
+ _MAX_CAPTURE = 1024 * 1024 # 1 MiB per stream
37
+
38
+
39
+ class ForgeError(Exception):
40
+ """A forge script exited non-zero (or timed out). Carries diagnostics."""
41
+
42
+ def __init__(self, script: str, message: str, *, returncode: int | None = None,
43
+ stdout: str = "", stderr: str = "") -> None:
44
+ super().__init__(message)
45
+ self.script = script
46
+ self.returncode = returncode
47
+ self.stdout = stdout
48
+ self.stderr = stderr
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ForgeResult:
53
+ returncode: int
54
+ stdout: str
55
+ stderr: str
56
+
57
+
58
+ def _scrubbed_env() -> dict[str, str]:
59
+ env = {
60
+ "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
61
+ "HOME": os.environ.get("HOME", "/tmp"),
62
+ "PYTHONDONTWRITEBYTECODE": "1",
63
+ "PYTHONUNBUFFERED": "1",
64
+ "LANG": "C.UTF-8",
65
+ }
66
+ tmp = os.environ.get("TMPDIR")
67
+ if tmp:
68
+ env["TMPDIR"] = tmp
69
+ return env
70
+
71
+
72
+ def run_forge(script: Path, *args: str, timeout: int = 120, cwd: Path | None = None) -> ForgeResult:
73
+ """Run a forge script; return captured output. Never raises on rc != 0."""
74
+ argv = [sys.executable, str(script), *args]
75
+ try:
76
+ proc = subprocess.run(
77
+ argv,
78
+ cwd=str(cwd) if cwd else None,
79
+ env=_scrubbed_env(),
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=timeout,
83
+ errors="replace",
84
+ )
85
+ except subprocess.TimeoutExpired as exc:
86
+ raise ForgeError(
87
+ script.name, f"{script.name} timed out after {timeout}s",
88
+ returncode=None, stdout=exc.stdout or "", stderr=exc.stderr or "",
89
+ ) from exc
90
+ return ForgeResult(
91
+ returncode=proc.returncode,
92
+ stdout=proc.stdout[:_MAX_CAPTURE],
93
+ stderr=proc.stderr[:_MAX_CAPTURE],
94
+ )
95
+
96
+
97
+ def probe_image(image_path: Path, *, timeout: int = 60) -> dict:
98
+ """Run stage1_intake/probe_image.py and parse its JSON stdout."""
99
+ result = run_forge(PROBE, str(image_path), timeout=timeout)
100
+ if result.returncode != 0:
101
+ raise ForgeError(
102
+ PROBE.name, f"probe_image failed (rc={result.returncode})",
103
+ returncode=result.returncode, stdout=result.stdout, stderr=result.stderr,
104
+ )
105
+ try:
106
+ payload = json.loads(result.stdout)
107
+ except json.JSONDecodeError as exc:
108
+ raise ForgeError(PROBE.name, "probe_image did not return JSON",
109
+ returncode=result.returncode, stdout=result.stdout,
110
+ stderr=result.stderr) from exc
111
+ if not isinstance(payload, dict):
112
+ raise ForgeError(PROBE.name, "probe_image returned non-object JSON",
113
+ returncode=result.returncode, stdout=result.stdout)
114
+ return payload
115
+
116
+
117
+ def validate_spec(spec_path: Path, *, strict: bool = True, timeout: int = 60) -> dict:
118
+ """Run the spec validator. Returns ``{ok, errors, warnings, ...}``.
119
+
120
+ The validator exits 0 on PASS and 1 on FAIL; both are normal outcomes.
121
+ rc > 1 (argparse/IO errors) raises ForgeError.
122
+ """
123
+ args = [str(spec_path), "--json"]
124
+ if strict:
125
+ args.append("--strict-quality")
126
+ result = run_forge(VALIDATE, *args, timeout=timeout)
127
+ if result.returncode not in (0, 1):
128
+ raise ForgeError(
129
+ VALIDATE.name, f"validate_sculpt_spec crashed (rc={result.returncode})",
130
+ returncode=result.returncode, stdout=result.stdout, stderr=result.stderr,
131
+ )
132
+ try:
133
+ payload = json.loads(result.stdout)
134
+ except json.JSONDecodeError as exc:
135
+ raise ForgeError(VALIDATE.name, "validator did not return JSON",
136
+ returncode=result.returncode, stdout=result.stdout,
137
+ stderr=result.stderr) from exc
138
+ payload.setdefault("ok", result.returncode == 0)
139
+ return payload
140
+
141
+
142
+ def orchestrate_sync(spec_path: Path, *, timeout: int = 60) -> None:
143
+ """Refresh sculptPipeline from reviewHistory (sync --in-place)."""
144
+ result = run_forge(ORCHESTRATE, "sync", str(spec_path), "--in-place", timeout=timeout)
145
+ if result.returncode != 0:
146
+ raise ForgeError(
147
+ ORCHESTRATE.name, f"orchestrate sync failed (rc={result.returncode})",
148
+ returncode=result.returncode, stdout=result.stdout, stderr=result.stderr,
149
+ )
150
+
151
+
152
+ def generate_factory(spec_path: Path, out_path: Path, *, pass_id: str | None = None,
153
+ timeout: int = 120) -> None:
154
+ """Emit the TypeScript factory. Raises ForgeError on gate/IO failure."""
155
+ args = [str(spec_path), "--out", str(out_path), "--force"]
156
+ if pass_id:
157
+ args.extend(["--pass-id", pass_id])
158
+ result = run_forge(GENERATE, *args, timeout=timeout)
159
+ if result.returncode != 0:
160
+ raise ForgeError(
161
+ GENERATE.name,
162
+ f"factory generation failed for pass {pass_id or '(current)'} "
163
+ f"(rc={result.returncode}): {result.stderr.strip()[:400]}",
164
+ returncode=result.returncode, stdout=result.stdout, stderr=result.stderr,
165
+ )
166
+ if not out_path.exists() or out_path.stat().st_size == 0:
167
+ raise ForgeError(GENERATE.name, "generator exited 0 but produced no output",
168
+ returncode=result.returncode, stdout=result.stdout,
169
+ stderr=result.stderr)
170
+
171
+
172
+ HOSTED_PREVIEW_PASS = "hosted-preview"
173
+ HOSTED_PRIMITIVES = {
174
+ "box", "sphere", "ellipsoid", "cylinder", "cone", "capsule", "torus",
175
+ "plane-card",
176
+ }
177
+
178
+
179
+ def _is_positive_number(value: object) -> bool:
180
+ return (
181
+ isinstance(value, (int, float))
182
+ and not isinstance(value, bool)
183
+ and float(value) > 0
184
+ )
185
+
186
+
187
+ def _resolved_dimensions(component: dict) -> tuple[object, object, object]:
188
+ dimensions = component.get("dimensions")
189
+ if not isinstance(dimensions, dict):
190
+ return None, None, None
191
+ radius = dimensions.get("radius")
192
+ diameter = float(radius) * 2 if _is_positive_number(radius) else None
193
+ width = dimensions.get("width", diameter)
194
+ height = dimensions.get("height", dimensions.get("length"))
195
+ depth = dimensions.get("depth", diameter)
196
+ if component.get("primitive") == "plane-card" and depth is None:
197
+ depth = 1.0
198
+ return width, height, depth
199
+
200
+
201
+ def hosted_spec_errors(spec: dict) -> list[str]:
202
+ """Return deterministic limitations of the hosted preview compiler.
203
+
204
+ The upstream schema accepts procedural primitive families that the
205
+ vendored generator still represents with placeholder boxes. The hosted
206
+ service rejects those before generation rather than returning a model
207
+ that silently disagrees with its spec. Parent cycles are also rejected
208
+ explicitly so generator recursion cannot hang.
209
+ """
210
+ components = [
211
+ item for item in spec.get("componentTree", []) if isinstance(item, dict)
212
+ ]
213
+ errors: list[str] = []
214
+ ids = {
215
+ str(item.get("id")) for item in components
216
+ if isinstance(item.get("id"), str) and item.get("id")
217
+ }
218
+ parents: dict[str, str | None] = {}
219
+ for index, component in enumerate(components):
220
+ component_id = component.get("id")
221
+ label = str(component_id or f"componentTree[{index}]")
222
+ primitive = str(component.get("primitive") or "")
223
+ if primitive not in HOSTED_PRIMITIVES:
224
+ errors.append(
225
+ f"hosted preview: component {label!r} uses unsupported primitive "
226
+ f"{primitive!r}; choose one of {', '.join(sorted(HOSTED_PRIMITIVES))}"
227
+ )
228
+ dimensions = _resolved_dimensions(component)
229
+ if not all(_is_positive_number(value) for value in dimensions):
230
+ errors.append(
231
+ f"hosted preview: component {label!r} needs positive width, height, "
232
+ "and depth dimensions (radius/length aliases are accepted)"
233
+ )
234
+ if isinstance(component_id, str) and component_id:
235
+ parent = component.get("parent")
236
+ parents[component_id] = str(parent) if parent is not None else None
237
+
238
+ for component_id, parent in parents.items():
239
+ if parent is not None and parent not in ids:
240
+ errors.append(
241
+ f"hosted preview: component {component_id!r} references missing parent {parent!r}"
242
+ )
243
+ continue
244
+ seen: set[str] = set()
245
+ cursor: str | None = component_id
246
+ while cursor is not None and cursor in parents:
247
+ if cursor in seen:
248
+ errors.append(
249
+ f"hosted preview: component parent cycle contains {cursor!r}"
250
+ )
251
+ break
252
+ seen.add(cursor)
253
+ cursor = parents[cursor]
254
+ return list(dict.fromkeys(errors))
255
+
256
+
257
+ def prepare_hosted_preview(spec: dict) -> dict:
258
+ """Create an app-only compile manifest without claiming visual approval.
259
+
260
+ ``spec`` remains the strict-gated upstream artifact with its locked pass
261
+ order and empty review history. The returned deep copy contains one
262
+ explicitly unreviewed preview pass referencing every declared component.
263
+ It is compiled solely to let users inspect the procedural draft; it is
264
+ not an upstream ``continue`` decision and carries no scores or evidence.
265
+ """
266
+ preview = copy.deepcopy(spec)
267
+ component_refs = [
268
+ item["id"] for item in preview.get("componentTree", [])
269
+ if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
270
+ ]
271
+ source_order = pass_order(spec)
272
+ preview["buildPasses"] = [{
273
+ "id": HOSTED_PREVIEW_PASS,
274
+ "label": "Hosted unreviewed preview",
275
+ "goal": "Compile all strict-validated components for inspection only",
276
+ "componentRefs": component_refs,
277
+ "acceptance": ["No visual acceptance is claimed by this preview"],
278
+ "acceptanceCriteria": ["No visual acceptance is claimed by this preview"],
279
+ }]
280
+ preview["sculptPipeline"] = {
281
+ "passGateMode": "hosted-unreviewed-preview",
282
+ "passOrder": [HOSTED_PREVIEW_PASS],
283
+ "currentPass": HOSTED_PREVIEW_PASS,
284
+ "completedPasses": [],
285
+ "sourcePassOrder": source_order,
286
+ }
287
+ preview["reviewHistory"] = []
288
+ preview["hostedPreview"] = {
289
+ "reviewStatus": "unreviewed",
290
+ "sourcePassOrder": source_order,
291
+ "notice": (
292
+ "Compile-only preview. No screenshot comparison, AI-vision score, "
293
+ "or upstream build-pass approval has occurred."
294
+ ),
295
+ }
296
+ return preview
297
+
298
+
299
+ def pass_order(spec: dict) -> list[str]:
300
+ ids = [
301
+ item["id"]
302
+ for item in spec.get("buildPasses", [])
303
+ if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].strip()
304
+ ]
305
+ return ids or ["blockout"]
306
+
307
+
308
+ _FACTORY_EXPORT_RE = re.compile(r"export function (create\w+Model)\b")
309
+
310
+
311
+ def factory_export_name(ts_source: str) -> str | None:
312
+ """Extract the ``create<Name>Model`` export from generated TypeScript."""
313
+ match = _FACTORY_EXPORT_RE.search(ts_source)
314
+ return match.group(1) if match else None
app/image_guard.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Untrusted image upload validation and normalisation.
2
+
3
+ Security contract (see docs/SECURITY.md):
4
+ * hard byte cap enforced before decoding;
5
+ * content sniffing with Pillow -- the client-supplied filename, extension
6
+ and Content-Type are never trusted;
7
+ * decompression-bomb guards (Pillow ``MAX_IMAGE_PIXELS`` + explicit pixel
8
+ and dimension caps);
9
+ * SVG rejected outright (scriptable XML, not an image for our purposes);
10
+ * output is a freshly re-encoded RGB PNG (EXIF/ICC/XMP metadata stripped,
11
+ orientation applied), downscaled so the pure-Python per-pixel PNG readers
12
+ in ``forge/`` and the LLM image payload stay cheap.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ from dataclasses import dataclass
19
+
20
+ from PIL import Image, ImageOps
21
+
22
+ # Pillow's own bomb guard; load_settings() re-applies the configured value
23
+ # at import time of the pipeline (module-level default keeps tests simple).
24
+ Image.MAX_IMAGE_PIXELS = 40_000_000
25
+
26
+ ALLOWED_FORMATS = {"PNG", "JPEG", "WEBP", "GIF", "BMP"}
27
+
28
+
29
+ class ImageRejected(Exception):
30
+ """Raised when an upload fails validation. Carries a user-safe reason."""
31
+
32
+ def __init__(self, reason: str, code: str = "image_rejected") -> None:
33
+ super().__init__(reason)
34
+ self.reason = reason
35
+ self.code = code
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class NormalizedImage:
40
+ png_bytes: bytes
41
+ width: int
42
+ height: int
43
+ original_format: str
44
+ original_width: int
45
+ original_height: int
46
+ downscaled: bool
47
+
48
+
49
+ def _sniff(data: bytes) -> Image.Image:
50
+ """Open enough of the image to identify its format and dimensions.
51
+
52
+ Do not decode pixels here. Callers must enforce declared dimension and
53
+ pixel caps before invoking ``load()``; that ordering is the protection
54
+ against small compressed files expanding into excessive work or memory.
55
+ """
56
+ if not data:
57
+ raise ImageRejected("The uploaded file is empty.", "empty_file")
58
+ # Cheap pre-check: SVG (and other XML) is rejected before Pillow ever
59
+ # sees it -- it is a document, not a raster image.
60
+ head = data[:512].lstrip().lower()
61
+ if head.startswith(b"<") and (b"<svg" in head or b"<?xml" in head):
62
+ raise ImageRejected(
63
+ "SVG and other vector/XML documents are not accepted. "
64
+ "Please upload a raster image (PNG, JPEG, WebP, GIF or BMP).",
65
+ "svg_rejected",
66
+ )
67
+ try:
68
+ img = Image.open(io.BytesIO(data))
69
+ except Image.DecompressionBombError as exc:
70
+ raise ImageRejected(
71
+ "The image is unreasonably large (decompression guard).",
72
+ "image_too_large",
73
+ ) from exc
74
+ except Exception as exc: # UnidentifiedImageError and friends
75
+ raise ImageRejected(
76
+ "The uploaded file could not be decoded as an image. "
77
+ "Accepted formats: PNG, JPEG, WebP, GIF, BMP.",
78
+ "not_an_image",
79
+ ) from exc
80
+ fmt = (img.format or "").upper()
81
+ if fmt not in ALLOWED_FORMATS:
82
+ img.close()
83
+ raise ImageRejected(
84
+ f"Unsupported image format {fmt or 'unknown'!r}. "
85
+ "Accepted formats: PNG, JPEG, WebP, GIF, BMP.",
86
+ "unsupported_format",
87
+ )
88
+ return img
89
+
90
+
91
+ def validate_and_normalize(
92
+ data: bytes,
93
+ *,
94
+ max_bytes: int = 10 * 1024 * 1024,
95
+ max_pixels: int = 40_000_000,
96
+ max_side: int = 8192,
97
+ normalize_max_side: int = 1024,
98
+ ) -> NormalizedImage:
99
+ """Validate raw upload bytes and return a normalised RGB PNG.
100
+
101
+ Raises ImageRejected with a user-safe reason on any violation.
102
+ """
103
+ if len(data) > max_bytes:
104
+ raise ImageRejected(
105
+ f"The file is {len(data) / (1024 * 1024):.1f} MiB; the limit is "
106
+ f"{max_bytes // (1024 * 1024)} MiB.",
107
+ "file_too_large",
108
+ )
109
+
110
+ Image.MAX_IMAGE_PIXELS = max_pixels
111
+ img = _sniff(data)
112
+ original_format = (img.format or "").upper()
113
+ original_width, original_height = img.size
114
+
115
+ if original_width <= 0 or original_height <= 0:
116
+ img.close()
117
+ raise ImageRejected(
118
+ "The image has invalid dimensions.",
119
+ "image_too_large",
120
+ )
121
+ if original_width * original_height > max_pixels:
122
+ img.close()
123
+ raise ImageRejected(
124
+ f"The image has {original_width * original_height:,} pixels; the limit is "
125
+ f"{max_pixels:,}.",
126
+ "image_too_large",
127
+ )
128
+ if max(original_width, original_height) > max_side:
129
+ img.close()
130
+ raise ImageRejected(
131
+ f"The image is {original_width}x{original_height}px; the longest side may "
132
+ f"be at most {max_side}px.",
133
+ "image_too_large",
134
+ )
135
+
136
+ source = img
137
+ try:
138
+ # Pixel decode happens only after the cheap header-based limits above.
139
+ try:
140
+ img.load()
141
+ except Image.DecompressionBombError as exc:
142
+ raise ImageRejected(
143
+ "The image is unreasonably large (decompression guard).",
144
+ "image_too_large",
145
+ ) from exc
146
+ except Exception as exc:
147
+ raise ImageRejected(
148
+ "The uploaded file could not be fully decoded as an image.",
149
+ "not_an_image",
150
+ ) from exc
151
+
152
+ # Apply EXIF orientation, then flatten to RGB (drops alpha compositing
153
+ # surprises and strips all metadata when re-encoded).
154
+ img = ImageOps.exif_transpose(img)
155
+ if img.mode == "P":
156
+ img = img.convert("RGBA")
157
+ if img.mode in ("RGBA", "LA"):
158
+ background = Image.new("RGB", img.size, (255, 255, 255))
159
+ alpha = img.getchannel("A") if "A" in img.getbands() else None
160
+ background.paste(img.convert("RGB"), mask=alpha)
161
+ img = background
162
+ elif img.mode != "RGB":
163
+ img = img.convert("RGB")
164
+
165
+ downscaled = False
166
+ if max(img.size) > normalize_max_side:
167
+ img = ImageOps.contain(img, (normalize_max_side, normalize_max_side))
168
+ downscaled = True
169
+
170
+ buf = io.BytesIO()
171
+ img.save(buf, format="PNG", optimize=True)
172
+ return NormalizedImage(
173
+ png_bytes=buf.getvalue(),
174
+ width=img.size[0],
175
+ height=img.size[1],
176
+ original_format=original_format,
177
+ original_width=original_width,
178
+ original_height=original_height,
179
+ downscaled=downscaled,
180
+ )
181
+ finally:
182
+ source.close()
183
+ if img is not source:
184
+ img.close()
app/llm.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async client for Anthropic-compatible Messages APIs (with an
2
+ OpenAI-compatible chat/completions fallback).
3
+
4
+ Verified wire protocol (2026-07, against https://openrouter.ai/api and
5
+ https://api.anthropic.com):
6
+
7
+ POST {base}/v1/messages (or {base}/messages when base ends /v1)
8
+ headers: x-api-key, authorization: Bearer, anthropic-version: 2023-06-01
9
+ body: {model, max_tokens, system, messages:[{role, content:[
10
+ {type:"image", source:{type:"base64", media_type, data}},
11
+ {type:"text", text}]}]}
12
+ reply: {content:[{type:"text", text}], stop_reason, ...}
13
+
14
+ OpenAI fallback (only on HTTP 404, or when LLM_API_STYLE=openai):
15
+ POST {base}/chat/completions (base normalised to include /v1)
16
+ headers: authorization: Bearer
17
+ body: {model, max_tokens, messages:[{role:"user", content:[
18
+ {type:"text", text}, {type:"image_url",
19
+ image_url:{url:"data:image/png;base64,..."}}]}]}
20
+ reply: {choices:[{message:{content}}]}
21
+
22
+ Retry policy: retry at most ``max_retries`` times on 408/409/429/5xx and
23
+ transport errors with exponential backoff + jitter, honouring Retry-After.
24
+ Never retry 400/401/403/404-style client faults. The API key is never logged
25
+ or included in error messages.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import random
33
+ import re
34
+ from dataclasses import dataclass
35
+
36
+ import httpx
37
+
38
+ from .config import Settings
39
+
40
+ ANTHROPIC_VERSION = "2023-06-01"
41
+ _RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504}
42
+
43
+
44
+ class LLMError(Exception):
45
+ """User-safe LLM failure description (never contains credentials)."""
46
+
47
+ def __init__(self, message: str, *, code: str = "llm_error",
48
+ status: int | None = None) -> None:
49
+ super().__init__(message)
50
+ self.code = code
51
+ self.status = status
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class LLMResponse:
56
+ text: str
57
+ stop_reason: str | None
58
+ style: str # "anthropic" | "openai"
59
+
60
+
61
+ def user_turn(prompt: str, image_png: bytes | None = None) -> dict:
62
+ """Anthropic-format user message; optionally with a PNG image block."""
63
+ content: list[dict] = []
64
+ if image_png is not None:
65
+ import base64
66
+
67
+ content.append({
68
+ "type": "image",
69
+ "source": {"type": "base64", "media_type": "image/png",
70
+ "data": base64.b64encode(image_png).decode("ascii")},
71
+ })
72
+ content.append({"type": "text", "text": prompt})
73
+ return {"role": "user", "content": content}
74
+
75
+
76
+ def assistant_turn(text: str) -> dict:
77
+ return {"role": "assistant", "content": [{"type": "text", "text": text}]}
78
+
79
+
80
+ def _messages_url(base: str) -> str:
81
+ base = base.rstrip("/")
82
+ return base + "/messages" if base.endswith("/v1") else base + "/v1/messages"
83
+
84
+
85
+ def _chat_completions_url(base: str) -> str:
86
+ base = base.rstrip("/")
87
+ return base + "/chat/completions" if base.endswith("/v1") else base + "/v1/chat/completions"
88
+
89
+
90
+ def _headers(settings: Settings, style: str) -> dict[str, str]:
91
+ headers = {"content-type": "application/json"}
92
+ key = settings.llm_api_key or ""
93
+ if style == "anthropic":
94
+ # Both header conventions are sent: Anthropic accepts x-api-key;
95
+ # OpenRouter's anthropic-compatible endpoint accepts either.
96
+ headers["x-api-key"] = key
97
+ headers["authorization"] = f"Bearer {key}"
98
+ headers["anthropic-version"] = ANTHROPIC_VERSION
99
+ else:
100
+ headers["authorization"] = f"Bearer {key}"
101
+ if settings.llm_referer:
102
+ headers["HTTP-Referer"] = settings.llm_referer
103
+ if settings.llm_title:
104
+ headers["X-Title"] = settings.llm_title
105
+ return headers
106
+
107
+
108
+ def _anthropic_body(settings: Settings, system: str, messages: list[dict]) -> dict:
109
+ return {
110
+ "model": settings.llm_model,
111
+ "max_tokens": settings.llm_max_tokens,
112
+ "system": system,
113
+ "messages": messages,
114
+ }
115
+
116
+
117
+ def _openai_body(settings: Settings, system: str, messages: list[dict]) -> dict:
118
+ converted: list[dict] = [{"role": "system", "content": system}]
119
+ for item in messages:
120
+ role = item.get("role", "user")
121
+ parts = item.get("content")
122
+ if isinstance(parts, str):
123
+ converted.append({"role": role, "content": parts})
124
+ continue
125
+ out_parts: list[dict] = []
126
+ for part in parts if isinstance(parts, list) else []:
127
+ if not isinstance(part, dict):
128
+ continue
129
+ if part.get("type") == "text":
130
+ out_parts.append({"type": "text", "text": part.get("text", "")})
131
+ elif part.get("type") == "image":
132
+ source = part.get("source") or {}
133
+ url = f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}"
134
+ out_parts.append({"type": "image_url", "image_url": {"url": url}})
135
+ # Collapse text-only content to a plain string (canonical OpenAI shape).
136
+ if out_parts and all(p["type"] == "text" for p in out_parts):
137
+ converted.append({"role": role,
138
+ "content": "".join(p["text"] for p in out_parts)})
139
+ else:
140
+ converted.append({"role": role, "content": out_parts})
141
+ return {
142
+ "model": settings.llm_model,
143
+ "max_tokens": settings.llm_max_tokens,
144
+ "messages": converted,
145
+ }
146
+
147
+
148
+ def _parse_anthropic(payload: dict) -> LLMResponse:
149
+ content = payload.get("content")
150
+ if not isinstance(content, list):
151
+ raise LLMError("The model returned an unexpected response shape (no content).",
152
+ code="llm_bad_response")
153
+ text = "".join(
154
+ block.get("text", "") for block in content
155
+ if isinstance(block, dict) and block.get("type") == "text"
156
+ ).strip()
157
+ if not text:
158
+ raise LLMError("The model returned an empty response.", code="llm_bad_response")
159
+ return LLMResponse(text=text, stop_reason=payload.get("stop_reason"), style="anthropic")
160
+
161
+
162
+ def _parse_openai(payload: dict) -> LLMResponse:
163
+ try:
164
+ choice = payload["choices"][0]
165
+ text = (choice.get("message") or {}).get("content") or ""
166
+ except (KeyError, IndexError, TypeError) as exc:
167
+ raise LLMError("The model returned an unexpected response shape.",
168
+ code="llm_bad_response") from exc
169
+ text = text.strip()
170
+ if not text:
171
+ raise LLMError("The model returned an empty response.", code="llm_bad_response")
172
+ return LLMResponse(text=text, stop_reason=choice.get("finish_reason"), style="openai")
173
+
174
+
175
+ class LLMClient:
176
+ """One configured provider client. Instantiate per job or share."""
177
+
178
+ def __init__(self, settings: Settings, http: httpx.AsyncClient | None = None) -> None:
179
+ self.settings = settings
180
+ self._http = http
181
+
182
+ async def complete_vision(
183
+ self,
184
+ *,
185
+ system: str,
186
+ messages: list[dict],
187
+ ) -> LLMResponse:
188
+ style_pref = self.settings.llm_api_style
189
+ styles = ["anthropic", "openai"] if style_pref == "auto" else [style_pref]
190
+
191
+ last_error: LLMError | None = None
192
+ for index, style in enumerate(styles):
193
+ try:
194
+ return await self._call(style, system, messages)
195
+ except LLMError as exc:
196
+ last_error = exc
197
+ # Only fall over to the other protocol when the endpoint
198
+ # plainly does not speak it.
199
+ if exc.status == 404 and index < len(styles) - 1:
200
+ continue
201
+ raise
202
+ raise last_error or LLMError("No LLM API style available.", code="llm_error")
203
+
204
+ async def _call(self, style: str, system: str,
205
+ messages: list[dict]) -> LLMResponse:
206
+ settings = self.settings
207
+ if style == "anthropic":
208
+ url = _messages_url(settings.llm_base_url)
209
+ body = _anthropic_body(settings, system, messages)
210
+ else:
211
+ url = _chat_completions_url(settings.llm_base_url)
212
+ body = _openai_body(settings, system, messages)
213
+
214
+ timeout = httpx.Timeout(connect=10.0, read=settings.llm_timeout_s,
215
+ write=30.0, pool=10.0)
216
+ close_client = False
217
+ http = self._http
218
+ if http is None:
219
+ http = httpx.AsyncClient(timeout=timeout)
220
+ close_client = True
221
+
222
+ attempts = max(1, settings.llm_max_retries + 1)
223
+ delay = 2.0
224
+ try:
225
+ for attempt in range(attempts):
226
+ try:
227
+ response = await http.post(url, headers=_headers(settings, style), json=body)
228
+ except httpx.HTTPError as exc:
229
+ if attempt + 1 >= attempts:
230
+ raise LLMError(
231
+ f"The model endpoint could not be reached ({type(exc).__name__}).",
232
+ code="llm_unreachable") from exc
233
+ await asyncio.sleep(delay + random.uniform(0, delay))
234
+ delay = min(delay * 4, 32.0)
235
+ continue
236
+
237
+ if response.status_code == 404:
238
+ raise LLMError("The model endpoint returned 404 (unknown path/model).",
239
+ code="llm_not_found", status=404)
240
+ if response.status_code in (400, 401, 403):
241
+ detail = _safe_error_detail(response)
242
+ raise LLMError(
243
+ f"The model endpoint rejected the request "
244
+ f"(HTTP {response.status_code}). {detail}",
245
+ code="llm_rejected", status=response.status_code)
246
+ if response.status_code in _RETRYABLE_STATUS:
247
+ if attempt + 1 >= attempts:
248
+ raise LLMError(
249
+ f"The model endpoint is unavailable "
250
+ f"(HTTP {response.status_code} after {attempts} attempts).",
251
+ code="llm_unavailable", status=response.status_code)
252
+ retry_after = response.headers.get("retry-after")
253
+ wait = delay + random.uniform(0, delay)
254
+ if retry_after:
255
+ try:
256
+ wait = max(wait, float(retry_after))
257
+ except ValueError:
258
+ pass
259
+ await asyncio.sleep(wait)
260
+ delay = min(delay * 4, 32.0)
261
+ continue
262
+ if response.status_code >= 400:
263
+ raise LLMError(
264
+ f"The model endpoint returned HTTP {response.status_code}.",
265
+ code="llm_error", status=response.status_code)
266
+
267
+ try:
268
+ payload = response.json()
269
+ except json.JSONDecodeError as exc:
270
+ raise LLMError("The model endpoint returned non-JSON.",
271
+ code="llm_bad_response") from exc
272
+ parsed = _parse_anthropic(payload) if style == "anthropic" else _parse_openai(payload)
273
+ if parsed.stop_reason in {"max_tokens", "length"}:
274
+ raise LLMError(
275
+ "The model's reply was truncated (max_tokens reached). "
276
+ "Increase LLM_MAX_TOKENS or simplify the subject.",
277
+ code="llm_truncated")
278
+ return parsed
279
+ finally:
280
+ if close_client:
281
+ await http.aclose()
282
+ raise LLMError("The model call failed unexpectedly.", code="llm_error")
283
+
284
+
285
+ def _safe_error_detail(response: httpx.Response) -> str:
286
+ """Extract a short, credential-free detail string from an error body."""
287
+ try:
288
+ payload = response.json()
289
+ message = payload.get("error", {})
290
+ if isinstance(message, dict):
291
+ message = message.get("message") or message.get("type") or ""
292
+ if isinstance(message, str) and message:
293
+ return message[:200]
294
+ except Exception:
295
+ pass
296
+ return ""
297
+
298
+
299
+ _FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
300
+
301
+
302
+ def extract_json_object(text: str) -> dict:
303
+ """Parse a JSON object from an LLM reply, tolerating markdown fences
304
+ and leading/trailing prose. Raises LLMError on failure."""
305
+ candidate = _FENCE_RE.sub("", text).strip()
306
+ try:
307
+ parsed = json.loads(candidate)
308
+ except json.JSONDecodeError:
309
+ start = candidate.find("{")
310
+ end = candidate.rfind("}")
311
+ if start == -1 or end == -1 or end <= start:
312
+ raise LLMError("The model did not return a JSON object.",
313
+ code="llm_bad_json")
314
+ try:
315
+ parsed = json.loads(candidate[start:end + 1])
316
+ except json.JSONDecodeError as exc:
317
+ raise LLMError(f"The model returned malformed JSON ({exc.msg}).",
318
+ code="llm_bad_json") from exc
319
+ if not isinstance(parsed, dict):
320
+ raise LLMError("The model returned JSON that is not an object.",
321
+ code="llm_bad_json")
322
+ return parsed
app/main.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application for the img2threejs Hugging Face Docker Space.
2
+
3
+ Routes
4
+ GET / SPA (static/index.html)
5
+ GET /static/* static assets
6
+ GET /health liveness + readiness signal
7
+ GET /api/config public, credential-free config view
8
+ POST /api/jobs start a conversion job (multipart image)
9
+ GET /api/jobs/{job_id} job status snapshot
10
+ GET /api/jobs/{job_id}/events SSE stream of pipeline events
11
+ GET /api/jobs/{job_id}/artifacts/{n} whitelisted per-job artifacts
12
+
13
+ Binding: 0.0.0.0 on $PORT (default 7860) per the HF Docker Space contract.
14
+ One uvicorn worker: the job registry is in-memory by design.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+
26
+ from fastapi import FastAPI, Request
27
+ from fastapi.responses import (FileResponse, JSONResponse, Response,
28
+ StreamingResponse)
29
+ from fastapi.staticfiles import StaticFiles
30
+ from starlette.datastructures import UploadFile
31
+ from starlette.types import ASGIApp, Receive, Scope, Send
32
+
33
+ from .config import load_settings
34
+ from .image_guard import ImageRejected
35
+ from .llm import LLMClient
36
+ from .pipeline import ARTIFACT_NAMES, JobRegistry, run_job
37
+ from .ratelimit import RateLimiter, client_key
38
+
39
+ logging.basicConfig(
40
+ level=logging.INFO,
41
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
42
+ )
43
+ logger = logging.getLogger("img2threejs")
44
+
45
+ REPO_ROOT = Path(__file__).resolve().parents[1]
46
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
47
+
48
+ settings = load_settings()
49
+ registry = JobRegistry(Path(settings.runs_dir))
50
+ limiter = RateLimiter(settings.rate_limit_jobs_per_hour)
51
+ job_semaphore = asyncio.Semaphore(settings.max_concurrent_jobs)
52
+
53
+ # Multipart boundary + the small ``hint`` field. The image itself still has
54
+ # the tighter ``max_upload_bytes`` limit below.
55
+ MULTIPART_OVERHEAD_BYTES = 256 * 1024
56
+
57
+
58
+ class _RequestBodyTooLarge(Exception):
59
+ pass
60
+
61
+
62
+ class UploadBodyLimitMiddleware:
63
+ """Cap the /api/jobs request stream before multipart parsing.
64
+
65
+ ``UploadFile`` avoids retaining large files in RAM, but without an ASGI
66
+ stream cap a caller could still force the multipart parser to spool an
67
+ unbounded request. Content-Length is a cheap early rejection; counting
68
+ receive chunks enforces the same limit when that header is absent or
69
+ false.
70
+ """
71
+
72
+ def __init__(self, app: ASGIApp) -> None:
73
+ self.app = app
74
+
75
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
76
+ if (
77
+ scope["type"] != "http"
78
+ or scope.get("method") != "POST"
79
+ or scope.get("path") != "/api/jobs"
80
+ ):
81
+ await self.app(scope, receive, send)
82
+ return
83
+
84
+ body_limit = settings.max_upload_bytes + MULTIPART_OVERHEAD_BYTES
85
+ declared_length: int | None = None
86
+ for name, value in scope.get("headers", []):
87
+ if name.lower() == b"content-length":
88
+ try:
89
+ declared_length = int(value)
90
+ except (TypeError, ValueError):
91
+ declared_length = None
92
+ break
93
+ if declared_length is not None and declared_length > body_limit:
94
+ await self._reject(scope, receive, send, body_limit)
95
+ return
96
+
97
+ received = 0
98
+
99
+ async def limited_receive():
100
+ nonlocal received
101
+ message = await receive()
102
+ if message.get("type") == "http.request":
103
+ received += len(message.get("body", b""))
104
+ if received > body_limit:
105
+ raise _RequestBodyTooLarge
106
+ return message
107
+
108
+ try:
109
+ await self.app(scope, limited_receive, send)
110
+ except _RequestBodyTooLarge:
111
+ await self._reject(scope, receive, send, body_limit)
112
+
113
+ @staticmethod
114
+ async def _reject(
115
+ scope: Scope, receive: Receive, send: Send, body_limit: int
116
+ ) -> None:
117
+ response = JSONResponse(
118
+ status_code=413,
119
+ content={
120
+ "error": "request_too_large",
121
+ "detail": (
122
+ "The multipart request exceeds the configured upload "
123
+ f"boundary ({body_limit} bytes including form overhead)."
124
+ ),
125
+ },
126
+ )
127
+ await response(scope, receive, send)
128
+
129
+
130
+ # A reservation covers the period between the cheap admission check and
131
+ # JobRegistry.create(). It closes the race where many multipart bodies could
132
+ # all pass the queue check while awaiting parsing.
133
+ _queue_guard = threading.Lock()
134
+ _pending_uploads = 0
135
+
136
+
137
+ def _reserve_job_slot() -> tuple[bool, int]:
138
+ global _pending_uploads
139
+ with _queue_guard:
140
+ in_flight = sum(1 for job in registry.jobs.values() if job.status == "running")
141
+ occupied = in_flight + _pending_uploads
142
+ if occupied >= settings.max_in_flight_jobs:
143
+ return False, occupied
144
+ _pending_uploads += 1
145
+ return True, occupied
146
+
147
+
148
+ def _release_job_slot() -> None:
149
+ global _pending_uploads
150
+ with _queue_guard:
151
+ _pending_uploads = max(0, _pending_uploads - 1)
152
+
153
+
154
+ def _create_reserved_job():
155
+ """Atomically convert one upload reservation into a registry job."""
156
+ global _pending_uploads
157
+ with _queue_guard:
158
+ job = registry.create()
159
+ _pending_uploads = max(0, _pending_uploads - 1)
160
+ return job
161
+
162
+
163
+ app = FastAPI(title="img2threejs", docs_url=None, redoc_url=None, openapi_url=None)
164
+ app.add_middleware(UploadBodyLimitMiddleware)
165
+ # Factory for the per-job LLM client; tests substitute a mock.
166
+ app.state.llm_factory = lambda s: LLMClient(s) # noqa: E731
167
+
168
+ CSP = (
169
+ "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; "
170
+ "img-src 'self' data: blob:; connect-src 'self'; frame-src 'self'; "
171
+ "object-src 'none'; base-uri 'none'"
172
+ )
173
+
174
+ # The sandboxed viewer shell runs LLM-influenced code inside an inline module
175
+ # script and blob: imports — its own policy. Sending the app-wide CSP on this
176
+ # path would intersect with the document's meta policy and block the shell
177
+ # (multiple CSPs intersect; 'self' never matches an opaque origin anyway).
178
+ VIEWER_CSP = (
179
+ "default-src 'none'; script-src 'unsafe-inline' blob:; "
180
+ "style-src 'unsafe-inline'; img-src blob: data:; worker-src blob:; "
181
+ "frame-ancestors 'self'; sandbox allow-scripts"
182
+ )
183
+ VIEWER_PATH = "/static/viewer.html"
184
+
185
+
186
+ @app.middleware("http")
187
+ async def security_headers(request: Request, call_next):
188
+ response = await call_next(request)
189
+ if request.url.path == VIEWER_PATH:
190
+ response.headers["Content-Security-Policy"] = VIEWER_CSP
191
+ else:
192
+ response.headers.setdefault("Content-Security-Policy", CSP)
193
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
194
+ response.headers.setdefault("Referrer-Policy", "no-referrer")
195
+ return response
196
+
197
+
198
+ @app.on_event("startup")
199
+ async def startup() -> None:
200
+ Path(settings.runs_dir).mkdir(parents=True, exist_ok=True)
201
+ logger.info(
202
+ "startup: llm_configured=%s model=%s base_url=%s port=%s space=%s",
203
+ settings.llm_configured, settings.llm_model or "(unset)",
204
+ settings.llm_base_url, settings.port, settings.space_id or "(local)",
205
+ )
206
+
207
+ async def reaper() -> None:
208
+ while True:
209
+ await asyncio.sleep(600)
210
+ doomed = registry.reap(settings.job_ttl_s)
211
+ if doomed:
212
+ logger.info("reaper: evicted %d expired jobs", len(doomed))
213
+
214
+ asyncio.create_task(reaper())
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Health + config
219
+ # ---------------------------------------------------------------------------
220
+
221
+ @app.get("/health")
222
+ async def health() -> JSONResponse:
223
+ """Liveness + lightweight readiness. The LLM is deliberately NOT probed:
224
+ a failing provider must not mark the container down — it is surfaced in
225
+ the UI instead."""
226
+ return JSONResponse({
227
+ "status": "ok",
228
+ "llm_configured": settings.llm_configured,
229
+ "space_id": settings.space_id,
230
+ "time": int(time.time()),
231
+ })
232
+
233
+
234
+ @app.get("/api/config")
235
+ async def config() -> JSONResponse:
236
+ """Public config view. Never includes the API key or any secret value."""
237
+ return JSONResponse({
238
+ "llm_configured": settings.llm_configured,
239
+ "missing_llm_vars": settings.missing_llm_vars,
240
+ "model": settings.llm_model if settings.llm_configured else None,
241
+ "max_upload_bytes": settings.max_upload_bytes,
242
+ "rate_limit_jobs_per_hour": settings.rate_limit_jobs_per_hour,
243
+ "space_host": settings.space_host,
244
+ })
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Jobs
249
+ # ---------------------------------------------------------------------------
250
+
251
+ @app.post("/api/jobs", status_code=202)
252
+ async def create_job(request: Request) -> JSONResponse:
253
+ if not settings.llm_configured:
254
+ missing = ", ".join(settings.missing_llm_vars)
255
+ return JSONResponse(status_code=503, content={
256
+ "error": "llm_not_configured",
257
+ "detail": (
258
+ f"This Space needs vision-LLM credentials to author the sculpt spec: set "
259
+ f"{missing} and LLM_BASE_URL as Space Secrets (Settings → Secrets), then "
260
+ "restart the Space. No model was generated — results are never fabricated."
261
+ ),
262
+ })
263
+
264
+ # Reserve capacity *before* parsing multipart data. Without this
265
+ # reservation, concurrent requests can all retain/spool their complete
266
+ # images while waiting to create an unbounded number of tasks.
267
+ reserved, occupied = _reserve_job_slot()
268
+ if not reserved:
269
+ return JSONResponse(
270
+ status_code=503,
271
+ headers={"Retry-After": "60"},
272
+ content={
273
+ "error": "queue_full",
274
+ "detail": (
275
+ f"The Space is busy ({occupied} jobs in flight, max "
276
+ f"{settings.max_in_flight_jobs}). Try again shortly."
277
+ ),
278
+ },
279
+ )
280
+
281
+ reservation_active = True
282
+ try:
283
+ # Uvicorn resolves forwarding headers only from configured trusted
284
+ # proxies; do not reinterpret raw X-Forwarded-For in application code.
285
+ key = client_key(request.client.host if request.client else None)
286
+ retry_after = limiter.check(key)
287
+ if retry_after is not None:
288
+ return JSONResponse(
289
+ status_code=429,
290
+ headers={"Retry-After": str(retry_after)},
291
+ content={
292
+ "error": "rate_limited",
293
+ "detail": (
294
+ f"At most {settings.rate_limit_jobs_per_hour} jobs per hour "
295
+ f"per client. Try again in {retry_after}s."
296
+ ),
297
+ },
298
+ )
299
+
300
+ request_content_type = request.headers.get("content-type", "").lower()
301
+ if request_content_type and not request_content_type.startswith("multipart/form-data"):
302
+ return JSONResponse(status_code=415, content={
303
+ "error": "unsupported_media_type",
304
+ "detail": "Expected multipart form data containing an image file.",
305
+ })
306
+
307
+ # Parse only after admission. max_files/max_fields constrain multipart
308
+ # metadata; UploadBodyLimitMiddleware constrains the complete stream.
309
+ async with request.form(max_files=1, max_fields=2, max_part_size=64 * 1024) as form:
310
+ file = form.get("file")
311
+ if not isinstance(file, UploadFile):
312
+ return JSONResponse(status_code=422, content={
313
+ "error": "file_required",
314
+ "detail": "A multipart image field named 'file' is required.",
315
+ })
316
+ declared = (file.content_type or "").lower()
317
+ if declared and not declared.startswith("image/"):
318
+ return JSONResponse(status_code=415, content={
319
+ "error": "unsupported_media_type",
320
+ "detail": "Expected an image upload (PNG, JPEG, WebP, GIF or BMP).",
321
+ })
322
+ raw = await file.read(settings.max_upload_bytes + 1)
323
+ if len(raw) > settings.max_upload_bytes:
324
+ return JSONResponse(status_code=413, content={
325
+ "error": "file_too_large",
326
+ "detail": (
327
+ "The upload exceeds the "
328
+ f"{settings.max_upload_bytes // (1024 * 1024)} MiB limit."
329
+ ),
330
+ })
331
+ hint_value = form.get("hint")
332
+ hint = hint_value if isinstance(hint_value, str) else None
333
+
334
+ job = _create_reserved_job()
335
+ reservation_active = False
336
+ object_hint = (hint or "").strip()[:80] or None
337
+ finally:
338
+ if reservation_active:
339
+ _release_job_slot()
340
+
341
+ async def runner() -> None:
342
+ async with job_semaphore:
343
+ await run_job(job, raw_upload=raw, object_hint=object_hint,
344
+ settings=settings,
345
+ llm=app.state.llm_factory(settings))
346
+
347
+ asyncio.create_task(runner())
348
+ return JSONResponse(status_code=202, content={
349
+ "job_id": job.id,
350
+ "events_url": f"/api/jobs/{job.id}/events",
351
+ "status_url": f"/api/jobs/{job.id}",
352
+ }, headers={"Cache-Control": "no-store"})
353
+
354
+
355
+ @app.get("/api/jobs/{job_id}")
356
+ async def job_status(job_id: str) -> JSONResponse:
357
+ job = registry.get(job_id)
358
+ if job is None:
359
+ return JSONResponse(
360
+ status_code=404,
361
+ content={"error": "job_not_found"},
362
+ headers={"Cache-Control": "no-store"},
363
+ )
364
+ return JSONResponse({
365
+ "job_id": job.id,
366
+ "status": job.status,
367
+ "stage": job.stage,
368
+ "result": job.result,
369
+ "error": job.error,
370
+ }, headers={"Cache-Control": "no-store"})
371
+
372
+
373
+ def _is_terminal(event: dict) -> bool:
374
+ """An event ends the SSE stream: any error, or the final done/done."""
375
+ return event.get("status") == "error" or (
376
+ event.get("status") == "done" and event.get("stage") == "done"
377
+ )
378
+
379
+
380
+ @app.get("/api/jobs/{job_id}/events", response_model=None)
381
+ async def job_events(job_id: str, request: Request) -> Response:
382
+ job = registry.get(job_id)
383
+ if job is None:
384
+ return JSONResponse(
385
+ status_code=404,
386
+ content={"error": "job_not_found"},
387
+ headers={"Cache-Control": "no-store"},
388
+ )
389
+
390
+ async def stream():
391
+ cursor = 0
392
+ while True:
393
+ # Replay any events not yet sent (covers reconnects).
394
+ while cursor < len(job.events):
395
+ event = job.events[cursor]
396
+ cursor += 1
397
+ yield f"data: {json.dumps(event)}\n\n"
398
+ if _is_terminal(event):
399
+ return
400
+ if job.status != "running":
401
+ return
402
+ if await request.is_disconnected():
403
+ return
404
+ try:
405
+ await asyncio.wait_for(job.waiter.wait(), timeout=15)
406
+ except asyncio.TimeoutError:
407
+ yield ": heartbeat\n\n"
408
+
409
+ return StreamingResponse(
410
+ stream(),
411
+ media_type="text/event-stream",
412
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
413
+ )
414
+
415
+
416
+ @app.get("/api/jobs/{job_id}/artifacts/{name}")
417
+ async def job_artifact(job_id: str, name: str, download: bool = False) -> Response:
418
+ job = registry.get(job_id)
419
+ if job is None:
420
+ return JSONResponse(
421
+ status_code=404,
422
+ content={"error": "job_not_found"},
423
+ headers={"Cache-Control": "no-store"},
424
+ )
425
+ if name not in ARTIFACT_NAMES:
426
+ return JSONResponse(
427
+ status_code=404,
428
+ content={"error": "artifact_not_found"},
429
+ headers={"Cache-Control": "no-store"},
430
+ )
431
+ path = (job.dir / name).resolve()
432
+ # Containment: the artifact must live inside this job's directory.
433
+ if not path.is_file() or path.parent != job.dir.resolve():
434
+ return JSONResponse(
435
+ status_code=404,
436
+ content={"error": "artifact_not_found"},
437
+ headers={"Cache-Control": "no-store"},
438
+ )
439
+ headers = {"Cache-Control": "no-store"}
440
+ if download or name in {"factory.ts", "standalone.html"}:
441
+ headers["Content-Disposition"] = f'attachment; filename="{name}"'
442
+ media_type = ARTIFACT_NAMES[name].split(";")[0]
443
+ return FileResponse(path, media_type=media_type, headers=headers)
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # Static SPA
448
+ # ---------------------------------------------------------------------------
449
+
450
+ @app.get("/", include_in_schema=False)
451
+ async def index() -> FileResponse:
452
+ return FileResponse(STATIC_DIR / "index.html")
453
+
454
+
455
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
456
+
457
+
458
+ @app.exception_handler(ImageRejected)
459
+ async def image_rejected_handler(_: Request, exc: ImageRejected) -> JSONResponse:
460
+ return JSONResponse(status_code=422, content={"error": exc.code, "detail": exc.reason})
461
+
462
+
463
+ def main() -> None:
464
+ """Console entry: python -m app.main"""
465
+ import uvicorn
466
+
467
+ uvicorn.run(
468
+ "app.main:app",
469
+ host="0.0.0.0",
470
+ port=settings.port,
471
+ workers=1,
472
+ proxy_headers=True,
473
+ log_level="info",
474
+ )
475
+
476
+
477
+ if __name__ == "__main__":
478
+ main()
app/pipeline.py ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job orchestration: image in -> ObjectSculptSpec -> Three.js factory -> bundle.
2
+
3
+ This preserves the upstream repository's intended conversion flow
4
+ (image -> probe -> LLM-authored ObjectSculptSpec -> strict-quality gate ->
5
+ an inspectable Three.js preview) and adapts the interactive agent loop to a
6
+ hosted request/response service:
7
+
8
+ * the LLM (vision) authors the spec, exactly as the skill intends;
9
+ * validate_sculpt_spec.py --strict-quality gates it; validator and hosted
10
+ compiler errors are fed back for up to ``spec_repair_rounds`` repairs;
11
+ * the original spec remains locked and unreviewed. A separate compile-only
12
+ manifest gathers its validated components into an explicitly unreviewed
13
+ hosted preview pass; it contains no invented scores or screenshot paths;
14
+ * the emitted TypeScript factory is bundled (esbuild, three included) into
15
+ a single ESM artifact plus a self-contained standalone HTML export.
16
+
17
+ Every failure path returns an honest error; nothing is ever fabricated.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import base64
24
+ import json
25
+ import re
26
+ import shutil
27
+ import time
28
+ import uuid
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from . import forge_bridge
34
+ from .config import Settings
35
+ from .image_guard import ImageRejected, validate_and_normalize
36
+ from .llm import LLMClient, LLMError, extract_json_object
37
+ from .prompt import build_repair_prompt, build_system_prompt, build_user_prompt
38
+
39
+ REPO_ROOT = Path(__file__).resolve().parents[1]
40
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
41
+
42
+ TARGET_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9 ]{0,38}$")
43
+ MAX_SPEC_BYTES = 512 * 1024
44
+
45
+ ARTIFACT_NAMES = {
46
+ "reference.png": "image/png",
47
+ "probe.json": "application/json",
48
+ "spec.json": "application/json",
49
+ "compile-spec.json": "application/json",
50
+ "validation.json": "application/json",
51
+ "factory.ts": "text/plain; charset=utf-8",
52
+ "model.bundle.js": "text/javascript; charset=utf-8",
53
+ "standalone.html": "text/html; charset=utf-8",
54
+ "events.jsonl": "application/x-ndjson; charset=utf-8",
55
+ }
56
+
57
+
58
+ class PipelineError(Exception):
59
+ """Honest, user-presentable pipeline failure."""
60
+
61
+ def __init__(self, message: str, *, code: str = "pipeline_error",
62
+ stage: str = "", detail: Any = None) -> None:
63
+ super().__init__(message)
64
+ self.code = code
65
+ self.stage = stage
66
+ self.detail = detail
67
+
68
+
69
+ @dataclass
70
+ class Job:
71
+ id: str
72
+ dir: Path
73
+ created: float = field(default_factory=time.time)
74
+ finished: float | None = None
75
+ status: str = "running" # running | done | error
76
+ stage: str = "queued"
77
+ seq: int = 0
78
+ events: list[dict] = field(default_factory=list)
79
+ result: dict | None = None
80
+ error: dict | None = None
81
+ waiter: asyncio.Event = field(default_factory=asyncio.Event)
82
+
83
+ def emit(self, stage: str, status: str, message: str, **data: Any) -> dict:
84
+ self.seq += 1
85
+ self.stage = stage
86
+ event = {
87
+ "seq": self.seq,
88
+ "ts": round(time.time(), 3),
89
+ "stage": stage,
90
+ "status": status, # started | progress | done | error
91
+ "message": message,
92
+ }
93
+ if data:
94
+ event["data"] = data
95
+ self.events.append(event)
96
+ try:
97
+ with (self.dir / "events.jsonl").open("a", encoding="utf-8") as fh:
98
+ fh.write(json.dumps(event) + "\n")
99
+ except OSError:
100
+ pass
101
+ self.waiter.set()
102
+ self.waiter.clear()
103
+ return event
104
+
105
+
106
+ class JobRegistry:
107
+ def __init__(self, runs_dir: Path) -> None:
108
+ self.runs_dir = runs_dir
109
+ self.jobs: dict[str, Job] = {}
110
+
111
+ def create(self) -> Job:
112
+ job_id = uuid.uuid4().hex
113
+ job_dir = self.runs_dir / job_id
114
+ job_dir.mkdir(parents=True, exist_ok=False)
115
+ job = Job(id=job_id, dir=job_dir)
116
+ self.jobs[job_id] = job
117
+ return job
118
+
119
+ def get(self, job_id: str) -> Job | None:
120
+ if not re.fullmatch(r"[0-9a-f]{32}", job_id or ""):
121
+ return None
122
+ return self.jobs.get(job_id)
123
+
124
+ def evict(self, job_id: str) -> None:
125
+ job = self.jobs.pop(job_id, None)
126
+ if job is not None:
127
+ shutil.rmtree(job.dir, ignore_errors=True)
128
+
129
+ def reap(self, ttl_s: int) -> list[str]:
130
+ now = time.time()
131
+ doomed = [
132
+ j.id for j in self.jobs.values()
133
+ if j.finished is not None and now - j.finished > ttl_s
134
+ ]
135
+ for job_id in doomed:
136
+ self.evict(job_id)
137
+ # Also sweep orphaned directories (e.g. after a crash).
138
+ if self.runs_dir.exists():
139
+ known = {j.dir for j in self.jobs.values()}
140
+ for child in self.runs_dir.iterdir():
141
+ if (
142
+ child.is_dir()
143
+ and child not in known
144
+ and re.fullmatch(r"[0-9a-f]{32}", child.name)
145
+ ):
146
+ try:
147
+ if now - child.stat().st_mtime > ttl_s:
148
+ shutil.rmtree(child, ignore_errors=True)
149
+ except OSError:
150
+ pass
151
+ return doomed
152
+
153
+
154
+ def sanitize_spec(spec: dict) -> dict:
155
+ """Clamp LLM-authored values that the generator/validator treat strictly."""
156
+ name = spec.get("targetName")
157
+ if not isinstance(name, str) or not TARGET_NAME_RE.fullmatch(name.strip()):
158
+ spec["targetName"] = "Object"
159
+ else:
160
+ spec["targetName"] = name.strip()
161
+ spec["schemaVersion"] = "2.0"
162
+ suitability = spec.get("suitability")
163
+ if suitability not in {"pass", "conditional", "reject"}:
164
+ spec["suitability"] = "conditional"
165
+ spec["reviewHistory"] = [] # LLM must not pre-approve its own passes
166
+ return spec
167
+
168
+
169
+ async def run_job(
170
+ job: Job,
171
+ *,
172
+ raw_upload: bytes,
173
+ object_hint: str | None,
174
+ settings: Settings,
175
+ llm: LLMClient,
176
+ ) -> None:
177
+ """Execute the full conversion for one job. Never raises: failures are
178
+ recorded on the job and emitted as terminal error events."""
179
+ started = time.time()
180
+ try:
181
+ async with asyncio.timeout(settings.job_timeout_s):
182
+ await _run(job, raw_upload=raw_upload, object_hint=object_hint,
183
+ settings=settings, llm=llm, started=started)
184
+ except TimeoutError:
185
+ _fail(
186
+ job, stage=job.stage, code="job_timeout",
187
+ message=(
188
+ f"The conversion exceeded the {max(1, round(settings.job_timeout_s / 60))}-minute "
189
+ "job deadline and was stopped. No unverified model was returned."
190
+ ),
191
+ )
192
+ except ImageRejected as exc:
193
+ _fail(job, stage="intake", code=exc.code, message=exc.reason)
194
+ except LLMError as exc:
195
+ _fail(job, stage="spec-authoring", code=exc.code, message=str(exc))
196
+ except PipelineError as exc:
197
+ _fail(job, stage=exc.stage or job.stage, code=exc.code, message=str(exc),
198
+ detail=exc.detail)
199
+ except Exception as exc: # pragma: no cover - defensive catch-all
200
+ _fail(job, stage=job.stage, code="internal_error",
201
+ message=f"Unexpected internal error: {type(exc).__name__}. "
202
+ "Nothing was generated.")
203
+
204
+
205
+ async def _run(job: Job, *, raw_upload: bytes, object_hint: str | None,
206
+ settings: Settings, llm: LLMClient, started: float) -> None:
207
+ # -- stage 1: intake -----------------------------------------------------
208
+ job.emit("intake", "started", "Validating and normalising the uploaded image.")
209
+ normalized = await asyncio.to_thread(
210
+ validate_and_normalize, raw_upload,
211
+ max_bytes=settings.max_upload_bytes,
212
+ max_pixels=settings.max_image_pixels,
213
+ normalize_max_side=settings.normalize_max_side,
214
+ )
215
+ reference = job.dir / "reference.png"
216
+ reference.write_bytes(normalized.png_bytes)
217
+ probe = await asyncio.to_thread(forge_bridge.probe_image, reference)
218
+ (job.dir / "probe.json").write_text(json.dumps(probe, indent=2), encoding="utf-8")
219
+ job.emit(
220
+ "intake", "done",
221
+ f"Image accepted: {normalized.original_format} "
222
+ f"{normalized.original_width}x{normalized.original_height}px"
223
+ + (" (downscaled for processing)" if normalized.downscaled else ""),
224
+ probe={"width": probe.get("width"), "height": probe.get("height"),
225
+ "warnings": probe.get("warnings", [])},
226
+ )
227
+
228
+ # -- stage 2: LLM authors the ObjectSculptSpec ---------------------------
229
+ system = build_system_prompt()
230
+ from .llm import assistant_turn, user_turn
231
+ messages: list[dict] = []
232
+ spec: dict | None = None
233
+ validation: dict = {}
234
+ repair_rounds = max(0, settings.spec_repair_rounds)
235
+ max_attempts = 1 + repair_rounds
236
+ for attempt in range(1, max_attempts + 1):
237
+ if attempt == 1:
238
+ job.emit(
239
+ "spec-authoring", "started",
240
+ f"The vision model ({settings.llm_model}) is studying the image and "
241
+ "authoring the ObjectSculptSpec (components, materials, proportions).",
242
+ attempt=attempt, maxAttempts=max_attempts,
243
+ )
244
+ messages.append(user_turn(
245
+ build_user_prompt(probe, object_hint=object_hint),
246
+ image_png=normalized.png_bytes,
247
+ ))
248
+ else:
249
+ job.emit(
250
+ "spec-authoring", "progress",
251
+ f"Validator rejected the spec; the model is repairing it "
252
+ f"(attempt {attempt} of {max_attempts}).",
253
+ attempt=attempt, maxAttempts=max_attempts,
254
+ validatorErrors=(validation.get("errors") or [])[:8],
255
+ )
256
+ messages.append(user_turn(build_repair_prompt(
257
+ validation.get("errors") or [], validation.get("warnings") or [])))
258
+
259
+ reply = await llm.complete_vision(system=system, messages=messages)
260
+ messages.append(assistant_turn(reply.text))
261
+ try:
262
+ candidate = extract_json_object(reply.text)
263
+ except LLMError as exc:
264
+ if exc.code != "llm_bad_json":
265
+ raise
266
+ validation = {
267
+ "ok": False,
268
+ "errors": [
269
+ "The model response was not one complete parseable JSON object."
270
+ ],
271
+ "warnings": [],
272
+ }
273
+ (job.dir / "validation.json").write_text(
274
+ json.dumps(validation, indent=2), encoding="utf-8")
275
+ continue
276
+ if len(json.dumps(candidate)) > MAX_SPEC_BYTES:
277
+ raise PipelineError(
278
+ "The model produced an unreasonably large spec (>512 KB). "
279
+ "Try a simpler subject or crop the image.",
280
+ code="spec_too_large", stage="spec-authoring")
281
+ candidate = sanitize_spec(candidate)
282
+
283
+ # Suitability "reject" is an honest, valid pipeline outcome.
284
+ if candidate.get("suitability") == "reject":
285
+ (job.dir / "spec.json").write_text(json.dumps(candidate, indent=2),
286
+ encoding="utf-8")
287
+ raise PipelineError(
288
+ "The model judged this image unsuitable for a faithful procedural "
289
+ "reconstruction (suitability: reject). Try a single object with a "
290
+ "clear silhouette on a plain background. No model was generated.",
291
+ code="unsuitable_image", stage="spec-authoring",
292
+ detail={"specUrl": f"/api/jobs/{job.id}/artifacts/spec.json"})
293
+
294
+ spec_path = job.dir / "spec.json"
295
+ spec_path.write_text(json.dumps(candidate, indent=2), encoding="utf-8")
296
+ validation = await asyncio.to_thread(
297
+ forge_bridge.validate_spec, spec_path, strict=True)
298
+ hosted_errors = forge_bridge.hosted_spec_errors(candidate)
299
+ if hosted_errors:
300
+ validation["ok"] = False
301
+ validation["errors"] = list(validation.get("errors") or []) + hosted_errors
302
+ (job.dir / "validation.json").write_text(json.dumps(validation, indent=2),
303
+ encoding="utf-8")
304
+ if validation.get("ok"):
305
+ spec = candidate
306
+ job.emit(
307
+ "spec-authoring", "done",
308
+ f"Spec accepted by the strict-quality gate on round {attempt}: "
309
+ f"{candidate.get('targetName', 'Object')} — "
310
+ f"{len(candidate.get('componentTree', []))} components, "
311
+ f"{len(candidate.get('materials', []))} materials.",
312
+ attempt=attempt,
313
+ warnings=validation.get("warnings") or [],
314
+ )
315
+ break
316
+ if spec is None:
317
+ raise PipelineError(
318
+ f"The spec failed the deterministic strict-quality gate after "
319
+ f"{max_attempts} attempts ({repair_rounds} repair rounds). This is the "
320
+ "gate working as designed, not a crash. "
321
+ "No model was generated.",
322
+ code="spec_validation_failed", stage="spec-authoring",
323
+ detail={"errors": (validation.get("errors") or [])[:20],
324
+ "validationUrl": f"/api/jobs/{job.id}/artifacts/validation.json"})
325
+
326
+ # -- stage 3: honest compile-only hosted preview -------------------------
327
+ order = forge_bridge.pass_order(spec)
328
+ job.emit("generation", "started",
329
+ "Strict gate passed. Preparing an unreviewed procedural preview; "
330
+ "the upstream pass order remains locked pending real screenshots "
331
+ f"and visual review ({' -> '.join(order)}).")
332
+
333
+ ts_path = job.dir / "factory.ts"
334
+ generated_pass = forge_bridge.HOSTED_PREVIEW_PASS
335
+ preview_spec = forge_bridge.prepare_hosted_preview(spec)
336
+ preview_path = job.dir / "compile-spec.json"
337
+ preview_path.write_text(json.dumps(preview_spec, indent=2), encoding="utf-8")
338
+ try:
339
+ await asyncio.to_thread(
340
+ forge_bridge.generate_factory,
341
+ preview_path,
342
+ ts_path,
343
+ pass_id=generated_pass,
344
+ )
345
+ except forge_bridge.ForgeError as exc:
346
+ raise PipelineError(
347
+ "The strict-validated spec could not be compiled into the hosted "
348
+ f"preview ({exc.stderr.strip()[:300] or 'generator refusal'}). "
349
+ "No model was generated.",
350
+ code="generation_failed", stage="generation",
351
+ ) from exc
352
+
353
+ ts_source = ts_path.read_text(encoding="utf-8")
354
+ if "TODO:" in ts_source:
355
+ raise PipelineError(
356
+ "The generator attempted to emit placeholder geometry. The preview "
357
+ "was refused instead of returning a fabricated shape.",
358
+ code="generation_failed", stage="generation",
359
+ )
360
+ export_name = forge_bridge.factory_export_name(ts_source)
361
+ if not export_name:
362
+ raise PipelineError(
363
+ "The generated factory has no create<Name>Model export. "
364
+ "No model was generated.",
365
+ code="generation_failed", stage="generation")
366
+
367
+ # -- stage 4: bundle for the browser (esbuild) ---------------------------
368
+ job.emit("bundling", "started",
369
+ "Unreviewed preview factory emitted. Bundling with three.js "
370
+ "for the in-browser viewer.")
371
+ entry = job.dir / "entry.js"
372
+ pascal = re.sub(r"^create|Model$", "", export_name)
373
+ entry.write_text(
374
+ f'export {{ {export_name} as makeModel, '
375
+ f'create{pascal}LookDevLights as makeLights }} from "./factory.ts";\n'
376
+ f'export {{ mountViewer }} from {json.dumps(str(STATIC_DIR / "viewer-core.js"))};\n',
377
+ encoding="utf-8")
378
+ bundle_path = job.dir / "model.bundle.js"
379
+ await _run_esbuild(job, entry, bundle_path, settings)
380
+
381
+ # Self-contained standalone export (works offline, single file).
382
+ bundle_text = bundle_path.read_text(encoding="utf-8")
383
+ standalone = _build_standalone(bundle_text, spec.get("targetName", "Object"))
384
+ (job.dir / "standalone.html").write_text(standalone, encoding="utf-8")
385
+
386
+ job.emit("bundling", "done", "Browser bundle and standalone export ready.")
387
+
388
+ # -- done ------------------------------------------------------------------
389
+ elapsed = round(time.time() - started, 1)
390
+ components = spec.get("componentTree", [])
391
+ result = {
392
+ "jobId": job.id,
393
+ "targetName": spec.get("targetName", "Object"),
394
+ "generatedPass": generated_pass,
395
+ "generationMode": "hosted-unreviewed-preview",
396
+ "reviewStatus": "unreviewed",
397
+ "passOrder": order,
398
+ "completedPasses": [],
399
+ "components": len(components),
400
+ "materials": len(spec.get("materials", [])),
401
+ "validationWarnings": validation.get("warnings") or [],
402
+ "elapsedSeconds": elapsed,
403
+ "artifacts": {name: f"/api/jobs/{job.id}/artifacts/{name}"
404
+ for name in ARTIFACT_NAMES if (job.dir / name).exists()},
405
+ "honesty": [
406
+ "Approximate procedural reconstruction from one image; hidden geometry "
407
+ "is a model inference, not an observation or measurement.",
408
+ "This is an unreviewed hosted preview. The original spec has no pass "
409
+ "approvals, screenshot comparisons, or visual-fidelity scores.",
410
+ "Use the upstream render/comparison/review loop before treating any "
411
+ "build pass as visually accepted.",
412
+ "The factory is the upstream generator's procedural scaffold: "
413
+ "proportions, materials and structure come from the LLM-authored spec; "
414
+ "fine surface artistry is out of scope for v1.",
415
+ ],
416
+ }
417
+ job.result = result
418
+ job.status = "done"
419
+ job.finished = time.time()
420
+ job.emit("done", "done",
421
+ f"Done in {elapsed}s — {result['targetName']} "
422
+ f"({result['components']} components, pass '{generated_pass}').",
423
+ result=result)
424
+
425
+
426
+ def _fail(job: Job, *, stage: str, code: str, message: str,
427
+ detail: Any = None) -> None:
428
+ job.status = "error"
429
+ job.finished = time.time()
430
+ job.error = {"code": code, "message": message, "stage": stage,
431
+ **({"detail": detail} if detail else {})}
432
+ job.emit(stage, "error", message, code=code, **({"detail": detail} if detail else {}))
433
+
434
+
435
+ async def _run_esbuild(job: Job, entry: Path, out: Path, settings: Settings) -> None:
436
+ esbuild = REPO_ROOT / settings.esbuild_entry
437
+ if not esbuild.exists():
438
+ raise PipelineError(
439
+ "The esbuild bundler is missing from this deployment (build misconfiguration).",
440
+ code="bundler_missing", stage="bundling")
441
+ # Let esbuild resolve `three` from the image's node_modules.
442
+ link = job.dir / "node_modules"
443
+ if not link.exists():
444
+ try:
445
+ link.symlink_to(REPO_ROOT / "node_modules", target_is_directory=True)
446
+ except OSError:
447
+ pass
448
+ argv = [
449
+ str(esbuild), str(entry),
450
+ "--bundle", "--format=esm", "--target=es2022", "--minify",
451
+ f"--outfile={out}",
452
+ ]
453
+ env = {"PATH": "/usr/local/bin:/usr/bin:/bin",
454
+ "NODE_PATH": str(REPO_ROOT / "node_modules")}
455
+ proc = await asyncio.create_subprocess_exec(
456
+ *argv, cwd=str(job.dir), env=env,
457
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
458
+ try:
459
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
460
+ except asyncio.TimeoutError as exc:
461
+ proc.kill()
462
+ raise PipelineError("esbuild timed out.", code="bundler_failed",
463
+ stage="bundling") from exc
464
+ if proc.returncode != 0:
465
+ raise PipelineError(
466
+ f"esbuild failed: {stderr.decode('utf-8', 'replace')[:300]}",
467
+ code="bundler_failed", stage="bundling")
468
+ if not out.exists() or out.stat().st_size == 0:
469
+ raise PipelineError("esbuild produced an empty bundle.",
470
+ code="bundler_failed", stage="bundling")
471
+
472
+
473
+ def _build_standalone(bundle_text: str, target_name: str) -> str:
474
+ """Single self-contained HTML file with a base64-embedded ESM bundle."""
475
+ encoded = base64.b64encode(bundle_text.encode("utf-8")).decode("ascii")
476
+ title = re.sub(r"[^A-Za-z0-9 ]", "", target_name) or "Object"
477
+ return STANDALONE_TEMPLATE.replace("__TITLE__", title).replace(
478
+ "__BUNDLE_BASE64__", encoded)
479
+
480
+
481
+ STANDALONE_TEMPLATE = """<!doctype html>
482
+ <html lang="en">
483
+ <head>
484
+ <meta charset="utf-8">
485
+ <meta name="viewport" content="width=device-width, initial-scale=1">
486
+ <title>__TITLE__ — img2threejs standalone model</title>
487
+ <!--
488
+ Generated by the img2threejs Hugging Face Space (https://huggingface.co/spaces/Mike0021/img2threejs)
489
+ from a single reference image. Approximate procedural reconstruction;
490
+ hidden sides are inferred, not measured. Built on hoainho/img2threejs (MIT).
491
+ three.js is MIT.
492
+ -->
493
+ <style>
494
+ html,body{margin:0;height:100%;background:#16181d;color:#e6e8ee;
495
+ font:14px/1.5 system-ui,sans-serif}
496
+ #viewer{position:fixed;inset:0}
497
+ .tag{position:fixed;left:12px;bottom:10px;font-size:12px;color:#9aa0ad;
498
+ background:rgba(22,24,29,.7);padding:4px 8px;border-radius:6px}
499
+ #fallback{padding:2rem;max-width:60ch}
500
+ </style>
501
+ </head>
502
+ <body>
503
+ <div id="viewer" role="region" aria-label="3D model viewer"></div>
504
+ <div class="tag">__TITLE__ — procedural Three.js reconstruction (img2threejs). Drag to orbit, scroll to zoom.</div>
505
+ <noscript><div id="fallback">This model needs JavaScript + WebGL to render.</div></noscript>
506
+ <script type="module">
507
+ let url;
508
+ try {
509
+ const bytes = Uint8Array.from(atob('__BUNDLE_BASE64__'), (char) => char.charCodeAt(0));
510
+ url = URL.createObjectURL(new Blob([bytes], { type: 'text/javascript' }));
511
+ const mod = await import(url);
512
+ const el = document.getElementById('viewer');
513
+ if (!document.createElement('canvas').getContext('webgl2') &&
514
+ !document.createElement('canvas').getContext('webgl')) {
515
+ throw new Error('WebGL is not available in this browser/GPU.');
516
+ }
517
+ mod.mountViewer(el, mod.makeModel,
518
+ typeof mod.makeLights === 'function' ? mod.makeLights : null, {});
519
+ } catch (err) {
520
+ const div = document.createElement('div');
521
+ div.id = 'fallback';
522
+ div.textContent = 'The generated model failed to render here: ' + (err && err.message || err);
523
+ document.body.appendChild(div);
524
+ } finally {
525
+ if (url) URL.revokeObjectURL(url);
526
+ }
527
+ </script>
528
+ </body>
529
+ </html>
530
+ """
app/prompt.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt construction for the LLM spec-authoring step.
2
+
3
+ The upstream project is an interactive agent skill: a vision-capable agent
4
+ authors an ``ObjectSculptSpec`` JSON describing the object in the reference
5
+ image, and the deterministic forge scripts gate and compile it. The Space
6
+ replaces the interactive agent with a single prompted vision call (plus
7
+ validator-feedback repair rounds). The system prompt therefore carries:
8
+
9
+ * the strict-quality contract, distilled from the validator
10
+ (forge/stage2_spec/validate_sculpt_spec.py) and the grimoire rubrics;
11
+ * a complete, verified strict-passing exemplar spec (app/exemplar_spec.json)
12
+ so the model imitates an exact working shape instead of guessing;
13
+ * hard output rules (JSON only, primitives subset, honesty rules).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from functools import lru_cache
20
+ from pathlib import Path
21
+
22
+ EXEMPLAR_PATH = Path(__file__).resolve().parent / "exemplar_spec.json"
23
+
24
+
25
+ @lru_cache(maxsize=1)
26
+ def _exemplar() -> str:
27
+ return EXEMPLAR_PATH.read_text(encoding="utf-8")
28
+
29
+
30
+ RULES = """\
31
+ You author exactly ONE JSON object: an ObjectSculptSpec (schemaVersion "2.0") describing the \
32
+ single main object in the reference image. A deterministic validator will strict-gate your spec; \
33
+ a compiler turns it into a procedural Three.js factory. Follow the exemplar's STRUCTURE exactly, \
34
+ but invent content that faithfully matches THIS image.
35
+
36
+ HARD RULES (each is enforced by the validator; violations waste a round):
37
+ 1. Output JSON only. No markdown fences, no prose, no comments.
38
+ 2. Required top-level keys: targetName (1-3 words, letters/spaces only), targetId, schemaVersion \
39
+ "2.0", suitability ("pass" or "conditional"), sourceImage, coordinateFrame, silhouette, \
40
+ preSpecAssessment, qualityContract, qualityTargets, actionReadiness, selfCorrectLoop, \
41
+ featureReviewTargets, buildPasses, sculptPipeline, lookDevTargets, lightingFromPhoto, \
42
+ componentTree, materials, proceduralStrategy, viewEvidence.
43
+ 3. preSpecAssessment.objectClass: primaryType (specific noun, e.g. "vessel", "hand-tool"), \
44
+ primaryDomain "object", and NON-EMPTY formLanguage, structureKind, motionPotential, \
45
+ materialFamilies. complexity.tier: use "simple" unless the object genuinely has 6+ distinct \
46
+ parts, then "moderate". specDepthDecision.requiredDepth must equal the tier. \
47
+ unknowns: [] (empty list).
48
+ 4. qualityContract: qualityBar (match tier), definitionOfDone (>=2 concrete checks), \
49
+ minimumSpecDepth (macroComponents>=1, mesoComponents>=1, microComponents>=0, minMaterials>=1, \
50
+ minRepetitionSystems>=0, reviewViewpoints>=2), featureGroups (>=3 groups each with id, name, \
51
+ qualityCriteria list), visualDeltaChecks (>=2), antiShallowSpecRules (>=2).
52
+ 5. qualityTargets.reviewViewpoints: >=2 named viewpoints. actionReadiness: filled like the exemplar.
53
+ 6. selfCorrectLoop.visualAcceptance: reviewer, threshold 0.7, featureReviewPolicy \
54
+ {enabled true, maxCritical 5, maxImportant 3, thresholds}.
55
+ 7. featureReviewTargets: 2-4 REAL identity-defining features of THIS object (never the generic \
56
+ starter ids from the exemplar) with tier critical/important, passIds, minimumScore.
57
+ 8. buildPasses: exactly ["blockout", "structural-pass", "material-pass"] in that order, each with \
58
+ id, label, goal, acceptanceCriteria list. sculptPipeline: passGateMode "locked-sequential", \
59
+ passOrder identical, currentPass "blockout", completedPasses [].
60
+ 9. lookDevTargets.qualityPriority: ALWAYS "balanced" (never "reference-fidelity").
61
+ 10. lightingFromPhoto: >=3 strings that between them name a key/fill/rim light AND contain the \
62
+ words "exposure" and "tone" (tone mapping) AND the phrase "contact shadow".
63
+ 11. detailInventory: targetMinDetails 3 (simple) or 6 (moderate); details list of that many \
64
+ entries {id, kind, description, mapsTo:{ref}} where kind comes from the taxonomy (gloss, bevel, \
65
+ fastener, linework, contour, seam, stitch, stain, scratch, chip, decal, emissive, hole, groove, \
66
+ ridge) and every mapsTo.ref resolves to a REAL id in your spec: a component id, a \
67
+ component localFeatures id, a material id, or a material localOverrides id. A "gloss" detail \
68
+ requires some material with roughness base < 0.35 or a clearcoat block. A "fastener" detail \
69
+ requires a repetitionSystems entry or a micro-level component.
70
+ 12. componentTree: 2-8 components. Each: unique id (kebab-case), name, role, primitive from ONLY \
71
+ {box, sphere, ellipsoid, cylinder, cone, capsule, torus, plane-card} (other primitives are \
72
+ rejected by the hosted compiler), level macro|meso|micro, parent (null for the root, else an \
73
+ existing component id), material (a declared material id), transform {position, rotation, \
74
+ scale} as [x,y,z] numbers, dimensions, importance, confidence. Proportions and positions must \
75
+ match the image as closely as you can infer them. Root positions are in object space; a child's \
76
+ position is LOCAL to its parent pivot (do not repeat the parent's world-space offset).
77
+ 13. ATTACHMENT CONTRACT: any component with a parent whose primitive is cylinder, cone, capsule \
78
+ or torus, or whose role/name implies handle/limb/tube, MUST include a complete "attachment": \
79
+ {parentId, parentSocket, localStart [x,y,z], localEnd [x,y,z], contactType \
80
+ ("embed"|"overlap"|"flush"), embedDepth > 0 (or overlap > 0), gapTolerance}. Nothing floats in \
81
+ mid-air. If you give a component actionProfile.sockets, each socket has id, name, position \
82
+ [x,y,z], purpose.
83
+ 14. materials: >=1 entry {id, name, baseColor "#RRGGBB", roughness {base, variation} (or number), \
84
+ metalness, colorVariation {palette: >=2 hex colours}, localOverrides: >=1 entry {id, ...}, \
85
+ ambientOcclusion {cavityStrength}}. Colours must be sampled from the image, not invented.
86
+ 15. viewEvidence: >=1 entry {id, description}. reviewHistory: []. risks: optional.
87
+ 16. Honesty: a single image cannot reveal hidden sides. Mark unseen geometry as an inference in \
88
+ proceduralStrategy notes; never claim it was observed or measured. Use suitability "conditional" when the image is \
89
+ ambiguous; never claim detail you cannot see.
90
+
91
+ EXEMPLAR (a verified strict-passing spec for a mug -- imitate its shape, depth and field style, \
92
+ not its content):
93
+ """
94
+
95
+
96
+ def build_system_prompt() -> str:
97
+ return RULES + _exemplar()
98
+
99
+
100
+ def build_user_prompt(probe: dict, *, object_hint: str | None = None) -> str:
101
+ parts = [
102
+ "Author the ObjectSculptSpec JSON for the main object in this reference image.",
103
+ "",
104
+ "Deterministic image probe (metadata only, not a visual analysis):",
105
+ json.dumps({
106
+ "type": probe.get("type"),
107
+ "width": probe.get("width"),
108
+ "height": probe.get("height"),
109
+ "megapixels": probe.get("megapixels"),
110
+ "warnings": probe.get("warnings"),
111
+ }, indent=2),
112
+ ]
113
+ if object_hint:
114
+ parts += ["", f"The user says the object is: {object_hint!r}. Honour this unless the image clearly contradicts it."]
115
+ parts += [
116
+ "",
117
+ "Remember: JSON only. Follow the exemplar structure. Match the image's real proportions, "
118
+ "colours and parts. targetName must be specific to this object.",
119
+ ]
120
+ return "\n".join(parts)
121
+
122
+
123
+ def build_repair_prompt(errors: list[str], warnings: list[str] | None = None) -> str:
124
+ shown = [e for e in errors if isinstance(e, str)][:20]
125
+ lines = [
126
+ "Your previous spec was REJECTED by the deterministic validator "
127
+ "(validate_sculpt_spec.py --strict-quality). Fix every error below and return the "
128
+ "complete corrected spec as JSON only:",
129
+ "",
130
+ ]
131
+ lines += [f"- {e}" for e in shown]
132
+ if warnings:
133
+ lines += ["", "Warnings (non-fatal, fix if cheap):"]
134
+ lines += [f"- {w}" for w in warnings[:10] if isinstance(w, str)]
135
+ lines += [
136
+ "",
137
+ "Return the ENTIRE corrected JSON object (not a diff). Keep everything that was not "
138
+ "flagged. JSON only.",
139
+ ]
140
+ return "\n".join(lines)
app/ratelimit.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory per-client rate limiting (token window) for job creation.
2
+
3
+ Single-process Spaces need no external store. The limiter is deliberately
4
+ simple: a fixed 1-hour window of timestamps per client key.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from collections import OrderedDict, deque
11
+
12
+
13
+ class RateLimiter:
14
+ """Fixed-window limiter with a strict LRU bound on client buckets."""
15
+
16
+ def __init__(self, max_per_hour: int, *, max_clients: int = 4096) -> None:
17
+ self.max_per_hour = max(1, int(max_per_hour))
18
+ self.max_clients = max(1, min(int(max_clients), 4096))
19
+ self._hits: OrderedDict[str, deque[float]] = OrderedDict()
20
+
21
+ def check(self, key: str) -> int | None:
22
+ """Record a hit. Returns None when allowed, else seconds until the
23
+ oldest hit expires (for Retry-After)."""
24
+ now = time.time()
25
+ key = (key or "?")[:128]
26
+ window = self._hits.get(key)
27
+ if window is None:
28
+ # Bound memory even when every request presents a new peer. LRU
29
+ # eviction is deterministic and happens *before* insertion, so
30
+ # the map can never transiently exceed max_clients.
31
+ if len(self._hits) >= self.max_clients:
32
+ self._hits.popitem(last=False)
33
+ window = deque()
34
+ self._hits[key] = window
35
+ else:
36
+ self._hits.move_to_end(key)
37
+ while window and now - window[0] > 3600:
38
+ window.popleft()
39
+ if len(window) >= self.max_per_hour:
40
+ return int(3600 - (now - window[0])) + 1
41
+ window.append(now)
42
+ return None
43
+
44
+
45
+ def client_key(peer_host: str | None) -> str:
46
+ """Return only the ASGI socket peer resolved by the trusted server.
47
+
48
+ Raw ``X-Forwarded-For`` is intentionally not accepted here. Uvicorn may
49
+ resolve trusted proxy headers into ``request.client`` according to its
50
+ own proxy policy; application code must not reinterpret caller-controlled
51
+ forwarding headers.
52
+ """
53
+ return (peer_host or "?")[:128]
app/static/app.js ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // img2threejs Space — client application.
2
+ // Upload -> SSE progress -> sandboxed three.js viewer + downloads.
3
+ // Every error state is explicit; nothing is ever simulated client-side.
4
+
5
+ const $ = (id) => document.getElementById(id);
6
+
7
+ const panels = {
8
+ upload: $('panel-upload'),
9
+ progress: $('panel-progress'),
10
+ result: $('panel-result'),
11
+ error: $('panel-error'),
12
+ };
13
+
14
+ const STAGE_LABELS = {
15
+ intake: 'Intake — validate image + deterministic probe',
16
+ 'spec-authoring': 'Spec authoring — vision model writes the ObjectSculptSpec',
17
+ generation: 'Generation — strict gate + unreviewed hosted preview',
18
+ bundling: 'Bundling — esbuild + three.js for the browser',
19
+ done: 'Done',
20
+ };
21
+
22
+ let pickedFile = null;
23
+ let eventSource = null;
24
+ let viewerSession = null; // {frame, ready, stats}
25
+
26
+ function showPanel(name) {
27
+ for (const [key, el] of Object.entries(panels)) el.hidden = key !== name;
28
+ }
29
+
30
+ function logLine(text) {
31
+ const log = $('log');
32
+ log.textContent += text + '\n';
33
+ log.scrollTop = log.scrollHeight;
34
+ }
35
+
36
+ // --------------------------------------------------------------------------
37
+ // Config / LLM badge
38
+ // --------------------------------------------------------------------------
39
+ async function loadConfig() {
40
+ try {
41
+ const res = await fetch('/api/config');
42
+ const cfg = await res.json();
43
+ const badge = $('llm-badge');
44
+ if (cfg.llm_configured) {
45
+ badge.textContent = 'LLM ready';
46
+ badge.className = 'badge badge-ok';
47
+ badge.title = `Model: ${cfg.model || 'unknown'}`;
48
+ } else {
49
+ badge.textContent = 'LLM not configured';
50
+ badge.className = 'badge badge-bad';
51
+ showError(
52
+ 'LLM credentials are not configured',
53
+ 'This Space needs a vision-capable LLM to author the sculpt spec. The Space owner must set '
54
+ + `${cfg.missing_llm_vars.join(' and ')} (plus LLM_BASE_URL) in Settings → Secrets, then restart. `
55
+ + 'No model can be generated until then — we never fabricate results.',
56
+ null,
57
+ );
58
+ $('run-btn').disabled = true;
59
+ return cfg;
60
+ }
61
+ } catch (err) {
62
+ $('llm-badge').textContent = 'config unavailable';
63
+ $('llm-badge').className = 'badge badge-bad';
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // --------------------------------------------------------------------------
69
+ // Upload handling
70
+ // --------------------------------------------------------------------------
71
+ function acceptFile(file) {
72
+ if (!file) return;
73
+ if (!/^image\//.test(file.type || '')) {
74
+ showError('Unsupported file', 'Please pick an image file (PNG, JPEG, WebP, GIF or BMP).', null);
75
+ return;
76
+ }
77
+ pickedFile = file;
78
+ const url = URL.createObjectURL(file);
79
+ $('preview').src = url;
80
+ $('preview-name').textContent = `${file.name || 'pasted image'} — ${(file.size / 1024).toFixed(0)} KiB`;
81
+ $('preview-row').hidden = false;
82
+ $('preview').onload = () => {
83
+ drawPalette($('preview'));
84
+ URL.revokeObjectURL(url);
85
+ };
86
+ }
87
+
88
+ function drawPalette(img) {
89
+ // Client-side dominant-colour swatches, honestly labelled approximate.
90
+ const canvas = document.createElement('canvas');
91
+ const size = 48;
92
+ canvas.width = size; canvas.height = size;
93
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
94
+ ctx.drawImage(img, 0, 0, size, size);
95
+ const { data } = ctx.getImageData(0, 0, size, size);
96
+ const buckets = new Map();
97
+ for (let i = 0; i < data.length; i += 4) {
98
+ const key = [data[i] >> 5, data[i + 1] >> 5, data[i + 2] >> 5].join(',');
99
+ buckets.set(key, (buckets.get(key) || 0) + 1);
100
+ }
101
+ const top = [...buckets.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
102
+ const palette = $('palette');
103
+ palette.textContent = '';
104
+ for (const [key] of top) {
105
+ const [r, g, b] = key.split(',').map((v) => (Number(v) << 5) + 16);
106
+ const sw = document.createElement('span');
107
+ sw.className = 'swatch';
108
+ sw.style.background = `rgb(${r},${g},${b})`;
109
+ sw.title = `≈ rgb(${r}, ${g}, ${b})`;
110
+ palette.appendChild(sw);
111
+ }
112
+ }
113
+
114
+ // --------------------------------------------------------------------------
115
+ // Job lifecycle
116
+ // --------------------------------------------------------------------------
117
+ async function startJob() {
118
+ if (!pickedFile) return;
119
+ $('run-btn').disabled = true;
120
+ showPanel('progress');
121
+ $('stages').textContent = '';
122
+ $('log').textContent = '';
123
+
124
+ const form = new FormData();
125
+ form.append('file', pickedFile, pickedFile.name || 'image.png');
126
+ const hint = $('hint').value.trim();
127
+ if (hint) form.append('hint', hint);
128
+
129
+ let jobId;
130
+ try {
131
+ const res = await fetch('/api/jobs', { method: 'POST', body: form });
132
+ const payload = await res.json().catch(() => ({}));
133
+ if (res.status === 503 && payload.error === 'llm_not_configured') {
134
+ showError('LLM credentials are not configured', payload.detail || payload.message, null);
135
+ return;
136
+ }
137
+ if (!res.ok) {
138
+ showError('Upload rejected', payload.detail || payload.message || `HTTP ${res.status}`, null);
139
+ return;
140
+ }
141
+ jobId = payload.job_id;
142
+ } catch (err) {
143
+ showError('Network error', `Could not start the job: ${err.message || err}`, null);
144
+ return;
145
+ }
146
+ followJob(jobId);
147
+ }
148
+
149
+ function stageItem(stage) {
150
+ const li = document.createElement('li');
151
+ li.id = `stage-${stage}`;
152
+ li.className = 'stage stage-pending';
153
+ li.innerHTML = `<span class="stage-dot"></span><span class="stage-name">${STAGE_LABELS[stage] || stage}</span><span class="stage-note"></span>`;
154
+ return li;
155
+ }
156
+
157
+ function followJob(jobId) {
158
+ const stagesEl = $('stages');
159
+ stagesEl.textContent = '';
160
+ for (const stage of ['intake', 'spec-authoring', 'generation', 'bundling', 'done']) {
161
+ stagesEl.appendChild(stageItem(stage));
162
+ }
163
+ if (eventSource) eventSource.close();
164
+ eventSource = new EventSource(`/api/jobs/${jobId}/events`);
165
+
166
+ eventSource.onmessage = (msg) => {
167
+ let event;
168
+ try { event = JSON.parse(msg.data); } catch { return; }
169
+ handleEvent(event, jobId);
170
+ };
171
+ eventSource.onerror = () => {
172
+ // EventSource auto-reconnects; if the job vanished we get a 404 and the
173
+ // stream ends — poll once to find out.
174
+ if (eventSource.readyState === EventSource.CLOSED) return;
175
+ fetch(`/api/jobs/${jobId}`).then(async (res) => {
176
+ if (res.status === 404) {
177
+ eventSource.close();
178
+ showError('Job expired', 'The job registry entry is gone (Space restarted or TTL elapsed). Please run again.', null);
179
+ }
180
+ }).catch(() => {});
181
+ };
182
+ }
183
+
184
+ function setStage(stage, status, note) {
185
+ const li = $(`stage-${stage}`);
186
+ if (!li) return;
187
+ li.className = `stage stage-${status}`;
188
+ if (note) li.querySelector('.stage-note').textContent = note;
189
+ }
190
+
191
+ function handleEvent(event, jobId) {
192
+ const when = new Date(event.ts * 1000).toLocaleTimeString();
193
+ logLine(`[${when}] ${event.stage}: ${event.message}`);
194
+ const li = $(`stage-${event.stage}`);
195
+ if (event.status === 'started' || event.status === 'progress') {
196
+ setStage(event.stage, 'active', event.status === 'progress' ? event.message : '');
197
+ } else if (event.status === 'done' && event.stage !== 'done') {
198
+ setStage(event.stage, 'done');
199
+ }
200
+ if (event.stage === 'done' && event.status === 'done') {
201
+ eventSource.close();
202
+ setStage('done', 'done');
203
+ showResult(event.data.result);
204
+ } else if (event.status === 'error') {
205
+ eventSource.close();
206
+ for (const s of document.querySelectorAll('.stage-active')) setStage(s.id.replace('stage-', ''), 'failed');
207
+ showError(
208
+ errorTitle(event.data && event.data.code),
209
+ event.message,
210
+ event.data && event.data.detail ? JSON.stringify(event.data.detail, null, 2) : null,
211
+ );
212
+ }
213
+ if (li && event.status === 'done') setStage(event.stage, 'done');
214
+ }
215
+
216
+ function errorTitle(code) {
217
+ switch (code) {
218
+ case 'unsuitable_image': return 'This image is not a viable 3D target';
219
+ case 'spec_validation_failed': return 'The quality gate rejected the spec';
220
+ case 'llm_rejected': return 'The model endpoint rejected the request';
221
+ case 'llm_unavailable': case 'llm_unreachable': return 'The model endpoint is unavailable';
222
+ case 'llm_truncated': return 'The model reply was truncated';
223
+ case 'generation_failed': return 'Factory generation failed';
224
+ case 'queue_full': return 'The conversion queue is full';
225
+ case 'job_timeout': return 'The conversion reached its time limit';
226
+ default: return 'The pipeline stopped honestly';
227
+ }
228
+ }
229
+
230
+ // --------------------------------------------------------------------------
231
+ // Result + viewer
232
+ // --------------------------------------------------------------------------
233
+ function showResult(result) {
234
+ showPanel('result');
235
+ $('result-title').textContent = result.targetName;
236
+ $('result-sub').textContent =
237
+ `${result.components} components · ${result.materials} materials · ` +
238
+ `unreviewed hosted preview generated in ${result.elapsedSeconds}s · ` +
239
+ `upstream review status: ${result.reviewStatus || 'unreviewed'}`;
240
+
241
+ const art = result.artifacts;
242
+ $('dl-ts').href = `${art['factory.ts']}?download=1`;
243
+ $('dl-ts').setAttribute('download', `create${result.targetName.replace(/\s+/g, '')}Model.ts`);
244
+ $('dl-spec').href = `${art['spec.json']}?download=1`;
245
+ $('dl-standalone').href = `${art['standalone.html']}?download=1`;
246
+ $('dl-standalone').setAttribute('download', `${result.targetName.replace(/\s+/g, '-').toLowerCase()}-standalone.html`);
247
+
248
+ const warnings = result.validationWarnings || [];
249
+ if (warnings.length) {
250
+ $('warnings-box').hidden = false;
251
+ $('warnings-list').textContent = '';
252
+ for (const w of warnings.slice(0, 12)) {
253
+ const li = document.createElement('li');
254
+ li.textContent = w;
255
+ $('warnings-list').appendChild(li);
256
+ }
257
+ }
258
+ $('honesty-list').textContent = '';
259
+ for (const note of result.honesty || []) {
260
+ const li = document.createElement('li');
261
+ li.textContent = note;
262
+ $('honesty-list').appendChild(li);
263
+ }
264
+
265
+ mountViewerFrame(art['model.bundle.js']);
266
+ $('dl-shot').onclick = captureScreenshot;
267
+ }
268
+
269
+ function mountViewerFrame(bundleUrl) {
270
+ const frame = $('viewer-frame');
271
+ const overlay = $('viewer-overlay');
272
+ overlay.textContent = 'Starting viewer…';
273
+ overlay.style.display = 'flex';
274
+ if (viewerSession && viewerSession.listener) {
275
+ window.removeEventListener('message', viewerSession.listener);
276
+ }
277
+ viewerSession = { frame, ready: false, stats: null, listener: null, captureWaiter: null };
278
+
279
+ const watchdog = setTimeout(() => {
280
+ if (!viewerSession.ready) {
281
+ overlay.textContent = 'The generated model did not start rendering within 45s. '
282
+ + 'Your downloads below still work — the .ts factory is the primary artifact.';
283
+ }
284
+ }, 45000);
285
+
286
+ const listener = async (event) => {
287
+ if (event.source !== frame.contentWindow) return;
288
+ const data = event.data || {};
289
+ if (data.type === 'shell-ready') {
290
+ try {
291
+ const res = await fetch(bundleUrl);
292
+ const bundleText = await res.text();
293
+ frame.contentWindow.postMessage({ type: 'init', bundleText }, '*');
294
+ } catch (err) {
295
+ overlay.textContent = `Could not load the model bundle: ${err.message || err}`;
296
+ }
297
+ } else if (data.type === 'ready') {
298
+ clearTimeout(watchdog);
299
+ viewerSession.ready = true;
300
+ viewerSession.stats = data.stats || {};
301
+ overlay.style.display = 'none';
302
+ } else if (data.type === 'error') {
303
+ clearTimeout(watchdog);
304
+ overlay.textContent = `Viewer error: ${data.message}. Downloads still work.`;
305
+ } else if (data.type === 'capture' && viewerSession.captureWaiter) {
306
+ viewerSession.captureWaiter(data.dataUrl);
307
+ viewerSession.captureWaiter = null;
308
+ }
309
+ };
310
+ viewerSession.listener = listener;
311
+ window.addEventListener('message', listener);
312
+ frame.src = '/static/viewer.html';
313
+ }
314
+
315
+ function captureScreenshot() {
316
+ if (!viewerSession || !viewerSession.ready) return;
317
+ viewerSession.captureWaiter = (dataUrl) => {
318
+ const a = document.createElement('a');
319
+ a.href = dataUrl;
320
+ a.download = 'render.png';
321
+ a.click();
322
+ };
323
+ viewerSession.frame.contentWindow.postMessage({ type: 'capture' }, '*');
324
+ }
325
+
326
+ // --------------------------------------------------------------------------
327
+ // Errors
328
+ // --------------------------------------------------------------------------
329
+ function showError(title, message, detail) {
330
+ showPanel('error');
331
+ $('error-title').textContent = title;
332
+ $('error-message').textContent = message || '';
333
+ const pre = $('error-detail');
334
+ if (detail) { pre.hidden = false; pre.textContent = detail; } else { pre.hidden = true; }
335
+ }
336
+
337
+ function reset() {
338
+ if (eventSource) { eventSource.close(); eventSource = null; }
339
+ pickedFile = null;
340
+ $('preview-row').hidden = true;
341
+ $('file-input').value = '';
342
+ $('run-btn').disabled = false;
343
+ showPanel('upload');
344
+ }
345
+
346
+ // --------------------------------------------------------------------------
347
+ // Wiring
348
+ // --------------------------------------------------------------------------
349
+ $('dropzone').addEventListener('click', () => $('file-input').click());
350
+ $('dropzone').addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') $('file-input').click(); });
351
+ $('file-input').addEventListener('change', (e) => acceptFile(e.target.files[0]));
352
+ $('dropzone').addEventListener('dragover', (e) => { e.preventDefault(); e.currentTarget.classList.add('drag'); });
353
+ $('dropzone').addEventListener('dragleave', (e) => e.currentTarget.classList.remove('drag'));
354
+ $('dropzone').addEventListener('drop', (e) => {
355
+ e.preventDefault();
356
+ e.currentTarget.classList.remove('drag');
357
+ acceptFile(e.dataTransfer.files && e.dataTransfer.files[0]);
358
+ });
359
+ window.addEventListener('paste', (e) => {
360
+ const item = [...(e.clipboardData ? e.clipboardData.items : [])].find((i) => i.type.startsWith('image/'));
361
+ if (item) acceptFile(item.getAsFile());
362
+ });
363
+ $('run-btn').addEventListener('click', startJob);
364
+ $('again-btn').addEventListener('click', reset);
365
+ $('error-again-btn').addEventListener('click', reset);
366
+
367
+ loadConfig();
app/static/index.html ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>img2threejs — image to procedural Three.js</title>
7
+ <meta name="description" content="Turn a reference image into an inspectable procedural Three.js draft written as code.">
8
+ <link rel="icon" href="/static/logo.svg" type="image/svg+xml">
9
+ <link rel="stylesheet" href="/static/styles.css">
10
+ </head>
11
+ <body>
12
+ <header class="site-header">
13
+ <img src="/static/logo.svg" alt="" width="34" height="34">
14
+ <div>
15
+ <h1>img2threejs</h1>
16
+ <p class="tagline">One reference image → an inspectable procedural Three.js draft, written as code.</p>
17
+ </div>
18
+ <span id="llm-badge" class="badge badge-unknown" title=""></span>
19
+ </header>
20
+
21
+ <main>
22
+ <section id="panel-upload" class="panel">
23
+ <div id="dropzone" class="dropzone" tabindex="0" role="button"
24
+ aria-label="Upload a reference image">
25
+ <p class="drop-title">Drop a reference image here</p>
26
+ <p class="drop-sub">or click to browse / paste from clipboard — PNG, JPEG, WebP, GIF, BMP · ≤ 10 MiB</p>
27
+ <input id="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>
28
+ </div>
29
+ <div id="preview-row" class="preview-row" hidden>
30
+ <img id="preview" alt="Uploaded reference preview">
31
+ <div class="preview-meta">
32
+ <div id="preview-name" class="preview-name"></div>
33
+ <div id="palette" class="palette" title="Dominant colours (approximate, client-side)"></div>
34
+ <label class="hint-label" for="hint">What is the object? <span>(optional hint for the model)</span></label>
35
+ <input id="hint" type="text" maxlength="80" placeholder="e.g. a red diesel locomotive">
36
+ <button id="run-btn" class="primary" type="button">Generate a Three.js preview</button>
37
+ </div>
38
+ </div>
39
+ <p class="honesty-note">
40
+ Honesty first: the result is an <em>approximate, unreviewed</em> procedural reconstruction from a
41
+ single image. Hidden geometry is inferred by the model, not observed or measured.
42
+ If the image cannot support a faithful reconstruction, the pipeline says so instead of
43
+ faking confidence. A hosted preview is not an approved upstream build pass.
44
+ </p>
45
+ </section>
46
+
47
+ <section id="panel-progress" class="panel" hidden>
48
+ <h2>Pipeline</h2>
49
+ <p class="progress-note">A vision model is authoring and validating a structured sculpt spec.
50
+ Provider latency and repair rounds can take several minutes. Stages advance only when real work
51
+ completes; there are no fake progress bars.</p>
52
+ <ol id="stages" class="stages" aria-live="polite"></ol>
53
+ <details class="log-drawer">
54
+ <summary>Live log</summary>
55
+ <pre id="log" class="log"></pre>
56
+ </details>
57
+ </section>
58
+
59
+ <section id="panel-result" class="panel" hidden>
60
+ <div class="result-grid">
61
+ <div class="viewer-column">
62
+ <div id="viewer-frame-wrap" class="viewer-frame-wrap">
63
+ <iframe id="viewer-frame" title="Generated 3D model viewer"
64
+ sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
65
+ <div id="viewer-overlay" class="viewer-overlay">Starting viewer…</div>
66
+ </div>
67
+ <p class="viewer-help">Drag to orbit · scroll to zoom · right-drag to pan</p>
68
+ </div>
69
+ <aside class="result-rail">
70
+ <h2 id="result-title">Result</h2>
71
+ <p id="result-sub" class="result-sub"></p>
72
+ <div class="downloads">
73
+ <a id="dl-ts" class="button" download>Factory (.ts)</a>
74
+ <a id="dl-spec" class="button" download>Spec (.json)</a>
75
+ <a id="dl-standalone" class="button" download>Standalone viewer (.html)</a>
76
+ <button id="dl-shot" class="button" type="button">Screenshot (.png)</button>
77
+ </div>
78
+ <details id="warnings-box" class="warnings-box" hidden>
79
+ <summary>Validator notes</summary>
80
+ <ul id="warnings-list"></ul>
81
+ </details>
82
+ <div class="honesty-box">
83
+ <h3>Honesty notes</h3>
84
+ <ul id="honesty-list"></ul>
85
+ </div>
86
+ <button id="again-btn" class="secondary" type="button">Start over</button>
87
+ </aside>
88
+ </div>
89
+ </section>
90
+
91
+ <section id="panel-error" class="panel panel-error" hidden>
92
+ <h2 id="error-title">Something went wrong</h2>
93
+ <p id="error-message"></p>
94
+ <pre id="error-detail" class="error-detail" hidden></pre>
95
+ <button id="error-again-btn" class="secondary" type="button">Start over</button>
96
+ </section>
97
+ </main>
98
+
99
+ <footer class="site-footer">
100
+ <p>
101
+ Built on <a href="https://github.com/hoainho/img2threejs">hoainho/img2threejs</a> (MIT) ·
102
+ reconstruction-by-code, not photogrammetry ·
103
+ <a href="https://huggingface.co/spaces/Mike0021/img2threejs">HF Space</a>
104
+ </p>
105
+ </footer>
106
+
107
+ <script type="module" src="/static/app.js"></script>
108
+ </body>
109
+ </html>
app/static/logo.svg ADDED
app/static/styles.css ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* img2threejs Space — dark theme (three.js-editor convention). */
2
+ :root {
3
+ --bg: #16181d;
4
+ --panel: #1e2128;
5
+ --panel-2: #23262f;
6
+ --grid: #262a33;
7
+ --text: #e6e8ee;
8
+ --muted: #9aa0ad;
9
+ --accent: #e8a33d;
10
+ --error: #e05d5d;
11
+ --success: #5dbb8a;
12
+ --radius: 10px;
13
+ }
14
+
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; padding: 0; }
17
+ body {
18
+ background: var(--bg); color: var(--text);
19
+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
20
+ min-height: 100vh; display: flex; flex-direction: column;
21
+ }
22
+
23
+ a { color: var(--accent); }
24
+ button, .button {
25
+ font: inherit; border-radius: 8px; border: 1px solid #3a3f4a;
26
+ background: var(--panel-2); color: var(--text);
27
+ padding: 0.55rem 0.9rem; cursor: pointer; text-decoration: none;
28
+ display: inline-block; text-align: center;
29
+ }
30
+ button:hover, .button:hover { border-color: var(--accent); }
31
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
32
+ button.primary {
33
+ background: var(--accent); border-color: var(--accent);
34
+ color: #1a1206; font-weight: 650; width: 100%;
35
+ }
36
+ button.secondary { width: 100%; margin-top: 1rem; }
37
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
38
+
39
+ .site-header {
40
+ display: flex; align-items: center; gap: 0.9rem;
41
+ padding: 1rem 1.4rem; border-bottom: 1px solid var(--grid);
42
+ }
43
+ .site-header h1 { font-size: 1.25rem; margin: 0; }
44
+ .tagline { margin: 0; color: var(--muted); font-size: 0.88rem; }
45
+ .badge { margin-left: auto; padding: 0.25rem 0.7rem; border-radius: 999px; font-size: 0.8rem; }
46
+ .badge-ok { background: rgba(93, 187, 138, 0.15); color: var(--success); border: 1px solid var(--success); }
47
+ .badge-bad { background: rgba(224, 93, 93, 0.15); color: var(--error); border: 1px solid var(--error); }
48
+ .badge-unknown { color: var(--muted); }
49
+
50
+ main { flex: 1; width: min(1120px, 100%); margin: 0 auto; padding: 1.4rem; }
51
+ .panel { background: var(--panel); border: 1px solid var(--grid); border-radius: var(--radius); padding: 1.3rem; }
52
+ .panel h2 { margin-top: 0; }
53
+
54
+ .dropzone {
55
+ border: 2px dashed #3a3f4a; border-radius: var(--radius);
56
+ padding: 3rem 1rem; text-align: center; cursor: pointer;
57
+ transition: border-color 0.15s, background 0.15s;
58
+ }
59
+ .dropzone:hover, .dropzone.drag { border-color: var(--accent); background: rgba(232, 163, 61, 0.06); }
60
+ .drop-title { font-size: 1.1rem; font-weight: 600; margin: 0 0 0.3rem; }
61
+ .drop-sub { color: var(--muted); margin: 0; font-size: 0.86rem; }
62
+
63
+ .preview-row { display: flex; gap: 1.2rem; margin-top: 1.2rem; align-items: flex-start; flex-wrap: wrap; }
64
+ .preview-row img {
65
+ width: 220px; max-height: 220px; object-fit: contain;
66
+ border-radius: 8px; border: 1px solid var(--grid); background: #0f1114;
67
+ }
68
+ .preview-meta { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 0.6rem; }
69
+ .preview-name { color: var(--muted); font-size: 0.85rem; word-break: break-all; }
70
+ .palette { display: flex; gap: 6px; }
71
+ .swatch { width: 26px; height: 26px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12); }
72
+ .hint-label { font-size: 0.9rem; }
73
+ .hint-label span { color: var(--muted); font-size: 0.8rem; }
74
+ #hint {
75
+ font: inherit; padding: 0.5rem 0.7rem; border-radius: 8px;
76
+ border: 1px solid #3a3f4a; background: var(--panel-2); color: var(--text);
77
+ }
78
+
79
+ .honesty-note { color: var(--muted); font-size: 0.85rem; margin: 1.2rem 0 0; border-top: 1px solid var(--grid); padding-top: 0.9rem; }
80
+
81
+ .progress-note { color: var(--muted); font-size: 0.85rem; margin-top: -0.4rem; }
82
+ .stages { list-style: none; margin: 0 0 1rem; padding: 0; }
83
+ .stage { display: flex; align-items: baseline; gap: 0.7rem; padding: 0.55rem 0.2rem; border-bottom: 1px solid var(--grid); }
84
+ .stage-dot { width: 10px; height: 10px; border-radius: 50%; background: #3a3f4a; flex: none; align-self: center; }
85
+ .stage-active .stage-dot { background: var(--accent); animation: pulse 1.2s infinite; }
86
+ .stage-done .stage-dot { background: var(--success); }
87
+ .stage-failed .stage-dot { background: var(--error); }
88
+ .stage-pending .stage-name { color: var(--muted); }
89
+ .stage-note { color: var(--muted); font-size: 0.82rem; margin-left: auto; max-width: 55%; text-align: right; }
90
+ @keyframes pulse { 50% { opacity: 0.35; } }
91
+
92
+ .log-drawer summary { cursor: pointer; color: var(--muted); }
93
+ .log {
94
+ background: #0f1114; border: 1px solid var(--grid); border-radius: 8px;
95
+ padding: 0.8rem; max-height: 260px; overflow: auto;
96
+ font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; color: #b7bcc7;
97
+ white-space: pre-wrap; word-break: break-word;
98
+ }
99
+
100
+ .result-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 1.2rem; }
101
+ @media (max-width: 860px) { .result-grid { grid-template-columns: 1fr; } }
102
+ .viewer-frame-wrap {
103
+ position: relative; aspect-ratio: 4 / 3; min-height: 320px;
104
+ background: #0f1114; border: 1px solid var(--grid); border-radius: 8px; overflow: hidden;
105
+ }
106
+ #viewer-frame { width: 100%; height: 100%; border: 0; display: block; }
107
+ .viewer-overlay {
108
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
109
+ color: var(--muted); background: rgba(15, 17, 20, 0.85); padding: 1rem; text-align: center;
110
+ }
111
+ .viewer-help { color: var(--muted); font-size: 0.8rem; text-align: center; }
112
+ .result-rail h2 { margin-bottom: 0.2rem; word-break: break-word; }
113
+ .result-sub { color: var(--muted); font-size: 0.85rem; }
114
+ .downloads { display: grid; gap: 0.5rem; margin: 1rem 0; }
115
+ .warnings-box, .honesty-box {
116
+ border: 1px solid var(--grid); border-radius: 8px; padding: 0.7rem 0.9rem;
117
+ margin-bottom: 0.8rem; font-size: 0.85rem; background: var(--panel-2);
118
+ }
119
+ .warnings-box summary { cursor: pointer; }
120
+ .honesty-box h3 { margin: 0 0 0.4rem; font-size: 0.9rem; color: var(--accent); }
121
+ .honesty-box ul, .warnings-box ul { margin: 0; padding-left: 1.1rem; color: var(--muted); }
122
+
123
+ .panel-error { border-color: var(--error); }
124
+ .panel-error h2 { color: var(--error); }
125
+ .error-detail {
126
+ background: #0f1114; border: 1px solid var(--grid); border-radius: 8px;
127
+ padding: 0.8rem; max-height: 300px; overflow: auto;
128
+ font: 12px/1.6 ui-monospace, Menlo, monospace; white-space: pre-wrap; word-break: break-word;
129
+ }
130
+
131
+ .site-footer { padding: 1rem 1.4rem; border-top: 1px solid var(--grid); color: var(--muted); font-size: 0.82rem; text-align: center; }
132
+
133
+ @media (prefers-reduced-motion: reduce) {
134
+ .stage-active .stage-dot { animation: none; }
135
+ }
app/static/viewer-core.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // viewer-core.js — three.js scene harness for generated img2threejs factories.
2
+ // Bundled into every per-job model bundle by esbuild (three included), and
3
+ // called with the generated factory exports. Runs inside the sandboxed
4
+ // viewer iframe (and inside standalone.html exports).
5
+ //
6
+ // Design notes:
7
+ // * Generated factories emit `import * as THREE from 'three'` and construct
8
+ // procedural canvas textures at call time, so this must run in a real
9
+ // browser (the factory falls back to flat materials when document is
10
+ // absent — the node smoke test relies on that).
11
+ // * RoomEnvironment + ACES keeps MeshPhysicalMaterial metals/clearcoat
12
+ // readable; the generated LookDevLights rig (neutral) is the rig the
13
+ // upstream review rubric assumes.
14
+
15
+ import * as THREE from 'three';
16
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
17
+ import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
18
+
19
+ export function mountViewer(el, makeModel, makeLights, opts = {}) {
20
+ if (typeof makeModel !== 'function') {
21
+ throw new Error('No create*Model export found in the generated factory.');
22
+ }
23
+ const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
24
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
25
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
26
+ el.appendChild(renderer.domElement);
27
+ renderer.domElement.style.display = 'block';
28
+
29
+ const scene = new THREE.Scene();
30
+ scene.background = new THREE.Color(0x16181d);
31
+ const pmrem = new THREE.PMREMGenerator(renderer);
32
+ scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
33
+
34
+ const model = makeModel({
35
+ textureSize: opts.textureSize || 512,
36
+ qualityPriority: 'balanced',
37
+ ...(opts.factoryOptions || {}),
38
+ });
39
+ scene.add(model);
40
+
41
+ let lights = null;
42
+ try {
43
+ lights = typeof makeLights === 'function' ? makeLights('neutral') : null;
44
+ } catch (_) {
45
+ lights = null;
46
+ }
47
+ if (lights) {
48
+ lights.traverse((node) => {
49
+ if (node.isDirectionalLight && node.shadow && node.shadow.mapSize) {
50
+ node.shadow.mapSize.set(2048, 2048);
51
+ }
52
+ });
53
+ scene.add(lights);
54
+ } else {
55
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x363b42, 1.1));
56
+ }
57
+ // Ambient floor so a spec that zeroes lights still shows silhouettes.
58
+ scene.add(new THREE.AmbientLight(0xffffff, 0.15));
59
+
60
+ const box = new THREE.Box3().setFromObject(model);
61
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
62
+ const radius = Math.max(sphere.radius, 1e-3);
63
+
64
+ const grid = new THREE.GridHelper(radius * 4, 20, 0x3a3f4a, 0x262a33);
65
+ grid.position.y = box.min.y;
66
+ scene.add(grid);
67
+
68
+ const ground = new THREE.Mesh(
69
+ new THREE.CircleGeometry(radius * 2.5, 48),
70
+ new THREE.ShadowMaterial({ opacity: 0.35 }),
71
+ );
72
+ ground.rotation.x = -Math.PI / 2;
73
+ ground.position.y = box.min.y - 0.001;
74
+ ground.receiveShadow = true;
75
+ scene.add(ground);
76
+
77
+ const camera = new THREE.PerspectiveCamera(40, 1, radius / 100, radius * 100);
78
+ const dir = new THREE.Vector3(1, 0.6, 1.4).normalize();
79
+ const distance = (radius / Math.sin(THREE.MathUtils.degToRad(20))) * 1.1;
80
+ camera.position.copy(sphere.center).addScaledVector(dir, distance);
81
+
82
+ const controls = new OrbitControls(camera, renderer.domElement);
83
+ controls.target.copy(sphere.center);
84
+ controls.enableDamping = true;
85
+ controls.update();
86
+
87
+ renderer.shadowMap.enabled = true;
88
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
89
+
90
+ const fit = () => {
91
+ const w = el.clientWidth || 640;
92
+ const h = el.clientHeight || 420;
93
+ renderer.setSize(w, h, false);
94
+ camera.aspect = w / h;
95
+ camera.updateProjectionMatrix();
96
+ };
97
+ const observer = new ResizeObserver(fit);
98
+ observer.observe(el);
99
+ fit();
100
+
101
+ let meshes = 0;
102
+ model.traverse((node) => { if (node.isMesh) meshes += 1; });
103
+
104
+ renderer.setAnimationLoop(() => {
105
+ controls.update();
106
+ renderer.render(scene, camera);
107
+ });
108
+
109
+ const capture = () => new Promise((resolve, reject) => {
110
+ renderer.render(scene, camera);
111
+ renderer.domElement.toBlob((blob) => {
112
+ if (!blob) return reject(new Error('capture failed'));
113
+ const reader = new FileReader();
114
+ reader.onload = () => resolve(reader.result);
115
+ reader.onerror = () => reject(new Error('capture read failed'));
116
+ reader.readAsDataURL(blob);
117
+ }, 'image/png');
118
+ });
119
+
120
+ const dispose = () => {
121
+ observer.disconnect();
122
+ renderer.setAnimationLoop(null);
123
+ scene.traverse((node) => {
124
+ if (node.geometry) node.geometry.dispose();
125
+ if (node.material) {
126
+ for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
127
+ for (const key of Object.keys(material)) {
128
+ const value = material[key];
129
+ if (value && value.isTexture) value.dispose();
130
+ }
131
+ material.dispose();
132
+ }
133
+ }
134
+ });
135
+ pmrem.dispose();
136
+ renderer.dispose();
137
+ renderer.domElement.remove();
138
+ };
139
+
140
+ return {
141
+ capture,
142
+ dispose,
143
+ stats: {
144
+ meshes,
145
+ topLevelChildren: model.children.length,
146
+ boundingSphereRadius: Number(radius.toFixed(4)),
147
+ runtimeNodes: Object.keys(
148
+ (model.userData && model.userData.sculptRuntime && model.userData.sculptRuntime.nodes) || {},
149
+ ).length,
150
+ },
151
+ };
152
+ }
app/static/viewer.html ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy"
7
+ content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src blob: data:; worker-src blob:">
8
+ <title>img2threejs viewer</title>
9
+ <style>
10
+ html, body { margin: 0; height: 100%; background: #16181d; overflow: hidden; }
11
+ #viewer { position: fixed; inset: 0; }
12
+ #status {
13
+ position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
14
+ color: #9aa0ad; font: 14px/1.5 system-ui, sans-serif; text-align: center; padding: 1rem;
15
+ }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <div id="viewer" role="region" aria-label="3D model viewer"></div>
20
+ <div id="status">Loading model…</div>
21
+ <script type="module">
22
+ // Sandboxed viewer shell. This document runs in an opaque origin
23
+ // (sandbox="allow-scripts", no allow-same-origin): the LLM-influenced
24
+ // generated code cannot touch the parent application's origin, cookies or
25
+ // storage. Communication with the parent is postMessage-only.
26
+ //
27
+ // Protocol (parent -> iframe):
28
+ // {type:'init', bundleText, targetName} — full ESM bundle source
29
+ // {type:'capture'} — request a PNG screenshot
30
+ // Protocol (iframe -> parent):
31
+ // {type:'ready', stats}
32
+ // {type:'error', message}
33
+ // {type:'capture', dataUrl}
34
+ const status = document.getElementById('status');
35
+ const viewerEl = document.getElementById('viewer');
36
+ let session = null;
37
+
38
+ function post(message) {
39
+ // The parent's origin is unknown to us (opaque origin); '*' is acceptable
40
+ // here because the payloads contain no secrets and the parent validates
41
+ // event.source. Model code never sees these messages.
42
+ parent.postMessage(message, '*');
43
+ }
44
+
45
+ async function boot(bundleText) {
46
+ const blob = new Blob([bundleText], { type: 'text/javascript' });
47
+ const url = URL.createObjectURL(blob);
48
+ try {
49
+ const mod = await import(url);
50
+ if (!document.createElement('canvas').getContext('webgl2') &&
51
+ !document.createElement('canvas').getContext('webgl')) {
52
+ throw new Error('WebGL is not available in this browser or GPU.');
53
+ }
54
+ session = mod.mountViewer(viewerEl, mod.makeModel,
55
+ typeof mod.makeLights === 'function' ? mod.makeLights : null, {});
56
+ status.remove();
57
+ post({ type: 'ready', stats: session.stats });
58
+ } finally {
59
+ URL.revokeObjectURL(url);
60
+ }
61
+ }
62
+
63
+ window.addEventListener('message', async (event) => {
64
+ if (event.source !== parent) return;
65
+ const data = event.data;
66
+ if (!data || typeof data !== 'object') return;
67
+ if (data.type === 'init' && typeof data.bundleText === 'string') {
68
+ try {
69
+ await boot(data.bundleText);
70
+ } catch (err) {
71
+ status.textContent = 'The generated model failed to render: ' +
72
+ ((err && err.message) || String(err));
73
+ post({ type: 'error', message: (err && err.message) || String(err) });
74
+ }
75
+ } else if (data.type === 'capture') {
76
+ try {
77
+ if (!session) throw new Error('viewer not ready');
78
+ const dataUrl = await session.capture();
79
+ post({ type: 'capture', dataUrl });
80
+ } catch (err) {
81
+ post({ type: 'error', message: 'capture failed: ' + ((err && err.message) || String(err)) });
82
+ }
83
+ }
84
+ });
85
+
86
+ // Signal to the parent that the shell itself loaded (watchdog baseline).
87
+ post({ type: 'shell-ready' });
88
+ </script>
89
+ </body>
90
+ </html>
docs/SECURITY.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security model
2
+
3
+ This public Space accepts untrusted images, sends normalized image content to a
4
+ configured external LLM provider, and renders generated procedural code in a
5
+ visitor's browser. The controls and remaining limitations are explicit below.
6
+
7
+ ## Upload boundary
8
+
9
+ `app/image_guard.py` and `app/main.py` enforce:
10
+
11
+ - A 10 MiB default byte cap while reading, before image decode.
12
+ - Pillow content sniffing; filename, extension, and declared MIME type are not
13
+ treated as proof of format.
14
+ - PNG, JPEG, WebP, GIF (first frame), and BMP only. SVG is rejected because it
15
+ is scriptable XML.
16
+ - Pillow decompression-bomb protection, a 40 MP default pixel cap, and an
17
+ 8192 px longest-side decode cap.
18
+ - EXIF orientation normalization followed by RGB PNG re-encoding, which strips
19
+ original metadata. The normalized image is downscaled to at most 1024 px
20
+ before forge/LLM processing.
21
+ - Server-generated job IDs and artifact names. The artifact route uses an
22
+ explicit filename allowlist and verifies that the resolved file remains
23
+ directly inside its job directory.
24
+
25
+ The normalized image is sent to the operator-configured LLM endpoint. Users
26
+ must treat that provider as a data processor; this Space does not make a local
27
+ model privacy claim.
28
+
29
+ ## Generated code and browser isolation
30
+
31
+ - The LLM authors JSON spec values, not executable TypeScript. A deterministic
32
+ vendored generator emits code and JSON-escapes spec-derived literals.
33
+ - The hosted compiler accepts only its implemented primitive allowlist and
34
+ rejects parent cycles. Generation fails if a TODO fallback is detected; an
35
+ unsupported primitive is never silently represented as a placeholder box.
36
+ - The interactive viewer runs in `<iframe sandbox="allow-scripts">` without
37
+ `allow-same-origin`. It has an opaque origin and cannot access parent cookies,
38
+ storage, or same-origin DOM. Parent/iframe communication is `postMessage`;
39
+ the parent validates `event.source`.
40
+ - Normal application responses use a restrictive policy including
41
+ `default-src 'self'`, `script-src 'self' blob:`, `object-src 'none'`, and
42
+ `base-uri 'none'`. `/static/viewer.html` receives a separate explicit CSP
43
+ permitting only its required inline bootstrap and blob module import.
44
+ - Generated artifacts are served as files. Standalone HTML base64-embeds the
45
+ bundle, creates a blob URL, and accesses module exports through
46
+ `await import(url)` rather than assuming export aliases are local bindings.
47
+
48
+ Browser sandboxing limits access to the Space origin; it is not a proof that
49
+ arbitrary generated code is harmless in every browser implementation. Keep the
50
+ sandbox and CSP in place when embedding or modifying the viewer.
51
+
52
+ ## Server-side process boundary
53
+
54
+ `app/forge_bridge.py` invokes forge and esbuild with:
55
+
56
+ - list-form argv and `shell=False`;
57
+ - per-call timeouts and bounded captured output;
58
+ - a scrubbed child environment that omits `LLM_*` and `ANTHROPIC_*` variables;
59
+ - server-controlled files inside a per-job temporary directory.
60
+
61
+ The strict-validated original spec remains locked and unreviewed. A deep-copied
62
+ `hosted-unreviewed-preview` manifest is used only to compile all supported
63
+ components for inspection. It carries no reviewer, screenshot, comparison
64
+ image, score, or upstream `continue` decision.
65
+
66
+ ## Secrets and deployment
67
+
68
+ - Runtime credentials are read from environment variables at startup and are
69
+ never logged or returned by `/api/config`.
70
+ - Hugging Face injects Space Secrets at runtime; no credential is a Docker
71
+ build argument or image layer.
72
+ - `scripts/deploy_space.py` uses the authenticated local `hf` CLI store, stages
73
+ a deterministic file allowlist, and performs one upload from that sanitized
74
+ directory.
75
+ - Environment-derived secret values are written only to a temporary mode-0600
76
+ secrets file, passed via `hf spaces secrets add --secrets-file`, and removed
77
+ in a `finally` block. Logs and process arguments contain secret names only.
78
+
79
+ ## Abuse and resource control
80
+
81
+ - The default per-client limit is 10 accepted jobs per hour with
82
+ `Retry-After`. The in-memory client map has an LRU size bound.
83
+ - Client identity comes from the ASGI socket peer as resolved by the trusted
84
+ Uvicorn proxy configuration. Application code does not reinterpret a raw,
85
+ caller-controlled `X-Forwarded-For` header.
86
+ - Two jobs run concurrently by default and queued plus running jobs are capped
87
+ at eight. A full queue returns `queue_full` with `Retry-After`; queued uploads
88
+ therefore cannot pin unbounded memory.
89
+ - Provider retries apply only to transient statuses, not 400/401/403.
90
+ - Terminal jobs have a two-hour default TTL. The reaper does not delete a
91
+ running job solely because it was created a long time ago.
92
+
93
+ All abuse controls are process-local. They reduce accidental/public-demo load;
94
+ they are not a distributed quota system.
95
+
96
+ ## Container boundary
97
+
98
+ - The service runs as non-root UID 1000 under `tini` on
99
+ `python:3.12-slim-bookworm` and binds to `0.0.0.0:7860`.
100
+ - The final Docker stage copies only runtime dependencies, `app/`, required
101
+ `forge/` code, and `LICENSE`.
102
+ - `.dockerignore`, the runtime COPY allowlist, and Docker verification exclude
103
+ VCS state, virtual environments, rollouts, caches, tests, scripts,
104
+ `upstream-src`, and local verification artifacts from the final image.
105
+ - Rebuild the image regularly to pick up base-image security updates.
106
+
107
+ ## Accepted limitations
108
+
109
+ - A single image does not reveal hidden geometry. Results are approximate and
110
+ hidden sides are inferred, not observed or measured.
111
+ - A strict-valid spec is not visually approved. The hosted result is explicitly
112
+ unreviewed; production use requires the upstream screenshot/comparison review
113
+ loop.
114
+ - Job artifacts live in ephemeral `/tmp` and expire; there is no persistent
115
+ user-content store.
116
+ - Rate limiting and job state reset when the single process restarts.
forge/_shared/feature_acceptance_policy.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Shared feature-level acceptance logic for visual sculpt passes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+
9
+ def is_number(value: Any) -> bool:
10
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
11
+
12
+
13
+ def feature_review_policy(spec: dict[str, Any]) -> dict[str, Any]:
14
+ loop = spec.get("selfCorrectLoop")
15
+ if not isinstance(loop, dict):
16
+ return {}
17
+ acceptance = loop.get("visualAcceptance")
18
+ if not isinstance(acceptance, dict):
19
+ return {}
20
+ policy = acceptance.get("featureReviewPolicy")
21
+ return policy if isinstance(policy, dict) else {}
22
+
23
+
24
+ def feature_targets_for_pass(spec: dict[str, Any], pass_id: str) -> list[dict[str, Any]]:
25
+ targets = spec.get("featureReviewTargets", [])
26
+ if not isinstance(targets, list):
27
+ return []
28
+ applicable: list[dict[str, Any]] = []
29
+ for target in targets:
30
+ if not isinstance(target, dict):
31
+ continue
32
+ pass_ids = target.get("passIds", [])
33
+ if isinstance(pass_ids, list) and pass_id in pass_ids:
34
+ applicable.append(target)
35
+ return applicable
36
+
37
+
38
+ def feature_gate_failures(
39
+ spec: dict[str, Any],
40
+ entry: dict[str, Any],
41
+ pass_id: str,
42
+ ) -> list[str]:
43
+ policy = feature_review_policy(spec)
44
+ if policy.get("enabled") is not True:
45
+ return []
46
+
47
+ targets = feature_targets_for_pass(spec, pass_id)
48
+ critical = [
49
+ target
50
+ for target in targets
51
+ if target.get("tier") == "critical" or target.get("mustPass") is True
52
+ ]
53
+ max_critical = policy.get("maxCriticalFeaturesPerPass", 5)
54
+ failures: list[str] = []
55
+ if is_number(max_critical) and len(critical) > int(max_critical):
56
+ failures.append(
57
+ f"pass {pass_id!r} defines {len(critical)} critical features; "
58
+ f"group them into at most {int(max_critical)} semantic systems"
59
+ )
60
+ important = [target for target in targets if target.get("tier") == "important"]
61
+ max_important = policy.get("maxImportantFeaturesPerPass", 3)
62
+ if is_number(max_important) and len(important) > int(max_important):
63
+ failures.append(
64
+ f"pass {pass_id!r} defines {len(important)} important features; "
65
+ f"keep only the {int(max_important)} most uncertain or high-value systems"
66
+ )
67
+
68
+ reviews = entry.get("featureReviews", [])
69
+ review_by_id = {
70
+ review.get("id"): review
71
+ for review in reviews
72
+ if isinstance(review, dict) and isinstance(review.get("id"), str)
73
+ } if isinstance(reviews, list) else {}
74
+
75
+ default_threshold = policy.get("criticalDefaultThreshold", 0.8)
76
+ for target in critical:
77
+ target_id = target.get("id")
78
+ if not isinstance(target_id, str) or not target_id:
79
+ continue
80
+ review = review_by_id.get(target_id)
81
+ if not isinstance(review, dict):
82
+ failures.append(f"critical feature {target_id!r} has no AI vision review")
83
+ continue
84
+ if review.get("visible") is False:
85
+ failures.append(f"critical feature {target_id!r} is not visible in the review view")
86
+ continue
87
+ score = review.get("score")
88
+ minimum = target.get("minimumScore", default_threshold)
89
+ if not is_number(score):
90
+ failures.append(f"critical feature {target_id!r} has no numeric score")
91
+ elif not is_number(minimum) or float(score) < float(minimum):
92
+ failures.append(
93
+ f"critical feature {target_id!r} score {score} is below {minimum}"
94
+ )
95
+ important_ids = {
96
+ target.get("id")
97
+ for target in targets
98
+ if target.get("tier") == "important" and isinstance(target.get("id"), str)
99
+ }
100
+ important_scores = [
101
+ float(review["score"])
102
+ for feature_id, review in review_by_id.items()
103
+ if feature_id in important_ids and is_number(review.get("score"))
104
+ ]
105
+ important_threshold = policy.get("importantAverageThreshold", 0.65)
106
+ if important_scores and is_number(important_threshold):
107
+ average = sum(important_scores) / len(important_scores)
108
+ if average < float(important_threshold):
109
+ failures.append(
110
+ f"reviewed important features average {average:.3f} is below "
111
+ f"{float(important_threshold):.3f}"
112
+ )
113
+ return failures
forge/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Three.js Object Sculptor scripts have NO third-party dependencies.
2
+ # Everything uses the Python 3.10+ standard library only (json, argparse, struct,
3
+ # zlib, pathlib, math, subprocess). PNG maps and comparison sheets are written with
4
+ # struct/zlib directly — no Pillow/numpy/OpenCV/Playwright required.
5
+ #
6
+ # Requires: python >= 3.10
forge/stage1_intake/build_detail_inventory.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Slice a reference image into inspection zones and scaffold a detailInventory to fill in.
3
+
4
+ Scans the reference zone by zone (a uniform grid, or named component regions) so small
5
+ identity-defining marks are not missed by a single glance at the whole image. Writes one
6
+ crop PNG per zone plus a detailInventory skeleton JSON (see docs/UPGRADE_PLAN.md 4.1 and
7
+ grimoire/intake/detail_inventory.md) with one detail stub per zone for the agent to classify,
8
+ describe, and link to a component/material field. This script only scaffolds zones and
9
+ crops; it does not judge what is in them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import shutil
17
+ import struct
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import zlib
22
+ from pathlib import Path
23
+
24
+
25
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
26
+
27
+ TARGET_MIN_DETAILS = {
28
+ "simple": 3,
29
+ "moderate": 6,
30
+ "complex": 10,
31
+ "ultra-complex": 16,
32
+ }
33
+
34
+ DEFAULT_COMPONENT_ZONES = [
35
+ ("upper", 0.0, 0.0, 1.0, 1.0 / 3),
36
+ ("middle", 0.0, 1.0 / 3, 1.0, 1.0 / 3),
37
+ ("lower", 0.0, 2.0 / 3, 1.0, 1.0 / 3),
38
+ ]
39
+
40
+
41
+ def paeth_predictor(a: int, b: int, c: int) -> int:
42
+ p = a + b - c
43
+ pa = abs(p - a)
44
+ pb = abs(p - b)
45
+ pc = abs(p - c)
46
+ if pa <= pb and pa <= pc:
47
+ return a
48
+ if pb <= pc:
49
+ return b
50
+ return c
51
+
52
+
53
+ def read_png(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
54
+ data = path.read_bytes()
55
+ if not data.startswith(PNG_SIGNATURE):
56
+ raise ValueError("not a PNG file")
57
+ cursor = len(PNG_SIGNATURE)
58
+ width = height = bit_depth = color_type = interlace = None
59
+ idat = bytearray()
60
+ while cursor + 8 <= len(data):
61
+ length = struct.unpack(">I", data[cursor : cursor + 4])[0]
62
+ chunk_type = data[cursor + 4 : cursor + 8]
63
+ chunk_data = data[cursor + 8 : cursor + 8 + length]
64
+ cursor += 12 + length
65
+ if chunk_type == b"IHDR":
66
+ width, height, bit_depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", chunk_data)
67
+ elif chunk_type == b"IDAT":
68
+ idat.extend(chunk_data)
69
+ elif chunk_type == b"IEND":
70
+ break
71
+ if width is None or height is None or bit_depth != 8 or interlace != 0:
72
+ raise ValueError("unsupported PNG; expected 8-bit non-interlaced image")
73
+ channels_by_type = {0: 1, 2: 3, 4: 2, 6: 4}
74
+ if color_type not in channels_by_type:
75
+ raise ValueError("unsupported PNG color type; convert to RGB/RGBA first")
76
+ channels = channels_by_type[color_type]
77
+ row_bytes = width * channels
78
+ raw = zlib.decompress(bytes(idat))
79
+ rows: list[bytearray] = []
80
+ offset = 0
81
+ previous = bytearray(row_bytes)
82
+ for _ in range(height):
83
+ filter_type = raw[offset]
84
+ offset += 1
85
+ row = bytearray(raw[offset : offset + row_bytes])
86
+ offset += row_bytes
87
+ for index in range(row_bytes):
88
+ left = row[index - channels] if index >= channels else 0
89
+ up = previous[index]
90
+ up_left = previous[index - channels] if index >= channels else 0
91
+ if filter_type == 1:
92
+ row[index] = (row[index] + left) & 0xFF
93
+ elif filter_type == 2:
94
+ row[index] = (row[index] + up) & 0xFF
95
+ elif filter_type == 3:
96
+ row[index] = (row[index] + ((left + up) // 2)) & 0xFF
97
+ elif filter_type == 4:
98
+ row[index] = (row[index] + paeth_predictor(left, up, up_left)) & 0xFF
99
+ elif filter_type != 0:
100
+ raise ValueError(f"unsupported PNG filter {filter_type}")
101
+ rows.append(row)
102
+ previous = row
103
+ pixels: list[tuple[int, int, int, int]] = []
104
+ for row in rows:
105
+ for x in range(width):
106
+ base = x * channels
107
+ if color_type == 0:
108
+ gray = row[base]
109
+ pixels.append((gray, gray, gray, 255))
110
+ elif color_type == 2:
111
+ pixels.append((row[base], row[base + 1], row[base + 2], 255))
112
+ elif color_type == 4:
113
+ gray = row[base]
114
+ pixels.append((gray, gray, gray, row[base + 1]))
115
+ elif color_type == 6:
116
+ pixels.append((row[base], row[base + 1], row[base + 2], row[base + 3]))
117
+ return width, height, pixels
118
+
119
+
120
+ def write_png_rgb(path: Path, width: int, height: int, pixels: list[tuple[int, int, int]]) -> None:
121
+ if len(pixels) != width * height:
122
+ raise ValueError("pixel payload has the wrong size")
123
+
124
+ def chunk(kind: bytes, payload: bytes) -> bytes:
125
+ checksum = zlib.crc32(kind)
126
+ checksum = zlib.crc32(payload, checksum) & 0xFFFFFFFF
127
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum)
128
+
129
+ scanlines = bytearray()
130
+ for y in range(height):
131
+ scanlines.append(0)
132
+ for red, green, blue in pixels[y * width : (y + 1) * width]:
133
+ scanlines.extend((red, green, blue))
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ path.write_bytes(
136
+ PNG_SIGNATURE
137
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
138
+ + chunk(b"IDAT", zlib.compress(bytes(scanlines), level=6))
139
+ + chunk(b"IEND", b"")
140
+ )
141
+
142
+
143
+ def load_image(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
144
+ try:
145
+ return read_png(path)
146
+ except Exception as direct_error:
147
+ sips = shutil.which("sips")
148
+ if not sips:
149
+ raise ValueError(f"could not decode {path.name} as PNG and sips is unavailable: {direct_error}") from direct_error
150
+ with tempfile.TemporaryDirectory() as tmpdir:
151
+ converted = Path(tmpdir) / "converted.png"
152
+ result = subprocess.run(
153
+ [sips, "-s", "format", "png", str(path), "--out", str(converted)],
154
+ capture_output=True,
155
+ text=True,
156
+ check=False,
157
+ )
158
+ if result.returncode != 0:
159
+ raise ValueError(result.stderr.strip() or result.stdout.strip() or "sips conversion failed")
160
+ return read_png(converted)
161
+
162
+
163
+ def composite_over_white(pixel: tuple[int, int, int, int]) -> tuple[int, int, int]:
164
+ red, green, blue, alpha = pixel
165
+ mix = alpha / 255.0
166
+ return (
167
+ round(red * mix + 255 * (1 - mix)),
168
+ round(green * mix + 255 * (1 - mix)),
169
+ round(blue * mix + 255 * (1 - mix)),
170
+ )
171
+
172
+
173
+ def parse_components(spec: str) -> list[tuple[str, float, float, float, float]]:
174
+ zones: list[tuple[str, float, float, float, float]] = []
175
+ for part in spec.split(";"):
176
+ part = part.strip()
177
+ if not part:
178
+ continue
179
+ name, sep, coords = part.partition(":")
180
+ if not sep:
181
+ raise ValueError(f"malformed --components entry (expected name:x,y,w,h): {part!r}")
182
+ values = [float(v) for v in coords.split(",")]
183
+ if len(values) != 4:
184
+ raise ValueError(f"malformed --components entry (expected 4 normalized values): {part!r}")
185
+ x, y, w, h = values
186
+ zones.append((name.strip(), x, y, w, h))
187
+ if not zones:
188
+ raise ValueError("--components produced no zones")
189
+ return zones
190
+
191
+
192
+ def build_zones(mode: str, components_spec: str | None) -> list[dict]:
193
+ if mode == "component-zones":
194
+ zones_spec = parse_components(components_spec) if components_spec else DEFAULT_COMPONENT_ZONES
195
+ return [
196
+ {"id": name, "region": {"x": x, "y": y, "width": w, "height": h, "units": "normalized"}}
197
+ for name, x, y, w, h in zones_spec
198
+ ]
199
+ grid = 3 if mode == "grid-3x3" else 4
200
+ step = 1.0 / grid
201
+ zones = []
202
+ for row in range(grid):
203
+ for col in range(grid):
204
+ zones.append(
205
+ {
206
+ "id": f"zone-r{row}c{col}",
207
+ "region": {
208
+ "x": round(col * step, 4),
209
+ "y": round(row * step, 4),
210
+ "width": round(step, 4),
211
+ "height": round(step, 4),
212
+ "units": "normalized",
213
+ },
214
+ }
215
+ )
216
+ return zones
217
+
218
+
219
+ def make_detail_stub(zone: dict, crop_path: Path) -> dict:
220
+ return {
221
+ "id": zone["id"],
222
+ "kind": "",
223
+ "description": "",
224
+ "region": zone["region"],
225
+ "scale": "",
226
+ "affects": "",
227
+ "mapsTo": {"type": "", "ref": ""},
228
+ "evidenceRef": str(crop_path),
229
+ "confidence": 0.0,
230
+ }
231
+
232
+
233
+ def build_inventory(
234
+ image: Path,
235
+ mode: str,
236
+ out_dir: Path,
237
+ target_min_details: int,
238
+ components_spec: str | None,
239
+ ) -> dict:
240
+ width, height, pixels = load_image(image)
241
+ zones = build_zones(mode, components_spec)
242
+ out_dir.mkdir(parents=True, exist_ok=True)
243
+ details = []
244
+ for zone in zones:
245
+ region = zone["region"]
246
+ x0 = round(region["x"] * width)
247
+ y0 = round(region["y"] * height)
248
+ x1 = min(width, x0 + max(1, round(region["width"] * width)))
249
+ y1 = min(height, y0 + max(1, round(region["height"] * height)))
250
+ crop_w = max(1, x1 - x0)
251
+ crop_h = max(1, y1 - y0)
252
+ crop_pixels = []
253
+ for y in range(y0, y0 + crop_h):
254
+ source_y = min(height - 1, y)
255
+ for x in range(x0, x0 + crop_w):
256
+ source_x = min(width - 1, x)
257
+ crop_pixels.append(composite_over_white(pixels[source_y * width + source_x]))
258
+ crop_path = out_dir / f"{zone['id']}.png"
259
+ write_png_rgb(crop_path, crop_w, crop_h, crop_pixels)
260
+ details.append(make_detail_stub(zone, crop_path))
261
+ return {
262
+ "scanMethod": mode,
263
+ "targetMinDetails": target_min_details,
264
+ "details": details,
265
+ }
266
+
267
+
268
+ def main(argv: list[str]) -> int:
269
+ parser = argparse.ArgumentParser(description=__doc__)
270
+ parser.add_argument("image", type=Path)
271
+ parser.add_argument(
272
+ "--mode",
273
+ choices=["grid-3x3", "grid-4x4", "component-zones"],
274
+ default="grid-3x3",
275
+ help="Zone layout to scan (default: grid-3x3)",
276
+ )
277
+ parser.add_argument(
278
+ "--out-dir",
279
+ type=Path,
280
+ help="Directory to write zone crop PNGs (default: <image-stem>-zones next to the image)",
281
+ )
282
+ parser.add_argument(
283
+ "--out",
284
+ type=Path,
285
+ help="Output detailInventory skeleton JSON path (default: <out-dir>/detail-inventory.json)",
286
+ )
287
+ parser.add_argument(
288
+ "--complexity",
289
+ choices=sorted(TARGET_MIN_DETAILS),
290
+ default="moderate",
291
+ help="Sets targetMinDetails from the complexity tier; overridden by --target-min-details",
292
+ )
293
+ parser.add_argument("--target-min-details", type=int, help="Override targetMinDetails directly")
294
+ parser.add_argument(
295
+ "--components",
296
+ help="component-zones only: 'name:x,y,w,h;name2:x,y,w,h' normalized regions "
297
+ "(default: upper/middle/lower thirds)",
298
+ )
299
+ parser.add_argument("--force", action="store_true", help="Overwrite existing output JSON")
300
+ args = parser.parse_args(argv)
301
+
302
+ image = args.image.expanduser().resolve()
303
+ if not image.exists():
304
+ parser.error(f"{image} does not exist")
305
+ out_dir = (args.out_dir or image.with_name(f"{image.stem}-zones")).expanduser().resolve()
306
+ out_path = (args.out or out_dir / "detail-inventory.json").expanduser().resolve()
307
+ if out_path.exists() and not args.force:
308
+ parser.error(f"{out_path} already exists; use --force to overwrite")
309
+ target_min_details = args.target_min_details or TARGET_MIN_DETAILS[args.complexity]
310
+
311
+ try:
312
+ inventory = build_inventory(image, args.mode, out_dir, target_min_details, args.components)
313
+ except Exception as exc:
314
+ print(f"error: {exc}", file=sys.stderr)
315
+ return 1
316
+
317
+ payload = {
318
+ "sourceImage": str(image),
319
+ "zonesDir": str(out_dir),
320
+ "detailInventory": inventory,
321
+ "authoringInstruction": (
322
+ "Open each zone crop under zonesDir and replace every detail stub's kind, description, "
323
+ "scale, affects, mapsTo, and confidence with what is actually observed. Add more detail "
324
+ "entries per zone if a single zone contains multiple distinct marks; do not leave stubs "
325
+ "unfilled or unlinked (mapsTo must reference a real component.localFeatures or "
326
+ "material.localOverrides entry)."
327
+ ),
328
+ }
329
+ out_path.parent.mkdir(parents=True, exist_ok=True)
330
+ out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
331
+ print(out_path)
332
+ return 0
333
+
334
+
335
+ if __name__ == "__main__":
336
+ raise SystemExit(main(sys.argv[1:]))
forge/stage1_intake/delight_albedo.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Approximate a neutral (de-lit) albedo from a single reference photo.
3
+
4
+ This is an approximation, not true inverse rendering. A single photo bakes
5
+ together albedo, direct light, ambient occlusion, and specular response
6
+ into one signal, and there is no way to fully separate those from pixels
7
+ alone. This script applies a per-pixel normalization against a low-frequency
8
+ luminance estimate (a box-blur "lighting" proxy): pixels darker than their
9
+ local neighborhood get brightened, pixels brighter than their neighborhood
10
+ get darkened, pulling the image toward flat, even lighting. Strong specular
11
+ hotspots, deep occlusion shadows, and directional cues that vary faster than
12
+ the blur radius will not be fully removed. Always review the output next to
13
+ the source image before treating it as a projection albedo.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import shutil
21
+ import struct
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import zlib
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+
30
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
31
+
32
+
33
+ def clamp(value: float, minimum: float, maximum: float) -> float:
34
+ return max(minimum, min(maximum, value))
35
+
36
+
37
+ def clamp01(value: float) -> float:
38
+ return clamp(value, 0.0, 1.0)
39
+
40
+
41
+ def srgb_luma(rgb: tuple[int, int, int]) -> float:
42
+ red, green, blue = rgb
43
+ return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0
44
+
45
+
46
+ def percentile(values: list[float], fraction: float, fallback: float = 0.0) -> float:
47
+ if not values:
48
+ return fallback
49
+ ordered = sorted(values)
50
+ index = int(round(clamp01(fraction) * (len(ordered) - 1)))
51
+ return ordered[index]
52
+
53
+
54
+ def paeth_predictor(a: int, b: int, c: int) -> int:
55
+ p = a + b - c
56
+ pa = abs(p - a)
57
+ pb = abs(p - b)
58
+ pc = abs(p - c)
59
+ if pa <= pb and pa <= pc:
60
+ return a
61
+ if pb <= pc:
62
+ return b
63
+ return c
64
+
65
+
66
+ def read_png(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
67
+ data = path.read_bytes()
68
+ if not data.startswith(PNG_SIGNATURE):
69
+ raise ValueError("not a PNG file")
70
+ cursor = len(PNG_SIGNATURE)
71
+ width = height = bit_depth = color_type = None
72
+ idat = bytearray()
73
+ interlace = 0
74
+ while cursor + 8 <= len(data):
75
+ length = struct.unpack(">I", data[cursor : cursor + 4])[0]
76
+ chunk_type = data[cursor + 4 : cursor + 8]
77
+ chunk_data = data[cursor + 8 : cursor + 8 + length]
78
+ cursor += 12 + length
79
+ if chunk_type == b"IHDR":
80
+ width, height, bit_depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", chunk_data)
81
+ elif chunk_type == b"IDAT":
82
+ idat.extend(chunk_data)
83
+ elif chunk_type == b"IEND":
84
+ break
85
+ if width is None or height is None or bit_depth != 8 or interlace != 0:
86
+ raise ValueError("unsupported PNG; expected 8-bit non-interlaced image")
87
+ channels_by_type = {0: 1, 2: 3, 4: 2, 6: 4}
88
+ if color_type not in channels_by_type:
89
+ raise ValueError("unsupported PNG color type; convert to RGB/RGBA first")
90
+ channels = channels_by_type[color_type]
91
+ row_bytes = width * channels
92
+ raw = zlib.decompress(bytes(idat))
93
+ rows: list[bytearray] = []
94
+ offset = 0
95
+ previous = bytearray(row_bytes)
96
+ for _ in range(height):
97
+ filter_type = raw[offset]
98
+ offset += 1
99
+ row = bytearray(raw[offset : offset + row_bytes])
100
+ offset += row_bytes
101
+ for index in range(row_bytes):
102
+ left = row[index - channels] if index >= channels else 0
103
+ up = previous[index]
104
+ up_left = previous[index - channels] if index >= channels else 0
105
+ if filter_type == 1:
106
+ row[index] = (row[index] + left) & 0xFF
107
+ elif filter_type == 2:
108
+ row[index] = (row[index] + up) & 0xFF
109
+ elif filter_type == 3:
110
+ row[index] = (row[index] + ((left + up) // 2)) & 0xFF
111
+ elif filter_type == 4:
112
+ predictor = paeth_predictor(left, up, up_left)
113
+ row[index] = (row[index] + predictor) & 0xFF
114
+ elif filter_type != 0:
115
+ raise ValueError(f"unsupported PNG filter {filter_type}")
116
+ rows.append(row)
117
+ previous = row
118
+ pixels: list[tuple[int, int, int, int]] = []
119
+ for row in rows:
120
+ for x in range(width):
121
+ base = x * channels
122
+ if color_type == 0:
123
+ gray = row[base]
124
+ pixels.append((gray, gray, gray, 255))
125
+ elif color_type == 2:
126
+ pixels.append((row[base], row[base + 1], row[base + 2], 255))
127
+ elif color_type == 4:
128
+ gray = row[base]
129
+ pixels.append((gray, gray, gray, row[base + 1]))
130
+ elif color_type == 6:
131
+ pixels.append((row[base], row[base + 1], row[base + 2], row[base + 3]))
132
+ return width, height, pixels
133
+
134
+
135
+ def write_png_rgba(path: Path, width: int, height: int, rgba: bytes) -> None:
136
+ if len(rgba) != width * height * 4:
137
+ raise ValueError("RGBA payload has the wrong size")
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+
140
+ def chunk(kind: bytes, payload: bytes) -> bytes:
141
+ checksum = zlib.crc32(kind)
142
+ checksum = zlib.crc32(payload, checksum) & 0xFFFFFFFF
143
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum)
144
+
145
+ scanlines = bytearray()
146
+ stride = width * 4
147
+ for y in range(height):
148
+ scanlines.append(0)
149
+ scanlines.extend(rgba[y * stride : (y + 1) * stride])
150
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
151
+ path.write_bytes(
152
+ PNG_SIGNATURE
153
+ + chunk(b"IHDR", ihdr)
154
+ + chunk(b"IDAT", zlib.compress(bytes(scanlines), level=6))
155
+ + chunk(b"IEND", b"")
156
+ )
157
+
158
+
159
+ def load_image(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]], list[str]]:
160
+ warnings: list[str] = []
161
+ try:
162
+ return (*read_png(path), warnings)
163
+ except Exception as direct_error:
164
+ sips = shutil.which("sips")
165
+ if not sips:
166
+ raise ValueError(
167
+ f"could not decode {path.name} as PNG and macOS sips is unavailable: {direct_error}"
168
+ ) from direct_error
169
+ with tempfile.TemporaryDirectory() as tmpdir:
170
+ converted = Path(tmpdir) / "converted.png"
171
+ command = [sips, "-s", "format", "png", str(path), "--out", str(converted)]
172
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
173
+ if result.returncode != 0:
174
+ raise ValueError(result.stderr.strip() or result.stdout.strip() or "sips conversion failed")
175
+ warnings.append("source image was converted to PNG with macOS sips before pixel extraction")
176
+ width, height, pixels = read_png(converted)
177
+ return width, height, pixels, warnings
178
+
179
+
180
+ def blur_scalar(values: list[float], width: int, height: int, radius: int) -> list[float]:
181
+ if radius <= 0:
182
+ return values[:]
183
+ horizontal = [0.0] * (width * height)
184
+ for y in range(height):
185
+ row_offset = y * width
186
+ running = 0.0
187
+ count = 0
188
+ for x in range(-radius, width + radius):
189
+ if 0 <= x < width:
190
+ running += values[row_offset + x]
191
+ count += 1
192
+ remove = x - radius * 2 - 1
193
+ if 0 <= remove < width:
194
+ running -= values[row_offset + remove]
195
+ count -= 1
196
+ write_x = x - radius
197
+ if 0 <= write_x < width:
198
+ horizontal[row_offset + write_x] = running / max(1, count)
199
+ vertical = [0.0] * (width * height)
200
+ for x in range(width):
201
+ running = 0.0
202
+ count = 0
203
+ for y in range(-radius, height + radius):
204
+ if 0 <= y < height:
205
+ running += horizontal[y * width + x]
206
+ count += 1
207
+ remove = y - radius * 2 - 1
208
+ if 0 <= remove < height:
209
+ running -= horizontal[remove * width + x]
210
+ count -= 1
211
+ write_y = y - radius
212
+ if 0 <= write_y < height:
213
+ vertical[write_y * width + x] = running / max(1, count)
214
+ return vertical
215
+
216
+
217
+ def delight(
218
+ width: int,
219
+ height: int,
220
+ pixels: list[tuple[int, int, int, int]],
221
+ strength: float,
222
+ blur_radius: int,
223
+ ) -> tuple[bytes, dict[str, Any]]:
224
+ lumas = [srgb_luma(pixel[:3]) for pixel in pixels]
225
+ target = percentile(lumas, 0.5, 0.5)
226
+ low_frequency = blur_scalar(lumas, width, height, blur_radius)
227
+ out = bytearray()
228
+ corrections: list[float] = []
229
+ for (red, green, blue, alpha), low in zip(pixels, low_frequency):
230
+ shade = clamp(low, 0.05, 1.0)
231
+ raw_scale = target / shade
232
+ # strength blends between no correction (1.0) and the full normalization
233
+ scale = 1.0 + (raw_scale - 1.0) * clamp01(strength)
234
+ scale = clamp(scale, 0.35, 2.6)
235
+ corrections.append(scale)
236
+ out.extend(
237
+ (
238
+ round(clamp(red * scale, 0, 255)),
239
+ round(clamp(green * scale, 0, 255)),
240
+ round(clamp(blue * scale, 0, 255)),
241
+ alpha,
242
+ )
243
+ )
244
+ luma_before_range = percentile(lumas, 0.95, 0.8) - percentile(lumas, 0.05, 0.2)
245
+ stats = {
246
+ "targetLuma": round(target, 4),
247
+ "blurRadius": blur_radius,
248
+ "lumaRangeBefore": round(luma_before_range, 4),
249
+ "meanCorrectionScale": round(sum(corrections) / max(1, len(corrections)), 4),
250
+ "maxCorrectionScale": round(max(corrections, default=1.0), 4),
251
+ "minCorrectionScale": round(min(corrections, default=1.0), 4),
252
+ }
253
+ return bytes(out), stats
254
+
255
+
256
+ def estimate_confidence(stats: dict[str, Any], strength: float, warnings: list[str]) -> tuple[float, list[str]]:
257
+ notes: list[str] = []
258
+ luma_range = float(stats.get("lumaRangeBefore", 0.4))
259
+ # a very large baked lighting range means more got corrected but also more
260
+ # residual error is likely, since the box blur is only a crude lighting proxy
261
+ range_penalty = clamp01((luma_range - 0.35) * 0.6)
262
+ strength_bonus = clamp01(strength) * 0.15
263
+ confidence = clamp01(0.55 - range_penalty * 0.25 + strength_bonus - min(0.1, len(warnings) * 0.04))
264
+ confidence = min(0.72, confidence) # single-image de-lighting is always capped
265
+ notes.append("single-image de-lighting cannot separate true albedo from baked light/AO/specular; confidence is capped")
266
+ if luma_range > 0.5:
267
+ notes.append("wide baked lighting range detected; expect visible residual shading after correction")
268
+ return round(confidence, 3), notes
269
+
270
+
271
+ def main(argv: list[str]) -> int:
272
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
273
+ parser.add_argument("image", type=Path)
274
+ parser.add_argument("--out", type=Path, required=True, help="Output de-lit PNG path")
275
+ parser.add_argument("--report", type=Path, help="Write the JSON report to this path (also printed to stdout)")
276
+ parser.add_argument(
277
+ "--strength",
278
+ type=float,
279
+ default=0.6,
280
+ help="0.0 = no correction (passthrough), 1.0 = full normalization against the blurred luminance proxy (default 0.6)",
281
+ )
282
+ parser.add_argument(
283
+ "--blur-radius",
284
+ type=int,
285
+ default=0,
286
+ help="Box-blur radius in pixels for the low-frequency lighting estimate; 0 = auto from image size",
287
+ )
288
+ args = parser.parse_args(argv)
289
+
290
+ image = args.image.expanduser().resolve()
291
+ if not image.exists():
292
+ parser.error(f"{image} does not exist")
293
+ out_path = args.out.expanduser().resolve()
294
+
295
+ try:
296
+ width, height, pixels, load_warnings = load_image(image)
297
+ blur_radius = args.blur_radius if args.blur_radius > 0 else max(6, min(48, min(width, height) // 20))
298
+ strength = clamp01(args.strength)
299
+ delit_rgba, stats = delight(width, height, pixels, strength, blur_radius)
300
+ write_png_rgba(out_path, width, height, delit_rgba)
301
+
302
+ confidence, confidence_notes = estimate_confidence(stats, strength, load_warnings)
303
+ report = {
304
+ "delightReference": {
305
+ "version": "1.0",
306
+ "sourceImage": str(image),
307
+ "outputImage": str(out_path),
308
+ "method": (
309
+ "per-pixel normalization against a box-blurred luminance proxy; an approximation of "
310
+ "de-lighting, not physically based inverse rendering or true light/albedo separation"
311
+ ),
312
+ "strength": strength,
313
+ "confidence": confidence,
314
+ "stats": stats,
315
+ "limitations": [
316
+ "this is an approximation, not true inverse rendering; it cannot recover ground-truth albedo",
317
+ "sharp specular highlights and hard shadow edges narrower than the blur radius will remain baked in",
318
+ "deep occlusion shadows (creases, undercuts) are only partially lifted",
319
+ "must be reviewed visually next to the source image before use as a projection albedo",
320
+ ]
321
+ + confidence_notes
322
+ + load_warnings,
323
+ "note": (
324
+ "If shadows or highlights are still visible in the output, try a larger --strength or a "
325
+ "smaller --blur-radius so the correction responds to tighter lighting gradients, then "
326
+ "re-review; this script does not know when the correction is visually sufficient."
327
+ ),
328
+ }
329
+ }
330
+ text = json.dumps(report, indent=2, ensure_ascii=False)
331
+ if args.report:
332
+ report_path = args.report.expanduser().resolve()
333
+ report_path.parent.mkdir(parents=True, exist_ok=True)
334
+ report_path.write_text(text + "\n", encoding="utf-8")
335
+ print(text)
336
+ return 0
337
+ except Exception as exc:
338
+ print(f"error: {exc}", file=sys.stderr)
339
+ return 1
340
+
341
+
342
+ if __name__ == "__main__":
343
+ raise SystemExit(main(sys.argv[1:]))
forge/stage1_intake/extract_landmarks.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Overlay a labelled proportion/landmark guide grid on a reference image and scaffold anatomy.
3
+
4
+ Draws head-unit ticks, a rule-of-thirds grid, a center symmetry axis, default face-line
5
+ guides (hairline/eye/nose/mouth), and default shoulder/hip lines onto a copy of the
6
+ reference (see docs/UPGRADE_PLAN.md 5.3-5.4 and grimoire/character/reconstruction.md),
7
+ then emits an anatomy skeleton JSON for the agent to fill from what the overlay reveals.
8
+ The drawn lines are generic starting positions, not measurements - the agent's vision
9
+ supplies the actual proportions, pose, and landmark coordinates.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import shutil
17
+ import struct
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import zlib
22
+ from pathlib import Path
23
+
24
+
25
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
26
+
27
+ MARGIN = 44
28
+
29
+ COLOR_THIRDS = (150, 150, 150)
30
+ COLOR_HEAD_UNIT = (60, 120, 220)
31
+ COLOR_HAIRLINE = (210, 80, 210)
32
+ COLOR_EYELINE = (230, 60, 60)
33
+ COLOR_NOSEBASE = (240, 150, 30)
34
+ COLOR_MOUTHLINE = (40, 170, 90)
35
+ COLOR_SHOULDER = (30, 140, 200)
36
+ COLOR_HIP = (170, 110, 40)
37
+ COLOR_CENTER = (20, 20, 20)
38
+
39
+ FONT_3X5 = {
40
+ "0": ["###", "#.#", "#.#", "#.#", "###"],
41
+ "1": [".#.", "##.", ".#.", ".#.", "###"],
42
+ "2": ["###", "..#", "###", "#..", "###"],
43
+ "3": ["###", "..#", "###", "..#", "###"],
44
+ "4": ["#.#", "#.#", "###", "..#", "..#"],
45
+ "5": ["###", "#..", "###", "..#", "###"],
46
+ "6": ["###", "#..", "###", "#.#", "###"],
47
+ "7": ["###", "..#", "..#", "..#", "..#"],
48
+ "8": ["###", "#.#", "###", "#.#", "###"],
49
+ "9": ["###", "#.#", "###", "..#", "###"],
50
+ "H": ["#.#", "#.#", "###", "#.#", "#.#"],
51
+ "E": ["###", "#..", "##.", "#..", "###"],
52
+ "N": ["#.#", "##.", "#.#", ".##", "#.#"],
53
+ "M": ["#.#", "###", "###", "#.#", "#.#"],
54
+ "S": [".##", "#..", ".#.", "..#", "##."],
55
+ "P": ["##.", "#.#", "##.", "#..", "#.."],
56
+ "C": [".##", "#..", "#..", "#..", ".##"],
57
+ }
58
+
59
+
60
+ def paeth_predictor(a: int, b: int, c: int) -> int:
61
+ p = a + b - c
62
+ pa = abs(p - a)
63
+ pb = abs(p - b)
64
+ pc = abs(p - c)
65
+ if pa <= pb and pa <= pc:
66
+ return a
67
+ if pb <= pc:
68
+ return b
69
+ return c
70
+
71
+
72
+ def read_png(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
73
+ data = path.read_bytes()
74
+ if not data.startswith(PNG_SIGNATURE):
75
+ raise ValueError("not a PNG file")
76
+ cursor = len(PNG_SIGNATURE)
77
+ width = height = bit_depth = color_type = interlace = None
78
+ idat = bytearray()
79
+ while cursor + 8 <= len(data):
80
+ length = struct.unpack(">I", data[cursor : cursor + 4])[0]
81
+ chunk_type = data[cursor + 4 : cursor + 8]
82
+ chunk_data = data[cursor + 8 : cursor + 8 + length]
83
+ cursor += 12 + length
84
+ if chunk_type == b"IHDR":
85
+ width, height, bit_depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", chunk_data)
86
+ elif chunk_type == b"IDAT":
87
+ idat.extend(chunk_data)
88
+ elif chunk_type == b"IEND":
89
+ break
90
+ if width is None or height is None or bit_depth != 8 or interlace != 0:
91
+ raise ValueError("unsupported PNG; expected 8-bit non-interlaced image")
92
+ channels_by_type = {0: 1, 2: 3, 4: 2, 6: 4}
93
+ if color_type not in channels_by_type:
94
+ raise ValueError("unsupported PNG color type; convert to RGB/RGBA first")
95
+ channels = channels_by_type[color_type]
96
+ row_bytes = width * channels
97
+ raw = zlib.decompress(bytes(idat))
98
+ rows: list[bytearray] = []
99
+ offset = 0
100
+ previous = bytearray(row_bytes)
101
+ for _ in range(height):
102
+ filter_type = raw[offset]
103
+ offset += 1
104
+ row = bytearray(raw[offset : offset + row_bytes])
105
+ offset += row_bytes
106
+ for index in range(row_bytes):
107
+ left = row[index - channels] if index >= channels else 0
108
+ up = previous[index]
109
+ up_left = previous[index - channels] if index >= channels else 0
110
+ if filter_type == 1:
111
+ row[index] = (row[index] + left) & 0xFF
112
+ elif filter_type == 2:
113
+ row[index] = (row[index] + up) & 0xFF
114
+ elif filter_type == 3:
115
+ row[index] = (row[index] + ((left + up) // 2)) & 0xFF
116
+ elif filter_type == 4:
117
+ row[index] = (row[index] + paeth_predictor(left, up, up_left)) & 0xFF
118
+ elif filter_type != 0:
119
+ raise ValueError(f"unsupported PNG filter {filter_type}")
120
+ rows.append(row)
121
+ previous = row
122
+ pixels: list[tuple[int, int, int, int]] = []
123
+ for row in rows:
124
+ for x in range(width):
125
+ base = x * channels
126
+ if color_type == 0:
127
+ gray = row[base]
128
+ pixels.append((gray, gray, gray, 255))
129
+ elif color_type == 2:
130
+ pixels.append((row[base], row[base + 1], row[base + 2], 255))
131
+ elif color_type == 4:
132
+ gray = row[base]
133
+ pixels.append((gray, gray, gray, row[base + 1]))
134
+ elif color_type == 6:
135
+ pixels.append((row[base], row[base + 1], row[base + 2], row[base + 3]))
136
+ return width, height, pixels
137
+
138
+
139
+ def write_png_rgb(path: Path, width: int, height: int, pixels: list[tuple[int, int, int]]) -> None:
140
+ if len(pixels) != width * height:
141
+ raise ValueError("pixel payload has the wrong size")
142
+
143
+ def chunk(kind: bytes, payload: bytes) -> bytes:
144
+ checksum = zlib.crc32(kind)
145
+ checksum = zlib.crc32(payload, checksum) & 0xFFFFFFFF
146
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum)
147
+
148
+ scanlines = bytearray()
149
+ for y in range(height):
150
+ scanlines.append(0)
151
+ for red, green, blue in pixels[y * width : (y + 1) * width]:
152
+ scanlines.extend((red, green, blue))
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ path.write_bytes(
155
+ PNG_SIGNATURE
156
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
157
+ + chunk(b"IDAT", zlib.compress(bytes(scanlines), level=6))
158
+ + chunk(b"IEND", b"")
159
+ )
160
+
161
+
162
+ def load_image(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
163
+ try:
164
+ return read_png(path)
165
+ except Exception as direct_error:
166
+ sips = shutil.which("sips")
167
+ if not sips:
168
+ raise ValueError(f"could not decode {path.name} as PNG and sips is unavailable: {direct_error}") from direct_error
169
+ with tempfile.TemporaryDirectory() as tmpdir:
170
+ converted = Path(tmpdir) / "converted.png"
171
+ result = subprocess.run(
172
+ [sips, "-s", "format", "png", str(path), "--out", str(converted)],
173
+ capture_output=True,
174
+ text=True,
175
+ check=False,
176
+ )
177
+ if result.returncode != 0:
178
+ raise ValueError(result.stderr.strip() or result.stdout.strip() or "sips conversion failed")
179
+ return read_png(converted)
180
+
181
+
182
+ def composite_over_white(pixel: tuple[int, int, int, int]) -> tuple[int, int, int]:
183
+ red, green, blue, alpha = pixel
184
+ mix = alpha / 255.0
185
+ return (
186
+ round(red * mix + 255 * (1 - mix)),
187
+ round(green * mix + 255 * (1 - mix)),
188
+ round(blue * mix + 255 * (1 - mix)),
189
+ )
190
+
191
+
192
+ def set_pixel(canvas: list[tuple[int, int, int]], width: int, height: int, x: int, y: int, color: tuple[int, int, int]) -> None:
193
+ if 0 <= x < width and 0 <= y < height:
194
+ canvas[y * width + x] = color
195
+
196
+
197
+ def draw_glyph(
198
+ canvas: list[tuple[int, int, int]],
199
+ width: int,
200
+ height: int,
201
+ x0: int,
202
+ y0: int,
203
+ glyph: list[str],
204
+ color: tuple[int, int, int],
205
+ scale: int,
206
+ ) -> None:
207
+ for row_index, row in enumerate(glyph):
208
+ for col_index, mark in enumerate(row):
209
+ if mark != "#":
210
+ continue
211
+ for dy in range(scale):
212
+ for dx in range(scale):
213
+ set_pixel(canvas, width, height, x0 + col_index * scale + dx, y0 + row_index * scale + dy, color)
214
+
215
+
216
+ def draw_text(
217
+ canvas: list[tuple[int, int, int]],
218
+ width: int,
219
+ height: int,
220
+ x0: int,
221
+ y0: int,
222
+ text: str,
223
+ color: tuple[int, int, int],
224
+ scale: int = 2,
225
+ ) -> None:
226
+ cursor_x = x0
227
+ for character in text:
228
+ glyph = FONT_3X5.get(character)
229
+ if glyph:
230
+ draw_glyph(canvas, width, height, cursor_x, y0, glyph, color, scale)
231
+ cursor_x += 3 * scale + scale
232
+
233
+
234
+ def draw_hline(
235
+ canvas: list[tuple[int, int, int]],
236
+ width: int,
237
+ height: int,
238
+ y: int,
239
+ x_start: int,
240
+ x_end: int,
241
+ color: tuple[int, int, int],
242
+ dash: int = 0,
243
+ ) -> None:
244
+ if not (0 <= y < height):
245
+ return
246
+ row = y * width
247
+ for x in range(max(0, x_start), min(width, x_end)):
248
+ if dash and (x // dash) % 2 == 1:
249
+ continue
250
+ canvas[row + x] = color
251
+
252
+
253
+ def draw_vline(
254
+ canvas: list[tuple[int, int, int]],
255
+ width: int,
256
+ height: int,
257
+ x: int,
258
+ y_start: int,
259
+ y_end: int,
260
+ color: tuple[int, int, int],
261
+ dash: int = 0,
262
+ ) -> None:
263
+ if not (0 <= x < width):
264
+ return
265
+ for y in range(max(0, y_start), min(height, y_end)):
266
+ if dash and (y // dash) % 2 == 1:
267
+ continue
268
+ canvas[y * width + x] = color
269
+
270
+
271
+ def build_overlay(image: Path, overlay_path: Path, heads: int) -> dict:
272
+ width, height, pixels = load_image(image)
273
+ base = [composite_over_white(pixel) for pixel in pixels]
274
+ canvas_w = width + MARGIN
275
+ canvas_h = height
276
+ canvas: list[tuple[int, int, int]] = [(255, 255, 255)] * (canvas_w * canvas_h)
277
+ for y in range(height):
278
+ source_row = y * width
279
+ dest_row = y * canvas_w + MARGIN
280
+ canvas[dest_row : dest_row + width] = base[source_row : source_row + width]
281
+
282
+ for fraction in (1.0 / 3, 2.0 / 3):
283
+ y = round(fraction * height)
284
+ draw_hline(canvas, canvas_w, canvas_h, y, MARGIN, canvas_w, COLOR_THIRDS, dash=6)
285
+ for fraction in (1.0 / 3, 2.0 / 3):
286
+ x = MARGIN + round(fraction * width)
287
+ draw_vline(canvas, canvas_w, canvas_h, x, 0, height, COLOR_THIRDS, dash=6)
288
+
289
+ center_x = MARGIN + width // 2
290
+ draw_vline(canvas, canvas_w, canvas_h, center_x, 0, height, COLOR_CENTER)
291
+ draw_text(canvas, canvas_w, canvas_h, 4, max(0, min(height - 6, height // 2 - 3)), "C", COLOR_CENTER)
292
+
293
+ step = height / heads
294
+ for i in range(1, heads):
295
+ y = round(i * step)
296
+ draw_hline(canvas, canvas_w, canvas_h, y, MARGIN, canvas_w, COLOR_HEAD_UNIT, dash=10)
297
+ draw_text(canvas, canvas_w, canvas_h, 4, max(0, y - 3), str(i), COLOR_HEAD_UNIT)
298
+
299
+ band = step
300
+ face_lines = [
301
+ ("H", COLOR_HAIRLINE, 0.05),
302
+ ("E", COLOR_EYELINE, 0.50),
303
+ ("N", COLOR_NOSEBASE, 0.65),
304
+ ("M", COLOR_MOUTHLINE, 0.80),
305
+ ]
306
+ for label, color, fraction in face_lines:
307
+ y = round(fraction * band)
308
+ draw_hline(canvas, canvas_w, canvas_h, y, MARGIN, MARGIN + width, color, dash=4)
309
+ draw_text(canvas, canvas_w, canvas_h, 4, max(0, y - 3), label, color)
310
+
311
+ shoulder_y = round(0.28 * height)
312
+ hip_y = round(0.55 * height)
313
+ draw_hline(canvas, canvas_w, canvas_h, shoulder_y, MARGIN, canvas_w, COLOR_SHOULDER, dash=14)
314
+ draw_text(canvas, canvas_w, canvas_h, 4, max(0, shoulder_y - 3), "S", COLOR_SHOULDER)
315
+ draw_hline(canvas, canvas_w, canvas_h, hip_y, MARGIN, canvas_w, COLOR_HIP, dash=14)
316
+ draw_text(canvas, canvas_w, canvas_h, 4, max(0, hip_y - 3), "P", COLOR_HIP)
317
+
318
+ write_png_rgb(overlay_path, canvas_w, canvas_h, canvas)
319
+ return {
320
+ "overlayImage": str(overlay_path),
321
+ "imageWidth": width,
322
+ "imageHeight": height,
323
+ "headUnitCount": heads,
324
+ "legend": {
325
+ "C": "center symmetry axis",
326
+ "1..N": "head-unit horizontal ticks (blue, dashed)",
327
+ "H": "hairline guide (default fraction of the first head-unit band)",
328
+ "E": "eye line guide",
329
+ "N": "nose base guide",
330
+ "M": "mouth line guide",
331
+ "S": "shoulder line guide (default fraction, adjust to observed pose)",
332
+ "P": "hip line guide (default fraction, adjust to observed pose)",
333
+ "grayDashed": "rule-of-thirds compositional grid",
334
+ },
335
+ "note": "Guide lines are generic starting positions, not measurements. Read the overlay "
336
+ "against the actual reference and fill anatomy with observed normalized values.",
337
+ }
338
+
339
+
340
+ def make_anatomy_skeleton(style_heads: float) -> dict:
341
+ joint_names = [
342
+ "neck",
343
+ "leftShoulder",
344
+ "rightShoulder",
345
+ "leftElbow",
346
+ "rightElbow",
347
+ "leftWrist",
348
+ "rightWrist",
349
+ "leftHip",
350
+ "rightHip",
351
+ "leftKnee",
352
+ "rightKnee",
353
+ "leftAnkle",
354
+ "rightAnkle",
355
+ ]
356
+ return {
357
+ "styleHeads": style_heads,
358
+ "proportions": {
359
+ "headUnit": None,
360
+ "torso": None,
361
+ "legs": None,
362
+ "shoulderWidth": None,
363
+ "hipWidth": None,
364
+ },
365
+ "pose": {
366
+ "type": "",
367
+ "jointAngles": {name: [0, 0, 0] for name in joint_names},
368
+ },
369
+ "faceLandmarks": {
370
+ "hairline": None,
371
+ "eyeLine": None,
372
+ "eyeSpacing": None,
373
+ "noseBase": None,
374
+ "mouthLine": None,
375
+ "earTop": None,
376
+ "earBottom": None,
377
+ },
378
+ "features": [],
379
+ "confidence": 0.0,
380
+ }
381
+
382
+
383
+ def main(argv: list[str]) -> int:
384
+ parser = argparse.ArgumentParser(description=__doc__)
385
+ parser.add_argument("image", type=Path)
386
+ parser.add_argument(
387
+ "--out",
388
+ type=Path,
389
+ help="Output anatomy skeleton JSON path (default: <image-stem>-anatomy.json next to the image)",
390
+ )
391
+ parser.add_argument(
392
+ "--overlay",
393
+ type=Path,
394
+ help="Output overlay PNG path (default: <image-stem>-landmarks.png next to the image)",
395
+ )
396
+ parser.add_argument(
397
+ "--style-heads",
398
+ type=float,
399
+ default=6.0,
400
+ help="Initial head-unit estimate driving the overlay grid "
401
+ "(realistic ~7.5, stylized ~5-6, chibi/figurine ~2-3); refine after visual inspection",
402
+ )
403
+ parser.add_argument(
404
+ "--heads",
405
+ type=int,
406
+ help="Override number of head-unit tick lines drawn (default: round(--style-heads))",
407
+ )
408
+ parser.add_argument("--force", action="store_true", help="Overwrite existing outputs")
409
+ args = parser.parse_args(argv)
410
+
411
+ image = args.image.expanduser().resolve()
412
+ if not image.exists():
413
+ parser.error(f"{image} does not exist")
414
+ overlay_path = (args.overlay or image.with_name(f"{image.stem}-landmarks.png")).expanduser().resolve()
415
+ out_path = (args.out or image.with_name(f"{image.stem}-anatomy.json")).expanduser().resolve()
416
+ if not args.force:
417
+ for existing in (overlay_path, out_path):
418
+ if existing.exists():
419
+ parser.error(f"{existing} already exists; use --force to overwrite")
420
+ heads = args.heads or max(1, round(args.style_heads))
421
+
422
+ try:
423
+ overlay_meta = build_overlay(image, overlay_path, heads)
424
+ except Exception as exc:
425
+ print(f"error: {exc}", file=sys.stderr)
426
+ return 1
427
+
428
+ payload = {
429
+ "sourceImage": str(image),
430
+ "overlayImage": str(overlay_path),
431
+ "overlayLegend": overlay_meta["legend"],
432
+ "anatomy": make_anatomy_skeleton(args.style_heads),
433
+ "authoringInstruction": (
434
+ "Open overlayImage and read the reference against its head-unit ticks, thirds grid, "
435
+ "face-line guides, shoulder/hip lines, and center axis. Replace every null/placeholder "
436
+ "value in anatomy with normalized coordinates or joint angles actually observed; the "
437
+ "drawn guide lines are generic starting positions, not measurements."
438
+ ),
439
+ }
440
+ out_path.parent.mkdir(parents=True, exist_ok=True)
441
+ out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
442
+ print(out_path)
443
+ return 0
444
+
445
+
446
+ if __name__ == "__main__":
447
+ raise SystemExit(main(sys.argv[1:]))
forge/stage1_intake/extract_pbr_evidence.py ADDED
@@ -0,0 +1,834 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Extract reference-derived PBR map evidence from an object image.
3
+
4
+ This is not photogrammetry and it does not claim exact inverse rendering from a
5
+ single image. It extracts pixel evidence that is useful for procedural PBR:
6
+ albedo palette, de-lit albedo, roughness estimate, height, normal, and AO maps.
7
+ If the estimated confidence is below the requested target, the script exits
8
+ non-zero and refuses to patch the sculpt spec unless --allow-low-confidence is
9
+ passed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import math
17
+ import shutil
18
+ import struct
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import zlib
23
+ from collections import Counter
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+
28
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
29
+
30
+
31
+ def slugify(value: str) -> str:
32
+ parts: list[str] = []
33
+ last_dash = False
34
+ for char in value.strip().lower():
35
+ if char.isalnum():
36
+ parts.append(char)
37
+ last_dash = False
38
+ elif not last_dash:
39
+ parts.append("-")
40
+ last_dash = True
41
+ return "".join(parts).strip("-") or "material"
42
+
43
+
44
+ def clamp(value: float, minimum: float, maximum: float) -> float:
45
+ return max(minimum, min(maximum, value))
46
+
47
+
48
+ def clamp01(value: float) -> float:
49
+ return clamp(value, 0.0, 1.0)
50
+
51
+
52
+ def srgb_luma(rgb: tuple[int, int, int]) -> float:
53
+ red, green, blue = rgb
54
+ return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0
55
+
56
+
57
+ def color_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> float:
58
+ return math.sqrt(sum((a[index] - b[index]) ** 2 for index in range(3)))
59
+
60
+
61
+ def saturation(rgb: tuple[int, int, int]) -> float:
62
+ high = max(rgb)
63
+ low = min(rgb)
64
+ return 0.0 if high <= 0 else (high - low) / high
65
+
66
+
67
+ def percentile(values: list[float], fraction: float, fallback: float = 0.0) -> float:
68
+ if not values:
69
+ return fallback
70
+ ordered = sorted(values)
71
+ index = int(round(clamp01(fraction) * (len(ordered) - 1)))
72
+ return ordered[index]
73
+
74
+
75
+ def median_color(samples: list[tuple[int, int, int]]) -> tuple[int, int, int]:
76
+ if not samples:
77
+ return (255, 255, 255)
78
+ return tuple(
79
+ int(percentile([float(sample[channel]) for sample in samples], 0.5))
80
+ for channel in range(3)
81
+ ) # type: ignore[return-value]
82
+
83
+
84
+ def read_png(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
85
+ data = path.read_bytes()
86
+ if not data.startswith(PNG_SIGNATURE):
87
+ raise ValueError("not a PNG file")
88
+ cursor = len(PNG_SIGNATURE)
89
+ width = height = bit_depth = color_type = None
90
+ idat = bytearray()
91
+ interlace = 0
92
+ while cursor + 8 <= len(data):
93
+ length = struct.unpack(">I", data[cursor : cursor + 4])[0]
94
+ chunk_type = data[cursor + 4 : cursor + 8]
95
+ chunk_data = data[cursor + 8 : cursor + 8 + length]
96
+ cursor += 12 + length
97
+ if chunk_type == b"IHDR":
98
+ width, height, bit_depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", chunk_data)
99
+ elif chunk_type == b"IDAT":
100
+ idat.extend(chunk_data)
101
+ elif chunk_type == b"IEND":
102
+ break
103
+ if width is None or height is None or bit_depth != 8 or interlace != 0:
104
+ raise ValueError("unsupported PNG; expected 8-bit non-interlaced image")
105
+ channels_by_type = {0: 1, 2: 3, 4: 2, 6: 4}
106
+ if color_type not in channels_by_type:
107
+ raise ValueError("unsupported PNG color type; convert to RGB/RGBA first")
108
+ channels = channels_by_type[color_type]
109
+ row_bytes = width * channels
110
+ raw = zlib.decompress(bytes(idat))
111
+ rows: list[bytearray] = []
112
+ offset = 0
113
+ previous = bytearray(row_bytes)
114
+ for _ in range(height):
115
+ filter_type = raw[offset]
116
+ offset += 1
117
+ row = bytearray(raw[offset : offset + row_bytes])
118
+ offset += row_bytes
119
+ for index in range(row_bytes):
120
+ left = row[index - channels] if index >= channels else 0
121
+ up = previous[index]
122
+ up_left = previous[index - channels] if index >= channels else 0
123
+ if filter_type == 1:
124
+ row[index] = (row[index] + left) & 0xFF
125
+ elif filter_type == 2:
126
+ row[index] = (row[index] + up) & 0xFF
127
+ elif filter_type == 3:
128
+ row[index] = (row[index] + ((left + up) // 2)) & 0xFF
129
+ elif filter_type == 4:
130
+ predictor = paeth_predictor(left, up, up_left)
131
+ row[index] = (row[index] + predictor) & 0xFF
132
+ elif filter_type != 0:
133
+ raise ValueError(f"unsupported PNG filter {filter_type}")
134
+ rows.append(row)
135
+ previous = row
136
+ pixels: list[tuple[int, int, int, int]] = []
137
+ for row in rows:
138
+ for x in range(width):
139
+ base = x * channels
140
+ if color_type == 0:
141
+ gray = row[base]
142
+ pixels.append((gray, gray, gray, 255))
143
+ elif color_type == 2:
144
+ pixels.append((row[base], row[base + 1], row[base + 2], 255))
145
+ elif color_type == 4:
146
+ gray = row[base]
147
+ pixels.append((gray, gray, gray, row[base + 1]))
148
+ elif color_type == 6:
149
+ pixels.append((row[base], row[base + 1], row[base + 2], row[base + 3]))
150
+ return width, height, pixels
151
+
152
+
153
+ def paeth_predictor(a: int, b: int, c: int) -> int:
154
+ p = a + b - c
155
+ pa = abs(p - a)
156
+ pb = abs(p - b)
157
+ pc = abs(p - c)
158
+ if pa <= pb and pa <= pc:
159
+ return a
160
+ if pb <= pc:
161
+ return b
162
+ return c
163
+
164
+
165
+ def write_png_rgb(path: Path, width: int, height: int, rgb: bytes) -> None:
166
+ if len(rgb) != width * height * 3:
167
+ raise ValueError("RGB payload has the wrong size")
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+
170
+ def chunk(kind: bytes, payload: bytes) -> bytes:
171
+ checksum = zlib.crc32(kind)
172
+ checksum = zlib.crc32(payload, checksum) & 0xFFFFFFFF
173
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum)
174
+
175
+ scanlines = bytearray()
176
+ stride = width * 3
177
+ for y in range(height):
178
+ scanlines.append(0)
179
+ scanlines.extend(rgb[y * stride : (y + 1) * stride])
180
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
181
+ path.write_bytes(
182
+ PNG_SIGNATURE
183
+ + chunk(b"IHDR", ihdr)
184
+ + chunk(b"IDAT", zlib.compress(bytes(scanlines), level=6))
185
+ + chunk(b"IEND", b"")
186
+ )
187
+
188
+
189
+ def load_image(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]], list[str]]:
190
+ warnings: list[str] = []
191
+ try:
192
+ return (*read_png(path), warnings)
193
+ except Exception as direct_error:
194
+ sips = shutil.which("sips")
195
+ if not sips:
196
+ raise ValueError(
197
+ f"could not decode {path.name} as PNG and macOS sips is unavailable: {direct_error}"
198
+ ) from direct_error
199
+ with tempfile.TemporaryDirectory() as tmpdir:
200
+ converted = Path(tmpdir) / "converted.png"
201
+ command = [sips, "-s", "format", "png", str(path), "--out", str(converted)]
202
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
203
+ if result.returncode != 0:
204
+ raise ValueError(result.stderr.strip() or result.stdout.strip() or "sips conversion failed")
205
+ warnings.append("source image was converted to PNG with macOS sips before pixel extraction")
206
+ width, height, pixels = read_png(converted)
207
+ return width, height, pixels, warnings
208
+
209
+
210
+ def sample_corner_background(
211
+ width: int,
212
+ height: int,
213
+ pixels: list[tuple[int, int, int, int]],
214
+ ) -> tuple[tuple[int, int, int], float]:
215
+ radius = max(3, min(width, height) // 40)
216
+ samples: list[tuple[int, int, int]] = []
217
+ corner_ranges = [
218
+ (0, radius, 0, radius),
219
+ (width - radius, width, 0, radius),
220
+ (0, radius, height - radius, height),
221
+ (width - radius, width, height - radius, height),
222
+ ]
223
+ for x0, x1, y0, y1 in corner_ranges:
224
+ for y in range(max(0, y0), min(height, y1)):
225
+ for x in range(max(0, x0), min(width, x1)):
226
+ red, green, blue, alpha = pixels[y * width + x]
227
+ if alpha > 16:
228
+ samples.append((red, green, blue))
229
+ background = median_color(samples)
230
+ noise = percentile([color_distance(sample, background) for sample in samples], 0.75, 0.0)
231
+ return background, noise
232
+
233
+
234
+ def build_foreground_mask(
235
+ width: int,
236
+ height: int,
237
+ pixels: list[tuple[int, int, int, int]],
238
+ ) -> tuple[list[bool], dict[str, Any], list[str]]:
239
+ warnings: list[str] = []
240
+ alpha_values = [pixel[3] for pixel in pixels]
241
+ transparent_fraction = sum(1 for alpha in alpha_values if alpha < 245) / max(1, len(alpha_values))
242
+ background, background_noise = sample_corner_background(width, height, pixels)
243
+ threshold = max(24.0, background_noise * 2.4)
244
+ mask: list[bool] = []
245
+ if transparent_fraction > 0.03:
246
+ for red, green, blue, alpha in pixels:
247
+ mask.append(alpha > 24)
248
+ else:
249
+ for red, green, blue, alpha in pixels:
250
+ rgb = (red, green, blue)
251
+ distance = color_distance(rgb, background)
252
+ sat = saturation(rgb)
253
+ luma = srgb_luma(rgb)
254
+ mask.append(alpha > 16 and (distance > threshold or (sat > 0.16 and luma < 0.94)))
255
+ coverage = sum(1 for value in mask if value) / max(1, len(mask))
256
+ if coverage < 0.035:
257
+ warnings.append("foreground mask is tiny; material extraction is likely unreliable")
258
+ mask = [pixel[3] > 16 for pixel in pixels]
259
+ coverage = sum(1 for value in mask if value) / max(1, len(mask))
260
+ if coverage > 0.9:
261
+ warnings.append("image is not clearly isolated from background; using most pixels as material evidence")
262
+ return (
263
+ mask,
264
+ {
265
+ "backgroundColor": rgb_to_hex(background),
266
+ "backgroundNoise": round(background_noise, 3),
267
+ "transparentPixelFraction": round(transparent_fraction, 4),
268
+ "foregroundCoverage": round(coverage, 4),
269
+ },
270
+ warnings,
271
+ )
272
+
273
+
274
+ def mask_bbox(width: int, height: int, mask: list[bool]) -> tuple[int, int, int, int]:
275
+ xs: list[int] = []
276
+ ys: list[int] = []
277
+ for index, value in enumerate(mask):
278
+ if value:
279
+ ys.append(index // width)
280
+ xs.append(index % width)
281
+ if not xs or not ys:
282
+ return (0, 0, width, height)
283
+ padding = max(2, min(width, height) // 80)
284
+ x0 = max(0, min(xs) - padding)
285
+ y0 = max(0, min(ys) - padding)
286
+ x1 = min(width, max(xs) + padding + 1)
287
+ y1 = min(height, max(ys) + padding + 1)
288
+ return (x0, y0, max(1, x1 - x0), max(1, y1 - y0))
289
+
290
+
291
+ def resample_crop(
292
+ width: int,
293
+ height: int,
294
+ pixels: list[tuple[int, int, int, int]],
295
+ mask: list[bool],
296
+ bbox: tuple[int, int, int, int],
297
+ size: int,
298
+ ) -> tuple[list[tuple[int, int, int]], list[bool]]:
299
+ x0, y0, crop_w, crop_h = bbox
300
+ sampled_pixels: list[tuple[int, int, int]] = []
301
+ sampled_mask: list[bool] = []
302
+ for y in range(size):
303
+ source_y = y0 + (y + 0.5) * crop_h / size
304
+ sy = min(height - 1, max(0, int(source_y)))
305
+ for x in range(size):
306
+ source_x = x0 + (x + 0.5) * crop_w / size
307
+ sx = min(width - 1, max(0, int(source_x)))
308
+ index = sy * width + sx
309
+ red, green, blue, alpha = pixels[index]
310
+ sampled_pixels.append((red, green, blue))
311
+ sampled_mask.append(mask[index] and alpha > 16)
312
+ return sampled_pixels, sampled_mask
313
+
314
+
315
+ def representative_samples(
316
+ pixels: list[tuple[int, int, int]],
317
+ mask: list[bool],
318
+ limit: int = 7000,
319
+ ) -> list[tuple[int, int, int]]:
320
+ candidates = [pixel for pixel, keep in zip(pixels, mask) if keep]
321
+ if not candidates:
322
+ candidates = pixels
323
+ if len(candidates) <= limit:
324
+ return candidates
325
+ step = max(1, len(candidates) // limit)
326
+ return candidates[::step][:limit]
327
+
328
+
329
+ def kmeans_palette(samples: list[tuple[int, int, int]], k: int = 5) -> list[str]:
330
+ if not samples:
331
+ return ["#8A7A5F"]
332
+ ordered = sorted(samples, key=lambda rgb: (srgb_luma(rgb), rgb[0], rgb[1], rgb[2]))
333
+ centers = [
334
+ ordered[int((index + 0.5) * (len(ordered) - 1) / k)]
335
+ for index in range(k)
336
+ ]
337
+ for _ in range(8):
338
+ groups: list[list[tuple[int, int, int]]] = [[] for _ in centers]
339
+ for sample in samples:
340
+ nearest = min(range(len(centers)), key=lambda idx: color_distance(sample, centers[idx]))
341
+ groups[nearest].append(sample)
342
+ new_centers: list[tuple[int, int, int]] = []
343
+ for group, center in zip(groups, centers):
344
+ if not group:
345
+ new_centers.append(center)
346
+ continue
347
+ new_centers.append(
348
+ tuple(int(round(sum(sample[channel] for sample in group) / len(group))) for channel in range(3))
349
+ ) # type: ignore[arg-type]
350
+ centers = new_centers
351
+ counts = Counter(
352
+ min(range(len(centers)), key=lambda idx: color_distance(sample, centers[idx]))
353
+ for sample in samples
354
+ )
355
+ palette = [rgb_to_hex(centers[index]) for index, _ in counts.most_common()]
356
+ return palette[:k]
357
+
358
+
359
+ def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
360
+ return "#{:02X}{:02X}{:02X}".format(*rgb)
361
+
362
+
363
+ def blur_scalar(values: list[float], size: int, radius: int) -> list[float]:
364
+ if radius <= 0:
365
+ return values[:]
366
+ horizontal = [0.0] * (size * size)
367
+ for y in range(size):
368
+ row_offset = y * size
369
+ running = 0.0
370
+ count = 0
371
+ for x in range(-radius, size + radius):
372
+ if 0 <= x < size:
373
+ running += values[row_offset + x]
374
+ count += 1
375
+ remove = x - radius * 2 - 1
376
+ if 0 <= remove < size:
377
+ running -= values[row_offset + remove]
378
+ count -= 1
379
+ write_x = x - radius
380
+ if 0 <= write_x < size:
381
+ horizontal[row_offset + write_x] = running / max(1, count)
382
+ vertical = [0.0] * (size * size)
383
+ for x in range(size):
384
+ running = 0.0
385
+ count = 0
386
+ for y in range(-radius, size + radius):
387
+ if 0 <= y < size:
388
+ running += horizontal[y * size + x]
389
+ count += 1
390
+ remove = y - radius * 2 - 1
391
+ if 0 <= remove < size:
392
+ running -= horizontal[remove * size + x]
393
+ count -= 1
394
+ write_y = y - radius
395
+ if 0 <= write_y < size:
396
+ vertical[write_y * size + x] = running / max(1, count)
397
+ return vertical
398
+
399
+
400
+ def make_maps(
401
+ pixels: list[tuple[int, int, int]],
402
+ mask: list[bool],
403
+ size: int,
404
+ palette: list[str],
405
+ ) -> tuple[dict[str, bytes], dict[str, Any]]:
406
+ masked_lumas = [srgb_luma(pixel) for pixel, keep in zip(pixels, mask) if keep]
407
+ fallback_luma = percentile(masked_lumas, 0.5, 0.55)
408
+ fallback_color = hex_to_rgb(palette[0] if palette else "#8A7A5F")
409
+ lumas = [srgb_luma(pixel) if keep else fallback_luma for pixel, keep in zip(pixels, mask)]
410
+ blur_radius = max(4, min(28, size // 48))
411
+ low_frequency = blur_scalar(lumas, size, blur_radius)
412
+ p05 = percentile(masked_lumas, 0.05, 0.2)
413
+ p95 = percentile(masked_lumas, 0.95, 0.8)
414
+ value_range = max(0.08, p95 - p05)
415
+ high_pass = [
416
+ clamp((luma - low + value_range * 0.5) / value_range, 0.0, 1.0)
417
+ for luma, low in zip(lumas, low_frequency)
418
+ ]
419
+ height = blur_scalar(high_pass, size, max(1, size // 256))
420
+ gradient_values: list[float] = []
421
+ for y in range(size):
422
+ for x in range(size):
423
+ left = height[y * size + max(0, x - 1)]
424
+ right = height[y * size + min(size - 1, x + 1)]
425
+ up = height[max(0, y - 1) * size + x]
426
+ down = height[min(size - 1, y + 1) * size + x]
427
+ gradient_values.append(math.sqrt((right - left) ** 2 + (down - up) ** 2))
428
+ grad_p90 = percentile(gradient_values, 0.9, 0.0)
429
+ normal_strength = clamp(10.0 + grad_p90 * 75.0, 10.0, 38.0)
430
+ albedo = bytearray()
431
+ roughness = bytearray()
432
+ height_map = bytearray()
433
+ normal = bytearray()
434
+ ao = bytearray()
435
+ roughness_values: list[float] = []
436
+ for index, ((red, green, blue), keep) in enumerate(zip(pixels, mask)):
437
+ luma = lumas[index]
438
+ shade = clamp(low_frequency[index], 0.08, 1.0)
439
+ scale = clamp((fallback_luma / shade) ** 0.42, 0.72, 1.35)
440
+ if keep:
441
+ out_r = clamp(red * scale, 0, 255)
442
+ out_g = clamp(green * scale, 0, 255)
443
+ out_b = clamp(blue * scale, 0, 255)
444
+ else:
445
+ out_r, out_g, out_b = fallback_color
446
+ albedo.extend((round(out_r), round(out_g), round(out_b)))
447
+ h = height[index]
448
+ local_gradient = gradient_values[index]
449
+ bright_highlight = max(0.0, luma - p95) / max(0.02, 1.0 - p95)
450
+ rough = clamp01(0.68 + min(0.22, local_gradient * 2.6) + (0.5 - h) * 0.12 - bright_highlight * 0.22)
451
+ roughness_values.append(rough)
452
+ rough_byte = round(rough * 255)
453
+ roughness.extend((rough_byte, rough_byte, rough_byte))
454
+ height_byte = round(h * 255)
455
+ height_map.extend((height_byte, height_byte, height_byte))
456
+ for y in range(size):
457
+ for x in range(size):
458
+ index = y * size + x
459
+ left = height[y * size + max(0, x - 1)]
460
+ right = height[y * size + min(size - 1, x + 1)]
461
+ up = height[max(0, y - 1) * size + x]
462
+ down = height[min(size - 1, y + 1) * size + x]
463
+ dx = (right - left) * normal_strength
464
+ dy = (down - up) * normal_strength
465
+ inv_len = 1.0 / math.sqrt(dx * dx + dy * dy + 1.0)
466
+ nx = -dx * inv_len
467
+ ny = -dy * inv_len
468
+ nz = inv_len
469
+ normal.extend(
470
+ (
471
+ round((nx * 0.5 + 0.5) * 255),
472
+ round((ny * 0.5 + 0.5) * 255),
473
+ round((nz * 0.5 + 0.5) * 255),
474
+ )
475
+ )
476
+ neighbors = (
477
+ left
478
+ + right
479
+ + up
480
+ + down
481
+ ) * 0.25
482
+ cavity = max(0.0, neighbors - height[index])
483
+ ao_value = clamp01(1.0 - cavity * 8.0 - max(0.0, 0.35 - height[index]) * 0.16)
484
+ ao_byte = round(ao_value * 255)
485
+ ao.extend((ao_byte, ao_byte, ao_byte))
486
+ return (
487
+ {
488
+ "albedo": bytes(albedo),
489
+ "roughness": bytes(roughness),
490
+ "height": bytes(height_map),
491
+ "normal": bytes(normal),
492
+ "ao": bytes(ao),
493
+ },
494
+ {
495
+ "valueRange": round(value_range, 4),
496
+ "heightP90Gradient": round(grad_p90, 5),
497
+ "roughnessBase": round(percentile(roughness_values, 0.5, 0.72), 3),
498
+ "roughnessVariation": round(max(0.05, percentile(roughness_values, 0.85, 0.82) - percentile(roughness_values, 0.15, 0.62)), 3),
499
+ "normalStrength": round(normal_strength / 64.0, 3),
500
+ "blurRadius": blur_radius,
501
+ },
502
+ )
503
+
504
+
505
+ def hex_to_rgb(value: str) -> tuple[int, int, int]:
506
+ if len(value) == 4 and value.startswith("#"):
507
+ return tuple(int(char * 2, 16) for char in value[1:]) # type: ignore[return-value]
508
+ if len(value) == 7 and value.startswith("#"):
509
+ return (int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16))
510
+ return (138, 122, 95)
511
+
512
+
513
+ def surface_bands_from_stats(stats: dict[str, Any]) -> list[dict[str, Any]]:
514
+ value_range = float(stats.get("valueRange", 0.4))
515
+ detail = float(stats.get("heightP90Gradient", 0.02))
516
+ return [
517
+ {
518
+ "id": "macro",
519
+ "frequency": 2.0,
520
+ "amplitude": round(clamp(0.28 + value_range * 0.35, 0.22, 0.52), 3),
521
+ "role": "reference-derived broad albedo and height breakup",
522
+ },
523
+ {
524
+ "id": "meso",
525
+ "frequency": 14.0,
526
+ "amplitude": round(clamp(0.15 + detail * 4.2, 0.12, 0.35), 3),
527
+ "role": "reference-derived cracks, ridges, pores, grain, or leaf clusters",
528
+ },
529
+ {
530
+ "id": "micro",
531
+ "frequency": 72.0,
532
+ "amplitude": round(clamp(0.055 + detail * 2.4, 0.045, 0.14), 3),
533
+ "role": "reference-derived micro highlight breakup under grazing light",
534
+ },
535
+ ]
536
+
537
+
538
+ def estimate_confidence(
539
+ width: int,
540
+ height: int,
541
+ mask_diagnostics: dict[str, Any],
542
+ stats: dict[str, Any],
543
+ warnings: list[str],
544
+ single_image: bool,
545
+ ) -> tuple[float, list[str]]:
546
+ confidence_notes: list[str] = []
547
+ min_dim = min(width, height)
548
+ resolution_score = clamp(min_dim / 1024.0, 0.35, 1.0)
549
+ coverage = float(mask_diagnostics.get("foregroundCoverage", 1.0))
550
+ if 0.08 <= coverage <= 0.82:
551
+ mask_score = 1.0
552
+ elif 0.035 <= coverage < 0.08:
553
+ mask_score = 0.55
554
+ confidence_notes.append("foreground mask is very small")
555
+ elif coverage > 0.9:
556
+ mask_score = 0.68
557
+ confidence_notes.append("object/background separation is weak")
558
+ else:
559
+ mask_score = 0.78
560
+ value_range = float(stats.get("valueRange", 0.0))
561
+ dynamic_score = clamp(value_range / 0.48, 0.35, 1.0)
562
+ detail_score = clamp(float(stats.get("heightP90Gradient", 0.0)) * 52.0, 0.35, 1.0)
563
+ warning_penalty = min(0.16, len(warnings) * 0.035)
564
+ single_image_cap = 0.86 if single_image else 0.93
565
+ confidence = (
566
+ 0.44
567
+ + resolution_score * 0.14
568
+ + mask_score * 0.14
569
+ + dynamic_score * 0.12
570
+ + detail_score * 0.16
571
+ - warning_penalty
572
+ )
573
+ confidence = min(single_image_cap, clamp01(confidence))
574
+ if single_image:
575
+ confidence_notes.append("single-image inverse rendering cannot prove true physical PBR; confidence is capped")
576
+ if dynamic_score < 0.5:
577
+ confidence_notes.append("low value range weakens height/roughness inference")
578
+ if detail_score < 0.5:
579
+ confidence_notes.append("low high-frequency detail weakens normal/roughness inference")
580
+ return round(confidence, 3), confidence_notes
581
+
582
+
583
+ def map_url(url_prefix: str, filename: str) -> str:
584
+ if not url_prefix:
585
+ return filename
586
+ return url_prefix.rstrip("/") + "/" + filename
587
+
588
+
589
+ def material_patch(
590
+ material_id: str,
591
+ image: Path,
592
+ out_dir: Path,
593
+ url_prefix: str,
594
+ size: int,
595
+ threshold: float,
596
+ confidence: float,
597
+ verdict: str,
598
+ palette: list[str],
599
+ map_stats: dict[str, Any],
600
+ diagnostics: dict[str, Any],
601
+ warnings: list[str],
602
+ ) -> dict[str, Any]:
603
+ prefix = slugify(material_id)
604
+ maps = {
605
+ channel: {
606
+ "path": str((out_dir / f"{prefix}_{channel}.png").resolve()),
607
+ "url": map_url(url_prefix, f"{prefix}_{channel}.png"),
608
+ "channel": channel,
609
+ "source": "reference-pixel-extraction",
610
+ }
611
+ for channel in ("albedo", "roughness", "height", "normal", "ao")
612
+ }
613
+ usable = confidence >= threshold
614
+ return {
615
+ "referencePbr": {
616
+ "version": "1.0",
617
+ "sourceImage": str(image.resolve()),
618
+ "extractor": "stage1_intake/extract_pbr_evidence.py",
619
+ "method": "single-image pixel evidence with de-lighting estimate; not photogrammetry",
620
+ "usable": usable,
621
+ "verdict": verdict,
622
+ "confidence": confidence,
623
+ "estimatedFidelity": confidence,
624
+ "targetThreshold": threshold,
625
+ "hardLimit": "A single image cannot uniquely recover true albedo/roughness/normal/AO; maps are reference-derived estimates.",
626
+ "maps": maps,
627
+ "diagnostics": diagnostics,
628
+ "warnings": warnings,
629
+ },
630
+ "textureResolution": size,
631
+ "albedo": {
632
+ "dominant": palette[0],
633
+ "secondary": palette[1:4],
634
+ "samplingNotes": "Reference-derived from foreground pixels; de-lit to reduce baked shadows/highlights.",
635
+ "map": maps["albedo"],
636
+ },
637
+ "colorVariation": {
638
+ "palette": palette,
639
+ "pattern": "reference-derived pixel palette",
640
+ "amplitude": round(clamp(float(map_stats.get("valueRange", 0.4)) * 0.42, 0.08, 0.35), 3),
641
+ "heightCorrelation": 0.42,
642
+ },
643
+ "roughness": {
644
+ "base": map_stats["roughnessBase"],
645
+ "variation": map_stats["roughnessVariation"],
646
+ "map": maps["roughness"],
647
+ "localResponse": "reference-derived roughness estimate; cavities and textured zones trend rougher, bright highlights trend smoother",
648
+ },
649
+ "normal": {
650
+ "pattern": "reference-derived height-gradient normal map",
651
+ "strength": map_stats["normalStrength"],
652
+ "map": maps["normal"],
653
+ "heightSource": maps["height"],
654
+ "space": "tangent",
655
+ },
656
+ "bump": {
657
+ "pattern": "reference-derived height field",
658
+ "amplitude": round(clamp(float(map_stats.get("heightP90Gradient", 0.02)) * 0.45, 0.01, 0.08), 3),
659
+ "map": maps["height"],
660
+ },
661
+ "ambientOcclusion": {
662
+ "cavityStrength": 0.38,
663
+ "contactShadowBias": 0.35,
664
+ "map": maps["ao"],
665
+ "notes": "Reference-derived cavity estimate from local height minima; verify against grazing-light screenshot.",
666
+ },
667
+ "surfaceFrequencyBands": surface_bands_from_stats(map_stats),
668
+ "localOverrides": [
669
+ {
670
+ "id": "reference-pbr-pixel-evidence",
671
+ "type": "material-map-evidence",
672
+ "evidenceRefs": ["full-object"],
673
+ "channels": ["albedo", "roughness", "height", "normal", "ambient-occlusion"],
674
+ "notes": "Use generated maps as material evidence, then refine after browser screenshot comparison.",
675
+ }
676
+ ],
677
+ "shaderNotes": [
678
+ "Reference-derived maps are estimates from image pixels; verify with neutral, grazing, and reference-matched renders.",
679
+ "Do not treat baked image shadows as final albedo; rerun extraction with a tighter material crop if highlights/shadows pollute the maps.",
680
+ ],
681
+ }
682
+
683
+
684
+ def merge_material_patch(spec: dict[str, Any], material_id: str, patch: dict[str, Any]) -> None:
685
+ materials = spec.get("materials")
686
+ if not isinstance(materials, list):
687
+ raise ValueError("spec.materials must be an array")
688
+ material = next(
689
+ (item for item in materials if isinstance(item, dict) and item.get("id") == material_id),
690
+ None,
691
+ )
692
+ if material is None:
693
+ raise ValueError(f"could not find material {material_id!r} in spec")
694
+ for key, value in patch.items():
695
+ if key == "localOverrides" and isinstance(material.get(key), list) and isinstance(value, list):
696
+ material[key].extend(value)
697
+ elif key == "shaderNotes" and isinstance(material.get(key), list) and isinstance(value, list):
698
+ material[key].extend(value)
699
+ else:
700
+ material[key] = value
701
+ history = spec.setdefault("pbrExtractionHistory", [])
702
+ if isinstance(history, list):
703
+ history.append(
704
+ {
705
+ "materialId": material_id,
706
+ "confidence": patch["referencePbr"]["confidence"],
707
+ "verdict": patch["referencePbr"]["verdict"],
708
+ "usable": patch["referencePbr"]["usable"],
709
+ "maps": patch["referencePbr"]["maps"],
710
+ }
711
+ )
712
+
713
+
714
+ def extract(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, Any]]:
715
+ image = args.image.expanduser().resolve()
716
+ if not image.exists():
717
+ raise ValueError(f"{image} does not exist")
718
+ size = int(2 ** round(math.log2(args.size)))
719
+ size = max(256, min(2048, size))
720
+ out_dir = args.out_dir.expanduser().resolve()
721
+ out_dir.mkdir(parents=True, exist_ok=True)
722
+ width, height, source_pixels, load_warnings = load_image(image)
723
+ mask, mask_diag, mask_warnings = build_foreground_mask(width, height, source_pixels)
724
+ bbox = mask_bbox(width, height, mask)
725
+ sampled_pixels, sampled_mask = resample_crop(width, height, source_pixels, mask, bbox, size)
726
+ samples = representative_samples(sampled_pixels, sampled_mask)
727
+ palette = kmeans_palette(samples, max(2, min(6, args.palette_size)))
728
+ maps, map_stats = make_maps(sampled_pixels, sampled_mask, size, palette)
729
+ for channel, payload in maps.items():
730
+ write_png_rgb(out_dir / f"{slugify(args.material_id)}_{channel}.png", size, size, payload)
731
+ warnings = load_warnings + mask_warnings
732
+ diagnostics = {
733
+ "sourceWidth": width,
734
+ "sourceHeight": height,
735
+ "mapSize": size,
736
+ "cropBBoxPixels": {
737
+ "x": bbox[0],
738
+ "y": bbox[1],
739
+ "width": bbox[2],
740
+ "height": bbox[3],
741
+ },
742
+ "mask": mask_diag,
743
+ "mapStats": map_stats,
744
+ "palette": palette,
745
+ }
746
+ confidence, confidence_notes = estimate_confidence(
747
+ width,
748
+ height,
749
+ mask_diag,
750
+ map_stats,
751
+ warnings,
752
+ single_image=not args.multi_view_reference,
753
+ )
754
+ warnings.extend(confidence_notes)
755
+ threshold = clamp01(args.target_threshold)
756
+ verdict = "pass" if confidence >= threshold else ("conditional" if confidence >= threshold - 0.12 else "reject")
757
+ patch = material_patch(
758
+ args.material_id,
759
+ image,
760
+ out_dir,
761
+ args.url_prefix,
762
+ size,
763
+ threshold,
764
+ confidence,
765
+ verdict,
766
+ palette,
767
+ map_stats,
768
+ diagnostics,
769
+ warnings,
770
+ )
771
+ report = {
772
+ "ok": confidence >= threshold,
773
+ "verdict": verdict,
774
+ "confidence": confidence,
775
+ "estimatedFidelity": confidence,
776
+ "targetThreshold": threshold,
777
+ "materialId": args.material_id,
778
+ "sourceImage": str(image),
779
+ "outDir": str(out_dir),
780
+ "palette": palette,
781
+ "maps": patch["referencePbr"]["maps"],
782
+ "diagnostics": diagnostics,
783
+ "warnings": warnings,
784
+ "limitation": "single-image PBR extraction is an estimate; 70%+ extraction confidence still needs render screenshot review",
785
+ }
786
+ return report, patch
787
+
788
+
789
+ def main(argv: list[str]) -> int:
790
+ parser = argparse.ArgumentParser(description=__doc__)
791
+ parser.add_argument("image", type=Path)
792
+ parser.add_argument("--out-dir", type=Path, required=True)
793
+ parser.add_argument("--material-id", default="base")
794
+ parser.add_argument("--size", type=int, default=1024)
795
+ parser.add_argument("--palette-size", type=int, default=5)
796
+ parser.add_argument("--target-threshold", type=float, default=0.7)
797
+ parser.add_argument("--url-prefix", default="")
798
+ parser.add_argument("--spec", type=Path, help="Optional ObjectSculptSpec JSON to patch")
799
+ parser.add_argument("--in-place", action="store_true", help="Patch --spec in place when confidence passes")
800
+ parser.add_argument("--out-spec", type=Path, help="Write patched spec to this path")
801
+ parser.add_argument("--report", type=Path, help="Write extraction report JSON")
802
+ parser.add_argument("--allow-low-confidence", action="store_true", help="Patch/write even when confidence is below threshold")
803
+ parser.add_argument("--multi-view-reference", action="store_true", help="Raise confidence cap when image belongs to a multi-view reference set")
804
+ args = parser.parse_args(argv)
805
+
806
+ try:
807
+ report, patch = extract(args)
808
+ if args.report:
809
+ args.report.expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
810
+ args.report.expanduser().resolve().write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
811
+ if args.spec:
812
+ if not report["ok"] and not args.allow_low_confidence:
813
+ raise ValueError(
814
+ f"PBR extraction confidence {report['confidence']} is below target "
815
+ f"{report['targetThreshold']}; spec was not patched"
816
+ )
817
+ spec_path = args.spec.expanduser().resolve()
818
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
819
+ if not isinstance(spec, dict):
820
+ raise ValueError("spec must be a JSON object")
821
+ merge_material_patch(spec, args.material_id, patch)
822
+ output = spec_path if args.in_place else (args.out_spec.expanduser().resolve() if args.out_spec else None)
823
+ if output:
824
+ output.parent.mkdir(parents=True, exist_ok=True)
825
+ output.write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
826
+ print(json.dumps(report, indent=2, ensure_ascii=False))
827
+ return 0 if report["ok"] or args.allow_low_confidence else 1
828
+ except Exception as exc:
829
+ print(f"error: {exc}", file=sys.stderr)
830
+ return 1
831
+
832
+
833
+ if __name__ == "__main__":
834
+ raise SystemExit(main(sys.argv[1:]))
forge/stage1_intake/probe_image.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Probe basic technical properties of a reference image before visual analysis."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import struct
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ def png_size(data: bytes) -> tuple[int, int] | None:
14
+ if data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) >= 24:
15
+ return struct.unpack(">II", data[16:24])
16
+ return None
17
+
18
+
19
+ def gif_size(data: bytes) -> tuple[int, int] | None:
20
+ if data[:6] in {b"GIF87a", b"GIF89a"} and len(data) >= 10:
21
+ return struct.unpack("<HH", data[6:10])
22
+ return None
23
+
24
+
25
+ def jpeg_size(data: bytes) -> tuple[int, int] | None:
26
+ if not data.startswith(b"\xff\xd8"):
27
+ return None
28
+ index = 2
29
+ while index + 9 < len(data):
30
+ if data[index] != 0xFF:
31
+ index += 1
32
+ continue
33
+ marker = data[index + 1]
34
+ index += 2
35
+ if marker in {0xD8, 0xD9}:
36
+ continue
37
+ if index + 2 > len(data):
38
+ return None
39
+ length = struct.unpack(">H", data[index : index + 2])[0]
40
+ if length < 2 or index + length > len(data):
41
+ return None
42
+ if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
43
+ if length >= 7:
44
+ height, width = struct.unpack(">HH", data[index + 3 : index + 7])
45
+ return width, height
46
+ index += length
47
+ return None
48
+
49
+
50
+ def webp_size(data: bytes) -> tuple[int, int] | None:
51
+ if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
52
+ return None
53
+ chunk = data[12:16]
54
+ if chunk == b"VP8X" and len(data) >= 30:
55
+ width = 1 + int.from_bytes(data[24:27], "little")
56
+ height = 1 + int.from_bytes(data[27:30], "little")
57
+ return width, height
58
+ if chunk == b"VP8 " and len(data) >= 30:
59
+ start = data.find(b"\x9d\x01\x2a")
60
+ if start != -1 and start + 7 <= len(data):
61
+ width, height = struct.unpack("<HH", data[start + 3 : start + 7])
62
+ return width & 0x3FFF, height & 0x3FFF
63
+ return None
64
+
65
+
66
+ def bmp_size(data: bytes) -> tuple[int, int] | None:
67
+ if len(data) >= 26 and data[:2] == b"BM":
68
+ width = struct.unpack("<I", data[18:22])[0]
69
+ height = abs(struct.unpack("<i", data[22:26])[0])
70
+ return width, height
71
+ return None
72
+
73
+
74
+ def tiff_size(data: bytes) -> tuple[int, int] | None:
75
+ if len(data) < 8:
76
+ return None
77
+ if data[:4] == b"II*\x00":
78
+ endian = "<"
79
+ elif data[:4] == b"MM\x00*":
80
+ endian = ">"
81
+ else:
82
+ return None
83
+ offset = struct.unpack(f"{endian}I", data[4:8])[0]
84
+ if offset + 2 > len(data):
85
+ return None
86
+ entries = struct.unpack(f"{endian}H", data[offset : offset + 2])[0]
87
+ width = height = None
88
+ cursor = offset + 2
89
+ for _ in range(entries):
90
+ if cursor + 12 > len(data):
91
+ return None
92
+ tag, value_type, count, raw_value = struct.unpack(f"{endian}HHII", data[cursor : cursor + 12])
93
+ if value_type in {3, 4} and count == 1:
94
+ value = raw_value if value_type == 4 else raw_value & 0xFFFF
95
+ if tag == 256:
96
+ width = value
97
+ elif tag == 257:
98
+ height = value
99
+ cursor += 12
100
+ if width and height:
101
+ return width, height
102
+ return None
103
+
104
+
105
+ def detect_image_type(data: bytes) -> str | None:
106
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
107
+ return "png"
108
+ if data.startswith(b"\xff\xd8"):
109
+ return "jpeg"
110
+ if data[:6] in {b"GIF87a", b"GIF89a"}:
111
+ return "gif"
112
+ if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
113
+ return "webp"
114
+ if data.startswith(b"BM"):
115
+ return "bmp"
116
+ if data[:4] in {b"II*\x00", b"MM\x00*"}:
117
+ return "tiff"
118
+ return None
119
+
120
+
121
+ def detect_size(data: bytes) -> tuple[int, int] | None:
122
+ return png_size(data) or jpeg_size(data) or gif_size(data) or webp_size(data) or bmp_size(data) or tiff_size(data)
123
+
124
+
125
+ def probe(path: Path) -> dict:
126
+ data = path.read_bytes()
127
+ image_type = detect_image_type(data)
128
+ size = detect_size(data)
129
+ warnings: list[str] = []
130
+ if not image_type:
131
+ warnings.append("unknown image type")
132
+ if not size:
133
+ warnings.append("could not read image dimensions")
134
+ width = height = None
135
+ aspect = None
136
+ else:
137
+ width, height = size
138
+ aspect = width / height if height else None
139
+ if width < 512 or height < 512:
140
+ warnings.append("low resolution; small geometry/material details may be unreliable")
141
+ if aspect and (aspect > 3.0 or aspect < 0.33):
142
+ warnings.append("extreme aspect ratio; object may be cropped or surrounded by empty space")
143
+ return {
144
+ "path": str(path),
145
+ "type": image_type,
146
+ "bytes": len(data),
147
+ "width": width,
148
+ "height": height,
149
+ "aspectRatio": aspect,
150
+ "technicalSuitability": "conditional" if warnings else "pass",
151
+ "warnings": warnings,
152
+ "note": "This is only technical image probing. Semantic object suitability still requires visual inspection.",
153
+ }
154
+
155
+
156
+ def main(argv: list[str]) -> int:
157
+ parser = argparse.ArgumentParser(description=__doc__)
158
+ parser.add_argument("image", type=Path)
159
+ args = parser.parse_args(argv)
160
+ path = args.image.expanduser().resolve()
161
+ if not path.exists():
162
+ parser.error(f"{path} does not exist")
163
+ print(json.dumps(probe(path), indent=2, ensure_ascii=False))
164
+ return 0
165
+
166
+
167
+ if __name__ == "__main__":
168
+ raise SystemExit(main(sys.argv[1:]))
forge/stage1_intake/solve_camera_pose.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Estimate a starting referenceCamera block for a reference image.
3
+
4
+ This is not camera calibration and it does not solve for the true focal
5
+ length, sensor, or 6-DoF pose that produced the photo. A single 2D image
6
+ under-constrains that problem. Instead this script emits a reasonable
7
+ default guess (FOV) plus image-derived facts (aspect ratio) and explicit
8
+ agent-fill placeholders (orientation, position) that the agent is expected
9
+ to refine by rendering the fitted mesh from this camera and visually
10
+ overlaying it against the reference image until silhouettes line up. The
11
+ `agentFill` flag on each field marks what still needs that visual pass.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import struct
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ def png_size(data: bytes) -> tuple[int, int] | None:
25
+ if data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) >= 24:
26
+ return struct.unpack(">II", data[16:24])
27
+ return None
28
+
29
+
30
+ def gif_size(data: bytes) -> tuple[int, int] | None:
31
+ if data[:6] in {b"GIF87a", b"GIF89a"} and len(data) >= 10:
32
+ return struct.unpack("<HH", data[6:10])
33
+ return None
34
+
35
+
36
+ def jpeg_size(data: bytes) -> tuple[int, int] | None:
37
+ if not data.startswith(b"\xff\xd8"):
38
+ return None
39
+ index = 2
40
+ while index + 9 < len(data):
41
+ if data[index] != 0xFF:
42
+ index += 1
43
+ continue
44
+ marker = data[index + 1]
45
+ index += 2
46
+ if marker in {0xD8, 0xD9}:
47
+ continue
48
+ if index + 2 > len(data):
49
+ return None
50
+ length = struct.unpack(">H", data[index : index + 2])[0]
51
+ if length < 2 or index + length > len(data):
52
+ return None
53
+ if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
54
+ if length >= 7:
55
+ height, width = struct.unpack(">HH", data[index + 3 : index + 7])
56
+ return width, height
57
+ index += length
58
+ return None
59
+
60
+
61
+ def webp_size(data: bytes) -> tuple[int, int] | None:
62
+ if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
63
+ return None
64
+ chunk = data[12:16]
65
+ if chunk == b"VP8X" and len(data) >= 30:
66
+ width = 1 + int.from_bytes(data[24:27], "little")
67
+ height = 1 + int.from_bytes(data[27:30], "little")
68
+ return width, height
69
+ if chunk == b"VP8 " and len(data) >= 30:
70
+ start = data.find(b"\x9d\x01\x2a")
71
+ if start != -1 and start + 7 <= len(data):
72
+ width, height = struct.unpack("<HH", data[start + 3 : start + 7])
73
+ return width & 0x3FFF, height & 0x3FFF
74
+ return None
75
+
76
+
77
+ def bmp_size(data: bytes) -> tuple[int, int] | None:
78
+ if len(data) >= 26 and data[:2] == b"BM":
79
+ width = struct.unpack("<I", data[18:22])[0]
80
+ height = abs(struct.unpack("<i", data[22:26])[0])
81
+ return width, height
82
+ return None
83
+
84
+
85
+ def detect_size(data: bytes) -> tuple[int, int] | None:
86
+ return png_size(data) or jpeg_size(data) or gif_size(data) or webp_size(data) or bmp_size(data)
87
+
88
+
89
+ def clamp(value: float, minimum: float, maximum: float) -> float:
90
+ return max(minimum, min(maximum, value))
91
+
92
+
93
+ def estimate_fov(aspect: float | None) -> tuple[float, str]:
94
+ """Return a default vertical FOV guess plus the rationale.
95
+
96
+ Most product/character reference photos are shot on a phone or a short
97
+ telephoto lens at a comfortable working distance, which lands roughly in
98
+ the 30-45 degree vertical FOV band. There is no way to recover the true
99
+ lens from pixels alone, so this is a fixed default, not a measurement.
100
+ """
101
+ if aspect is not None and aspect < 0.75:
102
+ return 38.0, "default guess for a portrait-oriented photo (typical phone/short-tele framing)"
103
+ return 35.0, "default guess for a landscape/square photo (typical phone/short-tele framing)"
104
+
105
+
106
+ def build_camera(image: Path, args: argparse.Namespace) -> dict[str, Any]:
107
+ data = image.read_bytes()
108
+ size = detect_size(data)
109
+ warnings: list[str] = []
110
+ if size is None:
111
+ warnings.append("could not read image dimensions; aspect defaults to 1.0")
112
+ width = height = None
113
+ aspect = 1.0
114
+ else:
115
+ width, height = size
116
+ aspect = round(width / height, 4) if height else 1.0
117
+
118
+ default_fov, fov_rationale = estimate_fov(aspect)
119
+ fov_degrees = args.fov_degrees if args.fov_degrees is not None else default_fov
120
+ fov_source = "user-supplied" if args.fov_degrees is not None else "default-guess"
121
+
122
+ distance = args.distance if args.distance is not None else 2.5
123
+ distance_source = "user-supplied" if args.distance is not None else "placeholder"
124
+
125
+ camera: dict[str, Any] = {
126
+ "version": "1.0",
127
+ "sourceImage": str(image),
128
+ "solver": "stage1_intake/solve_camera_pose.py",
129
+ "method": (
130
+ "heuristic default-guess camera, not solved from image content; image dimensions give an "
131
+ "exact aspect ratio, everything else is a starting point for agent refinement"
132
+ ),
133
+ "imageWidth": width,
134
+ "imageHeight": height,
135
+ "fovDegrees": {
136
+ "value": round(fov_degrees, 2),
137
+ "source": fov_source,
138
+ "agentFill": fov_source == "default-guess",
139
+ "rationale": fov_rationale,
140
+ },
141
+ "aspect": {
142
+ "value": aspect,
143
+ "source": "image-dimensions" if size else "fallback-default",
144
+ "agentFill": size is None,
145
+ },
146
+ "orientation": {
147
+ "yawDegrees": {"value": args.yaw, "source": "placeholder", "agentFill": True},
148
+ "pitchDegrees": {"value": args.pitch, "source": "placeholder", "agentFill": True},
149
+ "rollDegrees": {"value": args.roll, "source": "placeholder", "agentFill": True},
150
+ "note": "0/0/0 assumes a straight-on, level shot; adjust by eye against the reference image.",
151
+ },
152
+ "position": {
153
+ "hint": [0.0, args.height_offset, distance],
154
+ "distance": {"value": distance, "source": distance_source, "agentFill": distance_source == "placeholder"},
155
+ "note": "Position hint assumes the subject is centered at the origin and the camera looks down -Z.",
156
+ },
157
+ "confidence": 0.35 if size else 0.15,
158
+ "limitations": [
159
+ "no true camera calibration is performed; focal length/FOV/orientation are not recovered from pixels",
160
+ "fovDegrees is a genre default, not a measurement; wrong FOV distorts perceived proportions under overlay",
161
+ "orientation and position are placeholders and will almost always need manual/agent adjustment",
162
+ "this script cannot detect lens distortion, perspective foreshortening, or non-zero roll",
163
+ ]
164
+ + warnings,
165
+ "note": (
166
+ "Final camera match must be confirmed by overlay review: render the fitted mesh from this "
167
+ "camera, place it beside or over the reference image, and adjust fovDegrees/orientation/"
168
+ "position until silhouette and landmark alignment match before trusting projected texture bakes."
169
+ ),
170
+ }
171
+ return camera
172
+
173
+
174
+ def main(argv: list[str]) -> int:
175
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
176
+ parser.add_argument("image", type=Path)
177
+ parser.add_argument("--fov-degrees", type=float, default=None, help="Override the default FOV guess")
178
+ parser.add_argument("--yaw", type=float, default=0.0, help="Orientation placeholder, degrees")
179
+ parser.add_argument("--pitch", type=float, default=0.0, help="Orientation placeholder, degrees")
180
+ parser.add_argument("--roll", type=float, default=0.0, help="Orientation placeholder, degrees")
181
+ parser.add_argument("--distance", type=float, default=None, help="Camera distance hint, scene units")
182
+ parser.add_argument("--height-offset", type=float, default=0.0, help="Camera Y offset hint, scene units")
183
+ parser.add_argument("--out", type=Path, help="Write the referenceCamera JSON block to this path")
184
+ args = parser.parse_args(argv)
185
+
186
+ image = args.image.expanduser().resolve()
187
+ if not image.exists():
188
+ parser.error(f"{image} does not exist")
189
+
190
+ camera = build_camera(image, args)
191
+ payload = {"referenceCamera": camera}
192
+ text = json.dumps(payload, indent=2, ensure_ascii=False)
193
+ if args.out:
194
+ out_path = args.out.expanduser().resolve()
195
+ out_path.parent.mkdir(parents=True, exist_ok=True)
196
+ out_path.write_text(text + "\n", encoding="utf-8")
197
+ print(text)
198
+ return 0
199
+
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main(sys.argv[1:]))
forge/stage2_spec/new_pre_spec_assessment.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create a pre-spec assessment and quality contract skeleton before ObjectSculptSpec authoring."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from new_sculpt_spec import make_pre_spec_assessment, make_quality_contract
12
+
13
+
14
+ COMPLEXITY_MINIMUMS = {
15
+ "simple": {
16
+ "macroComponents": 1,
17
+ "mesoComponents": 0,
18
+ "microFeatureGroups": 0,
19
+ "materialLayers": 1,
20
+ "repetitionSystems": 0,
21
+ "reviewViewpoints": 2,
22
+ },
23
+ "moderate": {
24
+ "macroComponents": 2,
25
+ "mesoComponents": 3,
26
+ "microFeatureGroups": 2,
27
+ "materialLayers": 2,
28
+ "repetitionSystems": 0,
29
+ "reviewViewpoints": 3,
30
+ },
31
+ "complex": {
32
+ "macroComponents": 3,
33
+ "mesoComponents": 8,
34
+ "microFeatureGroups": 5,
35
+ "materialLayers": 3,
36
+ "repetitionSystems": 1,
37
+ "reviewViewpoints": 4,
38
+ },
39
+ "ultra-complex": {
40
+ "macroComponents": 5,
41
+ "mesoComponents": 16,
42
+ "microFeatureGroups": 8,
43
+ "materialLayers": 4,
44
+ "repetitionSystems": 2,
45
+ "reviewViewpoints": 5,
46
+ },
47
+ }
48
+
49
+
50
+ DETAIL_MINIMUMS = {
51
+ "simple": 3,
52
+ "moderate": 6,
53
+ "complex": 10,
54
+ "ultra-complex": 16,
55
+ }
56
+
57
+
58
+ def make_payload(target_name: str, image: str | None, complexity: str) -> dict:
59
+ assessment = make_pre_spec_assessment(target_name)
60
+ contract = make_quality_contract()
61
+ assessment["sourceImage"] = image or ""
62
+ assessment["complexity"]["tier"] = complexity
63
+ assessment["specDepthDecision"]["requiredDepth"] = complexity
64
+ assessment["detailInventory"]["targetMinDetails"] = DETAIL_MINIMUMS[complexity]
65
+ if complexity in {"complex", "ultra-complex"}:
66
+ assessment["specDepthDecision"]["needsRepetitionSystems"] = True
67
+ assessment["specDepthDecision"]["needsMaterialLocalOverrides"] = True
68
+ assessment["specDepthDecision"]["minimumComponentLevels"] = ["macro", "meso", "micro"]
69
+ elif complexity == "moderate":
70
+ assessment["specDepthDecision"]["minimumComponentLevels"] = ["macro", "meso"]
71
+ contract["qualityBar"] = complexity
72
+ contract["minimumSpecDepth"] = COMPLEXITY_MINIMUMS[complexity]
73
+ return {
74
+ "targetName": target_name,
75
+ "sourceImage": image or "",
76
+ "preSpecAssessment": assessment,
77
+ "qualityContract": contract,
78
+ "authoringInstruction": (
79
+ "Fill observed object class, complexity reasoning, featureGroups, visualDeltaChecks, "
80
+ "and unknowns before generating or implementing ObjectSculptSpec."
81
+ ),
82
+ }
83
+
84
+
85
+ def main(argv: list[str]) -> int:
86
+ parser = argparse.ArgumentParser(description=__doc__)
87
+ parser.add_argument("target_name", help="Human-readable object name")
88
+ parser.add_argument("--image", help="Reference image path or URL")
89
+ parser.add_argument(
90
+ "--complexity",
91
+ choices=sorted(COMPLEXITY_MINIMUMS),
92
+ default="moderate",
93
+ help="Initial complexity estimate. Refine after visual inspection.",
94
+ )
95
+ parser.add_argument("--out", type=Path, help="Output JSON path")
96
+ parser.add_argument("--force", action="store_true", help="Overwrite output file")
97
+ args = parser.parse_args(argv)
98
+
99
+ payload = json.dumps(make_payload(args.target_name, args.image, args.complexity), indent=2, ensure_ascii=False) + "\n"
100
+ if args.out:
101
+ output = args.out.expanduser().resolve()
102
+ if output.exists() and not args.force:
103
+ parser.error(f"{output} already exists; use --force to overwrite")
104
+ output.parent.mkdir(parents=True, exist_ok=True)
105
+ output.write_text(payload, encoding="utf-8")
106
+ print(output)
107
+ else:
108
+ print(payload, end="")
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ raise SystemExit(main(sys.argv[1:]))
forge/stage2_spec/new_sculpt_spec.py ADDED
@@ -0,0 +1,1129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create a starter ObjectSculptSpec JSON file."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ def slugify(value: str) -> str:
14
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", value.strip().lower()).strip("-")
15
+ return slug or "object"
16
+
17
+
18
+ def make_pre_spec_assessment(target_name: str) -> dict:
19
+ return {
20
+ "objectClass": {
21
+ "primaryType": "unassessed",
22
+ "primaryDomain": "unassessed",
23
+ "formLanguage": [],
24
+ "structureKind": [],
25
+ "motionPotential": [],
26
+ "materialFamilies": [],
27
+ "notes": "Fill from direct visual inspection before writing the final spec. Do not use fixed domain profiles. Set primaryDomain to object, character, or hybrid.",
28
+ },
29
+ "complexity": {
30
+ "tier": "unassessed",
31
+ "scores": {
32
+ "silhouetteComplexity": 0,
33
+ "componentCount": 0,
34
+ "hierarchyDepth": 0,
35
+ "repetitionDensity": 0,
36
+ "materialLayerCount": 0,
37
+ "localDetailDensity": 0,
38
+ "occlusionRisk": 0,
39
+ "actionReadinessNeed": 0,
40
+ },
41
+ "estimatedCounts": {
42
+ "macroComponents": 1,
43
+ "mesoComponents": 0,
44
+ "microFeatureGroups": 0,
45
+ "materialLayers": 1,
46
+ "repetitionSystems": 0,
47
+ },
48
+ "reasoning": [
49
+ f"Assess {target_name!r} from the image before finalizing componentTree/materials.",
50
+ ],
51
+ },
52
+ "specDepthDecision": {
53
+ "requiredDepth": "unassessed",
54
+ "minimumComponentLevels": ["macro"],
55
+ "needsRepetitionSystems": False,
56
+ "needsMaterialLocalOverrides": False,
57
+ "needsMultipleReviewViews": True,
58
+ "needsActionReadyHierarchy": True,
59
+ "rationale": "Choose simple/moderate/complex/ultra-complex from observed structure, not from a hardcoded domain.",
60
+ },
61
+ "unknownsToResolveBeforeImplementation": [],
62
+ "detailInventory": {
63
+ "scanMethod": "component-zones",
64
+ "targetMinDetails": 0,
65
+ "note": (
66
+ "Enumerate every identity-defining small detail before authoring the spec. "
67
+ "Each detail must map to a component.localFeatures entry or material.localOverrides entry, "
68
+ "never prose only. Use forge/stage1_intake/build_detail_inventory.py to scan zones."
69
+ ),
70
+ "details": [],
71
+ },
72
+ "anatomy": {
73
+ "applies": False,
74
+ "styleHeads": 0.0,
75
+ "proportions": {
76
+ "headUnit": 0.0,
77
+ "torso": 0.0,
78
+ "legs": 0.0,
79
+ "shoulderWidth": 0.0,
80
+ "hipWidth": 0.0,
81
+ },
82
+ "pose": {"type": "unassessed", "jointAngles": {}},
83
+ "faceLandmarks": {
84
+ "eyeLine": 0.0,
85
+ "eyeSpacing": 0.0,
86
+ "noseBase": 0.0,
87
+ "mouthLine": 0.0,
88
+ "hairline": 0.0,
89
+ },
90
+ "features": [],
91
+ "confidence": 0.0,
92
+ "note": (
93
+ "Only meaningful when objectClass.primaryDomain is character or hybrid. "
94
+ "Set applies=true and fill from forge/stage1_intake/extract_landmarks.py. "
95
+ "See grimoire/character/reconstruction.md and grimoire/character/likeness_maximization.md."
96
+ ),
97
+ },
98
+ }
99
+
100
+
101
+ def make_quality_contract() -> dict:
102
+ return {
103
+ "qualityBar": "unassessed",
104
+ "definitionOfDone": [
105
+ "The rendered model matches the reference silhouette, primary proportions, visible component hierarchy, material response, and most recognizable local features for the selected fidelity tier.",
106
+ ],
107
+ "minimumSpecDepth": {
108
+ "macroComponents": 1,
109
+ "mesoComponents": 0,
110
+ "microFeatureGroups": 0,
111
+ "materialLayers": 1,
112
+ "repetitionSystems": 0,
113
+ "reviewViewpoints": 3,
114
+ },
115
+ "featureGroups": [
116
+ {
117
+ "id": "overall-silhouette",
118
+ "name": "Overall silhouette and proportions",
119
+ "required": True,
120
+ "qualityCriteria": [
121
+ "Bounding shape, dominant curves, negative spaces, and scale relationships are explicitly described.",
122
+ ],
123
+ "evidenceRefs": ["full-object"],
124
+ "failureModes": [
125
+ "model reads as a generic placeholder instead of the reference object",
126
+ "major proportions are guessed without evidence",
127
+ ],
128
+ },
129
+ {
130
+ "id": "primary-structure",
131
+ "name": "Primary structure and hierarchy",
132
+ "required": True,
133
+ "qualityCriteria": [
134
+ "Major parts, joints, seams, contact points, and parent-child relationships are named before code generation.",
135
+ ],
136
+ "evidenceRefs": ["full-object"],
137
+ "failureModes": [
138
+ "large visible parts are merged into one mesh",
139
+ "component hierarchy is too shallow for the observed complexity",
140
+ ],
141
+ },
142
+ {
143
+ "id": "attachment-joint-correctness",
144
+ "name": "Attachment and joint correctness",
145
+ "required": True,
146
+ "qualityCriteria": [
147
+ "Every visible child appendage, branch, limb, handle, connector, tube, cable, horn, wing, leg, or hinged part has an attachment contract with parent socket, localStart/localEnd, contact type, embed/overlap, and gap tolerance.",
148
+ ],
149
+ "evidenceRefs": ["full-object"],
150
+ "failureModes": [
151
+ "child part root floats away from the parent",
152
+ "branch/limb/tube is centered in space instead of pivoting from its root",
153
+ "parent-child transform mixes world and local coordinates",
154
+ ],
155
+ },
156
+ {
157
+ "id": "surface-material-response",
158
+ "name": "Surface material response",
159
+ "required": True,
160
+ "qualityCriteria": [
161
+ "Albedo zones, roughness, normal/bump/displacement intent, cavity dirt, edge wear, and local overrides are specified where visible.",
162
+ "Important materials define independent albedo, roughness, height/normal, and AO responses instead of reusing one texture for unrelated PBR channels.",
163
+ "Surface response is decomposed into macro, meso, and micro frequency bands with scale and amplitude tied to object scale.",
164
+ ],
165
+ "evidenceRefs": ["full-object"],
166
+ "failureModes": [
167
+ "surface looks like flat plastic",
168
+ "local material variation is missing or not tied to image evidence",
169
+ ],
170
+ },
171
+ {
172
+ "id": "reference-lookdev",
173
+ "name": "Reference color, material, and lighting response",
174
+ "required": True,
175
+ "qualityCriteria": [
176
+ "Material-pass names the reference-derived albedo palette, roughness variation, tactile normal/bump/displacement response, and local masks.",
177
+ "When a source image is available, run reference PBR extraction and require confidence >= 0.7 before treating maps as implementation-ready.",
178
+ "Lighting-pass names key/fill/rim or environment light, exposure, tone mapping, background, and contact shadow behavior.",
179
+ "Neutral, grazing-angle, and reference-matched renders prove that surface relief survives relighting and is not painted into albedo.",
180
+ ],
181
+ "evidenceRefs": ["full-object"],
182
+ "failureModes": [
183
+ "model has acceptable shape but reads as flat shaded or plastic",
184
+ "colors are a generic average instead of reference-observed local color zones",
185
+ "lighting is evenly ambient and cannot reproduce the source value range",
186
+ ],
187
+ },
188
+ ],
189
+ "visualDeltaChecks": [
190
+ "silhouette and negative-space delta",
191
+ "component hierarchy depth delta",
192
+ "repetition density and distribution delta",
193
+ "material albedo/roughness/normal response delta",
194
+ "local feature placement and scale delta",
195
+ ],
196
+ "antiShallowSpecRules": [
197
+ "Do not proceed to code if qualityContract.qualityBar is unassessed.",
198
+ "Do not proceed to code if the spec only contains a root component for a moderate or complex object.",
199
+ "Do not proceed to code if required featureGroups are not represented by componentTree, materials, or repetitionSystems.",
200
+ "Do not proceed to code if visible local features are described only in prose and not attached to components/materials/evidenceRefs.",
201
+ "Do not proceed past structural-pass if attached child parts lack attachment.parentSocket, localStart, localEnd, embedDepth/overlap, and gapTolerance.",
202
+ "Do not pass material look-dev when albedo is reused as roughness, height, normal, or AO.",
203
+ "Do not pass material look-dev without macro, meso, and micro surface frequency bands for close-up materials.",
204
+ "Do not pass reference-fidelity material look-dev from a source image without usable referencePbr maps or an explicit documented limitation.",
205
+ "Do not patch a spec with extracted PBR maps when extraction confidence is below the target threshold unless the user explicitly accepts lower fidelity.",
206
+ ],
207
+ }
208
+
209
+
210
+ def load_assessment(path: Path | None) -> dict | None:
211
+ if path is None:
212
+ return None
213
+ payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
214
+ if not isinstance(payload, dict):
215
+ raise ValueError("assessment must be a JSON object")
216
+ return payload
217
+
218
+
219
+ def _cnode(cid, name, primitive, parent, position, scale,
220
+ material="skin", role="body", level="meso", rotation=(0, 0, 0),
221
+ importance=0.7, sockets=None, local_features=None, anim_role="static",
222
+ pivot_mode="center", evidence=None):
223
+ """Build a full schema-valid componentTree node with humanoid-friendly defaults."""
224
+ return {
225
+ "id": cid, "name": name, "level": level, "role": role,
226
+ "importance": importance, "confidence": 0.8, "primitive": primitive,
227
+ "geometryDescriptor": {
228
+ "topologyIntent": "stylized character part",
229
+ "edgeTreatment": {"type": "none", "bevelRadius": 0.0, "segments": 1},
230
+ "deformationStack": [], "uvStrategy": "generated procedural coordinates",
231
+ "normalStrategy": "smooth vertex normals",
232
+ },
233
+ "parent": parent, "attachment": None,
234
+ "dimensions": {"width": float(scale[0]), "height": float(scale[1]),
235
+ "depth": float(scale[2]), "units": "relative", "confidence": 0.8},
236
+ "transform": {"position": list(position), "rotation": list(rotation), "scale": list(scale)},
237
+ "actionProfile": {
238
+ "animationRole": anim_role,
239
+ "pivot": {"mode": pivot_mode, "localPosition": [0, 0, 0], "axis": [0, 1, 0], "confidence": 0.7},
240
+ "transformChannels": {"translate": True, "rotate": True, "scale": True,
241
+ "bend": False, "twist": False, "detach": False,
242
+ "visibility": True, "materialState": False},
243
+ "sockets": sockets or [],
244
+ "collider": {"type": "box", "offset": [0, 0, 0], "scale": [1, 1, 1],
245
+ "isTrigger": False, "notes": "box proxy"},
246
+ "constraints": [],
247
+ "destruction": {"breakable": False, "fractureGroup": cid, "seamRefs": [],
248
+ "detachableFragments": [], "breakImpulse": 0.0, "debrisMaterial": material},
249
+ },
250
+ "material": material, "materialLayers": [material], "deformations": [], "joints": [],
251
+ "seams": [], "localFeatures": local_features or [],
252
+ "surfaceDetail": {"macroRoughness": 0.0, "microRoughness": 0.0, "bumpAmplitude": 0.0,
253
+ "normalPattern": "", "displacementPattern": "", "occlusionPattern": "",
254
+ "edgeWearPattern": "", "notes": ""},
255
+ "evidenceRefs": evidence or ["full-object"], "details": [], "fidelityTier": "blockout",
256
+ }
257
+
258
+
259
+ def make_character_component_tree(anatomy: dict | None = None) -> list:
260
+ """A stylized humanoid bust template (head/neck/torso/arms + hair, glasses, headphones,
261
+ face features). Head-unit driven; HU ~= 0.28 world units.
262
+
263
+ The generator nests children under a parent node whose transform (incl. scale) cascades,
264
+ so non-uniform parent scale would distort descendants. To avoid that, every visible part is
265
+ authored with a logical parent + local offset, then FLATTENED to world space and parented to
266
+ a hidden, unit-scaled root. Parents used for offsets (torso, head) are unrotated, so summing
267
+ offsets is exact. Parts use primitives the generator already supports."""
268
+ hu = 0.28
269
+ # (id, name, primitive, logical_parent, offset, scale, material, role, level, rotation, importance, features)
270
+ parts = [
271
+ ("torso", "Torso (shirt)", "capsule", "root", (0, 0.55 * hu, 0), (2.4 * hu, 2.2 * hu, 1.5 * hu), "shirt", "shell", "macro", (0, 0, 0), 1.0, []),
272
+ ("shirt-decal", "Chest graphic (Orioles)", "plane-card", "torso", (0, 0.1 * hu, 0.78 * hu), (1.5 * hu, 0.9 * hu, 1.0), "shirt-decal", "decal", "micro", (0, 0, 0), 0.7, ["cursive orange team wordmark with white outline"]),
273
+ ("neck", "Neck", "cylinder", "root", (0, 1.65 * hu, 0), (0.55 * hu, 0.7 * hu, 0.55 * hu), "skin", "support", "meso", (0, 0, 0), 0.6, []),
274
+ ("head", "Head", "ellipsoid", "root", (0, 2.5 * hu, 0.02 * hu), (0.92 * hu, 1.12 * hu, 0.98 * hu), "skin", "body", "macro", (0, 0, 0), 1.0, []),
275
+ ("hair", "Hair (side-swept)", "ellipsoid", "head", (0, 0.28 * hu, -0.04 * hu), (1.06 * hu, 0.82 * hu, 1.08 * hu), "hair", "hair", "meso", (0, 0, 0), 0.9, ["short sides, longer swept-back top"]),
276
+ ("hair-front", "Hair front mass", "ellipsoid", "head", (0.12 * hu, 0.34 * hu, 0.34 * hu), (0.7 * hu, 0.5 * hu, 0.6 * hu), "hair", "hair", "micro", (0, 0, 0), 0.6, []),
277
+ ("brow-l", "Eyebrow L", "box", "head", (0.2 * hu, 0.12 * hu, 0.46 * hu), (0.22 * hu, 0.04 * hu, 0.06 * hu), "hair", "detail", "micro", (0, 0, 0), 0.4, []),
278
+ ("brow-r", "Eyebrow R", "box", "head", (-0.2 * hu, 0.12 * hu, 0.46 * hu), (0.22 * hu, 0.04 * hu, 0.06 * hu), "hair", "detail", "micro", (0, 0, 0), 0.4, []),
279
+ ("nose", "Nose", "cone", "head", (0, -0.04 * hu, 0.5 * hu), (0.14 * hu, 0.28 * hu, 0.18 * hu), "skin", "detail", "micro", (1.4, 0, 0), 0.4, []),
280
+ ("mouth", "Mouth", "box", "head", (0, -0.34 * hu, 0.46 * hu), (0.24 * hu, 0.04 * hu, 0.05 * hu), "lips", "detail", "micro", (0, 0, 0), 0.4, []),
281
+ ("glasses-frame-l", "Glasses frame L", "torus", "head", (0.21 * hu, 0.02 * hu, 0.48 * hu), (0.26 * hu, 0.22 * hu, 0.08 * hu), "glasses-frame", "connector", "meso", (0, 0, 0), 0.85, []),
282
+ ("glasses-frame-r", "Glasses frame R", "torus", "head", (-0.21 * hu, 0.02 * hu, 0.48 * hu), (0.26 * hu, 0.22 * hu, 0.08 * hu), "glasses-frame", "connector", "meso", (0, 0, 0), 0.85, []),
283
+ ("glasses-bridge", "Glasses bridge", "box", "head", (0, 0.04 * hu, 0.5 * hu), (0.12 * hu, 0.04 * hu, 0.04 * hu), "glasses-frame", "connector", "micro", (0, 0, 0), 0.5, []),
284
+ ("lens-l", "Lens L", "plane-card", "head", (0.21 * hu, 0.02 * hu, 0.485 * hu), (0.22 * hu, 0.18 * hu, 1.0), "glasses-lens", "panel", "micro", (0, 0, 0), 0.5, []),
285
+ ("lens-r", "Lens R", "plane-card", "head", (-0.21 * hu, 0.02 * hu, 0.485 * hu), (0.22 * hu, 0.18 * hu, 1.0), "glasses-lens", "panel", "micro", (0, 0, 0), 0.5, []),
286
+ ("hp-band", "Headphone band", "torus", "root", (0, 1.78 * hu, 0.05 * hu), (0.95 * hu, 0.62 * hu, 0.7 * hu), "headphone", "ring", "meso", (1.2, 0, 0), 0.85, []),
287
+ ("hp-cup-l", "Ear cup L", "cylinder", "root", (0.5 * hu, 1.52 * hu, 0.35 * hu), (0.42 * hu, 0.28 * hu, 0.42 * hu), "headphone", "detail", "meso", (0, 0, 1.57), 0.7, []),
288
+ ("hp-cup-r", "Ear cup R", "cylinder", "root", (-0.5 * hu, 1.52 * hu, 0.35 * hu), (0.42 * hu, 0.28 * hu, 0.42 * hu), "headphone", "detail", "meso", (0, 0, 1.57), 0.7, []),
289
+ ("arm-l", "Upper arm L", "capsule", "torso", (1.15 * hu, -0.35 * hu, 0.1 * hu), (0.55 * hu, 1.5 * hu, 0.55 * hu), "shirt", "arm", "meso", (0, 0, 0.25), 0.7, []),
290
+ ("arm-r", "Upper arm R", "capsule", "torso", (-1.15 * hu, -0.35 * hu, 0.1 * hu), (0.55 * hu, 1.5 * hu, 0.55 * hu), "shirt", "arm", "meso", (0, 0, -0.25), 0.7, []),
291
+ ]
292
+ offsets = {"root": (0.0, 0.0, 0.0)}
293
+ for pid, _n, _p, parent, off, *_rest in parts:
294
+ offsets[pid] = off # local offset; world resolved below
295
+
296
+ def world_pos(pid, parent, off):
297
+ x, y, z = off
298
+ cur = parent
299
+ # walk up the logical parent chain (parents are unrotated), summing offsets
300
+ while cur and cur != "root":
301
+ po = offsets.get(cur, (0.0, 0.0, 0.0))
302
+ x += po[0]; y += po[1]; z += po[2]
303
+ cur = parent_of.get(cur, "root")
304
+ return (x, y, z)
305
+
306
+ parent_of = {p[0]: p[3] for p in parts}
307
+ tree = [_cnode("root", "Character (root)", "box", None, (0, 0, 0), (1, 1, 1),
308
+ material="hidden", role="body", level="macro", importance=1.0, anim_role="root")]
309
+ for pid, name, prim, parent, off, scale, mat, role, level, rot, imp, feats in parts:
310
+ tree.append(_cnode(pid, name, prim, "root", world_pos(pid, parent, off), scale,
311
+ material=mat, role=role, level=level, rotation=rot, importance=imp,
312
+ local_features=feats))
313
+ return tree
314
+
315
+
316
+ def _shade_hex(hex_color: str, factor: float) -> str:
317
+ """Return a slightly darker/lighter shade of a #RRGGBB color (factor<1 darker)."""
318
+ h = hex_color.lstrip("#")
319
+ if len(h) != 6:
320
+ return hex_color
321
+ r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
322
+ r, g, b = (max(0, min(255, round(c * factor))) for c in (r, g, b))
323
+ return f"#{r:02x}{g:02x}{b:02x}"
324
+
325
+
326
+ CHARACTER_MATERIALS = [
327
+ {"id": "hidden", "baseColor": "#000000", "roughness": {"base": 1.0, "variation": 0.0}, "opacity": {"base": 0.0}},
328
+ {"id": "skin", "baseColor": "#e8b98f", "roughness": {"base": 0.55, "variation": 0.08}},
329
+ {"id": "hair", "baseColor": "#171310", "roughness": {"base": 0.42, "variation": 0.1}},
330
+ {"id": "shirt", "baseColor": "#20202a", "roughness": {"base": 0.85, "variation": 0.12}},
331
+ {"id": "shirt-decal", "baseColor": "#d24a20", "roughness": {"base": 0.7, "variation": 0.05}},
332
+ {"id": "glasses-frame", "baseColor": "#111114", "roughness": {"base": 0.35, "variation": 0.05}},
333
+ {"id": "glasses-lens", "baseColor": "#a9c6d8", "roughness": {"base": 0.08, "variation": 0.02}},
334
+ {"id": "headphone", "baseColor": "#0e0e10", "roughness": {"base": 0.5, "variation": 0.08}},
335
+ {"id": "lips", "baseColor": "#c98070", "roughness": {"base": 0.5, "variation": 0.05}},
336
+ ]
337
+
338
+
339
+ def make_character_build_passes() -> list:
340
+ base = make_pre_spec_assessment # noqa: reference to keep import graph obvious
341
+ passes = [
342
+ {"id": "blockout", "goal": "Match head-unit proportions and pose silhouette.",
343
+ "componentRefs": ["root"], "acceptance": ["Bust proportions and 3/4 pose read correctly without materials."]},
344
+ {"id": "proportion-lock", "goal": "Lock head/torso/limb head-unit ratios and pose angles.",
345
+ "componentRefs": ["root", "head", "torso"], "acceptance": ["Head-unit ratios match anatomy; silhouette matches reference."]},
346
+ {"id": "feature-placement", "goal": "Place facial features, hair, glasses, headphones to landmarks.",
347
+ "componentRefs": ["head", "hair", "glasses-frame-l", "hp-band"],
348
+ "acceptance": ["Eyeline/nose/mouth on landmark lines; glasses and headphones placed as in reference."]},
349
+ {"id": "material-pass", "goal": "Match skin/hair/cloth/metal color and roughness.",
350
+ "componentRefs": ["root"], "acceptance": ["Skin, hair, shirt, decal, glasses, headphone materials match reference palette."]},
351
+ {"id": "lighting-pass", "goal": "Soft key from reference direction plus rim.",
352
+ "componentRefs": ["root"], "acceptance": ["Readable under neutral light; reference-matched lighting added."]},
353
+ {"id": "interaction-pass", "goal": "Rig-ready pivots and sockets.",
354
+ "componentRefs": ["root"], "acceptance": ["Head/neck/arm pivots and face socket exposed."]},
355
+ {"id": "optimization-pass", "goal": "Protect runtime performance.",
356
+ "componentRefs": ["root"], "acceptance": ["Triangle/draw-call budget documented."]},
357
+ ]
358
+ del base
359
+ return passes
360
+
361
+
362
+ def make_character_feature_targets() -> list:
363
+ return [
364
+ {"id": "anatomy-proportion", "name": "Head-unit proportions and pose", "tier": "critical",
365
+ "passIds": ["blockout", "proportion-lock"], "minimumScore": 0.78, "mustPass": True,
366
+ "componentRefs": ["root", "head", "torso"], "evidenceRefs": ["full-object"]},
367
+ {"id": "face-landmark-placement", "name": "Face landmarks + glasses placement", "tier": "critical",
368
+ "passIds": ["feature-placement"], "minimumScore": 0.75, "mustPass": True,
369
+ "componentRefs": ["head", "glasses-frame-l"], "evidenceRefs": ["full-object"]},
370
+ {"id": "pose-silhouette", "name": "Pose and bust silhouette", "tier": "critical",
371
+ "passIds": ["blockout", "proportion-lock"], "minimumScore": 0.75, "mustPass": True,
372
+ "componentRefs": ["root", "arm-l"], "evidenceRefs": ["full-object"]},
373
+ {"id": "outfit-and-palette", "name": "Outfit + accessories + palette", "tier": "important",
374
+ "passIds": ["material-pass"], "minimumScore": 0.7, "mustPass": False,
375
+ "componentRefs": ["shirt-decal", "headphone", "glasses-frame-l"], "evidenceRefs": ["full-object"]},
376
+ ]
377
+
378
+
379
+ def apply_character_template(spec: dict, anatomy: dict | None = None) -> dict:
380
+ """Swap in the humanoid componentTree, character materials, build passes, and feature
381
+ targets. Object specs are untouched; only called when primaryDomain is character/hybrid."""
382
+ spec["componentTree"] = make_character_component_tree(anatomy)
383
+ existing = {m.get("id"): m for m in spec.get("materials", []) if isinstance(m, dict)}
384
+ for mat in CHARACTER_MATERIALS:
385
+ merged = dict(existing.get("base", {}))
386
+ merged.update(mat)
387
+ merged.setdefault("name", mat["id"])
388
+ merged.setdefault("type", "standard")
389
+ # the generator colours meshes from `color`/`albedo`, so keep them in sync with baseColor
390
+ base_color = mat.get("baseColor")
391
+ if base_color:
392
+ shade = _shade_hex(base_color, 0.82)
393
+ merged["color"] = base_color
394
+ # the generator only honours a palette with >= 2 entries (else it blends in beige
395
+ # fallback tones), so provide two near-identical shades of the intended colour.
396
+ merged["albedo"] = {"dominant": base_color, "secondary": [shade]}
397
+ merged["colorVariation"] = {"palette": [base_color, shade], "pattern": "flat",
398
+ "amplitude": 0.05, "heightCorrelation": 0.0}
399
+ existing[mat["id"]] = merged
400
+ spec["materials"] = list(existing.values())
401
+ spec["buildPasses"] = make_character_build_passes()
402
+ # A humanoid is reviewed as a whole each pass, so every pass renders all parts
403
+ # (unlike the object pipeline where passes add parts incrementally).
404
+ all_ids = [c["id"] for c in spec["componentTree"] if isinstance(c, dict) and c.get("id")]
405
+ for build_pass in spec["buildPasses"]:
406
+ build_pass["componentRefs"] = all_ids
407
+ spec["featureReviewTargets"] = make_character_feature_targets()
408
+ pipeline = spec.setdefault("sculptPipeline", {})
409
+ pipeline["passOrder"] = [p["id"] for p in spec["buildPasses"]]
410
+ pipeline["currentPass"] = "blockout"
411
+ return spec
412
+
413
+
414
+ def make_spec(target_name: str, image: str | None, assessment_payload: dict | None = None) -> dict:
415
+ target_id = slugify(target_name)
416
+ pre_spec_assessment = make_pre_spec_assessment(target_name)
417
+ quality_contract = make_quality_contract()
418
+ if assessment_payload:
419
+ incoming_assessment = assessment_payload.get("preSpecAssessment")
420
+ incoming_contract = assessment_payload.get("qualityContract")
421
+ if isinstance(incoming_assessment, dict):
422
+ pre_spec_assessment = incoming_assessment
423
+ if isinstance(incoming_contract, dict):
424
+ quality_contract = incoming_contract
425
+ return {
426
+ "targetName": target_name,
427
+ "targetId": target_id,
428
+ "schemaVersion": "2.0",
429
+ "terminologyProfile": {
430
+ "domain": "real-time procedural Three.js asset",
431
+ "geometryTerms": [
432
+ "silhouette",
433
+ "topology",
434
+ "primitive",
435
+ "bevel",
436
+ "chamfer",
437
+ "taper",
438
+ "bend",
439
+ "boolean cut",
440
+ "edge loop",
441
+ "surface normal",
442
+ "displacement",
443
+ ],
444
+ "materialTerms": [
445
+ "albedo",
446
+ "baseColor",
447
+ "roughness",
448
+ "metalness",
449
+ "normal map",
450
+ "bump map",
451
+ "ambient occlusion",
452
+ "cavity dirt",
453
+ "edge wear",
454
+ "clearcoat",
455
+ ],
456
+ "lightingTerms": [
457
+ "key light",
458
+ "fill light",
459
+ "rim light",
460
+ "HDRI/environment reflection",
461
+ "contact shadow",
462
+ ],
463
+ "descriptionRule": "Use measurable 3D graphics terms. Avoid vague words unless they are paired with concrete geometry/material/shader parameters.",
464
+ },
465
+ "sourceImage": image or "",
466
+ "referenceCamera": {
467
+ "solved": False,
468
+ "fovDegrees": 40.0,
469
+ "aspect": 1.0,
470
+ "orientation": {"yaw": 0.0, "pitch": 0.0, "roll": 0.0},
471
+ "positionHint": [0.0, 0.0, 3.0],
472
+ "note": (
473
+ "For likeness work, solve the reference camera (forge/stage1_intake/solve_camera_pose.py) so the "
474
+ "review render aligns with the photo and the reference can be projected. Confirm by overlay review."
475
+ ),
476
+ },
477
+ "suitability": "conditional",
478
+ "scores": {
479
+ "object_isolation": 0,
480
+ "silhouette_readability": 0,
481
+ "depth_inference": 0,
482
+ "primitive_decomposition": 0,
483
+ "material_procedurality": 0,
484
+ "occlusion_risk": 0,
485
+ "interaction_fit": 0,
486
+ },
487
+ "preSpecAssessment": pre_spec_assessment,
488
+ "qualityContract": quality_contract,
489
+ "qualityTargets": {
490
+ "targetFidelity": 0.7,
491
+ "mustMatch": [
492
+ "macro silhouette and proportions",
493
+ "primary material albedo/roughness response",
494
+ "reference-derived PBR material response at or above 0.7 confidence when source pixels are usable",
495
+ "most recognizable local features",
496
+ ],
497
+ "niceToHave": [
498
+ "micro scratches, stains, chips, and dirt masks",
499
+ "secondary lighting match",
500
+ ],
501
+ "fpsTarget": 60,
502
+ "reviewViewpoints": ["front", "three-quarter", "side"],
503
+ },
504
+ "selfCorrectLoop": {
505
+ "enabled": True,
506
+ "visualAcceptance": {
507
+ "reviewer": "ai-vision",
508
+ "threshold": 0.7,
509
+ "comparisonArtifactRequired": True,
510
+ "layerScoresRequired": True,
511
+ "codePixelDiffIsAcceptanceAuthority": False,
512
+ "scoringRule": "AI vision must inspect a side-by-side reference/render sheet and score the current pass from 0 to 1. Pixel-diff code may assist diagnostics but cannot approve a pass.",
513
+ "requiredLayerScores": [
514
+ "silhouetteProportion",
515
+ "componentStructure",
516
+ "formDetail",
517
+ "materialSurface",
518
+ "lightingCamera",
519
+ ],
520
+ "featureReviewPolicy": {
521
+ "enabled": True,
522
+ "reviewUnit": "semantic-subsystem",
523
+ "maxCriticalFeaturesPerPass": 5,
524
+ "maxImportantFeaturesPerPass": 3,
525
+ "criticalDefaultThreshold": 0.8,
526
+ "importantAverageThreshold": 0.65,
527
+ "adaptiveEscalation": True,
528
+ "singleImagePairOnly": True,
529
+ "selectionRule": "Choose only the most visually salient, identity-defining, user-prioritized, or high-risk semantic systems. Group repeated parts instead of reviewing every mesh. AI vision scores every selected feature from the same full reference/render pair.",
530
+ },
531
+ },
532
+ "reviewAfterPasses": [
533
+ "blockout",
534
+ "structural-pass",
535
+ "form-refinement",
536
+ "material-pass",
537
+ "surface-pass",
538
+ "lighting-pass",
539
+ "interaction-pass",
540
+ "optimization-pass",
541
+ ],
542
+ "allowedActions": [
543
+ "continue",
544
+ "refine-spec",
545
+ "refine-code",
546
+ "request-input",
547
+ "stop",
548
+ ],
549
+ "specRefineTriggers": [
550
+ "missing component",
551
+ "wrong primitive family",
552
+ "wrong proportions",
553
+ "material layer under-specified",
554
+ "local feature not traceable to viewEvidence",
555
+ "reference ambiguity discovered during implementation",
556
+ ],
557
+ "codeRefineTriggers": [
558
+ "spec is adequate but generated geometry/material does not match",
559
+ "browser render differs from reference",
560
+ "performance budget exceeded",
561
+ "lighting hides geometry or material response",
562
+ ],
563
+ "stopCriteria": [
564
+ "target fidelity reached or user accepts current approximation",
565
+ "remaining gaps require new reference images or manual art",
566
+ ],
567
+ "screenshotPolicy": {
568
+ "requiredForPasses": [
569
+ "blockout",
570
+ "structural-pass",
571
+ "form-refinement",
572
+ "material-pass",
573
+ "surface-pass",
574
+ "lighting-pass",
575
+ "interaction-pass",
576
+ ],
577
+ "preferredCapture": "in-app-browser-screenshot",
578
+ "fallbackCapture": "user-supplied-screenshot-path",
579
+ "minimumEvidence": "Each visual pass needs a reference image, rendered screenshot, side-by-side comparison sheet, AI vision score, layer scores, and critique before choosing continue.",
580
+ "reviewPairRule": "Compare the same camera/viewpoint whenever possible; do not judge a front reference against a random render angle.",
581
+ "acceptanceAuthority": "AI vision review of the comparison sheet. Code-generated pixel similarity is not sufficient evidence.",
582
+ },
583
+ },
584
+ "featureReviewTargets": [
585
+ {
586
+ "id": "overall-silhouette",
587
+ "name": "Overall silhouette and proportion system",
588
+ "tier": "critical",
589
+ "passIds": ["blockout"],
590
+ "minimumScore": 0.8,
591
+ "mustPass": True,
592
+ "componentRefs": ["root"],
593
+ "evidenceRefs": ["full-object"],
594
+ },
595
+ {
596
+ "id": "primary-structure",
597
+ "name": "Primary identity-defining structure",
598
+ "tier": "critical",
599
+ "passIds": ["structural-pass", "form-refinement"],
600
+ "minimumScore": 0.8,
601
+ "mustPass": True,
602
+ "componentRefs": ["root"],
603
+ "evidenceRefs": ["full-object"],
604
+ },
605
+ {
606
+ "id": "reference-material-system",
607
+ "name": "Primary reference material and surface response",
608
+ "tier": "critical",
609
+ "passIds": ["material-pass", "surface-pass"],
610
+ "minimumScore": 0.75,
611
+ "mustPass": True,
612
+ "componentRefs": ["root"],
613
+ "evidenceRefs": ["full-object"],
614
+ },
615
+ ],
616
+ "sculptPipeline": {
617
+ "passGateMode": "locked-sequential",
618
+ "passOrder": [
619
+ "blockout",
620
+ "structural-pass",
621
+ "form-refinement",
622
+ "material-pass",
623
+ "surface-pass",
624
+ "lighting-pass",
625
+ "interaction-pass",
626
+ "optimization-pass",
627
+ ],
628
+ "currentPass": "blockout",
629
+ "completedPasses": [],
630
+ "lastCompletedPass": "",
631
+ "blockedReason": "blockout requires a browser screenshot and self-correction review before structural-pass unlocks",
632
+ "nextRequiredEvidence": [
633
+ "blockout browser render screenshot from your agent's browser/screenshot tool",
634
+ "side-by-side reference/render comparison sheet",
635
+ "AI vision score >= 0.7 with layer scores and mismatch critique",
636
+ "critical semantic feature scores from the same image pair meeting their individual thresholds",
637
+ "reviewHistory entry for blockout with action=continue",
638
+ ],
639
+ },
640
+ "lookDevTargets": {
641
+ "qualityPriority": "reference-fidelity",
642
+ "materialPass": {
643
+ "albedoPaletteRequired": True,
644
+ "roughnessVariationRequired": True,
645
+ "normalOrBumpRequired": True,
646
+ "localOverridesRequired": True,
647
+ "minimumTextureResolution": 1024,
648
+ "preferredTextureResolution": 2048,
649
+ "independentMapChannels": [
650
+ "albedo",
651
+ "roughness",
652
+ "height",
653
+ "normal",
654
+ "ambient-occlusion",
655
+ ],
656
+ "requiredSurfaceFrequencyBands": ["macro", "meso", "micro"],
657
+ "geometryReliefRequiredWhenSilhouetteAffected": True,
658
+ "referencePbrExtraction": {
659
+ "requiredWhenSourceImagePresent": True,
660
+ "targetThreshold": 0.7,
661
+ "stopOnLowConfidence": True,
662
+ "script": "forge/stage1_intake/extract_pbr_evidence.py",
663
+ "acceptedLimitation": "single-image extraction is reference-derived inference, not exact photogrammetry",
664
+ },
665
+ "mustAvoid": [
666
+ "single flat albedo per material",
667
+ "uniform roughness",
668
+ "albedo texture reused as roughness/height/normal/AO",
669
+ "single-frequency random noise",
670
+ "plastic-looking smooth bark, stone, cloth, foliage, or aged material",
671
+ "local color/detail described only in prose without material masks",
672
+ "claiming exact PBR recovery when confidence is below the target threshold",
673
+ ],
674
+ },
675
+ "lightingPass": {
676
+ "requiredTerms": [
677
+ "key light",
678
+ "fill light",
679
+ "rim or environment light",
680
+ "exposure",
681
+ "tone mapping",
682
+ "background",
683
+ "contact shadow",
684
+ ],
685
+ "mustAvoid": [
686
+ "ambient-only lighting",
687
+ "flat value range",
688
+ "missing contact shadow",
689
+ "reference lighting copied without separating material readability",
690
+ ],
691
+ },
692
+ "screenshotReview": [
693
+ "Compare albedo palette and local color zones.",
694
+ "Compare roughness/normal/bump response under light.",
695
+ "Compare cavity dirt, edge wear, stains, moss, scratches, or other local masks.",
696
+ "Compare key/fill/rim structure, exposure, tone mapping, background, and contact shadows.",
697
+ "Capture a neutral-light render to verify material readability without reference lighting.",
698
+ "Capture a grazing-light close-up to expose flat normals, uniform roughness, tiling, and plastic highlights.",
699
+ "Capture a reference-matched render from the same camera framing as the source.",
700
+ ],
701
+ },
702
+ "actionReadiness": {
703
+ "contract": "Every macro/meso component should be generated as a stable named Object3D pivot node with a mesh child, action metadata, optional sockets, collider proxy, and destruction metadata.",
704
+ "defaultRigType": "action-ready-static-rig",
705
+ "rootMotionNode": "root",
706
+ "requiredComponentFields": [
707
+ "id",
708
+ "parent",
709
+ "transform",
710
+ "attachment for child appendages, connectors, limbs, tubes, handles, legs, horns, wings, branches, or cables",
711
+ "actionProfile.animationRole",
712
+ "actionProfile.pivot",
713
+ "actionProfile.collider",
714
+ "actionProfile.destruction",
715
+ ],
716
+ "transformChannels": [
717
+ "translate",
718
+ "rotate",
719
+ "scale",
720
+ "bend",
721
+ "twist",
722
+ "detach",
723
+ "visibility",
724
+ "material-state",
725
+ ],
726
+ "authoringRules": [
727
+ "Do not collapse independently movable parts into one mesh.",
728
+ "Put transforms on component pivot groups, not only on raw meshes.",
729
+ "For attached child parts, put the pivot at the semantic root/socket and build visible geometry from localStart to localEnd.",
730
+ "Represent hinge, socket, detachable, and breakable intent even when no animation is implemented yet.",
731
+ "Use simplified collider proxies for runtime physics instead of visual mesh colliders by default.",
732
+ ],
733
+ "destructionPolicy": {
734
+ "defaultBreakable": False,
735
+ "fractureGroupNaming": "Use stable semantic names such as body-shell, left-hinge, glass-panel, branch-segment.",
736
+ "debrisStrategy": "Prefer detachable component groups and a small number of procedural fragments over random mesh explosion.",
737
+ },
738
+ },
739
+ "assumptions": [],
740
+ "coordinateFrame": {
741
+ "front": "camera-facing side in the reference image",
742
+ "up": "image up direction",
743
+ "scaleReference": "unit scale; adjust after first browser render",
744
+ },
745
+ "silhouette": {
746
+ "boundingShape": "",
747
+ "aspectRatios": [],
748
+ "symmetry": "",
749
+ "dominantCurves": [],
750
+ "negativeSpaces": [],
751
+ "landmarks": [],
752
+ },
753
+ "viewEvidence": [
754
+ {
755
+ "id": "full-object",
756
+ "view": "primary",
757
+ "imageRegion": {
758
+ "x": 0.0,
759
+ "y": 0.0,
760
+ "width": 1.0,
761
+ "height": 1.0,
762
+ "units": "normalized",
763
+ },
764
+ "observations": [],
765
+ "confidence": 0.5,
766
+ }
767
+ ],
768
+ "componentTree": [
769
+ {
770
+ "id": "root",
771
+ "name": target_name,
772
+ "level": "macro",
773
+ "role": "body",
774
+ "importance": 1.0,
775
+ "confidence": 0.5,
776
+ "primitive": "box",
777
+ "geometryDescriptor": {
778
+ "topologyIntent": "low-poly blockout with bevel-ready edges",
779
+ "edgeTreatment": {
780
+ "type": "none",
781
+ "bevelRadius": 0.0,
782
+ "segments": 1,
783
+ },
784
+ "deformationStack": [],
785
+ "uvStrategy": "generated procedural coordinates",
786
+ "normalStrategy": "vertex normals from generated geometry",
787
+ },
788
+ "parent": None,
789
+ "attachment": None,
790
+ "dimensions": {
791
+ "width": 1.0,
792
+ "height": 1.0,
793
+ "depth": 1.0,
794
+ "units": "relative",
795
+ "confidence": 0.5,
796
+ },
797
+ "transform": {
798
+ "position": [0, 0, 0],
799
+ "rotation": [0, 0, 0],
800
+ "scale": [1, 1, 1],
801
+ },
802
+ "actionProfile": {
803
+ "animationRole": "root",
804
+ "pivot": {
805
+ "mode": "center",
806
+ "localPosition": [0, 0, 0],
807
+ "axis": [0, 1, 0],
808
+ "confidence": 0.5,
809
+ },
810
+ "transformChannels": {
811
+ "translate": True,
812
+ "rotate": True,
813
+ "scale": True,
814
+ "bend": False,
815
+ "twist": False,
816
+ "detach": False,
817
+ "visibility": True,
818
+ "materialState": True,
819
+ },
820
+ "sockets": [],
821
+ "collider": {
822
+ "type": "box",
823
+ "offset": [0, 0, 0],
824
+ "scale": [1, 1, 1],
825
+ "isTrigger": False,
826
+ "notes": "Replace with sphere/capsule/compound proxy when the object shape demands it.",
827
+ },
828
+ "constraints": [],
829
+ "destruction": {
830
+ "breakable": False,
831
+ "fractureGroup": "root",
832
+ "seamRefs": [],
833
+ "detachableFragments": [],
834
+ "breakImpulse": 0.0,
835
+ "debrisMaterial": "base",
836
+ },
837
+ },
838
+ "material": "base",
839
+ "materialLayers": ["base"],
840
+ "deformations": [],
841
+ "joints": [],
842
+ "seams": [],
843
+ "localFeatures": [],
844
+ "surfaceDetail": {
845
+ "macroRoughness": 0.0,
846
+ "microRoughness": 0.0,
847
+ "bumpAmplitude": 0.0,
848
+ "normalPattern": "",
849
+ "displacementPattern": "",
850
+ "occlusionPattern": "",
851
+ "edgeWearPattern": "",
852
+ "notes": "",
853
+ },
854
+ "evidenceRefs": ["full-object"],
855
+ "details": [],
856
+ "fidelityTier": "blockout",
857
+ }
858
+ ],
859
+ "materials": [
860
+ {
861
+ "id": "base",
862
+ "name": "Base material",
863
+ "type": "standard",
864
+ "shaderModel": "MeshStandardMaterial / PBR approximation",
865
+ "baseColor": "#8A7A5F",
866
+ "color": "#8A7A5F",
867
+ "albedo": {
868
+ "dominant": "#8A7A5F",
869
+ "secondary": ["#6E614B", "#A08F70"],
870
+ "samplingNotes": "Use image-observed local color zones, not a single averaged color.",
871
+ },
872
+ "colorVariation": {
873
+ "palette": ["#8A7A5F", "#6E614B", "#A08F70"],
874
+ "pattern": "mottled",
875
+ "amplitude": 0.15,
876
+ "heightCorrelation": 0.3,
877
+ },
878
+ "textureResolution": 1024,
879
+ "textureProjection": {
880
+ "mode": "uv",
881
+ "repeat": [2.0, 2.0],
882
+ "anisotropy": 8,
883
+ "texelDensityIntent": "Preserve stable world/object-scale detail; do not stretch micro detail with component scale.",
884
+ },
885
+ "surfaceFrequencyBands": [
886
+ {
887
+ "id": "macro",
888
+ "frequency": 2.0,
889
+ "amplitude": 0.42,
890
+ "role": "broad color and height breakup",
891
+ },
892
+ {
893
+ "id": "meso",
894
+ "frequency": 12.0,
895
+ "amplitude": 0.22,
896
+ "role": "ridges, pores, grain, dents, or equivalent visible relief",
897
+ },
898
+ {
899
+ "id": "micro",
900
+ "frequency": 56.0,
901
+ "amplitude": 0.08,
902
+ "role": "highlight breakup visible under grazing light",
903
+ },
904
+ ],
905
+ "roughness": {
906
+ "base": 0.75,
907
+ "variation": 0.15,
908
+ "map": "independent-procedural-field",
909
+ "localResponse": "higher roughness in cavities, lower roughness on worn edges",
910
+ },
911
+ "metalness": {
912
+ "base": 0.0,
913
+ "variation": 0.0,
914
+ },
915
+ "normal": {
916
+ "pattern": "derived-from-independent-height-field",
917
+ "strength": 0.35,
918
+ "scale": 24.0,
919
+ "space": "tangent",
920
+ },
921
+ "bump": {
922
+ "pattern": "none",
923
+ "amplitude": 0.0,
924
+ "scale": 1.0,
925
+ },
926
+ "displacement": {
927
+ "pattern": "none",
928
+ "amplitude": 0.0,
929
+ "scale": 1.0,
930
+ "silhouetteAffects": False,
931
+ },
932
+ "ambientOcclusion": {
933
+ "cavityStrength": 0.25,
934
+ "contactShadowBias": 0.35,
935
+ "notes": "Darken creases, seams, intersections, and recessed local features.",
936
+ },
937
+ "wear": {
938
+ "edgeWear": 0.0,
939
+ "scratches": [],
940
+ "chips": [],
941
+ },
942
+ "dirt": {
943
+ "amount": 0.0,
944
+ "cavityBias": 0.0,
945
+ "color": "#2F2A22",
946
+ },
947
+ "localOverrides": [],
948
+ "shaderNotes": [
949
+ "Prefer MeshPhysicalMaterial when clearcoat, sheen, transmission, or thin-surface response is observed; otherwise use MeshStandardMaterial-compatible PBR channels.",
950
+ "Generate albedo, roughness, height/normal, and AO independently; never alias albedo into roughness.",
951
+ "Use normal/bump/displacement only when they map to observed surface relief.",
952
+ "Use displacement geometry when the observed relief changes the close-up silhouette; texture-only relief is insufficient there.",
953
+ ],
954
+ "notes": "Replace with image-derived color, roughness, noise, and edge-wear notes.",
955
+ }
956
+ ],
957
+ "repetitionSystems": [],
958
+ "buildPasses": [
959
+ {
960
+ "id": "blockout",
961
+ "goal": "Match macro silhouette and proportions.",
962
+ "componentRefs": ["root"],
963
+ "acceptance": [
964
+ "Silhouette reads correctly without materials.",
965
+ "Quality contract has named all required macro feature groups before code generation.",
966
+ "AI vision comparison score meets selfCorrectLoop.visualAcceptance.threshold.",
967
+ ],
968
+ },
969
+ {
970
+ "id": "structural-pass",
971
+ "goal": "Build the component hierarchy implied by the pre-spec complexity assessment.",
972
+ "componentRefs": ["root"],
973
+ "acceptance": [
974
+ "Macro, meso, and repeated structures meet qualityContract.minimumSpecDepth.",
975
+ "Parent-child relations, joints, seams, sockets, and contact points are explicit.",
976
+ "Every attached child appendage/connector has parentSocket, localStart/localEnd, contactType, embedDepth or overlap, and gapTolerance.",
977
+ "AI vision comparison score meets selfCorrectLoop.visualAcceptance.threshold.",
978
+ ],
979
+ },
980
+ {
981
+ "id": "form-refinement",
982
+ "goal": "Refine shape, deformation, bevels, tapers, curves, asymmetry, and visible local geometry.",
983
+ "componentRefs": ["root"],
984
+ "acceptance": [
985
+ "Important visible forms are represented in component geometryDescriptor, deformations, localFeatures, or repetitionSystems.",
986
+ "Endpoint-based child parts are rooted at their attachment sockets and do not visibly float away from parents.",
987
+ "AI vision comparison score meets selfCorrectLoop.visualAcceptance.threshold.",
988
+ ],
989
+ },
990
+ {
991
+ "id": "material-pass",
992
+ "goal": "Match material color, roughness, bump, and local variation.",
993
+ "componentRefs": ["root"],
994
+ "acceptance": [
995
+ "Reference-derived albedo palette records dominant, secondary, and accent colors per visible material.",
996
+ "Each important material defines roughness variation and at least one normal/bump/displacement response.",
997
+ "Local material overrides, dirt/wear/stains/moss/chips/scratches or equivalent masks are tied to evidenceRefs.",
998
+ "Thin, transparent, reflective, wet, or fibrous materials document alpha/transmission/clearcoat/metalness/fiber response when relevant.",
999
+ "Generated preview uses procedural albedo/roughness/bump texture or vertex color variation instead of one flat color.",
1000
+ "Generated preview uses independent PBR maps at 1024px or higher for the quality-first tier.",
1001
+ "If source pixels are available, referencePbr extraction passed at confidence >= 0.7 or the pass is stopped/requesting better references.",
1002
+ "Macro, meso, and micro surface frequency bands are visible at the intended review distance without obvious tiling.",
1003
+ "AI vision comparison score meets selfCorrectLoop.visualAcceptance.threshold.",
1004
+ ],
1005
+ },
1006
+ {
1007
+ "id": "surface-pass",
1008
+ "goal": "Add procedural surface locality such as normal/bump/displacement, AO, dirt, stains, chips, grain, moss, scratches, and wear.",
1009
+ "componentRefs": ["root"],
1010
+ "acceptance": [
1011
+ "Every required material feature group has local overrides or surfaceDetail tied to evidenceRefs.",
1012
+ "A grazing-angle close-up proves that normal/height detail breaks highlights naturally and does not read as smooth plastic.",
1013
+ "AI vision comparison score meets selfCorrectLoop.visualAcceptance.threshold.",
1014
+ ],
1015
+ },
1016
+ {
1017
+ "id": "lighting-pass",
1018
+ "goal": "Make material and form readable under neutral turntable lighting plus optional reference lighting.",
1019
+ "componentRefs": ["root"],
1020
+ "acceptance": [
1021
+ "lightingFromPhoto identifies key light direction/color/intensity, fill light, rim or environment light, and ambient color.",
1022
+ "Exposure, tone mapping, background color/gradient, shadow softness, and contact shadow behavior are specified.",
1023
+ "Lighting does not hide geometry/material gaps and screenshots can be compared fairly to the reference.",
1024
+ "Neutral, grazing, and reference-matched lighting checks distinguish material errors from lighting errors.",
1025
+ "AI vision comparison score meets selfCorrectLoop.visualAcceptance.threshold.",
1026
+ ],
1027
+ },
1028
+ {
1029
+ "id": "interaction-pass",
1030
+ "goal": "Make the model ready for future animation, transformation, physics, or destruction.",
1031
+ "componentRefs": ["root"],
1032
+ "acceptance": [
1033
+ "Macro and movable meso components have stable pivot nodes.",
1034
+ "Sockets, collider proxies, and destruction metadata are present for future runtime actions.",
1035
+ "AI vision comparison score meets selfCorrectLoop.visualAcceptance.threshold.",
1036
+ ],
1037
+ },
1038
+ {
1039
+ "id": "optimization-pass",
1040
+ "goal": "Protect runtime performance after visual fidelity is accepted.",
1041
+ "componentRefs": ["root"],
1042
+ "acceptance": [
1043
+ "Triangle count, draw calls, instancing, LOD strategy, and FPS target are documented or verified.",
1044
+ "Repeated detail is instanced or simplified where possible without breaking silhouette/material believability.",
1045
+ ],
1046
+ },
1047
+ ],
1048
+ "visualEvidence": [],
1049
+ "reviewHistory": [],
1050
+ "lodPlan": [
1051
+ {
1052
+ "tier": "near",
1053
+ "distance": 0,
1054
+ "strategy": "full component tree and material layers",
1055
+ },
1056
+ {
1057
+ "tier": "far",
1058
+ "distance": 30,
1059
+ "strategy": "merge static components and reduce local feature geometry",
1060
+ },
1061
+ ],
1062
+ "performanceBudget": {
1063
+ "qualityPriority": "reference-fidelity",
1064
+ "targetTriangles": 250000,
1065
+ "maxDrawCalls": 160,
1066
+ "textureSize": 2048,
1067
+ "fpsTarget": 30,
1068
+ "optimizationPolicy": "Reach accepted visual fidelity first, then optimize without removing reference-critical geometry or surface layers.",
1069
+ },
1070
+ "lightingFromPhoto": [],
1071
+ "proceduralStrategy": [
1072
+ "Block out macro silhouette first.",
1073
+ "Add component hierarchy and joints.",
1074
+ "Create stable pivot groups, sockets, collider proxies, and destruction metadata before visual polish.",
1075
+ "Refine forms with bevels, tapers, bends, and procedural noise.",
1076
+ "Run reference PBR extraction for important source-image materials and stop when confidence is below the target threshold.",
1077
+ "Add material variation before adding expensive micro-geometry.",
1078
+ ],
1079
+ "animationAnchors": [
1080
+ "root pivot node supports whole-object translation, rotation, scale, and visibility changes",
1081
+ "component pivot groups support later local transforms without rebuilding geometry",
1082
+ ],
1083
+ "destructionAnchors": [
1084
+ "actionProfile.destruction.fractureGroup marks detachable or breakable component sets",
1085
+ "component seams and sockets define plausible break points instead of random explosions",
1086
+ ],
1087
+ "risks": [],
1088
+ }
1089
+
1090
+
1091
+ def main(argv: list[str]) -> int:
1092
+ parser = argparse.ArgumentParser(description=__doc__)
1093
+ parser.add_argument("target_name", help="Human-readable object name")
1094
+ parser.add_argument("--image", help="Reference image path or URL")
1095
+ parser.add_argument("--assessment", type=Path, help="Pre-spec assessment JSON from stage2_spec/new_pre_spec_assessment.py")
1096
+ parser.add_argument("--out", type=Path, help="Output JSON path")
1097
+ parser.add_argument("--force", action="store_true", help="Overwrite output file")
1098
+ parser.add_argument("--character", action="store_true",
1099
+ help="Use the humanoid character template (auto-enabled when the assessment primaryDomain is character/hybrid)")
1100
+ args = parser.parse_args(argv)
1101
+
1102
+ assessment = load_assessment(args.assessment)
1103
+ spec = make_spec(args.target_name, args.image, assessment)
1104
+ domain = None
1105
+ if isinstance(assessment, dict):
1106
+ pre = assessment.get("preSpecAssessment", {})
1107
+ oc = pre.get("objectClass", {}) if isinstance(pre, dict) else {}
1108
+ domain = oc.get("primaryDomain") if isinstance(oc, dict) else None
1109
+ if args.character or domain in {"character", "hybrid"}:
1110
+ anatomy = None
1111
+ if isinstance(assessment, dict) and isinstance(assessment.get("preSpecAssessment"), dict):
1112
+ anatomy = assessment["preSpecAssessment"].get("anatomy")
1113
+ apply_character_template(spec, anatomy)
1114
+ payload = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
1115
+
1116
+ if args.out:
1117
+ output = args.out.expanduser().resolve()
1118
+ if output.exists() and not args.force:
1119
+ parser.error(f"{output} already exists; use --force to overwrite")
1120
+ output.parent.mkdir(parents=True, exist_ok=True)
1121
+ output.write_text(payload, encoding="utf-8")
1122
+ print(output)
1123
+ else:
1124
+ print(payload, end="")
1125
+ return 0
1126
+
1127
+
1128
+ if __name__ == "__main__":
1129
+ raise SystemExit(main(sys.argv[1:]))
forge/stage2_spec/validate_sculpt_spec.py ADDED
@@ -0,0 +1,1730 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Validate an ObjectSculptSpec JSON file for procedural Three.js generation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "_shared"))
14
+ from feature_acceptance_policy import feature_gate_failures, feature_review_policy
15
+
16
+
17
+ REQUIRED_TOP_LEVEL = {
18
+ "targetName": str,
19
+ "suitability": str,
20
+ "coordinateFrame": dict,
21
+ "silhouette": dict,
22
+ "componentTree": list,
23
+ "materials": list,
24
+ "proceduralStrategy": list,
25
+ }
26
+ VALID_SUITABILITY = {"pass", "conditional", "reject"}
27
+ VALID_PRIMITIVES = {
28
+ "box",
29
+ "sphere",
30
+ "ellipsoid",
31
+ "cylinder",
32
+ "cone",
33
+ "capsule",
34
+ "torus",
35
+ "tube",
36
+ "lathe",
37
+ "extrude",
38
+ "curve-sweep",
39
+ "plane-card",
40
+ "instanced-cluster",
41
+ }
42
+ VALID_COMPONENT_LEVELS = {"macro", "meso", "micro"}
43
+ VALID_COMPLEXITY_TIERS = {"unassessed", "simple", "moderate", "complex", "ultra-complex"}
44
+ TERMINOLOGY_LIST_FIELDS = {"geometryTerms", "materialTerms", "lightingTerms"}
45
+ VALID_REVIEW_ACTIONS = {"continue", "refine-spec", "refine-code", "request-input", "stop"}
46
+ VISUAL_PASS_IDS = {
47
+ "blockout",
48
+ "structural-pass",
49
+ "form-refinement",
50
+ "material-pass",
51
+ "surface-pass",
52
+ "lighting-pass",
53
+ "interaction-pass",
54
+ }
55
+ VALID_PIPELINE_PASS_IDS = VISUAL_PASS_IDS | {"optimization-pass"}
56
+ ATTACHMENT_ROLES = {
57
+ "appendage",
58
+ "branch",
59
+ "limb",
60
+ "arm",
61
+ "leg",
62
+ "handle",
63
+ "connector",
64
+ "tube",
65
+ "cable",
66
+ "horn",
67
+ "wing",
68
+ "tail",
69
+ "root",
70
+ "fork",
71
+ "rib",
72
+ "support",
73
+ "hinge",
74
+ "socket",
75
+ "pipe",
76
+ }
77
+ ATTACHMENT_PRIMITIVES = {"cylinder", "cone", "capsule", "tube", "curve-sweep"}
78
+
79
+
80
+ def is_number(value: Any) -> bool:
81
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
82
+
83
+
84
+ def validate_unit_interval(value: Any, label: str, errors: list[str]) -> None:
85
+ if not is_number(value) or value < 0 or value > 1:
86
+ errors.append(f"{label} must be a number from 0 to 1")
87
+
88
+
89
+ def load_spec(path: Path) -> dict[str, Any]:
90
+ try:
91
+ payload = json.loads(path.read_text(encoding="utf-8"))
92
+ except json.JSONDecodeError as exc:
93
+ raise ValueError(f"invalid JSON: {exc}") from exc
94
+ if not isinstance(payload, dict):
95
+ raise ValueError("spec must be a JSON object")
96
+ return payload
97
+
98
+
99
+ def as_number_list(value: Any, length: int) -> bool:
100
+ return (
101
+ isinstance(value, list)
102
+ and len(value) == length
103
+ and all(isinstance(item, (int, float)) for item in value)
104
+ )
105
+
106
+
107
+ def validate_score_block(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
108
+ scores = spec.get("scores")
109
+ if scores is None:
110
+ warnings.append("missing scores block; image validation evidence will be weaker")
111
+ return
112
+ if not isinstance(scores, dict):
113
+ errors.append("scores must be an object")
114
+ return
115
+ for key, value in scores.items():
116
+ if not isinstance(value, int) or value < 0 or value > 3:
117
+ errors.append(f"score {key!r} must be an integer from 0 to 3")
118
+
119
+
120
+ def validate_nonnegative_int(value: Any, label: str, errors: list[str]) -> None:
121
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
122
+ errors.append(f"{label} must be a non-negative integer")
123
+
124
+
125
+ def validate_pre_spec_assessment(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
126
+ assessment = spec.get("preSpecAssessment")
127
+ if assessment is None:
128
+ warnings.append("quality: missing preSpecAssessment; spec may be shallow because complexity was not assessed first")
129
+ return
130
+ if not isinstance(assessment, dict):
131
+ errors.append("preSpecAssessment must be an object")
132
+ return
133
+ object_class = assessment.get("objectClass")
134
+ if not isinstance(object_class, dict):
135
+ errors.append("preSpecAssessment.objectClass must be an object")
136
+ else:
137
+ primary_type = object_class.get("primaryType")
138
+ if primary_type is not None and not isinstance(primary_type, str):
139
+ errors.append("preSpecAssessment.objectClass.primaryType must be a string")
140
+ if primary_type in {None, "", "unassessed"}:
141
+ warnings.append("quality: preSpecAssessment.objectClass.primaryType is unassessed")
142
+ for field in ("formLanguage", "structureKind", "motionPotential", "materialFamilies"):
143
+ validate_string_array(object_class.get(field), f"preSpecAssessment.objectClass.{field}", errors)
144
+ if isinstance(object_class.get(field), list) and not object_class[field]:
145
+ warnings.append(f"quality: preSpecAssessment.objectClass.{field} is empty")
146
+ complexity = assessment.get("complexity")
147
+ if not isinstance(complexity, dict):
148
+ errors.append("preSpecAssessment.complexity must be an object")
149
+ else:
150
+ tier = complexity.get("tier")
151
+ if tier not in VALID_COMPLEXITY_TIERS:
152
+ errors.append(f"preSpecAssessment.complexity.tier must be one of: {', '.join(sorted(VALID_COMPLEXITY_TIERS))}")
153
+ if tier == "unassessed":
154
+ warnings.append("quality: preSpecAssessment.complexity.tier is unassessed")
155
+ scores = complexity.get("scores")
156
+ if not isinstance(scores, dict):
157
+ errors.append("preSpecAssessment.complexity.scores must be an object")
158
+ else:
159
+ for key, value in scores.items():
160
+ if not isinstance(value, int) or value < 0 or value > 3:
161
+ errors.append(f"preSpecAssessment.complexity.scores.{key} must be an integer from 0 to 3")
162
+ estimated = complexity.get("estimatedCounts")
163
+ if not isinstance(estimated, dict):
164
+ errors.append("preSpecAssessment.complexity.estimatedCounts must be an object")
165
+ else:
166
+ for field in ("macroComponents", "mesoComponents", "microFeatureGroups", "materialLayers", "repetitionSystems"):
167
+ if field in estimated:
168
+ validate_nonnegative_int(estimated[field], f"preSpecAssessment.complexity.estimatedCounts.{field}", errors)
169
+ validate_string_array(complexity.get("reasoning"), "preSpecAssessment.complexity.reasoning", errors)
170
+ decision = assessment.get("specDepthDecision")
171
+ if not isinstance(decision, dict):
172
+ errors.append("preSpecAssessment.specDepthDecision must be an object")
173
+ else:
174
+ required_depth = decision.get("requiredDepth")
175
+ if required_depth not in VALID_COMPLEXITY_TIERS:
176
+ errors.append("preSpecAssessment.specDepthDecision.requiredDepth must be a valid complexity tier")
177
+ if required_depth == "unassessed":
178
+ warnings.append("quality: preSpecAssessment.specDepthDecision.requiredDepth is unassessed")
179
+ validate_string_array(decision.get("minimumComponentLevels"), "preSpecAssessment.specDepthDecision.minimumComponentLevels", errors)
180
+ for field in (
181
+ "needsRepetitionSystems",
182
+ "needsMaterialLocalOverrides",
183
+ "needsMultipleReviewViews",
184
+ "needsActionReadyHierarchy",
185
+ ):
186
+ if field in decision and not isinstance(decision[field], bool):
187
+ errors.append(f"preSpecAssessment.specDepthDecision.{field} must be boolean")
188
+ unknowns = assessment.get("unknownsToResolveBeforeImplementation")
189
+ validate_string_array(unknowns, "preSpecAssessment.unknownsToResolveBeforeImplementation", errors)
190
+ if isinstance(unknowns, list) and unknowns:
191
+ warnings.append("quality: preSpecAssessment has unresolved unknowns before implementation")
192
+
193
+
194
+ def validate_terminology_profile(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
195
+ profile = spec.get("terminologyProfile")
196
+ if profile is None:
197
+ warnings.append("missing terminologyProfile; descriptions may drift into vague non-3D language")
198
+ return
199
+ if not isinstance(profile, dict):
200
+ errors.append("terminologyProfile must be an object")
201
+ return
202
+ for field in TERMINOLOGY_LIST_FIELDS:
203
+ value = profile.get(field)
204
+ if value is None:
205
+ warnings.append(f"terminologyProfile.{field} is missing")
206
+ continue
207
+ if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
208
+ errors.append(f"terminologyProfile.{field} must be an array of non-empty strings")
209
+ rule = profile.get("descriptionRule")
210
+ if rule is not None and not isinstance(rule, str):
211
+ errors.append("terminologyProfile.descriptionRule must be a string")
212
+
213
+
214
+ def validate_evidence(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> set[str]:
215
+ refs: set[str] = set()
216
+ evidence = spec.get("viewEvidence", [])
217
+ if evidence is None:
218
+ return refs
219
+ if not isinstance(evidence, list):
220
+ errors.append("viewEvidence must be an array when present")
221
+ return refs
222
+ for index, item in enumerate(evidence):
223
+ if not isinstance(item, dict):
224
+ errors.append(f"viewEvidence[{index}] must be an object")
225
+ continue
226
+ evidence_id = item.get("id")
227
+ if not isinstance(evidence_id, str) or not evidence_id.strip():
228
+ errors.append(f"viewEvidence[{index}].id is required")
229
+ continue
230
+ if evidence_id in refs:
231
+ errors.append(f"duplicate viewEvidence id {evidence_id!r}")
232
+ refs.add(evidence_id)
233
+ confidence = item.get("confidence")
234
+ if confidence is not None:
235
+ validate_unit_interval(confidence, f"viewEvidence {evidence_id!r} confidence", errors)
236
+ region = item.get("imageRegion")
237
+ if region is not None:
238
+ if not isinstance(region, dict):
239
+ errors.append(f"viewEvidence {evidence_id!r} imageRegion must be an object")
240
+ else:
241
+ for key in ("x", "y", "width", "height"):
242
+ if key in region and not is_number(region[key]):
243
+ errors.append(f"viewEvidence {evidence_id!r} imageRegion.{key} must be numeric")
244
+ if not refs:
245
+ warnings.append("missing viewEvidence; local visual claims cannot be traced back to image regions")
246
+ return refs
247
+
248
+
249
+ def validate_material_scalar_or_layer(value: Any, label: str, errors: list[str]) -> None:
250
+ if value is None:
251
+ return
252
+ if is_number(value):
253
+ return
254
+ if not isinstance(value, dict):
255
+ errors.append(f"{label} must be a number or object")
256
+ return
257
+ base = value.get("base")
258
+ if base is not None and not is_number(base):
259
+ errors.append(f"{label}.base must be numeric")
260
+ variation = value.get("variation")
261
+ if variation is not None and not is_number(variation):
262
+ errors.append(f"{label}.variation must be numeric")
263
+
264
+
265
+ def validate_reference_pbr_map(value: Any, label: str, errors: list[str]) -> None:
266
+ if not isinstance(value, dict):
267
+ errors.append(f"{label} must be an object")
268
+ return
269
+ has_locator = False
270
+ for field in ("path", "url"):
271
+ item = value.get(field)
272
+ if item is not None:
273
+ if not isinstance(item, str) or not item.strip():
274
+ errors.append(f"{label}.{field} must be a non-empty string when present")
275
+ else:
276
+ has_locator = True
277
+ if not has_locator:
278
+ errors.append(f"{label} needs a path or url")
279
+ channel = value.get("channel")
280
+ if channel is not None and not isinstance(channel, str):
281
+ errors.append(f"{label}.channel must be a string")
282
+
283
+
284
+ def validate_reference_pbr(material_id: str, value: Any, errors: list[str], warnings: list[str]) -> None:
285
+ if value is None:
286
+ return
287
+ if not isinstance(value, dict):
288
+ errors.append(f"material {material_id!r} referencePbr must be an object")
289
+ return
290
+ for field in ("version", "sourceImage", "extractor", "method", "verdict", "hardLimit"):
291
+ item = value.get(field)
292
+ if item is not None and not isinstance(item, str):
293
+ errors.append(f"material {material_id!r} referencePbr.{field} must be a string")
294
+ usable = value.get("usable")
295
+ if usable is not None and not isinstance(usable, bool):
296
+ errors.append(f"material {material_id!r} referencePbr.usable must be boolean")
297
+ for field in ("confidence", "estimatedFidelity", "targetThreshold"):
298
+ item = value.get(field)
299
+ if item is not None:
300
+ validate_unit_interval(item, f"material {material_id!r} referencePbr.{field}", errors)
301
+ maps = value.get("maps")
302
+ if maps is None:
303
+ warnings.append(f"quality: material {material_id!r} referencePbr is missing maps")
304
+ return
305
+ if not isinstance(maps, dict):
306
+ errors.append(f"material {material_id!r} referencePbr.maps must be an object")
307
+ return
308
+ required = ("albedo", "roughness", "height", "normal", "ao")
309
+ for channel in required:
310
+ if channel not in maps:
311
+ warnings.append(f"quality: material {material_id!r} referencePbr.maps missing {channel}")
312
+ else:
313
+ validate_reference_pbr_map(maps[channel], f"material {material_id!r} referencePbr.maps.{channel}", errors)
314
+
315
+
316
+ def validate_materials(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> set[str]:
317
+ material_ids: set[str] = set()
318
+ for index, material in enumerate(spec.get("materials", [])):
319
+ if not isinstance(material, dict):
320
+ errors.append(f"materials[{index}] must be an object")
321
+ continue
322
+ material_id = material.get("id")
323
+ if not isinstance(material_id, str) or not material_id.strip():
324
+ errors.append(f"materials[{index}].id is required")
325
+ continue
326
+ if material_id in material_ids:
327
+ errors.append(f"duplicate material id {material_id!r}")
328
+ material_ids.add(material_id)
329
+ color = material.get("baseColor", material.get("color"))
330
+ if color is not None and not (isinstance(color, str) and color.startswith("#") and len(color) in {4, 7}):
331
+ errors.append(f"material {material_id!r} baseColor/color should be #RGB or #RRGGBB")
332
+ for field in ("shaderModel", "type"):
333
+ value = material.get(field)
334
+ if value is not None and not isinstance(value, str):
335
+ errors.append(f"material {material_id!r} {field} must be a string")
336
+ for field in ("albedo", "ambientOcclusion"):
337
+ value = material.get(field)
338
+ if value is not None and not isinstance(value, dict):
339
+ errors.append(f"material {material_id!r} {field} must be an object")
340
+ validate_material_scalar_or_layer(material.get("roughness"), f"material {material_id!r} roughness", errors)
341
+ validate_material_scalar_or_layer(material.get("metalness"), f"material {material_id!r} metalness", errors)
342
+ for field in ("normal", "bump", "displacement", "wear", "dirt"):
343
+ value = material.get(field)
344
+ if value is not None and not isinstance(value, dict):
345
+ errors.append(f"material {material_id!r} {field} must be an object")
346
+ texture_resolution = material.get("textureResolution")
347
+ if texture_resolution is not None and (
348
+ not isinstance(texture_resolution, int)
349
+ or isinstance(texture_resolution, bool)
350
+ or texture_resolution < 64
351
+ or texture_resolution > 4096
352
+ ):
353
+ errors.append(f"material {material_id!r} textureResolution must be an integer from 64 to 4096")
354
+ projection = material.get("textureProjection")
355
+ if projection is not None:
356
+ if not isinstance(projection, dict):
357
+ errors.append(f"material {material_id!r} textureProjection must be an object")
358
+ else:
359
+ mode = projection.get("mode")
360
+ if mode is not None and not isinstance(mode, str):
361
+ errors.append(f"material {material_id!r} textureProjection.mode must be a string")
362
+ repeat = projection.get("repeat")
363
+ if repeat is not None and not (
364
+ isinstance(repeat, list)
365
+ and len(repeat) == 2
366
+ and all(is_number(item) and item > 0 for item in repeat)
367
+ ):
368
+ errors.append(f"material {material_id!r} textureProjection.repeat must contain two positive numbers")
369
+ anisotropy = projection.get("anisotropy")
370
+ if anisotropy is not None and (not is_number(anisotropy) or anisotropy < 1):
371
+ errors.append(f"material {material_id!r} textureProjection.anisotropy must be >= 1")
372
+ frequency_bands = material.get("surfaceFrequencyBands")
373
+ if frequency_bands is not None:
374
+ if not isinstance(frequency_bands, list):
375
+ errors.append(f"material {material_id!r} surfaceFrequencyBands must be an array")
376
+ else:
377
+ seen_band_ids: set[str] = set()
378
+ for band_index, band in enumerate(frequency_bands):
379
+ if not isinstance(band, dict):
380
+ errors.append(
381
+ f"material {material_id!r} surfaceFrequencyBands[{band_index}] must be an object"
382
+ )
383
+ continue
384
+ band_id = band.get("id")
385
+ if not isinstance(band_id, str) or not band_id.strip():
386
+ errors.append(
387
+ f"material {material_id!r} surfaceFrequencyBands[{band_index}].id is required"
388
+ )
389
+ elif band_id in seen_band_ids:
390
+ errors.append(f"material {material_id!r} has duplicate surface band {band_id!r}")
391
+ else:
392
+ seen_band_ids.add(band_id)
393
+ for field in ("frequency", "amplitude"):
394
+ value = band.get(field)
395
+ if not is_number(value) or value <= 0:
396
+ errors.append(
397
+ f"material {material_id!r} surfaceFrequencyBands[{band_index}].{field} "
398
+ "must be a positive number"
399
+ )
400
+ local_overrides = material.get("localOverrides", [])
401
+ if local_overrides is not None and not isinstance(local_overrides, list):
402
+ errors.append(f"material {material_id!r} localOverrides must be an array")
403
+ shader_notes = material.get("shaderNotes")
404
+ if shader_notes is not None:
405
+ validate_string_array(shader_notes, f"material {material_id!r} shaderNotes", errors)
406
+ validate_reference_pbr(material_id, material.get("referencePbr"), errors, warnings)
407
+ if not material_ids:
408
+ errors.append("at least one material is required")
409
+ return material_ids
410
+
411
+
412
+ def validate_dimensions(component_id: str, dimensions: Any, errors: list[str]) -> None:
413
+ if dimensions is None:
414
+ return
415
+ if not isinstance(dimensions, dict):
416
+ errors.append(f"component {component_id!r} dimensions must be an object")
417
+ return
418
+ for field in ("width", "height", "depth", "radius", "length"):
419
+ if field in dimensions and not is_number(dimensions[field]):
420
+ errors.append(f"component {component_id!r} dimensions.{field} must be numeric")
421
+ confidence = dimensions.get("confidence")
422
+ if confidence is not None:
423
+ validate_unit_interval(confidence, f"component {component_id!r} dimensions.confidence", errors)
424
+
425
+
426
+ def validate_geometry_descriptor(component_id: str, descriptor: Any, errors: list[str]) -> None:
427
+ if descriptor is None:
428
+ return
429
+ if not isinstance(descriptor, dict):
430
+ errors.append(f"component {component_id!r} geometryDescriptor must be an object")
431
+ return
432
+ for field in ("topologyIntent", "uvStrategy", "normalStrategy"):
433
+ value = descriptor.get(field)
434
+ if value is not None and not isinstance(value, str):
435
+ errors.append(f"component {component_id!r} geometryDescriptor.{field} must be a string")
436
+ edge = descriptor.get("edgeTreatment")
437
+ if edge is not None:
438
+ if not isinstance(edge, dict):
439
+ errors.append(f"component {component_id!r} geometryDescriptor.edgeTreatment must be an object")
440
+ else:
441
+ if "bevelRadius" in edge and not is_number(edge["bevelRadius"]):
442
+ errors.append(f"component {component_id!r} edgeTreatment.bevelRadius must be numeric")
443
+ if "segments" in edge and not isinstance(edge["segments"], int):
444
+ errors.append(f"component {component_id!r} edgeTreatment.segments must be an integer")
445
+ stack = descriptor.get("deformationStack")
446
+ if stack is not None and not isinstance(stack, list):
447
+ errors.append(f"component {component_id!r} geometryDescriptor.deformationStack must be an array")
448
+
449
+
450
+ def validate_bool_object(value: Any, label: str, errors: list[str]) -> None:
451
+ if value is None:
452
+ return
453
+ if not isinstance(value, dict):
454
+ errors.append(f"{label} must be an object")
455
+ return
456
+ for key, item in value.items():
457
+ if not isinstance(key, str):
458
+ errors.append(f"{label} keys must be strings")
459
+ if not isinstance(item, bool):
460
+ errors.append(f"{label}.{key} must be boolean")
461
+
462
+
463
+ def validate_action_profile(component_id: str, profile: Any, errors: list[str], warnings: list[str]) -> None:
464
+ if profile is None:
465
+ warnings.append(f"component {component_id!r} is missing actionProfile; future animation/destruction may require refactor")
466
+ return
467
+ if not isinstance(profile, dict):
468
+ errors.append(f"component {component_id!r} actionProfile must be an object")
469
+ return
470
+ role = profile.get("animationRole")
471
+ if role is not None and not isinstance(role, str):
472
+ errors.append(f"component {component_id!r} actionProfile.animationRole must be a string")
473
+ pivot = profile.get("pivot")
474
+ if pivot is not None:
475
+ if not isinstance(pivot, dict):
476
+ errors.append(f"component {component_id!r} actionProfile.pivot must be an object")
477
+ else:
478
+ mode = pivot.get("mode")
479
+ if mode is not None and not isinstance(mode, str):
480
+ errors.append(f"component {component_id!r} actionProfile.pivot.mode must be a string")
481
+ for field in ("localPosition", "axis"):
482
+ if field in pivot and not as_number_list(pivot[field], 3):
483
+ errors.append(f"component {component_id!r} actionProfile.pivot.{field} must be [number, number, number]")
484
+ confidence = pivot.get("confidence")
485
+ if confidence is not None:
486
+ validate_unit_interval(confidence, f"component {component_id!r} actionProfile.pivot.confidence", errors)
487
+ validate_bool_object(profile.get("transformChannels"), f"component {component_id!r} actionProfile.transformChannels", errors)
488
+ sockets = profile.get("sockets")
489
+ if sockets is not None:
490
+ if not isinstance(sockets, list):
491
+ errors.append(f"component {component_id!r} actionProfile.sockets must be an array")
492
+ else:
493
+ for socket_index, socket in enumerate(sockets):
494
+ if not isinstance(socket, dict):
495
+ errors.append(f"component {component_id!r} actionProfile.sockets[{socket_index}] must be an object")
496
+ continue
497
+ socket_id = socket.get("id")
498
+ if socket_id is not None and not isinstance(socket_id, str):
499
+ errors.append(f"component {component_id!r} actionProfile.sockets[{socket_index}].id must be a string")
500
+ for field in ("localPosition", "position", "localRotation", "rotation"):
501
+ if field in socket and not as_number_list(socket[field], 3):
502
+ errors.append(
503
+ f"component {component_id!r} actionProfile.sockets[{socket_index}].{field} must be [number, number, number]"
504
+ )
505
+ collider = profile.get("collider")
506
+ if collider is not None:
507
+ if not isinstance(collider, dict):
508
+ errors.append(f"component {component_id!r} actionProfile.collider must be an object")
509
+ else:
510
+ collider_type = collider.get("type")
511
+ if collider_type is not None and not isinstance(collider_type, str):
512
+ errors.append(f"component {component_id!r} actionProfile.collider.type must be a string")
513
+ for field in ("offset", "scale"):
514
+ if field in collider and not as_number_list(collider[field], 3):
515
+ errors.append(f"component {component_id!r} actionProfile.collider.{field} must be [number, number, number]")
516
+ if "isTrigger" in collider and not isinstance(collider["isTrigger"], bool):
517
+ errors.append(f"component {component_id!r} actionProfile.collider.isTrigger must be boolean")
518
+ constraints = profile.get("constraints")
519
+ if constraints is not None and not isinstance(constraints, list):
520
+ errors.append(f"component {component_id!r} actionProfile.constraints must be an array")
521
+ destruction = profile.get("destruction")
522
+ if destruction is not None:
523
+ if not isinstance(destruction, dict):
524
+ errors.append(f"component {component_id!r} actionProfile.destruction must be an object")
525
+ else:
526
+ if "breakable" in destruction and not isinstance(destruction["breakable"], bool):
527
+ errors.append(f"component {component_id!r} actionProfile.destruction.breakable must be boolean")
528
+ if "breakImpulse" in destruction and not is_number(destruction["breakImpulse"]):
529
+ errors.append(f"component {component_id!r} actionProfile.destruction.breakImpulse must be numeric")
530
+ for field in ("fractureGroup", "debrisMaterial"):
531
+ value = destruction.get(field)
532
+ if value is not None and not isinstance(value, str):
533
+ errors.append(f"component {component_id!r} actionProfile.destruction.{field} must be a string")
534
+ for field in ("seamRefs", "detachableFragments"):
535
+ validate_string_array(destruction.get(field), f"component {component_id!r} actionProfile.destruction.{field}", errors)
536
+
537
+
538
+ def component_requires_attachment(component: dict[str, Any]) -> bool:
539
+ if not component.get("parent"):
540
+ return False
541
+ role = str(component.get("role") or "").lower()
542
+ name = str(component.get("name") or component.get("id") or "").lower()
543
+ primitive = str(component.get("primitive") or "").lower()
544
+ profile = component.get("actionProfile") if isinstance(component.get("actionProfile"), dict) else {}
545
+ animation_role = str(profile.get("animationRole") or "").lower()
546
+ tokens = {role, animation_role} | set(re.findall(r"[a-z0-9]+", name))
547
+ return bool(tokens & ATTACHMENT_ROLES) or primitive in ATTACHMENT_PRIMITIVES
548
+
549
+
550
+ def has_attachment_number(value: Any) -> bool:
551
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
552
+
553
+
554
+ def attachment_is_complete(attachment: dict[str, Any]) -> bool:
555
+ has_endpoint = as_number_list(attachment.get("localStart"), 3) and as_number_list(attachment.get("localEnd"), 3)
556
+ has_socket = isinstance(attachment.get("parentSocket"), str) and bool(attachment["parentSocket"].strip())
557
+ has_parent_id = isinstance(attachment.get("parentId"), str) and bool(attachment["parentId"].strip())
558
+ has_contact = isinstance(attachment.get("contactType"), str) and bool(attachment["contactType"].strip())
559
+ has_overlap = (
560
+ has_attachment_number(attachment.get("embedDepth"))
561
+ and float(attachment["embedDepth"]) > 0
562
+ ) or (
563
+ has_attachment_number(attachment.get("overlap"))
564
+ and float(attachment["overlap"]) > 0
565
+ )
566
+ has_tolerance = has_attachment_number(attachment.get("gapTolerance"))
567
+ return has_endpoint and (has_socket or has_parent_id) and has_contact and has_overlap and has_tolerance
568
+
569
+
570
+ def validate_attachment(
571
+ component_id: str,
572
+ parent: str | None,
573
+ attachment: Any,
574
+ required: bool,
575
+ errors: list[str],
576
+ warnings: list[str],
577
+ ) -> None:
578
+ if attachment is None:
579
+ if required:
580
+ warnings.append(
581
+ f"quality: component {component_id!r} requires attachment.parentSocket, localStart/localEnd, "
582
+ "contactType, embedDepth or overlap, and gapTolerance"
583
+ )
584
+ return
585
+ if not isinstance(attachment, dict):
586
+ errors.append(f"component {component_id!r} attachment must be an object")
587
+ return
588
+ for field in ("parentId", "parentSocket", "contactType"):
589
+ value = attachment.get(field)
590
+ if value is not None and not isinstance(value, str):
591
+ errors.append(f"component {component_id!r} attachment.{field} must be a string")
592
+ if parent and isinstance(attachment.get("parentId"), str) and attachment["parentId"] != parent:
593
+ warnings.append(
594
+ f"quality: component {component_id!r} attachment.parentId {attachment['parentId']!r} "
595
+ f"does not match parent {parent!r}"
596
+ )
597
+ for field in ("localStart", "localEnd", "contactNormal"):
598
+ value = attachment.get(field)
599
+ if value is not None and not as_number_list(value, 3):
600
+ errors.append(f"component {component_id!r} attachment.{field} must be [number, number, number]")
601
+ for field in ("embedDepth", "overlap", "gapTolerance", "baseRadius", "endRadius"):
602
+ value = attachment.get(field)
603
+ if value is not None and (not has_attachment_number(value) or float(value) < 0):
604
+ errors.append(f"component {component_id!r} attachment.{field} must be a non-negative number")
605
+ validate_string_array(attachment.get("evidenceRefs"), f"component {component_id!r} attachment.evidenceRefs", errors)
606
+ if required and not attachment_is_complete(attachment):
607
+ warnings.append(
608
+ f"quality: component {component_id!r} requires attachment.parentSocket, localStart/localEnd, "
609
+ "contactType, embedDepth or overlap, and gapTolerance"
610
+ )
611
+
612
+
613
+ def validate_string_array(value: Any, label: str, errors: list[str]) -> None:
614
+ if value is None:
615
+ return
616
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
617
+ errors.append(f"{label} must be an array of strings")
618
+
619
+
620
+ def validate_components(
621
+ spec: dict[str, Any],
622
+ material_ids: set[str],
623
+ evidence_ids: set[str],
624
+ errors: list[str],
625
+ warnings: list[str],
626
+ ) -> None:
627
+ components = spec.get("componentTree", [])
628
+ ids: set[str] = set()
629
+ parent_refs: list[tuple[str, str]] = []
630
+ for index, component in enumerate(components):
631
+ if not isinstance(component, dict):
632
+ errors.append(f"componentTree[{index}] must be an object")
633
+ continue
634
+ component_id = component.get("id")
635
+ if not isinstance(component_id, str) or not component_id.strip():
636
+ errors.append(f"componentTree[{index}].id is required")
637
+ continue
638
+ if component_id in ids:
639
+ errors.append(f"duplicate component id {component_id!r}")
640
+ ids.add(component_id)
641
+ primitive = component.get("primitive")
642
+ if primitive not in VALID_PRIMITIVES:
643
+ errors.append(
644
+ f"component {component_id!r} primitive must be one of: {', '.join(sorted(VALID_PRIMITIVES))}"
645
+ )
646
+ level = component.get("level")
647
+ if level is not None and level not in VALID_COMPONENT_LEVELS:
648
+ errors.append(f"component {component_id!r} level must be macro, meso, or micro")
649
+ for field in ("importance", "confidence"):
650
+ value = component.get(field)
651
+ if value is not None:
652
+ validate_unit_interval(value, f"component {component_id!r} {field}", errors)
653
+ parent = component.get("parent")
654
+ if parent:
655
+ if not isinstance(parent, str):
656
+ errors.append(f"component {component_id!r} parent must be a string or null")
657
+ else:
658
+ parent_refs.append((component_id, parent))
659
+ material = component.get("material")
660
+ if material and material not in material_ids:
661
+ errors.append(f"component {component_id!r} references unknown material {material!r}")
662
+ validate_geometry_descriptor(component_id, component.get("geometryDescriptor"), errors)
663
+ material_layers = component.get("materialLayers")
664
+ if material_layers is not None:
665
+ validate_string_array(material_layers, f"component {component_id!r} materialLayers", errors)
666
+ if isinstance(material_layers, list):
667
+ for material_layer in material_layers:
668
+ if material_layer not in material_ids:
669
+ errors.append(
670
+ f"component {component_id!r} materialLayers references unknown material {material_layer!r}"
671
+ )
672
+ validate_dimensions(component_id, component.get("dimensions"), errors)
673
+ transform = component.get("transform", {})
674
+ if transform is not None and not isinstance(transform, dict):
675
+ errors.append(f"component {component_id!r} transform must be an object")
676
+ elif isinstance(transform, dict):
677
+ for field in ("position", "rotation", "scale"):
678
+ if field in transform and not as_number_list(transform[field], 3):
679
+ errors.append(f"component {component_id!r} transform.{field} must be [number, number, number]")
680
+ validate_action_profile(component_id, component.get("actionProfile"), errors, warnings)
681
+ validate_attachment(
682
+ component_id,
683
+ parent if isinstance(parent, str) else None,
684
+ component.get("attachment"),
685
+ component_requires_attachment(component),
686
+ errors,
687
+ warnings,
688
+ )
689
+ for field in ("deformations", "joints", "seams", "localFeatures"):
690
+ value = component.get(field)
691
+ if value is not None and not isinstance(value, list):
692
+ errors.append(f"component {component_id!r} {field} must be an array")
693
+ surface = component.get("surfaceDetail")
694
+ if surface is not None:
695
+ if not isinstance(surface, dict):
696
+ errors.append(f"component {component_id!r} surfaceDetail must be an object")
697
+ else:
698
+ for field in ("macroRoughness", "microRoughness", "bumpAmplitude"):
699
+ if field in surface and not is_number(surface[field]):
700
+ errors.append(f"component {component_id!r} surfaceDetail.{field} must be numeric")
701
+ evidence_refs = component.get("evidenceRefs")
702
+ if evidence_refs is not None:
703
+ validate_string_array(evidence_refs, f"component {component_id!r} evidenceRefs", errors)
704
+ if isinstance(evidence_refs, list):
705
+ for evidence_ref in evidence_refs:
706
+ if evidence_ids and evidence_ref not in evidence_ids:
707
+ errors.append(f"component {component_id!r} references missing evidence {evidence_ref!r}")
708
+ for component_id, parent in parent_refs:
709
+ if parent not in ids:
710
+ errors.append(f"component {component_id!r} references missing parent {parent!r}")
711
+ if not ids:
712
+ errors.append("at least one component is required")
713
+ if len(ids) == 1:
714
+ warnings.append("only one component found; this is likely still blockout quality")
715
+
716
+
717
+ def validate_quality_targets(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
718
+ targets = spec.get("qualityTargets")
719
+ if targets is None:
720
+ warnings.append("missing qualityTargets; self-correction loop has no explicit fidelity bar")
721
+ return
722
+ if not isinstance(targets, dict):
723
+ errors.append("qualityTargets must be an object")
724
+ return
725
+ target_fidelity = targets.get("targetFidelity")
726
+ if target_fidelity is not None:
727
+ validate_unit_interval(target_fidelity, "qualityTargets.targetFidelity", errors)
728
+ for field in ("mustMatch", "niceToHave", "reviewViewpoints"):
729
+ validate_string_array(targets.get(field), f"qualityTargets.{field}", errors)
730
+
731
+
732
+ def validate_quality_contract(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
733
+ contract = spec.get("qualityContract")
734
+ if contract is None:
735
+ warnings.append("quality: missing qualityContract; no explicit definition of done prevents shallow specs")
736
+ return
737
+ if not isinstance(contract, dict):
738
+ errors.append("qualityContract must be an object")
739
+ return
740
+ quality_bar = contract.get("qualityBar")
741
+ if quality_bar is not None and not isinstance(quality_bar, str):
742
+ errors.append("qualityContract.qualityBar must be a string")
743
+ if quality_bar in {None, "", "unassessed"}:
744
+ warnings.append("quality: qualityContract.qualityBar is unassessed")
745
+ validate_string_array(contract.get("definitionOfDone"), "qualityContract.definitionOfDone", errors)
746
+ if isinstance(contract.get("definitionOfDone"), list) and not contract["definitionOfDone"]:
747
+ warnings.append("quality: qualityContract.definitionOfDone is empty")
748
+ minimums = contract.get("minimumSpecDepth")
749
+ if not isinstance(minimums, dict):
750
+ errors.append("qualityContract.minimumSpecDepth must be an object")
751
+ else:
752
+ for field in (
753
+ "macroComponents",
754
+ "mesoComponents",
755
+ "microFeatureGroups",
756
+ "materialLayers",
757
+ "repetitionSystems",
758
+ "reviewViewpoints",
759
+ ):
760
+ if field in minimums:
761
+ validate_nonnegative_int(minimums[field], f"qualityContract.minimumSpecDepth.{field}", errors)
762
+ feature_groups = contract.get("featureGroups")
763
+ if not isinstance(feature_groups, list):
764
+ errors.append("qualityContract.featureGroups must be an array")
765
+ else:
766
+ if len(feature_groups) < 3:
767
+ warnings.append("quality: qualityContract.featureGroups has fewer than 3 groups; spec may miss important visual layers")
768
+ for index, group in enumerate(feature_groups):
769
+ if not isinstance(group, dict):
770
+ errors.append(f"qualityContract.featureGroups[{index}] must be an object")
771
+ continue
772
+ for field in ("id", "name"):
773
+ value = group.get(field)
774
+ if not isinstance(value, str) or not value.strip():
775
+ errors.append(f"qualityContract.featureGroups[{index}].{field} is required")
776
+ if "required" in group and not isinstance(group["required"], bool):
777
+ errors.append(f"qualityContract.featureGroups[{index}].required must be boolean")
778
+ validate_string_array(group.get("qualityCriteria"), f"qualityContract.featureGroups[{index}].qualityCriteria", errors)
779
+ validate_string_array(group.get("evidenceRefs"), f"qualityContract.featureGroups[{index}].evidenceRefs", errors)
780
+ validate_string_array(group.get("failureModes"), f"qualityContract.featureGroups[{index}].failureModes", errors)
781
+ if group.get("required") is True and not group.get("qualityCriteria"):
782
+ warnings.append(f"quality: required feature group {group.get('id', index)!r} has no qualityCriteria")
783
+ for field in ("visualDeltaChecks", "antiShallowSpecRules"):
784
+ validate_string_array(contract.get(field), f"qualityContract.{field}", errors)
785
+ if isinstance(contract.get(field), list) and not contract[field]:
786
+ warnings.append(f"quality: qualityContract.{field} is empty")
787
+
788
+
789
+ def validate_quality_depth(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
790
+ contract = spec.get("qualityContract")
791
+ if not isinstance(contract, dict) or not isinstance(contract.get("minimumSpecDepth"), dict):
792
+ return
793
+ minimums = contract["minimumSpecDepth"]
794
+ components = [item for item in spec.get("componentTree", []) if isinstance(item, dict)]
795
+ level_counts = {
796
+ "macroComponents": sum(1 for item in components if item.get("level") == "macro"),
797
+ "mesoComponents": sum(1 for item in components if item.get("level") == "meso"),
798
+ "microFeatureGroups": sum(
799
+ len(item.get("localFeatures", []))
800
+ for item in components
801
+ if isinstance(item.get("localFeatures", []), list)
802
+ ),
803
+ "materialLayers": len([item for item in spec.get("materials", []) if isinstance(item, dict)]),
804
+ "repetitionSystems": len([item for item in spec.get("repetitionSystems", []) if isinstance(item, dict)]),
805
+ "reviewViewpoints": len(spec.get("qualityTargets", {}).get("reviewViewpoints", []))
806
+ if isinstance(spec.get("qualityTargets"), dict)
807
+ and isinstance(spec.get("qualityTargets", {}).get("reviewViewpoints"), list)
808
+ else 0,
809
+ }
810
+ for field, actual in level_counts.items():
811
+ required = minimums.get(field)
812
+ if isinstance(required, int) and actual < required:
813
+ warnings.append(f"quality: {field} below qualityContract minimum ({actual} < {required})")
814
+
815
+
816
+ def validate_action_readiness(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
817
+ readiness = spec.get("actionReadiness")
818
+ if readiness is None:
819
+ warnings.append("missing actionReadiness; generated model may not be ready for animation/transformation/destruction")
820
+ return
821
+ if not isinstance(readiness, dict):
822
+ errors.append("actionReadiness must be an object")
823
+ return
824
+ for field in ("contract", "defaultRigType", "rootMotionNode"):
825
+ value = readiness.get(field)
826
+ if value is not None and not isinstance(value, str):
827
+ errors.append(f"actionReadiness.{field} must be a string")
828
+ for field in ("requiredComponentFields", "transformChannels", "authoringRules"):
829
+ validate_string_array(readiness.get(field), f"actionReadiness.{field}", errors)
830
+ policy = readiness.get("destructionPolicy")
831
+ if policy is not None and not isinstance(policy, dict):
832
+ errors.append("actionReadiness.destructionPolicy must be an object")
833
+
834
+
835
+ def validate_self_correct_loop(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
836
+ loop = spec.get("selfCorrectLoop")
837
+ if loop is None:
838
+ warnings.append("missing selfCorrectLoop; construction may not review/refine after each pass")
839
+ return
840
+ if not isinstance(loop, dict):
841
+ errors.append("selfCorrectLoop must be an object")
842
+ return
843
+ enabled = loop.get("enabled")
844
+ if enabled is not None and not isinstance(enabled, bool):
845
+ errors.append("selfCorrectLoop.enabled must be boolean")
846
+ for field in ("reviewAfterPasses", "allowedActions", "specRefineTriggers", "codeRefineTriggers", "stopCriteria"):
847
+ validate_string_array(loop.get(field), f"selfCorrectLoop.{field}", errors)
848
+ actions = loop.get("allowedActions", [])
849
+ if isinstance(actions, list):
850
+ for action in actions:
851
+ if action not in VALID_REVIEW_ACTIONS:
852
+ errors.append(f"selfCorrectLoop.allowedActions contains invalid action {action!r}")
853
+ visual_acceptance = loop.get("visualAcceptance")
854
+ if visual_acceptance is None:
855
+ warnings.append("quality: selfCorrectLoop.visualAcceptance is missing; AI vision cannot enforce visual fidelity")
856
+ elif not isinstance(visual_acceptance, dict):
857
+ errors.append("selfCorrectLoop.visualAcceptance must be an object")
858
+ else:
859
+ reviewer = visual_acceptance.get("reviewer")
860
+ if reviewer is not None and not isinstance(reviewer, str):
861
+ errors.append("selfCorrectLoop.visualAcceptance.reviewer must be a string")
862
+ threshold = visual_acceptance.get("threshold")
863
+ if threshold is None:
864
+ warnings.append("quality: selfCorrectLoop.visualAcceptance.threshold is missing")
865
+ else:
866
+ validate_unit_interval(threshold, "selfCorrectLoop.visualAcceptance.threshold", errors)
867
+ for field in (
868
+ "comparisonArtifactRequired",
869
+ "layerScoresRequired",
870
+ "codePixelDiffIsAcceptanceAuthority",
871
+ ):
872
+ value = visual_acceptance.get(field)
873
+ if value is not None and not isinstance(value, bool):
874
+ errors.append(f"selfCorrectLoop.visualAcceptance.{field} must be boolean")
875
+ scoring_rule = visual_acceptance.get("scoringRule")
876
+ if scoring_rule is not None and not isinstance(scoring_rule, str):
877
+ errors.append("selfCorrectLoop.visualAcceptance.scoringRule must be a string")
878
+ validate_string_array(
879
+ visual_acceptance.get("requiredLayerScores"),
880
+ "selfCorrectLoop.visualAcceptance.requiredLayerScores",
881
+ errors,
882
+ )
883
+ feature_policy = visual_acceptance.get("featureReviewPolicy")
884
+ if feature_policy is None:
885
+ warnings.append("quality: visualAcceptance.featureReviewPolicy is missing")
886
+ elif not isinstance(feature_policy, dict):
887
+ errors.append("selfCorrectLoop.visualAcceptance.featureReviewPolicy must be an object")
888
+ else:
889
+ for field in (
890
+ "enabled",
891
+ "adaptiveEscalation",
892
+ "singleImagePairOnly",
893
+ ):
894
+ value = feature_policy.get(field)
895
+ if value is not None and not isinstance(value, bool):
896
+ errors.append(
897
+ f"selfCorrectLoop.visualAcceptance.featureReviewPolicy.{field} must be boolean"
898
+ )
899
+ for field in ("maxCriticalFeaturesPerPass", "maxImportantFeaturesPerPass"):
900
+ value = feature_policy.get(field)
901
+ if value is not None:
902
+ validate_nonnegative_int(
903
+ value,
904
+ f"selfCorrectLoop.visualAcceptance.featureReviewPolicy.{field}",
905
+ errors,
906
+ )
907
+ for field in ("criticalDefaultThreshold", "importantAverageThreshold"):
908
+ value = feature_policy.get(field)
909
+ if value is not None:
910
+ validate_unit_interval(
911
+ value,
912
+ f"selfCorrectLoop.visualAcceptance.featureReviewPolicy.{field}",
913
+ errors,
914
+ )
915
+ for field in ("reviewUnit", "selectionRule"):
916
+ value = feature_policy.get(field)
917
+ if value is not None and not isinstance(value, str):
918
+ errors.append(
919
+ f"selfCorrectLoop.visualAcceptance.featureReviewPolicy.{field} must be a string"
920
+ )
921
+ policy = loop.get("screenshotPolicy")
922
+ if policy is None:
923
+ warnings.append("selfCorrectLoop.screenshotPolicy is missing; visual review may drift without screenshots")
924
+ elif not isinstance(policy, dict):
925
+ errors.append("selfCorrectLoop.screenshotPolicy must be an object")
926
+ else:
927
+ validate_string_array(policy.get("requiredForPasses"), "selfCorrectLoop.screenshotPolicy.requiredForPasses", errors)
928
+ for field in (
929
+ "preferredCapture",
930
+ "fallbackCapture",
931
+ "minimumEvidence",
932
+ "reviewPairRule",
933
+ "acceptanceAuthority",
934
+ ):
935
+ value = policy.get(field)
936
+ if value is not None and not isinstance(value, str):
937
+ errors.append(f"selfCorrectLoop.screenshotPolicy.{field} must be a string")
938
+
939
+
940
+ def validate_visual_evidence_item(item: Any, label: str, errors: list[str]) -> None:
941
+ if not isinstance(item, dict):
942
+ errors.append(f"{label} must be an object")
943
+ return
944
+ for field in (
945
+ "passId",
946
+ "referenceScreenshot",
947
+ "renderScreenshot",
948
+ "comparisonImage",
949
+ "cameraView",
950
+ "notes",
951
+ "aiVisionNotes",
952
+ ):
953
+ value = item.get(field)
954
+ if value is not None and not isinstance(value, str):
955
+ errors.append(f"{label}.{field} must be a string")
956
+ fidelity = item.get("estimatedFidelity")
957
+ if fidelity is not None:
958
+ validate_unit_interval(fidelity, f"{label}.estimatedFidelity", errors)
959
+ score = item.get("aiVisionScore")
960
+ if score is not None:
961
+ validate_unit_interval(score, f"{label}.aiVisionScore", errors)
962
+ threshold = item.get("visualAcceptanceThreshold")
963
+ if threshold is not None:
964
+ validate_unit_interval(threshold, f"{label}.visualAcceptanceThreshold", errors)
965
+ layer_scores = item.get("layerScores")
966
+ if layer_scores is not None:
967
+ if not isinstance(layer_scores, dict):
968
+ errors.append(f"{label}.layerScores must be an object")
969
+ else:
970
+ for key, value in layer_scores.items():
971
+ if not isinstance(key, str):
972
+ errors.append(f"{label}.layerScores keys must be strings")
973
+ if not is_number(value) or value < 0 or value > 1:
974
+ errors.append(f"{label}.layerScores.{key} must be a number from 0 to 1")
975
+
976
+
977
+ def validate_feature_review_targets(
978
+ spec: dict[str, Any],
979
+ errors: list[str],
980
+ warnings: list[str],
981
+ ) -> None:
982
+ targets = spec.get("featureReviewTargets")
983
+ policy = feature_review_policy(spec)
984
+ if targets is None:
985
+ if policy.get("enabled") is True:
986
+ errors.append("featureReviewTargets must be an array when feature review is enabled")
987
+ else:
988
+ warnings.append("quality: featureReviewTargets is missing; feature-level visual gating is disabled")
989
+ return
990
+ if not isinstance(targets, list):
991
+ errors.append("featureReviewTargets must be an array")
992
+ return
993
+ if not targets:
994
+ warnings.append("quality: featureReviewTargets is empty; component-level visual gaps can hide in the overall score")
995
+ return
996
+ ids: set[str] = set()
997
+ critical_by_pass: dict[str, int] = {}
998
+ important_by_pass: dict[str, int] = {}
999
+ for index, target in enumerate(targets):
1000
+ label = f"featureReviewTargets[{index}]"
1001
+ if not isinstance(target, dict):
1002
+ errors.append(f"{label} must be an object")
1003
+ continue
1004
+ target_id = target.get("id")
1005
+ if not isinstance(target_id, str) or not target_id.strip():
1006
+ errors.append(f"{label}.id is required")
1007
+ elif target_id in ids:
1008
+ errors.append(f"duplicate feature review target id {target_id!r}")
1009
+ else:
1010
+ ids.add(target_id)
1011
+ if not isinstance(target.get("name"), str) or not target["name"].strip():
1012
+ errors.append(f"{label}.name is required")
1013
+ tier = target.get("tier")
1014
+ if tier not in {"critical", "important", "detail"}:
1015
+ errors.append(f"{label}.tier must be critical, important, or detail")
1016
+ validate_string_array(target.get("passIds"), f"{label}.passIds", errors)
1017
+ validate_string_array(target.get("componentRefs"), f"{label}.componentRefs", errors)
1018
+ validate_string_array(target.get("evidenceRefs"), f"{label}.evidenceRefs", errors)
1019
+ minimum = target.get("minimumScore")
1020
+ if minimum is not None:
1021
+ validate_unit_interval(minimum, f"{label}.minimumScore", errors)
1022
+ for field in ("mustPass",):
1023
+ value = target.get(field)
1024
+ if value is not None and not isinstance(value, bool):
1025
+ errors.append(f"{label}.{field} must be boolean")
1026
+ if tier == "critical" or target.get("mustPass") is True:
1027
+ pass_ids = target.get("passIds", [])
1028
+ if isinstance(pass_ids, list):
1029
+ for pass_id in pass_ids:
1030
+ if isinstance(pass_id, str):
1031
+ critical_by_pass[pass_id] = critical_by_pass.get(pass_id, 0) + 1
1032
+ elif tier == "important":
1033
+ pass_ids = target.get("passIds", [])
1034
+ if isinstance(pass_ids, list):
1035
+ for pass_id in pass_ids:
1036
+ if isinstance(pass_id, str):
1037
+ important_by_pass[pass_id] = important_by_pass.get(pass_id, 0) + 1
1038
+ maximum = policy.get("maxCriticalFeaturesPerPass", 5)
1039
+ if is_number(maximum):
1040
+ for pass_id, count in critical_by_pass.items():
1041
+ if count > int(maximum):
1042
+ errors.append(
1043
+ f"pass {pass_id!r} has {count} critical feature targets; "
1044
+ f"maximum is {int(maximum)}"
1045
+ )
1046
+ important_maximum = policy.get("maxImportantFeaturesPerPass", 3)
1047
+ if is_number(important_maximum):
1048
+ for pass_id, count in important_by_pass.items():
1049
+ if count > int(important_maximum):
1050
+ errors.append(
1051
+ f"pass {pass_id!r} has {count} important feature targets; "
1052
+ f"maximum is {int(important_maximum)}"
1053
+ )
1054
+ assessment = spec.get("preSpecAssessment")
1055
+ complexity = (
1056
+ assessment.get("complexity", {}).get("tier")
1057
+ if isinstance(assessment, dict) and isinstance(assessment.get("complexity"), dict)
1058
+ else None
1059
+ )
1060
+ starter_ids = {
1061
+ "overall-silhouette",
1062
+ "primary-structure",
1063
+ "reference-material-system",
1064
+ }
1065
+ if complexity in {"moderate", "complex", "ultra-complex"} and ids.issubset(starter_ids):
1066
+ warnings.append(
1067
+ "quality: replace generic starter featureReviewTargets with object-specific "
1068
+ "identity-defining semantic systems before strict validation"
1069
+ )
1070
+
1071
+
1072
+ def validate_feature_reviews(
1073
+ entry: dict[str, Any],
1074
+ label: str,
1075
+ errors: list[str],
1076
+ ) -> None:
1077
+ reviews = entry.get("featureReviews")
1078
+ if reviews is None:
1079
+ return
1080
+ if not isinstance(reviews, list):
1081
+ errors.append(f"{label}.featureReviews must be an array")
1082
+ return
1083
+ ids: set[str] = set()
1084
+ for index, review in enumerate(reviews):
1085
+ item_label = f"{label}.featureReviews[{index}]"
1086
+ if not isinstance(review, dict):
1087
+ errors.append(f"{item_label} must be an object")
1088
+ continue
1089
+ feature_id = review.get("id")
1090
+ if not isinstance(feature_id, str) or not feature_id.strip():
1091
+ errors.append(f"{item_label}.id is required")
1092
+ elif feature_id in ids:
1093
+ errors.append(f"{label}.featureReviews has duplicate id {feature_id!r}")
1094
+ else:
1095
+ ids.add(feature_id)
1096
+ score = review.get("score")
1097
+ if score is not None:
1098
+ validate_unit_interval(score, f"{item_label}.score", errors)
1099
+ for field in ("notes",):
1100
+ value = review.get(field)
1101
+ if value is not None and not isinstance(value, str):
1102
+ errors.append(f"{item_label}.{field} must be a string")
1103
+ visible = review.get("visible")
1104
+ if visible is not None and not isinstance(visible, bool):
1105
+ errors.append(f"{item_label}.visible must be boolean")
1106
+
1107
+
1108
+ def validate_review_history(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
1109
+ history = spec.get("reviewHistory", [])
1110
+ if history is None:
1111
+ return
1112
+ if not isinstance(history, list):
1113
+ errors.append("reviewHistory must be an array")
1114
+ return
1115
+ for index, entry in enumerate(history):
1116
+ if not isinstance(entry, dict):
1117
+ errors.append(f"reviewHistory[{index}] must be an object")
1118
+ continue
1119
+ action = entry.get("action")
1120
+ if action is not None and action not in VALID_REVIEW_ACTIONS:
1121
+ errors.append(f"reviewHistory[{index}].action is invalid")
1122
+ fidelity = entry.get("estimatedFidelity")
1123
+ if fidelity is not None:
1124
+ validate_unit_interval(fidelity, f"reviewHistory[{index}].estimatedFidelity", errors)
1125
+ for field in ("matched", "mismatches", "specFixes", "codeFixes", "evidence"):
1126
+ validate_string_array(entry.get(field), f"reviewHistory[{index}].{field}", errors)
1127
+ visual = entry.get("visualEvidence")
1128
+ if visual is not None:
1129
+ validate_visual_evidence_item(visual, f"reviewHistory[{index}].visualEvidence", errors)
1130
+ validate_feature_reviews(entry, f"reviewHistory[{index}]", errors)
1131
+ pass_id = entry.get("passId")
1132
+ if (
1133
+ pass_id in VISUAL_PASS_IDS
1134
+ and action == "continue"
1135
+ and not (isinstance(visual, dict) and visual.get("renderScreenshot"))
1136
+ ):
1137
+ warnings.append(
1138
+ f"reviewHistory[{index}] continues visual pass {pass_id!r} without a render screenshot"
1139
+ )
1140
+ if pass_id in VISUAL_PASS_IDS and action == "continue":
1141
+ if not isinstance(visual, dict) or not visual.get("comparisonImage"):
1142
+ warnings.append(
1143
+ f"quality: reviewHistory[{index}] continues visual pass {pass_id!r} without an AI vision comparison image"
1144
+ )
1145
+ score = entry.get("aiVisionScore")
1146
+ threshold = entry.get("visualAcceptanceThreshold", 0.7)
1147
+ if not is_number(score):
1148
+ warnings.append(
1149
+ f"quality: reviewHistory[{index}] continues visual pass {pass_id!r} without aiVisionScore"
1150
+ )
1151
+ elif is_number(threshold) and float(score) < float(threshold):
1152
+ warnings.append(
1153
+ f"quality: reviewHistory[{index}] aiVisionScore {score} is below threshold {threshold}"
1154
+ )
1155
+ loop = spec.get("selfCorrectLoop")
1156
+ acceptance = loop.get("visualAcceptance", {}) if isinstance(loop, dict) else {}
1157
+ if isinstance(acceptance, dict) and acceptance.get("layerScoresRequired") is True:
1158
+ layer_scores = entry.get("layerScores")
1159
+ if not isinstance(layer_scores, dict) or not layer_scores:
1160
+ warnings.append(
1161
+ f"quality: reviewHistory[{index}] continues visual pass {pass_id!r} without layerScores"
1162
+ )
1163
+ else:
1164
+ required_layers = acceptance.get("requiredLayerScores", [])
1165
+ if isinstance(required_layers, list):
1166
+ missing_layers = [
1167
+ layer
1168
+ for layer in required_layers
1169
+ if isinstance(layer, str) and layer not in layer_scores
1170
+ ]
1171
+ if missing_layers:
1172
+ warnings.append(
1173
+ f"quality: reviewHistory[{index}] layerScores missing: "
1174
+ + ", ".join(missing_layers)
1175
+ )
1176
+ failures = feature_gate_failures(spec, entry, str(pass_id))
1177
+ for failure in failures:
1178
+ warnings.append(
1179
+ f"quality: reviewHistory[{index}] feature gate failed: {failure}"
1180
+ )
1181
+
1182
+
1183
+ def validate_visual_evidence_history(spec: dict[str, Any], errors: list[str]) -> None:
1184
+ visual_history = spec.get("visualEvidence", [])
1185
+ if visual_history is None:
1186
+ return
1187
+ if not isinstance(visual_history, list):
1188
+ errors.append("visualEvidence must be an array")
1189
+ return
1190
+ for index, item in enumerate(visual_history):
1191
+ validate_visual_evidence_item(item, f"visualEvidence[{index}]", errors)
1192
+
1193
+
1194
+ def validate_build_passes(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> list[str]:
1195
+ build_passes = spec.get("buildPasses")
1196
+ if build_passes is None:
1197
+ warnings.append("quality: missing buildPasses; model construction can skip blockout/structural/material gates")
1198
+ return []
1199
+ if not isinstance(build_passes, list):
1200
+ errors.append("buildPasses must be an array")
1201
+ return []
1202
+ ids: list[str] = []
1203
+ for index, item in enumerate(build_passes):
1204
+ if not isinstance(item, dict):
1205
+ errors.append(f"buildPasses[{index}] must be an object")
1206
+ continue
1207
+ pass_id = item.get("id")
1208
+ if not isinstance(pass_id, str) or not pass_id.strip():
1209
+ errors.append(f"buildPasses[{index}].id is required")
1210
+ continue
1211
+ if pass_id in ids:
1212
+ errors.append(f"duplicate buildPasses id {pass_id!r}")
1213
+ ids.append(pass_id)
1214
+ for field in ("goal",):
1215
+ value = item.get(field)
1216
+ if value is not None and not isinstance(value, str):
1217
+ errors.append(f"buildPasses[{index}].{field} must be a string")
1218
+ validate_string_array(item.get("componentRefs"), f"buildPasses[{index}].componentRefs", errors)
1219
+ validate_string_array(item.get("acceptance"), f"buildPasses[{index}].acceptance", errors)
1220
+ if ids:
1221
+ if ids[0] != "blockout":
1222
+ warnings.append("quality: first build pass should be blockout")
1223
+ if "structural-pass" not in ids:
1224
+ warnings.append("quality: missing structural-pass; component hierarchy may be skipped")
1225
+ if not ({"material-pass", "surface-pass"} & set(ids)):
1226
+ warnings.append("quality: missing material/surface pass; model may stay as flat geometry")
1227
+ return ids
1228
+
1229
+
1230
+ def review_completes_pass(
1231
+ spec: dict[str, Any],
1232
+ entry: dict[str, Any],
1233
+ pass_id: str,
1234
+ ) -> bool:
1235
+ if entry.get("passId") != pass_id or entry.get("action") != "continue":
1236
+ return False
1237
+ visual = entry.get("visualEvidence")
1238
+ if pass_id in VISUAL_PASS_IDS:
1239
+ if not (
1240
+ isinstance(visual, dict)
1241
+ and visual.get("renderScreenshot")
1242
+ and visual.get("comparisonImage")
1243
+ ):
1244
+ return False
1245
+ score = entry.get("aiVisionScore")
1246
+ threshold = entry.get("visualAcceptanceThreshold", 0.7)
1247
+ if not is_number(score) or not is_number(threshold) or float(score) < float(threshold):
1248
+ return False
1249
+ if feature_gate_failures(spec, entry, pass_id):
1250
+ return False
1251
+ return True
1252
+
1253
+
1254
+ def completed_passes_from_history(spec: dict[str, Any], pass_ids: list[str]) -> list[str]:
1255
+ history = spec.get("reviewHistory", [])
1256
+ if not isinstance(history, list):
1257
+ return []
1258
+ completed: list[str] = []
1259
+ for pass_id in pass_ids:
1260
+ if any(
1261
+ isinstance(entry, dict) and review_completes_pass(spec, entry, pass_id)
1262
+ for entry in history
1263
+ ):
1264
+ completed.append(pass_id)
1265
+ else:
1266
+ break
1267
+ return completed
1268
+
1269
+
1270
+ def validate_sculpt_pipeline(
1271
+ spec: dict[str, Any],
1272
+ build_pass_ids: list[str],
1273
+ errors: list[str],
1274
+ warnings: list[str],
1275
+ ) -> None:
1276
+ pipeline = spec.get("sculptPipeline")
1277
+ if pipeline is None:
1278
+ warnings.append("quality: missing sculptPipeline; pass order is not locked and generation can skip build passes")
1279
+ return
1280
+ if not isinstance(pipeline, dict):
1281
+ errors.append("sculptPipeline must be an object")
1282
+ return
1283
+ pass_order = pipeline.get("passOrder")
1284
+ if pass_order is None:
1285
+ warnings.append("quality: sculptPipeline.passOrder is missing")
1286
+ pass_order_ids = build_pass_ids
1287
+ else:
1288
+ validate_string_array(pass_order, "sculptPipeline.passOrder", errors)
1289
+ pass_order_ids = [str(value) for value in pass_order] if isinstance(pass_order, list) else build_pass_ids
1290
+ if build_pass_ids and pass_order_ids and pass_order_ids != build_pass_ids:
1291
+ warnings.append("sculptPipeline.passOrder differs from buildPasses order; sync the pipeline before generation")
1292
+ current = pipeline.get("currentPass")
1293
+ if current is not None and current != "complete" and current not in (pass_order_ids or build_pass_ids):
1294
+ errors.append("sculptPipeline.currentPass must be a known build pass or complete")
1295
+ completed = pipeline.get("completedPasses", [])
1296
+ validate_string_array(completed, "sculptPipeline.completedPasses", errors)
1297
+ if isinstance(completed, list):
1298
+ expected = completed_passes_from_history(spec, pass_order_ids or build_pass_ids)
1299
+ if list(completed) != expected:
1300
+ warnings.append("sculptPipeline.completedPasses is out of sync with reviewHistory; run stage3_build/orchestrate_passes.py sync")
1301
+ for pass_id in completed:
1302
+ if pass_id not in (pass_order_ids or build_pass_ids):
1303
+ errors.append(f"sculptPipeline.completedPasses contains unknown pass {pass_id!r}")
1304
+ gate_mode = pipeline.get("passGateMode")
1305
+ if gate_mode != "locked-sequential":
1306
+ warnings.append("quality: sculptPipeline.passGateMode should be locked-sequential")
1307
+ validate_string_array(pipeline.get("nextRequiredEvidence"), "sculptPipeline.nextRequiredEvidence", errors)
1308
+
1309
+
1310
+ def has_non_empty_detail(value: Any) -> bool:
1311
+ if isinstance(value, str):
1312
+ return bool(value.strip()) and value.strip().lower() not in {"none", "unassessed", "n/a"}
1313
+ if isinstance(value, list):
1314
+ return any(has_non_empty_detail(item) for item in value)
1315
+ if isinstance(value, dict):
1316
+ return any(has_non_empty_detail(item) for item in value.values())
1317
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
1318
+ return abs(float(value)) > 0
1319
+ return False
1320
+
1321
+
1322
+ def layer_number(value: Any, keys: tuple[str, ...]) -> float:
1323
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
1324
+ return float(value)
1325
+ if isinstance(value, dict):
1326
+ for key in keys:
1327
+ item = value.get(key)
1328
+ if isinstance(item, (int, float)) and not isinstance(item, bool):
1329
+ return float(item)
1330
+ return 0.0
1331
+
1332
+
1333
+ def reference_pbr_usable(material: dict[str, Any], threshold: float) -> tuple[bool, str]:
1334
+ reference = material.get("referencePbr")
1335
+ material_id = str(material.get("id") or "(unnamed)")
1336
+ if not isinstance(reference, dict):
1337
+ return False, f"material {material_id!r} needs usable referencePbr extracted from source pixels"
1338
+ if reference.get("usable") is not True:
1339
+ return False, f"material {material_id!r} referencePbr.usable must be true"
1340
+ confidence = reference.get("confidence", reference.get("estimatedFidelity"))
1341
+ if not is_number(confidence) or float(confidence) < threshold:
1342
+ return False, f"material {material_id!r} referencePbr confidence must be >= {threshold}"
1343
+ maps = reference.get("maps")
1344
+ if not isinstance(maps, dict):
1345
+ return False, f"material {material_id!r} referencePbr needs maps"
1346
+ for channel in ("albedo", "roughness", "height", "normal", "ao"):
1347
+ entry = maps.get(channel)
1348
+ if not isinstance(entry, dict) or not has_non_empty_detail(entry.get("url") or entry.get("path")):
1349
+ return False, f"material {material_id!r} referencePbr missing {channel} map path/url"
1350
+ return True, ""
1351
+
1352
+
1353
+ def validate_look_dev_targets(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
1354
+ targets = spec.get("lookDevTargets")
1355
+ if targets is None:
1356
+ warnings.append("quality: missing lookDevTargets; material/color/lighting passes may stay flat")
1357
+ elif not isinstance(targets, dict):
1358
+ errors.append("lookDevTargets must be an object")
1359
+ materials = [item for item in spec.get("materials", []) if isinstance(item, dict)]
1360
+ if materials:
1361
+ has_palette = any(
1362
+ has_non_empty_detail(item.get("colorVariation"))
1363
+ or has_non_empty_detail(item.get("albedo", {}).get("secondary") if isinstance(item.get("albedo"), dict) else None)
1364
+ for item in materials
1365
+ )
1366
+ has_response = any(
1367
+ layer_number(item.get("roughness"), ("variation", "base")) > 0
1368
+ or layer_number(item.get("normal"), ("strength", "amplitude")) > 0
1369
+ or layer_number(item.get("bump"), ("amplitude", "strength")) > 0
1370
+ or layer_number(item.get("displacement"), ("amplitude", "strength")) > 0
1371
+ for item in materials
1372
+ )
1373
+ has_locality = any(
1374
+ has_non_empty_detail(item.get("localOverrides"))
1375
+ or (
1376
+ isinstance(item.get("wear"), dict)
1377
+ and (
1378
+ layer_number(item["wear"].get("edgeWear"), ("base", "amount")) > 0
1379
+ or has_non_empty_detail(item["wear"].get("scratches"))
1380
+ or has_non_empty_detail(item["wear"].get("chips"))
1381
+ )
1382
+ )
1383
+ or (
1384
+ isinstance(item.get("dirt"), dict)
1385
+ and (
1386
+ layer_number(item["dirt"].get("amount"), ("base", "amount")) > 0
1387
+ or layer_number(item["dirt"].get("cavityBias"), ("base", "amount")) > 0
1388
+ )
1389
+ )
1390
+ or has_non_empty_detail(item.get("moss"))
1391
+ or has_non_empty_detail(item.get("stains"))
1392
+ or has_non_empty_detail(item.get("scratches"))
1393
+ or has_non_empty_detail(item.get("chips"))
1394
+ or has_non_empty_detail(item.get("wetness"))
1395
+ or has_non_empty_detail(item.get("patina"))
1396
+ for item in materials
1397
+ )
1398
+ if not has_palette:
1399
+ warnings.append("quality: material-pass needs reference-derived albedo palette or secondary/accent color zones")
1400
+ if not has_response:
1401
+ warnings.append("quality: material-pass needs roughness variation or normal/bump/displacement response")
1402
+ if not has_locality:
1403
+ warnings.append("quality: material-pass needs local overrides, AO, dirt, wear, stains, moss, chips, scratches, or equivalent masks")
1404
+ quality_first = isinstance(targets, dict) and targets.get("qualityPriority") == "reference-fidelity"
1405
+ if quality_first:
1406
+ material_targets = targets.get("materialPass", {})
1407
+ if not isinstance(material_targets, dict):
1408
+ warnings.append("quality: quality-first lookDevTargets.materialPass must be an object")
1409
+ material_targets = {}
1410
+ minimum_resolution = material_targets.get("minimumTextureResolution", 1024)
1411
+ if not isinstance(minimum_resolution, int) or isinstance(minimum_resolution, bool):
1412
+ warnings.append("quality: minimumTextureResolution must be an integer")
1413
+ minimum_resolution = 1024
1414
+ required_channels = {
1415
+ str(item).lower()
1416
+ for item in material_targets.get("independentMapChannels", [])
1417
+ if isinstance(item, str)
1418
+ }
1419
+ extraction_targets = material_targets.get("referencePbrExtraction", {})
1420
+ if not isinstance(extraction_targets, dict):
1421
+ extraction_targets = {}
1422
+ pbr_required = (
1423
+ extraction_targets.get("requiredWhenSourceImagePresent") is True
1424
+ and has_non_empty_detail(spec.get("sourceImage"))
1425
+ )
1426
+ pbr_threshold = extraction_targets.get("targetThreshold", 0.7)
1427
+ if not is_number(pbr_threshold):
1428
+ pbr_threshold = 0.7
1429
+ expected_channels = {"albedo", "roughness", "height", "normal", "ambient-occlusion"}
1430
+ if not expected_channels.issubset(required_channels):
1431
+ warnings.append(
1432
+ "quality: quality-first materialPass must require independent albedo, roughness, "
1433
+ "height, normal, and ambient-occlusion channels"
1434
+ )
1435
+ for material in materials:
1436
+ if material.get("qualityTier") == "utility":
1437
+ continue
1438
+ material_id = str(material.get("id") or "(unnamed)")
1439
+ resolution = material.get("textureResolution")
1440
+ if not isinstance(resolution, int) or isinstance(resolution, bool) or resolution < minimum_resolution:
1441
+ warnings.append(
1442
+ f"quality: material {material_id!r} textureResolution must be >= {minimum_resolution}"
1443
+ )
1444
+ projection = material.get("textureProjection")
1445
+ if not isinstance(projection, dict) or not has_non_empty_detail(projection.get("mode")):
1446
+ warnings.append(
1447
+ f"quality: material {material_id!r} needs textureProjection.mode and texel-density intent"
1448
+ )
1449
+ bands = material.get("surfaceFrequencyBands")
1450
+ band_ids = {
1451
+ str(item.get("id")).lower()
1452
+ for item in bands
1453
+ if isinstance(item, dict) and has_non_empty_detail(item.get("id"))
1454
+ } if isinstance(bands, list) else set()
1455
+ missing_bands = {"macro", "meso", "micro"} - band_ids
1456
+ if missing_bands:
1457
+ warnings.append(
1458
+ f"quality: material {material_id!r} missing surface frequency bands: "
1459
+ + ", ".join(sorted(missing_bands))
1460
+ )
1461
+ roughness = material.get("roughness")
1462
+ roughness_map = roughness.get("map") if isinstance(roughness, dict) else None
1463
+ if not has_non_empty_detail(roughness_map) or "albedo" in str(roughness_map).lower():
1464
+ warnings.append(f"quality: material {material_id!r} needs an independent roughness map")
1465
+ if not has_non_empty_detail(material.get("ambientOcclusion")):
1466
+ warnings.append(
1467
+ f"quality: material {material_id!r} needs an independent ambient-occlusion response"
1468
+ )
1469
+ if pbr_required:
1470
+ ok, message = reference_pbr_usable(material, float(pbr_threshold))
1471
+ if not ok:
1472
+ warnings.append(f"quality: {message}")
1473
+ lighting = spec.get("lightingFromPhoto", [])
1474
+ if not isinstance(lighting, list):
1475
+ errors.append("lightingFromPhoto must be an array")
1476
+ else:
1477
+ meaningful = [item for item in lighting if has_non_empty_detail(item)]
1478
+ if len(meaningful) < 3:
1479
+ warnings.append("quality: lighting-pass needs concrete key/fill/rim or environment light entries")
1480
+ lighting_text = " ".join(str(item).lower() for item in meaningful)
1481
+ if meaningful and not any(term in lighting_text for term in ("exposure", "tone", "aces", "filmic")):
1482
+ warnings.append("quality: lighting-pass needs exposure and tone mapping intent")
1483
+ if meaningful and not any(term in lighting_text for term in ("contact shadow", "ground shadow", "ambient occlusion", "ao")):
1484
+ warnings.append("quality: lighting-pass needs contact shadow or ground shadow behavior")
1485
+
1486
+
1487
+ VALID_DETAIL_KINDS = {
1488
+ "gloss", "bevel", "fastener", "linework", "contour", "seam", "stitch",
1489
+ "stain", "scratch", "chip", "decal", "emissive", "hole", "groove", "ridge",
1490
+ }
1491
+
1492
+
1493
+ def _detail_link_keys(spec: dict[str, Any]) -> set[str]:
1494
+ """Collect keys a detailInventory item may map to: component ids, local feature ids,
1495
+ material ids, and material localOverride ids (with and without owner prefix)."""
1496
+ keys: set[str] = set()
1497
+ for comp in spec.get("componentTree", []):
1498
+ if not isinstance(comp, dict):
1499
+ continue
1500
+ cid = comp.get("id")
1501
+ if isinstance(cid, str):
1502
+ keys.add(cid)
1503
+ for feat in comp.get("localFeatures", []) or []:
1504
+ if isinstance(feat, str):
1505
+ keys.add(feat)
1506
+ if isinstance(cid, str):
1507
+ keys.add(f"{cid}/{feat}")
1508
+ elif isinstance(feat, dict) and isinstance(feat.get("id"), str):
1509
+ keys.add(feat["id"])
1510
+ if isinstance(cid, str):
1511
+ keys.add(f"{cid}/{feat['id']}")
1512
+ for mat in spec.get("materials", []):
1513
+ if not isinstance(mat, dict):
1514
+ continue
1515
+ mid = mat.get("id")
1516
+ if isinstance(mid, str):
1517
+ keys.add(mid)
1518
+ for over in mat.get("localOverrides", []) or []:
1519
+ if isinstance(over, dict) and isinstance(over.get("id"), str):
1520
+ keys.add(over["id"])
1521
+ if isinstance(mid, str):
1522
+ keys.add(f"{mid}/{over['id']}")
1523
+ return keys
1524
+
1525
+
1526
+ def _has_gloss_response(spec: dict[str, Any]) -> bool:
1527
+ for mat in spec.get("materials", []):
1528
+ if not isinstance(mat, dict):
1529
+ continue
1530
+ rough = mat.get("roughness")
1531
+ base = rough.get("base") if isinstance(rough, dict) else rough
1532
+ if is_number(base) and float(base) < 0.35:
1533
+ return True
1534
+ if is_number(mat.get("clearcoat")) or isinstance(mat.get("clearcoat"), dict):
1535
+ return True
1536
+ for over in mat.get("localOverrides", []) or []:
1537
+ if isinstance(over, dict) and is_number(over.get("roughness")) and float(over["roughness"]) < 0.3:
1538
+ return True
1539
+ return False
1540
+
1541
+
1542
+ def _has_repetition_or_small_parts(spec: dict[str, Any]) -> bool:
1543
+ if [r for r in spec.get("repetitionSystems", []) if isinstance(r, dict)]:
1544
+ return True
1545
+ return any(
1546
+ isinstance(c, dict) and c.get("level") == "micro"
1547
+ for c in spec.get("componentTree", [])
1548
+ )
1549
+
1550
+
1551
+ def validate_detail_inventory(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
1552
+ """Gate the detail inventory. Backward compatible: only enforced when a detailInventory
1553
+ block with a positive targetMinDetails is present (new-pipeline specs)."""
1554
+ assessment = spec.get("preSpecAssessment")
1555
+ if not isinstance(assessment, dict):
1556
+ return
1557
+ inv = assessment.get("detailInventory")
1558
+ if not isinstance(inv, dict):
1559
+ return
1560
+ details = inv.get("details", [])
1561
+ if not isinstance(details, list):
1562
+ errors.append("preSpecAssessment.detailInventory.details must be an array")
1563
+ return
1564
+ target = inv.get("targetMinDetails", 0)
1565
+ if not (isinstance(target, int) and not isinstance(target, bool) and target > 0):
1566
+ return # not a new-pipeline spec; skip enforcement
1567
+ if len(details) < target:
1568
+ warnings.append(
1569
+ f"quality: detailInventory has {len(details)} details but targetMinDetails is {target}; "
1570
+ "enumerate identity-defining details (gloss, bevel, fasteners, linework, stains) before code generation"
1571
+ )
1572
+ link_keys = _detail_link_keys(spec)
1573
+ has_gloss = has_fastener = False
1574
+ for index, detail in enumerate(details):
1575
+ if not isinstance(detail, dict):
1576
+ errors.append(f"detailInventory.details[{index}] must be an object")
1577
+ continue
1578
+ did = detail.get("id", index)
1579
+ kind = detail.get("kind")
1580
+ if kind not in VALID_DETAIL_KINDS:
1581
+ warnings.append(f"quality: detailInventory detail {did!r} has unknown kind {kind!r}")
1582
+ maps = detail.get("mapsTo")
1583
+ ref = maps.get("ref") if isinstance(maps, dict) else None
1584
+ if not (isinstance(ref, str) and ref in link_keys):
1585
+ warnings.append(
1586
+ f"quality: detailInventory detail {did!r} does not map to a component.localFeatures "
1587
+ "or material.localOverrides entry (no prose-only details)"
1588
+ )
1589
+ if kind == "gloss":
1590
+ has_gloss = True
1591
+ elif kind == "fastener":
1592
+ has_fastener = True
1593
+ if has_gloss and not _has_gloss_response(spec):
1594
+ warnings.append(
1595
+ "quality: detailInventory lists a gloss detail but no material provides low roughness or clearcoat response"
1596
+ )
1597
+ if has_fastener and not _has_repetition_or_small_parts(spec):
1598
+ warnings.append(
1599
+ "quality: detailInventory lists fastener details but no repetitionSystem/instancing or micro parts represent them"
1600
+ )
1601
+
1602
+
1603
+ def validate_character_track(spec: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
1604
+ """Gate the character track. Backward compatible: only enforced when primaryDomain is
1605
+ character or hybrid."""
1606
+ assessment = spec.get("preSpecAssessment")
1607
+ if not isinstance(assessment, dict):
1608
+ return
1609
+ object_class = assessment.get("objectClass")
1610
+ domain = object_class.get("primaryDomain") if isinstance(object_class, dict) else None
1611
+ if domain not in {"character", "hybrid"}:
1612
+ return
1613
+ anatomy = assessment.get("anatomy")
1614
+ if not isinstance(anatomy, dict) or anatomy.get("applies") is not True:
1615
+ warnings.append(
1616
+ "quality: primaryDomain is character/hybrid but anatomy.applies is not true; "
1617
+ "fill anatomy (styleHeads, proportions, pose, faceLandmarks) from the reference"
1618
+ )
1619
+ return
1620
+ if not (is_number(anatomy.get("styleHeads")) and float(anatomy["styleHeads"]) > 0):
1621
+ warnings.append("quality: character anatomy.styleHeads must be greater than 0 (head-unit proportion)")
1622
+ proportions = anatomy.get("proportions")
1623
+ if not (isinstance(proportions, dict) and any(
1624
+ is_number(proportions.get(k)) and float(proportions[k]) > 0 for k in ("torso", "legs")
1625
+ )):
1626
+ warnings.append("quality: character anatomy.proportions must set torso/legs head-unit ratios")
1627
+ landmarks = anatomy.get("faceLandmarks")
1628
+ if not (isinstance(landmarks, dict) and any(
1629
+ is_number(landmarks.get(k)) and float(landmarks[k]) > 0 for k in ("eyeLine", "noseBase", "mouthLine")
1630
+ )):
1631
+ warnings.append("quality: character anatomy.faceLandmarks must set eyeLine/noseBase/mouthLine from the reference")
1632
+ targets = spec.get("featureReviewTargets", [])
1633
+ character_ids = {"anatomy-proportion", "face-landmark-placement", "pose-silhouette", "outfit-and-palette"}
1634
+ if not any(isinstance(t, dict) and t.get("id") in character_ids for t in targets):
1635
+ warnings.append(
1636
+ "quality: character track needs featureReviewTargets covering anatomy/face/pose/outfit "
1637
+ "(add anatomy-proportion, face-landmark-placement, pose-silhouette, outfit-and-palette)"
1638
+ )
1639
+
1640
+
1641
+ def validate_spec(spec: dict[str, Any]) -> tuple[list[str], list[str]]:
1642
+ errors: list[str] = []
1643
+ warnings: list[str] = []
1644
+ for key, expected_type in REQUIRED_TOP_LEVEL.items():
1645
+ if key not in spec:
1646
+ errors.append(f"missing top-level field {key!r}")
1647
+ elif not isinstance(spec[key], expected_type):
1648
+ errors.append(f"field {key!r} must be {expected_type.__name__}")
1649
+ suitability = spec.get("suitability")
1650
+ if suitability not in VALID_SUITABILITY:
1651
+ errors.append("suitability must be pass, conditional, or reject")
1652
+ validate_pre_spec_assessment(spec, errors, warnings)
1653
+ validate_terminology_profile(spec, errors, warnings)
1654
+ validate_score_block(spec, errors, warnings)
1655
+ validate_quality_targets(spec, errors, warnings)
1656
+ validate_quality_contract(spec, errors, warnings)
1657
+ validate_action_readiness(spec, errors, warnings)
1658
+ validate_self_correct_loop(spec, errors, warnings)
1659
+ validate_feature_review_targets(spec, errors, warnings)
1660
+ validate_review_history(spec, errors, warnings)
1661
+ validate_visual_evidence_history(spec, errors)
1662
+ build_pass_ids = validate_build_passes(spec, errors, warnings)
1663
+ validate_sculpt_pipeline(spec, build_pass_ids, errors, warnings)
1664
+ validate_look_dev_targets(spec, errors, warnings)
1665
+ evidence_ids = validate_evidence(spec, errors, warnings)
1666
+ material_ids = validate_materials(spec, errors, warnings)
1667
+ validate_components(spec, material_ids, evidence_ids, errors, warnings)
1668
+ lod_plan = spec.get("lodPlan")
1669
+ if lod_plan is not None and not isinstance(lod_plan, list):
1670
+ errors.append("lodPlan must be an array")
1671
+ performance = spec.get("performanceBudget")
1672
+ if performance is not None and not isinstance(performance, dict):
1673
+ errors.append("performanceBudget must be an object")
1674
+ validate_quality_depth(spec, errors, warnings)
1675
+ validate_detail_inventory(spec, errors, warnings)
1676
+ validate_character_track(spec, errors, warnings)
1677
+ if suitability == "pass" and spec.get("risks"):
1678
+ warnings.append("suitability is pass but risks are present; confirm they are acceptable")
1679
+ return errors, warnings
1680
+
1681
+
1682
+ def main(argv: list[str]) -> int:
1683
+ parser = argparse.ArgumentParser(description=__doc__)
1684
+ parser.add_argument("spec", type=Path)
1685
+ parser.add_argument("--json", action="store_true", help="Print machine-readable result")
1686
+ parser.add_argument(
1687
+ "--strict-quality",
1688
+ action="store_true",
1689
+ help="Treat quality warnings as validation errors before implementation/generation",
1690
+ )
1691
+ args = parser.parse_args(argv)
1692
+
1693
+ try:
1694
+ spec = load_spec(args.spec)
1695
+ errors, warnings = validate_spec(spec)
1696
+ except ValueError as exc:
1697
+ errors, warnings = [str(exc)], []
1698
+
1699
+ if args.strict_quality:
1700
+ errors.extend(
1701
+ f"strict quality failure: {warning.removeprefix('quality: ').strip()}"
1702
+ for warning in warnings
1703
+ if warning.startswith("quality:")
1704
+ )
1705
+
1706
+ ok = not errors
1707
+ result = {
1708
+ "ok": ok,
1709
+ "errors": errors,
1710
+ "warnings": warnings,
1711
+ "summary": {
1712
+ "targetName": spec.get("targetName") if "spec" in locals() else None,
1713
+ "suitability": spec.get("suitability") if "spec" in locals() else None,
1714
+ "components": len(spec.get("componentTree", [])) if "spec" in locals() else 0,
1715
+ "materials": len(spec.get("materials", [])) if "spec" in locals() else 0,
1716
+ },
1717
+ }
1718
+ if args.json:
1719
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1720
+ else:
1721
+ print("PASS" if ok else "FAIL")
1722
+ for warning in warnings:
1723
+ print(f"warning: {warning}")
1724
+ for error in errors:
1725
+ print(f"error: {error}")
1726
+ return 0 if ok else 1
1727
+
1728
+
1729
+ if __name__ == "__main__":
1730
+ raise SystemExit(main(sys.argv[1:]))
forge/stage3_build/bake_projected_texture.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Emit a projection/UV-bake descriptor for photo-projected texturing.
3
+
4
+ This script does not perform GPU projective texturing and it does not
5
+ rasterize or bake any pixels. Actual camera-space projection of the
6
+ (ideally de-lit) reference image onto the fitted mesh, and the bake of that
7
+ projection into the mesh's UV space, is a Three.js runtime operation (a
8
+ projective ShaderMaterial, e.g. the `three-projected-material` technique).
9
+ What this script produces is the plan: a validated, versioned descriptor
10
+ that records which camera, which source image(s), which mesh, and which
11
+ projection settings the Three.js generator/agent should use to actually run
12
+ that bake, plus the back/side inference strategy for regions the camera
13
+ never saw.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ VALID_PROJECTION_MODES = ("perspective-camera-projection", "orthographic-front-projection", "triplanar-fallback")
26
+ VALID_UNSEEN_STRATEGIES = ("mirror-symmetry", "palette-continue", "request-additional-view", "leave-unprojected")
27
+
28
+
29
+ def clamp01(value: float) -> float:
30
+ return max(0.0, min(1.0, value))
31
+
32
+
33
+ def load_camera(camera_arg: str | None) -> tuple[dict[str, Any] | None, list[str]]:
34
+ warnings: list[str] = []
35
+ if not camera_arg:
36
+ warnings.append("no --camera reference supplied; projection will use an identity/front camera assumption")
37
+ return None, warnings
38
+ path = Path(camera_arg).expanduser()
39
+ if not path.exists():
40
+ warnings.append(f"--camera path {camera_arg!r} does not exist; recording it as an opaque reference id instead")
41
+ return {"reference": camera_arg}, warnings
42
+ try:
43
+ data = json.loads(path.read_text(encoding="utf-8"))
44
+ except Exception as exc:
45
+ warnings.append(f"could not parse --camera JSON at {path}: {exc}; recording it as an opaque reference id")
46
+ return {"reference": camera_arg}, warnings
47
+ camera = data.get("referenceCamera", data) if isinstance(data, dict) else None
48
+ if not isinstance(camera, dict):
49
+ warnings.append(f"--camera JSON at {path} did not contain a referenceCamera object")
50
+ return {"reference": camera_arg}, warnings
51
+ return camera, warnings
52
+
53
+
54
+ def build_descriptor(args: argparse.Namespace) -> dict[str, Any]:
55
+ camera, camera_warnings = load_camera(args.camera)
56
+ warnings = list(camera_warnings)
57
+
58
+ source_images: dict[str, str] = {"reference": str(Path(args.reference_image).expanduser())}
59
+ if args.delit_image:
60
+ source_images["delit"] = str(Path(args.delit_image).expanduser())
61
+ else:
62
+ warnings.append(
63
+ "no --delit-image supplied; projecting the raw reference will bake its lighting into the mesh "
64
+ "unless the runtime applies its own de-lighting pass first"
65
+ )
66
+
67
+ unseen_strategy = args.unseen_strategy
68
+ unseen_confidence = {
69
+ "mirror-symmetry": 0.45,
70
+ "palette-continue": 0.3,
71
+ "request-additional-view": 0.0,
72
+ "leave-unprojected": 0.0,
73
+ }[unseen_strategy]
74
+
75
+ bake_steps = [
76
+ "load the fitted mesh identified by targetMeshId and its UV layout",
77
+ "load sourceImages.delit if present, otherwise sourceImages.reference, as the projection source texture",
78
+ "construct a projection camera in the Three.js scene from the camera block (fovDegrees, aspect, orientation, position)",
79
+ f"apply {args.projection_mode} to project the source texture onto mesh surfaces facing the projection camera within tolerance",
80
+ f"for surfaces outside the camera's view frustum or facing away, apply the '{unseen_strategy}' strategy",
81
+ f"rasterize the resulting camera-space projection into a {args.texture_size}x{args.texture_size} UV-space texture",
82
+ "flag any UV texels that received no projected sample (fully unseen regions) in the bake output metadata",
83
+ "hand the baked texture back to the material pipeline as the projected albedo input",
84
+ ]
85
+
86
+ return {
87
+ "projectedTextureBake": {
88
+ "version": "1.0",
89
+ "generator": "stage3_build/bake_projected_texture.py",
90
+ "status": "descriptor-only; no pixels are baked or rasterized by this script",
91
+ "targetMeshId": args.mesh_id,
92
+ "projectionMode": args.projection_mode,
93
+ "textureSize": args.texture_size,
94
+ "camera": camera,
95
+ "sourceImages": source_images,
96
+ "unseenRegionStrategy": {
97
+ "mode": unseen_strategy,
98
+ "confidence": unseen_confidence,
99
+ "note": "Regions the reference camera never saw (back, occluded folds, underside) are inferred, not observed.",
100
+ },
101
+ "runtimeApproach": (
102
+ "actual projective texturing and UV bake happen in the Three.js runtime via a projective "
103
+ "ShaderMaterial (the three-projected-material approach) or an equivalent camera-space "
104
+ "projection shader; this descriptor only records the plan for that step"
105
+ ),
106
+ "bakeSteps": bake_steps,
107
+ "limitations": [
108
+ "this script performs no image sampling, projection math, or UV rasterization",
109
+ "camera accuracy is inherited from whatever produced the camera block; an unrefined camera will misalign the projection",
110
+ "unseen-region inference is a heuristic guess, not observed geometry or texture",
111
+ "the resulting bake still needs a rendered overlay review against the reference image before being trusted",
112
+ ]
113
+ + warnings,
114
+ "note": (
115
+ "Feed this descriptor to the Three.js generator/agent to run the actual projection and bake, "
116
+ "then re-render and visually compare the baked mesh against the reference image before "
117
+ "accepting the result as final."
118
+ ),
119
+ }
120
+ }
121
+
122
+
123
+ def main(argv: list[str]) -> int:
124
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
125
+ parser.add_argument("--reference-image", required=True, help="Path to the original reference photo")
126
+ parser.add_argument("--delit-image", help="Path to a de-lit albedo produced by stage1_intake/delight_albedo.py, if available")
127
+ parser.add_argument("--camera", help="Path to a referenceCamera JSON produced by stage1_intake/solve_camera_pose.py")
128
+ parser.add_argument("--mesh-id", required=True, help="Identifier of the target mesh/node to project onto")
129
+ parser.add_argument(
130
+ "--projection-mode",
131
+ choices=VALID_PROJECTION_MODES,
132
+ default="perspective-camera-projection",
133
+ help="Projection technique the Three.js runtime should use (default perspective-camera-projection)",
134
+ )
135
+ parser.add_argument("--texture-size", type=int, default=1024, help="Target baked texture resolution (square)")
136
+ parser.add_argument(
137
+ "--unseen-strategy",
138
+ choices=VALID_UNSEEN_STRATEGIES,
139
+ default="mirror-symmetry",
140
+ help="How to handle mesh regions outside the reference camera's view (default mirror-symmetry)",
141
+ )
142
+ parser.add_argument("--out", type=Path, help="Write the descriptor JSON to this path")
143
+ args = parser.parse_args(argv)
144
+
145
+ if args.texture_size <= 0:
146
+ parser.error("--texture-size must be positive")
147
+
148
+ try:
149
+ descriptor = build_descriptor(args)
150
+ text = json.dumps(descriptor, indent=2, ensure_ascii=False)
151
+ if args.out:
152
+ out_path = args.out.expanduser().resolve()
153
+ out_path.parent.mkdir(parents=True, exist_ok=True)
154
+ out_path.write_text(text + "\n", encoding="utf-8")
155
+ print(text)
156
+ return 0
157
+ except Exception as exc:
158
+ print(f"error: {exc}", file=sys.stderr)
159
+ return 1
160
+
161
+
162
+ if __name__ == "__main__":
163
+ raise SystemExit(main(sys.argv[1:]))
forge/stage3_build/generate_threejs_factory.py ADDED
@@ -0,0 +1,986 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate a TypeScript Three.js factory skeleton from an ObjectSculptSpec."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from orchestrate_passes import pass_specific_gaps
14
+
15
+
16
+ VALID_PRIMITIVES = {
17
+ "box",
18
+ "sphere",
19
+ "ellipsoid",
20
+ "cylinder",
21
+ "cone",
22
+ "capsule",
23
+ "torus",
24
+ "tube",
25
+ "lathe",
26
+ "extrude",
27
+ "curve-sweep",
28
+ "plane-card",
29
+ "instanced-cluster",
30
+ }
31
+ DEFAULT_PASS_ORDER = [
32
+ "blockout",
33
+ "structural-pass",
34
+ "form-refinement",
35
+ "material-pass",
36
+ "surface-pass",
37
+ "lighting-pass",
38
+ "interaction-pass",
39
+ "optimization-pass",
40
+ ]
41
+ VISUAL_PASS_IDS = set(DEFAULT_PASS_ORDER) - {"optimization-pass"}
42
+ PASS_LEVELS = {
43
+ "blockout": {"macro"},
44
+ "structural-pass": {"macro", "meso"},
45
+ "form-refinement": {"macro", "meso", "micro"},
46
+ "material-pass": {"macro", "meso", "micro"},
47
+ "surface-pass": {"macro", "meso", "micro"},
48
+ "lighting-pass": {"macro", "meso", "micro"},
49
+ "interaction-pass": {"macro", "meso", "micro"},
50
+ "optimization-pass": {"macro", "meso", "micro"},
51
+ }
52
+
53
+
54
+ def load_spec(path: Path) -> dict[str, Any]:
55
+ payload = json.loads(path.read_text(encoding="utf-8"))
56
+ if not isinstance(payload, dict):
57
+ raise ValueError("spec must be a JSON object")
58
+ return payload
59
+
60
+
61
+ def pass_order(spec: dict[str, Any]) -> list[str]:
62
+ ids: list[str] = []
63
+ for item in spec.get("buildPasses", []):
64
+ if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].strip():
65
+ ids.append(item["id"])
66
+ return ids or DEFAULT_PASS_ORDER.copy()
67
+
68
+
69
+ def review_visual_evidence(entry: dict[str, Any]) -> dict[str, Any]:
70
+ visual = entry.get("visualEvidence")
71
+ return visual if isinstance(visual, dict) else {}
72
+
73
+
74
+ def review_completes_pass(entry: dict[str, Any], pass_id: str) -> bool:
75
+ if entry.get("passId") != pass_id or entry.get("action") != "continue":
76
+ return False
77
+ if pass_id in VISUAL_PASS_IDS and not review_visual_evidence(entry).get("renderScreenshot"):
78
+ return False
79
+ return True
80
+
81
+
82
+ def completed_passes(spec: dict[str, Any], ids: list[str]) -> list[str]:
83
+ history = spec.get("reviewHistory", [])
84
+ if not isinstance(history, list):
85
+ return []
86
+ completed: list[str] = []
87
+ for pass_id in ids:
88
+ if any(isinstance(entry, dict) and review_completes_pass(entry, pass_id) for entry in history):
89
+ completed.append(pass_id)
90
+ else:
91
+ break
92
+ return completed
93
+
94
+
95
+ def unlocked_pass(spec: dict[str, Any]) -> str:
96
+ ids = pass_order(spec)
97
+ completed = completed_passes(spec, ids)
98
+ if len(completed) >= len(ids):
99
+ return ids[-1]
100
+ return ids[len(completed)]
101
+
102
+
103
+ def assert_pass_unlocked(spec: dict[str, Any], requested_pass: str) -> None:
104
+ ids = pass_order(spec)
105
+ if requested_pass not in ids:
106
+ raise ValueError(f"unknown build pass {requested_pass!r}; expected one of: {', '.join(ids)}")
107
+ completed = completed_passes(spec, ids)
108
+ current = ids[-1] if len(completed) >= len(ids) else ids[len(completed)]
109
+ if requested_pass in completed or requested_pass == current:
110
+ return
111
+ previous_index = ids.index(requested_pass) - 1
112
+ previous = ids[previous_index] if previous_index >= 0 else ""
113
+ raise ValueError(
114
+ f"build pass {requested_pass!r} is locked; complete {previous!r} first with "
115
+ "stage4_review/append_review.py action=continue and browser screenshot evidence"
116
+ )
117
+
118
+
119
+ def component_refs_for_pass(spec: dict[str, Any], pass_id: str) -> set[str]:
120
+ ids = pass_order(spec)
121
+ if pass_id not in ids:
122
+ return set()
123
+ allowed_ids = set(ids[: ids.index(pass_id) + 1])
124
+ refs: set[str] = set()
125
+ for item in spec.get("buildPasses", []):
126
+ if not isinstance(item, dict) or item.get("id") not in allowed_ids:
127
+ continue
128
+ component_refs = item.get("componentRefs", [])
129
+ if isinstance(component_refs, list):
130
+ refs.update(str(value) for value in component_refs if str(value).strip())
131
+ return refs
132
+
133
+
134
+ def filter_components_for_pass(spec: dict[str, Any], components: list[dict[str, Any]], pass_id: str) -> list[dict[str, Any]]:
135
+ allowed_levels = PASS_LEVELS.get(pass_id, {"macro"})
136
+ explicit_refs = component_refs_for_pass(spec, pass_id)
137
+ included: list[dict[str, Any]] = []
138
+ included_ids: set[str] = set()
139
+ visiting_ids: set[str] = set()
140
+ component_by_id = {str(item.get("id")): item for item in components if item.get("id") is not None}
141
+
142
+ def include_component(component: dict[str, Any]) -> None:
143
+ component_id = str(component.get("id") or "")
144
+ if not component_id or component_id in included_ids:
145
+ return
146
+ if component_id in visiting_ids:
147
+ raise ValueError(f"component parent cycle contains {component_id!r}")
148
+ visiting_ids.add(component_id)
149
+ parent_id = component.get("parent")
150
+ if parent_id is not None and str(parent_id) in component_by_id:
151
+ include_component(component_by_id[str(parent_id)])
152
+ visiting_ids.remove(component_id)
153
+ included.append(component)
154
+ included_ids.add(component_id)
155
+
156
+ for component in components:
157
+ component_id = str(component.get("id") or "")
158
+ level = str(component.get("level") or "macro")
159
+ tier = str(component.get("fidelityTier") or "")
160
+ if component_id in explicit_refs or level in allowed_levels or tier == pass_id:
161
+ include_component(component)
162
+ if not included and components:
163
+ included.append(components[0])
164
+ return included
165
+
166
+
167
+ def pascal_case(value: str) -> str:
168
+ parts = re.findall(r"[A-Za-z0-9]+", value)
169
+ return "".join(part[:1].upper() + part[1:] for part in parts) or "Object"
170
+
171
+
172
+ def const_name(value: str) -> str:
173
+ name = re.sub(r"[^A-Za-z0-9_]", "_", value.strip())
174
+ if not name:
175
+ return "component"
176
+ if name[0].isdigit():
177
+ name = "_" + name
178
+ return name
179
+
180
+
181
+ def local_var(prefix: str, value: str, index: int) -> str:
182
+ return f"{prefix}_{const_name(value)}_{index}"
183
+
184
+
185
+ def hex_to_number(value: Any, fallback: str = "#8A7A5F") -> str:
186
+ color = value if isinstance(value, str) else fallback
187
+ if re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
188
+ return "0x" + color[1:]
189
+ if re.fullmatch(r"#[0-9A-Fa-f]{3}", color):
190
+ return "0x" + "".join(ch * 2 for ch in color[1:])
191
+ return "0x" + fallback[1:]
192
+
193
+
194
+ def material_base_value(value: Any, fallback: float) -> float:
195
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
196
+ return float(value)
197
+ if isinstance(value, dict) and isinstance(value.get("base"), (int, float)):
198
+ return float(value["base"])
199
+ return fallback
200
+
201
+
202
+ def json_literal(value: Any) -> str:
203
+ return json.dumps(value, ensure_ascii=False)
204
+
205
+
206
+ def vector(values: Any, fallback: list[float]) -> str:
207
+ if (
208
+ isinstance(values, list)
209
+ and len(values) == 3
210
+ and all(isinstance(item, (int, float)) for item in values)
211
+ ):
212
+ return ", ".join(str(float(item)) for item in values)
213
+ return ", ".join(str(item) for item in fallback)
214
+
215
+
216
+ def dimension_values(component: dict[str, Any]) -> tuple[float, float, float]:
217
+ dimensions = component.get("dimensions")
218
+ if isinstance(dimensions, dict):
219
+ radius = dimensions.get("radius")
220
+ diameter = float(radius) * 2 if isinstance(radius, (int, float)) and not isinstance(radius, bool) else 1.0
221
+ width = dimensions.get("width", diameter)
222
+ height = dimensions.get("height", dimensions.get("length", 1.0))
223
+ depth = dimensions.get("depth", diameter)
224
+ if all(
225
+ isinstance(item, (int, float))
226
+ and not isinstance(item, bool)
227
+ and float(item) > 0
228
+ for item in (width, height, depth)
229
+ ):
230
+ return float(width), float(height), float(depth)
231
+ return 1.0, 1.0, 1.0
232
+
233
+
234
+ def dimension_scale_vector(component: dict[str, Any], primitive: str) -> str:
235
+ """Scale a normalized primitive to the spec dimensions.
236
+
237
+ Component transform.scale belongs on its pivot node; dimensions belong on
238
+ the mesh. Keeping them separate prevents a parent's physical dimensions
239
+ from multiplying child coordinates.
240
+ """
241
+ width, height, depth = dimension_values(component)
242
+ if primitive == "capsule":
243
+ values = [width / 0.7, height / 1.4, depth / 0.7]
244
+ elif primitive == "torus":
245
+ # TorusGeometry(0.4, 0.1) has a 1 x 1 x 0.2 bounding box.
246
+ values = [width, height, depth / 0.2]
247
+ elif primitive == "plane-card":
248
+ values = [width, height, 1.0]
249
+ else:
250
+ values = [width, height, depth]
251
+ return vector(values, [1, 1, 1])
252
+
253
+
254
+ AXIS_ATTACHMENT_PRIMITIVES = {"cylinder", "cone", "capsule"}
255
+
256
+
257
+ def attachment_for_geometry(component: dict[str, Any], primitive: str) -> dict[str, Any] | None:
258
+ attachment = component.get("attachment")
259
+ if primitive not in AXIS_ATTACHMENT_PRIMITIVES or not isinstance(attachment, dict):
260
+ return None
261
+ payload = dict(attachment)
262
+ width, _height, depth = dimension_values(component)
263
+ radius = max(0.005, min(width, depth) / 2)
264
+ payload.setdefault("baseRadius", radius)
265
+ payload.setdefault("endRadius", 0.003 if primitive == "cone" else radius)
266
+ return payload
267
+
268
+
269
+ def geometry_for(primitive: str) -> str:
270
+ if primitive == "box":
271
+ return "new THREE.BoxGeometry(1, 1, 1, 12, 12, 12)"
272
+ if primitive in {"sphere", "ellipsoid"}:
273
+ return "new THREE.SphereGeometry(0.5, 64, 40)"
274
+ if primitive == "cylinder":
275
+ return "new THREE.CylinderGeometry(0.5, 0.5, 1, 48, 16)"
276
+ if primitive == "cone":
277
+ return "new THREE.ConeGeometry(0.5, 1, 48, 16)"
278
+ if primitive == "capsule":
279
+ return "new THREE.CapsuleGeometry(0.35, 0.7, 16, 32)"
280
+ if primitive == "torus":
281
+ return "new THREE.TorusGeometry(0.4, 0.1, 24, 96)"
282
+ if primitive == "plane-card":
283
+ return "new THREE.PlaneGeometry(1, 1, 24, 24)"
284
+ raise ValueError(f"primitive {primitive!r} has no implemented geometry")
285
+
286
+
287
+ def attachment_geometry_for(primitive: str, endpoint_var: str) -> str:
288
+ if primitive == "cylinder":
289
+ return (
290
+ f"new THREE.CylinderGeometry({endpoint_var}.endRadius, "
291
+ f"{endpoint_var}.baseRadius, {endpoint_var}.length, 48, 16)"
292
+ )
293
+ if primitive == "cone":
294
+ return (
295
+ f"new THREE.ConeGeometry({endpoint_var}.baseRadius, "
296
+ f"{endpoint_var}.length, 48, 16)"
297
+ )
298
+ if primitive == "capsule":
299
+ radius = f"Math.min({endpoint_var}.baseRadius, {endpoint_var}.length / 2)"
300
+ return (
301
+ f"new THREE.CapsuleGeometry({radius}, "
302
+ f"Math.max(0.001, {endpoint_var}.length - 2 * {radius}), 16, 32)"
303
+ )
304
+ raise ValueError(f"primitive {primitive!r} cannot follow attachment endpoints")
305
+
306
+
307
+ def generate(spec: dict[str, Any], pass_id: str) -> str:
308
+ target = str(spec.get("targetName") or "Procedural Object")
309
+ type_name = pascal_case(target)
310
+ function_name = f"create{type_name}Model"
311
+ materials = {
312
+ str(material.get("id") or f"material{index}"): material
313
+ for index, material in enumerate(spec.get("materials", []))
314
+ if isinstance(material, dict)
315
+ }
316
+ all_components = [item for item in spec.get("componentTree", []) if isinstance(item, dict)]
317
+ components = filter_components_for_pass(spec, all_components, pass_id)
318
+
319
+ lines: list[str] = [
320
+ "import * as THREE from 'three';",
321
+ "",
322
+ "export type ProceduralModelOptions = {",
323
+ " wireframe?: boolean;",
324
+ " castShadow?: boolean;",
325
+ " receiveShadow?: boolean;",
326
+ " textureSize?: number;",
327
+ " textureAnisotropy?: number;",
328
+ " qualityPriority?: 'reference-fidelity' | 'balanced';",
329
+ "};",
330
+ "",
331
+ "export type ProceduralModelRuntime = {",
332
+ " nodes: Record<string, THREE.Object3D>;",
333
+ " meshes: Record<string, THREE.Mesh>;",
334
+ " sockets: Record<string, THREE.Object3D>;",
335
+ " colliders: Record<string, unknown>;",
336
+ " destructionGroups: Record<string, THREE.Object3D[]>;",
337
+ "};",
338
+ "",
339
+ "type SculptMaterialSpec = Record<string, any>;",
340
+ "",
341
+ "function hashString(value: string): number {",
342
+ " let hash = 2166136261;",
343
+ " for (let index = 0; index < value.length; index += 1) {",
344
+ " hash ^= value.charCodeAt(index);",
345
+ " hash = Math.imul(hash, 16777619);",
346
+ " }",
347
+ " return hash >>> 0;",
348
+ "}",
349
+ "",
350
+ "function readLayerNumber(value: unknown, keys: string[], fallback: number): number {",
351
+ " if (typeof value === 'number') return value;",
352
+ " if (value && typeof value === 'object') {",
353
+ " const record = value as Record<string, unknown>;",
354
+ " for (const key of keys) {",
355
+ " if (typeof record[key] === 'number') return record[key] as number;",
356
+ " }",
357
+ " }",
358
+ " return fallback;",
359
+ "}",
360
+ "",
361
+ "function hexToRgb(hex: string): [number, number, number] {",
362
+ " const normalized = /^#[0-9a-f]{3}$/i.test(hex)",
363
+ " ? '#' + hex.slice(1).split('').map((part) => part + part).join('')",
364
+ " : hex;",
365
+ " const value = /^#[0-9a-f]{6}$/i.test(normalized) ? Number.parseInt(normalized.slice(1), 16) : 0x8a7a5f;",
366
+ " return [(value >> 16) & 255, (value >> 8) & 255, value & 255];",
367
+ "}",
368
+ "",
369
+ "function materialPalette(spec: SculptMaterialSpec): string[] {",
370
+ " const palette = spec.colorVariation?.palette;",
371
+ " if (Array.isArray(palette) && palette.length > 0) return palette.filter((value) => typeof value === 'string');",
372
+ " const secondary = spec.albedo?.secondary;",
373
+ " const colors = [spec.baseColor ?? spec.color ?? spec.albedo?.dominant, ...(Array.isArray(secondary) ? secondary : [])];",
374
+ " return colors.filter((value): value is string => typeof value === 'string' && value.startsWith('#'));",
375
+ "}",
376
+ "",
377
+ "function clamp01(value: number): number {",
378
+ " return Math.max(0, Math.min(1, value));",
379
+ "}",
380
+ "",
381
+ "function smoothCurve(value: number): number {",
382
+ " return value * value * (3 - 2 * value);",
383
+ "}",
384
+ "",
385
+ "function periodicHash(x: number, y: number, seed: number, periodX: number, periodY: number): number {",
386
+ " const wrappedX = ((x % periodX) + periodX) % periodX;",
387
+ " const wrappedY = ((y % periodY) + periodY) % periodY;",
388
+ " let value = Math.imul(wrappedX + seed * 17, 374761393) ^ Math.imul(wrappedY + seed * 31, 668265263);",
389
+ " value = Math.imul(value ^ (value >>> 13), 1274126177);",
390
+ " return ((value ^ (value >>> 16)) >>> 0) / 4294967295;",
391
+ "}",
392
+ "",
393
+ "function periodicValueNoise(u: number, v: number, seed: number, periodX: number, periodY: number): number {",
394
+ " const x = u * periodX;",
395
+ " const y = v * periodY;",
396
+ " const x0 = Math.floor(x);",
397
+ " const y0 = Math.floor(y);",
398
+ " const tx = smoothCurve(x - x0);",
399
+ " const ty = smoothCurve(y - y0);",
400
+ " const a = periodicHash(x0, y0, seed, periodX, periodY);",
401
+ " const b = periodicHash(x0 + 1, y0, seed, periodX, periodY);",
402
+ " const c = periodicHash(x0, y0 + 1, seed, periodX, periodY);",
403
+ " const d = periodicHash(x0 + 1, y0 + 1, seed, periodX, periodY);",
404
+ " return THREE.MathUtils.lerp(THREE.MathUtils.lerp(a, b, tx), THREE.MathUtils.lerp(c, d, tx), ty);",
405
+ "}",
406
+ "",
407
+ "type SurfaceBand = {",
408
+ " frequency: number;",
409
+ " amplitude: number;",
410
+ " stretchX: number;",
411
+ " stretchY: number;",
412
+ " ridge: boolean;",
413
+ "};",
414
+ "",
415
+ "function surfaceBands(spec: SculptMaterialSpec): SurfaceBand[] {",
416
+ " const source = Array.isArray(spec.surfaceFrequencyBands) ? spec.surfaceFrequencyBands : [];",
417
+ " const parsed = source.flatMap((item: unknown) => {",
418
+ " if (!item || typeof item !== 'object') return [];",
419
+ " const band = item as Record<string, unknown>;",
420
+ " const frequency = typeof band.frequency === 'number' ? band.frequency : 0;",
421
+ " const amplitude = typeof band.amplitude === 'number' ? band.amplitude : 0;",
422
+ " if (frequency <= 0 || amplitude <= 0) return [];",
423
+ " const stretch = Array.isArray(band.stretch) ? band.stretch : [1, 1];",
424
+ " const description = `${String(band.pattern ?? '')} ${String(band.role ?? '')}`.toLowerCase();",
425
+ " return [{",
426
+ " frequency,",
427
+ " amplitude,",
428
+ " stretchX: typeof stretch[0] === 'number' ? Math.max(0.1, stretch[0]) : 1,",
429
+ " stretchY: typeof stretch[1] === 'number' ? Math.max(0.1, stretch[1]) : 1,",
430
+ " ridge: /(ridge|groove|grain|fiber|striated|crack)/.test(description),",
431
+ " }];",
432
+ " });",
433
+ " return parsed.length > 0 ? parsed : [",
434
+ " { frequency: 2, amplitude: 0.42, stretchX: 1, stretchY: 1, ridge: false },",
435
+ " { frequency: 12, amplitude: 0.22, stretchX: 1, stretchY: 1, ridge: false },",
436
+ " { frequency: 56, amplitude: 0.08, stretchX: 1, stretchY: 1, ridge: false },",
437
+ " ];",
438
+ "}",
439
+ "",
440
+ "function sampleSurface(u: number, v: number, bands: SurfaceBand[], seed: number): number {",
441
+ " let value = 0;",
442
+ " let weight = 0;",
443
+ " for (let index = 0; index < bands.length; index += 1) {",
444
+ " const band = bands[index];",
445
+ " const periodX = Math.max(1, Math.round(band.frequency * band.stretchX));",
446
+ " const periodY = Math.max(1, Math.round(band.frequency * band.stretchY));",
447
+ " let sample = periodicValueNoise(u, v, seed + index * 1013, periodX, periodY);",
448
+ " if (band.ridge) sample = 1 - Math.abs(sample * 2 - 1);",
449
+ " value += sample * band.amplitude;",
450
+ " weight += band.amplitude;",
451
+ " }",
452
+ " return weight > 0 ? clamp01(value / weight) : 0.5;",
453
+ "}",
454
+ "",
455
+ "function mixPalette(colors: [number, number, number][], value: number): [number, number, number] {",
456
+ " if (colors.length === 1) return colors[0];",
457
+ " const scaled = clamp01(value) * (colors.length - 1);",
458
+ " const index = Math.min(colors.length - 2, Math.floor(scaled));",
459
+ " const mix = scaled - index;",
460
+ " const a = colors[index];",
461
+ " const b = colors[index + 1];",
462
+ " return [",
463
+ " Math.round(THREE.MathUtils.lerp(a[0], b[0], mix)),",
464
+ " Math.round(THREE.MathUtils.lerp(a[1], b[1], mix)),",
465
+ " Math.round(THREE.MathUtils.lerp(a[2], b[2], mix)),",
466
+ " ];",
467
+ "}",
468
+ "",
469
+ "function writePixel(data: Uint8ClampedArray, offset: number, red: number, green: number, blue: number): void {",
470
+ " data[offset] = Math.max(0, Math.min(255, Math.round(red)));",
471
+ " data[offset + 1] = Math.max(0, Math.min(255, Math.round(green)));",
472
+ " data[offset + 2] = Math.max(0, Math.min(255, Math.round(blue)));",
473
+ " data[offset + 3] = 255;",
474
+ "}",
475
+ "",
476
+ "function makeCanvas(size: number): HTMLCanvasElement {",
477
+ " const canvas = document.createElement('canvas');",
478
+ " canvas.width = size;",
479
+ " canvas.height = size;",
480
+ " return canvas;",
481
+ "}",
482
+ "",
483
+ "function createMapTexture(",
484
+ " canvas: HTMLCanvasElement,",
485
+ " colorSpace: THREE.ColorSpace,",
486
+ " spec: SculptMaterialSpec,",
487
+ " options: ProceduralModelOptions,",
488
+ "): THREE.CanvasTexture {",
489
+ " const texture = new THREE.CanvasTexture(canvas);",
490
+ " const projection = spec.textureProjection && typeof spec.textureProjection === 'object' ? spec.textureProjection : {};",
491
+ " const repeat = Array.isArray(projection.repeat) ? projection.repeat : [2, 2];",
492
+ " texture.colorSpace = colorSpace;",
493
+ " texture.wrapS = THREE.RepeatWrapping;",
494
+ " texture.wrapT = THREE.RepeatWrapping;",
495
+ " texture.repeat.set(",
496
+ " typeof repeat[0] === 'number' ? repeat[0] : 2,",
497
+ " typeof repeat[1] === 'number' ? repeat[1] : 2,",
498
+ " );",
499
+ " texture.anisotropy = Math.max(1, Math.round(options.textureAnisotropy ?? projection.anisotropy ?? 8));",
500
+ " texture.needsUpdate = true;",
501
+ " return texture;",
502
+ "}",
503
+ "",
504
+ "type ProceduralTextureSet = {",
505
+ " albedo: THREE.Texture;",
506
+ " roughness: THREE.Texture;",
507
+ " height: THREE.Texture;",
508
+ " normal: THREE.Texture;",
509
+ " ao: THREE.Texture;",
510
+ " source: 'reference-pixel-extraction' | 'procedural';",
511
+ "};",
512
+ "",
513
+ "function referenceMapUrl(spec: SculptMaterialSpec, channel: string): string | null {",
514
+ " const reference = spec.referencePbr;",
515
+ " if (!reference || typeof reference !== 'object') return null;",
516
+ " if (reference.usable === false) return null;",
517
+ " const confidence = typeof reference.confidence === 'number'",
518
+ " ? reference.confidence",
519
+ " : (typeof reference.estimatedFidelity === 'number' ? reference.estimatedFidelity : 0);",
520
+ " const threshold = typeof reference.targetThreshold === 'number' ? reference.targetThreshold : 0.7;",
521
+ " if (confidence < threshold) return null;",
522
+ " const maps = reference.maps;",
523
+ " if (!maps || typeof maps !== 'object') return null;",
524
+ " const map = (maps as Record<string, unknown>)[channel];",
525
+ " if (!map || typeof map !== 'object') return null;",
526
+ " const record = map as Record<string, unknown>;",
527
+ " const url = typeof record.url === 'string' && record.url.trim() ? record.url : record.path;",
528
+ " return typeof url === 'string' && url.trim() ? url : null;",
529
+ "}",
530
+ "",
531
+ "function createLoadedMapTexture(",
532
+ " url: string,",
533
+ " colorSpace: THREE.ColorSpace,",
534
+ " spec: SculptMaterialSpec,",
535
+ " options: ProceduralModelOptions,",
536
+ "): THREE.Texture {",
537
+ " const texture = new THREE.TextureLoader().load(url);",
538
+ " const projection = spec.textureProjection && typeof spec.textureProjection === 'object' ? spec.textureProjection : {};",
539
+ " const repeat = Array.isArray(projection.repeat) ? projection.repeat : [1, 1];",
540
+ " texture.colorSpace = colorSpace;",
541
+ " texture.wrapS = THREE.RepeatWrapping;",
542
+ " texture.wrapT = THREE.RepeatWrapping;",
543
+ " texture.repeat.set(",
544
+ " typeof repeat[0] === 'number' ? repeat[0] : 1,",
545
+ " typeof repeat[1] === 'number' ? repeat[1] : 1,",
546
+ " );",
547
+ " texture.anisotropy = Math.max(1, Math.round(options.textureAnisotropy ?? projection.anisotropy ?? 8));",
548
+ " texture.needsUpdate = true;",
549
+ " return texture;",
550
+ "}",
551
+ "",
552
+ "function makeReferenceTextureSet(spec: SculptMaterialSpec, options: ProceduralModelOptions): ProceduralTextureSet | null {",
553
+ " const albedo = referenceMapUrl(spec, 'albedo');",
554
+ " const roughness = referenceMapUrl(spec, 'roughness');",
555
+ " const height = referenceMapUrl(spec, 'height');",
556
+ " const normal = referenceMapUrl(spec, 'normal');",
557
+ " const ao = referenceMapUrl(spec, 'ao');",
558
+ " if (!albedo || !roughness || !height || !normal || !ao) return null;",
559
+ " return {",
560
+ " albedo: createLoadedMapTexture(albedo, THREE.SRGBColorSpace, spec, options),",
561
+ " roughness: createLoadedMapTexture(roughness, THREE.NoColorSpace, spec, options),",
562
+ " height: createLoadedMapTexture(height, THREE.NoColorSpace, spec, options),",
563
+ " normal: createLoadedMapTexture(normal, THREE.NoColorSpace, spec, options),",
564
+ " ao: createLoadedMapTexture(ao, THREE.NoColorSpace, spec, options),",
565
+ " source: 'reference-pixel-extraction',",
566
+ " };",
567
+ "}",
568
+ "",
569
+ "function makeProceduralTextureSet(",
570
+ " id: string,",
571
+ " spec: SculptMaterialSpec,",
572
+ " options: ProceduralModelOptions,",
573
+ "): ProceduralTextureSet | null {",
574
+ " if (typeof document === 'undefined') return null;",
575
+ " const qualityFirst = (options.qualityPriority ?? 'reference-fidelity') === 'reference-fidelity';",
576
+ " const requested = options.textureSize ?? spec.textureResolution;",
577
+ " const requestedSize = typeof requested === 'number' && Number.isFinite(requested)",
578
+ " ? requested",
579
+ " : (qualityFirst ? 1024 : 512);",
580
+ " const size = Math.max(256, Math.min(2048, 2 ** Math.round(Math.log2(requestedSize))));",
581
+ " const canvases = {",
582
+ " albedo: makeCanvas(size),",
583
+ " roughness: makeCanvas(size),",
584
+ " height: makeCanvas(size),",
585
+ " normal: makeCanvas(size),",
586
+ " ao: makeCanvas(size),",
587
+ " };",
588
+ " const contexts = {",
589
+ " albedo: canvases.albedo.getContext('2d'),",
590
+ " roughness: canvases.roughness.getContext('2d'),",
591
+ " height: canvases.height.getContext('2d'),",
592
+ " normal: canvases.normal.getContext('2d'),",
593
+ " ao: canvases.ao.getContext('2d'),",
594
+ " };",
595
+ " if (!contexts.albedo || !contexts.roughness || !contexts.height || !contexts.normal || !contexts.ao) return null;",
596
+ " const images = {",
597
+ " albedo: contexts.albedo.createImageData(size, size),",
598
+ " roughness: contexts.roughness.createImageData(size, size),",
599
+ " height: contexts.height.createImageData(size, size),",
600
+ " normal: contexts.normal.createImageData(size, size),",
601
+ " ao: contexts.ao.createImageData(size, size),",
602
+ " };",
603
+ " const seed = hashString(id);",
604
+ " const bands = surfaceBands(spec);",
605
+ " const heightField = new Float32Array(size * size);",
606
+ " const roughnessField = new Float32Array(size * size);",
607
+ " const palette = materialPalette(spec);",
608
+ " const fallback = typeof spec.baseColor === 'string' ? spec.baseColor : '#8A7A5F';",
609
+ " const colors = (palette.length >= 2 ? palette : [fallback, '#6E614B', '#A08F70']).map(hexToRgb);",
610
+ " const baseRoughness = clamp01(readLayerNumber(spec.roughness, ['base'], 0.76));",
611
+ " const roughnessVariation = clamp01(readLayerNumber(spec.roughness, ['variation'], 0.18));",
612
+ " const colorAmplitude = clamp01(readLayerNumber(spec.colorVariation, ['amplitude', 'variation'], 0.18));",
613
+ " const heightCorrelation = clamp01(readLayerNumber(spec.colorVariation, ['heightCorrelation'], 0.3));",
614
+ " for (let y = 0; y < size; y += 1) {",
615
+ " const v = y / size;",
616
+ " for (let x = 0; x < size; x += 1) {",
617
+ " const u = x / size;",
618
+ " const index = y * size + x;",
619
+ " const height = sampleSurface(u, v, bands, seed + 101);",
620
+ " const roughNoise = sampleSurface(u, v, bands, seed + 7001);",
621
+ " const colorNoise = sampleSurface(u, v, bands, seed + 15013);",
622
+ " heightField[index] = height;",
623
+ " roughnessField[index] = clamp01(baseRoughness + (roughNoise - 0.5) * roughnessVariation * 2);",
624
+ " const paletteValue = clamp01(",
625
+ " 0.5 + (colorNoise - 0.5) * colorAmplitude * 2 + (height - 0.5) * heightCorrelation",
626
+ " );",
627
+ " const color = mixPalette(colors, paletteValue);",
628
+ " writePixel(images.albedo.data, index * 4, color[0], color[1], color[2]);",
629
+ " }",
630
+ " }",
631
+ " const normalStrength = Math.max(0.05, readLayerNumber(spec.normal, ['strength', 'amplitude'], 0.35));",
632
+ " const aoStrength = clamp01(readLayerNumber(spec.ambientOcclusion, ['cavityStrength', 'strength'], 0.35));",
633
+ " for (let y = 0; y < size; y += 1) {",
634
+ " const up = ((y - 1 + size) % size) * size;",
635
+ " const down = ((y + 1) % size) * size;",
636
+ " for (let x = 0; x < size; x += 1) {",
637
+ " const left = (x - 1 + size) % size;",
638
+ " const right = (x + 1) % size;",
639
+ " const index = y * size + x;",
640
+ " const center = heightField[index];",
641
+ " const dx = (heightField[y * size + right] - heightField[y * size + left]) * normalStrength * 6;",
642
+ " const dy = (heightField[down + x] - heightField[up + x]) * normalStrength * 6;",
643
+ " const inverseLength = 1 / Math.sqrt(dx * dx + dy * dy + 1);",
644
+ " const normalX = -dx * inverseLength;",
645
+ " const normalY = -dy * inverseLength;",
646
+ " const normalZ = inverseLength;",
647
+ " const neighborAverage = (",
648
+ " heightField[y * size + left] + heightField[y * size + right]",
649
+ " + heightField[up + x] + heightField[down + x]",
650
+ " ) * 0.25;",
651
+ " const cavity = Math.max(0, neighborAverage - center);",
652
+ " const ao = clamp01(1 - aoStrength * (cavity * 12 + (1 - center) * 0.16));",
653
+ " const offset = index * 4;",
654
+ " const heightByte = center * 255;",
655
+ " const roughnessByte = roughnessField[index] * 255;",
656
+ " writePixel(images.height.data, offset, heightByte, heightByte, heightByte);",
657
+ " writePixel(images.roughness.data, offset, roughnessByte, roughnessByte, roughnessByte);",
658
+ " writePixel(",
659
+ " images.normal.data, offset,",
660
+ " (normalX * 0.5 + 0.5) * 255,",
661
+ " (normalY * 0.5 + 0.5) * 255,",
662
+ " (normalZ * 0.5 + 0.5) * 255,",
663
+ " );",
664
+ " writePixel(images.ao.data, offset, ao * 255, ao * 255, ao * 255);",
665
+ " }",
666
+ " }",
667
+ " contexts.albedo.putImageData(images.albedo, 0, 0);",
668
+ " contexts.roughness.putImageData(images.roughness, 0, 0);",
669
+ " contexts.height.putImageData(images.height, 0, 0);",
670
+ " contexts.normal.putImageData(images.normal, 0, 0);",
671
+ " contexts.ao.putImageData(images.ao, 0, 0);",
672
+ " return {",
673
+ " albedo: createMapTexture(canvases.albedo, THREE.SRGBColorSpace, spec, options),",
674
+ " roughness: createMapTexture(canvases.roughness, THREE.NoColorSpace, spec, options),",
675
+ " height: createMapTexture(canvases.height, THREE.NoColorSpace, spec, options),",
676
+ " normal: createMapTexture(canvases.normal, THREE.NoColorSpace, spec, options),",
677
+ " ao: createMapTexture(canvases.ao, THREE.NoColorSpace, spec, options),",
678
+ " source: 'procedural',",
679
+ " };",
680
+ "}",
681
+ "",
682
+ "function createSculptMaterial(id: string, spec: SculptMaterialSpec, options: ProceduralModelOptions): THREE.MeshPhysicalMaterial {",
683
+ " const textures = makeReferenceTextureSet(spec, options) ?? makeProceduralTextureSet(id, spec, options);",
684
+ " const material = new THREE.MeshPhysicalMaterial({",
685
+ " color: textures ? 0xffffff : new THREE.Color(typeof spec.baseColor === 'string' ? spec.baseColor : '#8A7A5F'),",
686
+ " roughness: textures ? 1 : clamp01(readLayerNumber(spec.roughness, ['base'], 0.76)),",
687
+ " metalness: clamp01(readLayerNumber(spec.metalness, ['base'], 0.0)),",
688
+ " clearcoat: clamp01(readLayerNumber(spec.clearcoat, ['base', 'amount'], 0)),",
689
+ " clearcoatRoughness: clamp01(readLayerNumber(spec.clearcoatRoughness, ['base'], 0.25)),",
690
+ " transmission: clamp01(readLayerNumber(spec.transmission, ['base', 'amount'], 0)),",
691
+ " opacity: clamp01(readLayerNumber(spec.opacity, ['base'], 1)),",
692
+ " transparent: readLayerNumber(spec.transmission, ['base', 'amount'], 0) > 0 || readLayerNumber(spec.opacity, ['base'], 1) < 1,",
693
+ " alphaTest: Math.max(0, readLayerNumber(spec.alpha, ['cutoff', 'alphaTest'], 0)),",
694
+ " wireframe: options.wireframe ?? false,",
695
+ " side: spec.doubleSided === true ? THREE.DoubleSide : THREE.FrontSide,",
696
+ " });",
697
+ " if (textures) {",
698
+ " material.map = textures.albedo;",
699
+ " material.roughnessMap = textures.roughness;",
700
+ " material.normalMap = textures.normal;",
701
+ " material.normalScale.setScalar(Math.max(0.05, readLayerNumber(spec.normal, ['strength', 'amplitude'], 0.35)));",
702
+ " material.aoMap = textures.ao;",
703
+ " material.aoMap.channel = 0;",
704
+ " material.aoMapIntensity = readLayerNumber(spec.ambientOcclusion, ['cavityStrength', 'strength'], 0.35);",
705
+ " const bumpScale = Math.max(0, readLayerNumber(spec.bump, ['amplitude', 'strength'], 0));",
706
+ " if (bumpScale > 0) {",
707
+ " material.bumpMap = textures.height;",
708
+ " material.bumpScale = bumpScale;",
709
+ " }",
710
+ " const displacementScale = Math.max(0, readLayerNumber(spec.displacement, ['amplitude', 'strength'], 0));",
711
+ " if (displacementScale > 0) {",
712
+ " material.displacementMap = textures.height;",
713
+ " material.displacementScale = displacementScale;",
714
+ " material.displacementBias = -displacementScale * 0.5;",
715
+ " }",
716
+ " }",
717
+ " material.envMapIntensity = readLayerNumber(spec, ['envMapIntensity'], 0.8);",
718
+ " material.userData.sculptMaterial = spec;",
719
+ " material.userData.proceduralMapsIndependent = true;",
720
+ " material.userData.pbrTextureSource = textures?.source ?? 'flat-fallback';",
721
+ " material.userData.referencePbr = spec.referencePbr ?? null;",
722
+ " material.needsUpdate = true;",
723
+ " return material;",
724
+ "}",
725
+ "",
726
+ "type AttachmentEndpoint = {",
727
+ " start: THREE.Vector3;",
728
+ " midpoint: THREE.Vector3;",
729
+ " quaternion: THREE.Quaternion;",
730
+ " length: number;",
731
+ " baseRadius: number;",
732
+ " endRadius: number;",
733
+ "};",
734
+ "",
735
+ "function readVector3(value: unknown, fallback: [number, number, number]): THREE.Vector3 {",
736
+ " if (Array.isArray(value) && value.length === 3 && value.every((item) => typeof item === 'number')) {",
737
+ " return new THREE.Vector3(value[0], value[1], value[2]);",
738
+ " }",
739
+ " return new THREE.Vector3(fallback[0], fallback[1], fallback[2]);",
740
+ "}",
741
+ "",
742
+ "function readNumber(value: unknown, fallback: number): number {",
743
+ " return typeof value === 'number' && Number.isFinite(value) ? value : fallback;",
744
+ "}",
745
+ "",
746
+ "function makeAttachmentEndpoint(attachment: unknown): AttachmentEndpoint | null {",
747
+ " if (!attachment || typeof attachment !== 'object') return null;",
748
+ " const record = attachment as Record<string, unknown>;",
749
+ " const start = readVector3(record.localStart, [0, 0, 0]);",
750
+ " const end = readVector3(record.localEnd, [0, 1, 0]);",
751
+ " const delta = end.clone().sub(start);",
752
+ " const length = delta.length();",
753
+ " if (length <= 0.0001) return null;",
754
+ " const direction = delta.clone().normalize();",
755
+ " const quaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);",
756
+ " const baseRadius = Math.max(0.005, readNumber(record.baseRadius, 0.06));",
757
+ " const endRadius = Math.max(0.003, readNumber(record.endRadius, baseRadius * 0.55));",
758
+ " return {",
759
+ " start,",
760
+ " midpoint: delta.multiplyScalar(0.5),",
761
+ " quaternion,",
762
+ " length,",
763
+ " baseRadius,",
764
+ " endRadius,",
765
+ " };",
766
+ "}",
767
+ "",
768
+ f"// Generated from ObjectSculptSpec target: {target}",
769
+ f"// Sculpt build pass: {pass_id}",
770
+ (
771
+ "// Compile-only hosted preview: unreviewed; no upstream pass approval is claimed."
772
+ if pass_id == "hosted-preview"
773
+ else "// Pass-gated output. Finish browser screenshot review before unlocking deeper passes."
774
+ ),
775
+ f"export function {function_name}(options: ProceduralModelOptions = {{}}): THREE.Group {{",
776
+ " const root = new THREE.Group();",
777
+ f" root.name = {json.dumps(target)};",
778
+ "",
779
+ " const materialMap: Record<string, THREE.Material> = {};",
780
+ ]
781
+ for material_id, material in materials.items():
782
+ lines.extend(
783
+ [
784
+ f" materialMap[{json.dumps(material_id)}] = createSculptMaterial(",
785
+ f" {json.dumps(material_id)},",
786
+ f" {json_literal(material)},",
787
+ " options",
788
+ " );",
789
+ ]
790
+ )
791
+ lines.extend(
792
+ [
793
+ "",
794
+ " const nodes: Record<string, THREE.Object3D> = { root };",
795
+ " const meshes: Record<string, THREE.Mesh> = {};",
796
+ " const sockets: Record<string, THREE.Object3D> = {};",
797
+ " const colliders: Record<string, unknown> = {};",
798
+ " const destructionGroups: Record<string, THREE.Object3D[]> = {};",
799
+ ]
800
+ )
801
+
802
+ for index, component in enumerate(components):
803
+ component_id = str(component.get("id") or f"component-{index}")
804
+ component_var = local_var("mesh", component_id, index)
805
+ node_var = local_var("node", component_id, index)
806
+ primitive = str(component.get("primitive") or "box")
807
+ if primitive not in VALID_PRIMITIVES:
808
+ primitive = "box"
809
+ transform = component.get("transform", {}) if isinstance(component.get("transform"), dict) else {}
810
+ action_profile = component.get("actionProfile") if isinstance(component.get("actionProfile"), dict) else {}
811
+ sockets_spec = action_profile.get("sockets", []) if isinstance(action_profile.get("sockets"), list) else []
812
+ destruction = action_profile.get("destruction") if isinstance(action_profile.get("destruction"), dict) else {}
813
+ fracture_group = destruction.get("fractureGroup") if isinstance(destruction, dict) else None
814
+ attachment = attachment_for_geometry(component, primitive)
815
+ attachment_var = local_var("attachment", component_id, index)
816
+ endpoint_var = local_var("endpoint", component_id, index)
817
+ material_id = str(component.get("material") or next(iter(materials.keys()), "base"))
818
+ parent = component.get("parent") or "root"
819
+ name = str(component.get("name") or component_id)
820
+ lines.extend(
821
+ [
822
+ "",
823
+ f" const {attachment_var} = {json.dumps(attachment, ensure_ascii=False)};",
824
+ f" const {endpoint_var} = makeAttachmentEndpoint({attachment_var});",
825
+ f" const {node_var} = new THREE.Group();",
826
+ f" {node_var}.name = {json.dumps(name + '__pivot')};",
827
+ f" if ({endpoint_var}) {{",
828
+ f" {node_var}.position.copy({endpoint_var}.start);",
829
+ f" {node_var}.rotation.set(0, 0, 0);",
830
+ f" {node_var}.scale.set(1, 1, 1);",
831
+ " } else {",
832
+ f" {node_var}.position.set({vector(transform.get('position'), [0, 0, 0])});",
833
+ f" {node_var}.rotation.set({vector(transform.get('rotation'), [0, 0, 0])});",
834
+ f" {node_var}.scale.set({vector(transform.get('scale'), [1, 1, 1])});",
835
+ " }",
836
+ f" {node_var}.userData.sculptComponent = {json.dumps(component, ensure_ascii=False)};",
837
+ f" {node_var}.userData.actionProfile = {json.dumps(action_profile, ensure_ascii=False)};",
838
+ f" (nodes[{json.dumps(str(parent))}] ?? root).add({node_var});",
839
+ f" nodes[{json.dumps(component_id)}] = {node_var};",
840
+ ]
841
+ )
842
+ if primitive in AXIS_ATTACHMENT_PRIMITIVES:
843
+ lines.extend(
844
+ [
845
+ f" const {component_var}Geometry = {endpoint_var}",
846
+ f" ? {attachment_geometry_for(primitive, endpoint_var)}",
847
+ f" : {geometry_for(primitive)};",
848
+ ]
849
+ )
850
+ else:
851
+ lines.append(f" const {component_var}Geometry = {geometry_for(primitive)};")
852
+ lines.extend(
853
+ [
854
+ f" const {component_var} = new THREE.Mesh(",
855
+ f" {component_var}Geometry,",
856
+ f" materialMap[{json.dumps(material_id)}] ?? new THREE.MeshStandardMaterial({{ color: 0x888888 }})",
857
+ " );",
858
+ f" {component_var}.name = {json.dumps(name)};",
859
+ f" if ({endpoint_var}) {{",
860
+ f" {component_var}.position.copy({endpoint_var}.midpoint);",
861
+ f" {component_var}.quaternion.copy({endpoint_var}.quaternion);",
862
+ " } else {",
863
+ f" {component_var}.scale.set({dimension_scale_vector(component, primitive)});",
864
+ " }",
865
+ f" {component_var}.castShadow = options.castShadow ?? true;",
866
+ f" {component_var}.receiveShadow = options.receiveShadow ?? true;",
867
+ f" {component_var}.userData.sculptComponent = {json.dumps(component, ensure_ascii=False)};",
868
+ f" {node_var}.add({component_var});",
869
+ f" meshes[{json.dumps(component_id)}] = {component_var};",
870
+ f" colliders[{json.dumps(component_id)}] = {json.dumps(action_profile.get('collider', {}), ensure_ascii=False)};",
871
+ ]
872
+ )
873
+ if isinstance(fracture_group, str) and fracture_group:
874
+ lines.extend(
875
+ [
876
+ f" destructionGroups[{json.dumps(fracture_group)}] ??= [];",
877
+ f" destructionGroups[{json.dumps(fracture_group)}].push({node_var});",
878
+ ]
879
+ )
880
+ for socket_index, socket in enumerate(sockets_spec):
881
+ if not isinstance(socket, dict):
882
+ continue
883
+ socket_id = str(socket.get("id") or f"socket-{socket_index}")
884
+ socket_var = local_var("socket", f"{component_id}_{socket_id}", socket_index)
885
+ local_position = socket.get("localPosition", socket.get("position"))
886
+ local_rotation = socket.get("localRotation", socket.get("rotation"))
887
+ socket_key = f"{component_id}:{socket_id}"
888
+ lines.extend(
889
+ [
890
+ f" const {socket_var} = new THREE.Object3D();",
891
+ f" {socket_var}.name = {json.dumps(socket_id)};",
892
+ f" {socket_var}.position.set({vector(local_position, [0, 0, 0])});",
893
+ f" {socket_var}.rotation.set({vector(local_rotation, [0, 0, 0])});",
894
+ f" {socket_var}.userData.socket = {json.dumps(socket, ensure_ascii=False)};",
895
+ f" {node_var}.add({socket_var});",
896
+ f" sockets[{json.dumps(socket_key)}] = {socket_var};",
897
+ ]
898
+ )
899
+ look_dev_targets = spec.get("lookDevTargets", {})
900
+ lighting_from_photo = spec.get("lightingFromPhoto", [])
901
+ lines.extend(
902
+ [
903
+ "",
904
+ " root.userData.sculptRuntime = { nodes, meshes, sockets, colliders, destructionGroups } satisfies ProceduralModelRuntime;",
905
+ f" root.userData.lookDevTargets = {json_literal(look_dev_targets)};",
906
+ " root.userData.actionReadiness = {",
907
+ " note: 'Use root.userData.sculptRuntime.nodes for transforms, sockets for attachments, colliders for physics proxies, and destructionGroups for breakable sets.',",
908
+ " };",
909
+ " return root;",
910
+ "}",
911
+ "",
912
+ f"export function create{type_name}LookDevLights(",
913
+ " mode: 'neutral' | 'grazing' | 'reference' = 'neutral',",
914
+ "): THREE.Group {",
915
+ " const lights = new THREE.Group();",
916
+ f" lights.name = {json.dumps(target + ' look-dev lights')};",
917
+ " const hemi = new THREE.HemisphereLight(",
918
+ " mode === 'reference' ? 0xfff0d6 : 0xf2f4ff,",
919
+ " 0x363b42,",
920
+ " mode === 'grazing' ? 0.28 : mode === 'reference' ? 0.72 : 0.85,",
921
+ " );",
922
+ " lights.add(hemi);",
923
+ " const key = new THREE.DirectionalLight(",
924
+ " mode === 'reference' ? 0xffcf8a : 0xfff4e8,",
925
+ " mode === 'grazing' ? 4.2 : mode === 'reference' ? 2.6 : 2.15,",
926
+ " );",
927
+ " if (mode === 'grazing') key.position.set(7.5, 1.1, 4.0);",
928
+ " else if (mode === 'reference') key.position.set(-4.5, 7.5, 5.0);",
929
+ " else key.position.set(-4.0, 6.0, 5.5);",
930
+ " key.castShadow = true;",
931
+ " key.shadow.mapSize.set(4096, 4096);",
932
+ " key.shadow.bias = -0.00025;",
933
+ " key.shadow.normalBias = 0.018;",
934
+ " lights.add(key);",
935
+ " const fill = new THREE.DirectionalLight(0xa8c4ff, mode === 'grazing' ? 0.12 : 0.42);",
936
+ " fill.position.set(4.0, 3.0, 3.5);",
937
+ " lights.add(fill);",
938
+ " const rim = new THREE.DirectionalLight(0xfff1c4, mode === 'grazing' ? 0.28 : 0.85);",
939
+ " rim.position.set(0.5, 4.5, -6.0);",
940
+ " lights.add(rim);",
941
+ " lights.userData.reviewMode = mode;",
942
+ f" lights.userData.lightingFromPhoto = {json_literal(lighting_from_photo)};",
943
+ f" lights.userData.lookDevTargets = {json_literal(look_dev_targets)};",
944
+ " return lights;",
945
+ "}",
946
+ "",
947
+ ]
948
+ )
949
+ return "\n".join(lines)
950
+
951
+
952
+ def main(argv: list[str]) -> int:
953
+ parser = argparse.ArgumentParser(description=__doc__)
954
+ parser.add_argument("spec", type=Path)
955
+ parser.add_argument("--out", type=Path, required=True)
956
+ parser.add_argument(
957
+ "--pass-id",
958
+ help="Build pass to generate. Defaults to the current unlocked sculptPipeline pass.",
959
+ )
960
+ parser.add_argument("--force", action="store_true")
961
+ args = parser.parse_args(argv)
962
+
963
+ spec = load_spec(args.spec.expanduser().resolve())
964
+ pass_id = args.pass_id or unlocked_pass(spec)
965
+ try:
966
+ assert_pass_unlocked(spec, pass_id)
967
+ except ValueError as exc:
968
+ parser.error(str(exc))
969
+ gaps = pass_specific_gaps(spec, pass_id)
970
+ if gaps:
971
+ parser.error(f"build pass {pass_id!r} needs spec refinement: {'; '.join(gaps)}")
972
+ output = args.out.expanduser().resolve()
973
+ if output.exists() and not args.force:
974
+ parser.error(f"{output} already exists; use --force to overwrite")
975
+ output.parent.mkdir(parents=True, exist_ok=True)
976
+ try:
977
+ source = generate(spec, pass_id)
978
+ except ValueError as exc:
979
+ parser.error(str(exc))
980
+ output.write_text(source, encoding="utf-8")
981
+ print(output)
982
+ return 0
983
+
984
+
985
+ if __name__ == "__main__":
986
+ raise SystemExit(main(sys.argv[1:]))
forge/stage3_build/orchestrate_passes.py ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Gate procedural sculpt generation through ordered build passes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "_shared"))
14
+ from feature_acceptance_policy import feature_gate_failures
15
+
16
+
17
+ DEFAULT_PASS_ORDER = [
18
+ "blockout",
19
+ "structural-pass",
20
+ "form-refinement",
21
+ "material-pass",
22
+ "surface-pass",
23
+ "lighting-pass",
24
+ "interaction-pass",
25
+ "optimization-pass",
26
+ ]
27
+ VISUAL_PASS_IDS = set(DEFAULT_PASS_ORDER) - {"optimization-pass"}
28
+ ATTACHMENT_ROLES = {
29
+ "appendage",
30
+ "branch",
31
+ "limb",
32
+ "arm",
33
+ "leg",
34
+ "handle",
35
+ "connector",
36
+ "tube",
37
+ "cable",
38
+ "horn",
39
+ "wing",
40
+ "tail",
41
+ "root",
42
+ "fork",
43
+ "rib",
44
+ "support",
45
+ "hinge",
46
+ "socket",
47
+ "pipe",
48
+ }
49
+ ATTACHMENT_PRIMITIVES = {"cylinder", "cone", "capsule", "tube", "curve-sweep"}
50
+
51
+
52
+ def load_spec(path: Path) -> dict[str, Any]:
53
+ payload = json.loads(path.read_text(encoding="utf-8"))
54
+ if not isinstance(payload, dict):
55
+ raise ValueError("spec must be a JSON object")
56
+ return payload
57
+
58
+
59
+ def write_spec(path: Path, spec: dict[str, Any]) -> None:
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ path.write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
62
+
63
+
64
+ def pass_order(spec: dict[str, Any]) -> list[str]:
65
+ ids: list[str] = []
66
+ for item in spec.get("buildPasses", []):
67
+ if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].strip():
68
+ ids.append(item["id"])
69
+ return ids or DEFAULT_PASS_ORDER.copy()
70
+
71
+
72
+ def pass_acceptance(spec: dict[str, Any], pass_id: str) -> list[str]:
73
+ for item in spec.get("buildPasses", []):
74
+ if isinstance(item, dict) and item.get("id") == pass_id:
75
+ acceptance = item.get("acceptance", [])
76
+ if isinstance(acceptance, list):
77
+ return [str(value) for value in acceptance if str(value).strip()]
78
+ return []
79
+
80
+
81
+ def visual_evidence(entry: dict[str, Any]) -> dict[str, Any]:
82
+ visual = entry.get("visualEvidence")
83
+ return visual if isinstance(visual, dict) else {}
84
+
85
+
86
+ def review_completes_pass(spec: dict[str, Any], entry: dict[str, Any], pass_id: str) -> bool:
87
+ if entry.get("passId") != pass_id or entry.get("action") != "continue":
88
+ return False
89
+ if pass_id in VISUAL_PASS_IDS:
90
+ visual = visual_evidence(entry)
91
+ if not visual.get("renderScreenshot") or not visual.get("comparisonImage"):
92
+ return False
93
+ score = entry.get("aiVisionScore")
94
+ threshold = entry.get("visualAcceptanceThreshold", 0.7)
95
+ if not has_number(score) or not has_number(threshold) or float(score) < float(threshold):
96
+ return False
97
+ if feature_gate_failures(spec, entry, pass_id):
98
+ return False
99
+ return True
100
+
101
+
102
+ def completed_passes(spec: dict[str, Any], ids: list[str]) -> list[str]:
103
+ history = spec.get("reviewHistory", [])
104
+ if not isinstance(history, list):
105
+ return []
106
+ completed: list[str] = []
107
+ for pass_id in ids:
108
+ if any(
109
+ isinstance(entry, dict) and review_completes_pass(spec, entry, pass_id)
110
+ for entry in history
111
+ ):
112
+ completed.append(pass_id)
113
+ else:
114
+ break
115
+ return completed
116
+
117
+
118
+ def current_pass(ids: list[str], completed: list[str]) -> str:
119
+ if len(completed) >= len(ids):
120
+ return "complete"
121
+ return ids[len(completed)]
122
+
123
+
124
+ def next_required_evidence(spec: dict[str, Any], pass_id: str) -> list[str]:
125
+ if pass_id == "complete":
126
+ return []
127
+ evidence = pass_acceptance(spec, pass_id)
128
+ evidence.extend(pass_specific_evidence(pass_id))
129
+ if pass_id in VISUAL_PASS_IDS:
130
+ evidence.append("browser render screenshot from your agent's browser/screenshot tool")
131
+ evidence.append("side-by-side reference/render comparison sheet for AI vision review")
132
+ evidence.append("AI vision score at or above the visual acceptance threshold")
133
+ evidence.append("all critical semantic feature scores from the shared image pair at or above their thresholds")
134
+ evidence.append("self-correction review appended with action=continue before the next pass")
135
+ return evidence
136
+
137
+
138
+ def has_non_empty(value: Any) -> bool:
139
+ if isinstance(value, str):
140
+ return bool(value.strip()) and value.strip().lower() not in {"none", "unassessed", "n/a"}
141
+ if isinstance(value, list):
142
+ return any(has_non_empty(item) for item in value)
143
+ if isinstance(value, dict):
144
+ return any(has_non_empty(item) for item in value.values())
145
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
146
+ return abs(float(value)) > 0
147
+ return False
148
+
149
+
150
+ def number_from_layer(value: Any, keys: tuple[str, ...]) -> float:
151
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
152
+ return float(value)
153
+ if isinstance(value, dict):
154
+ for key in keys:
155
+ item = value.get(key)
156
+ if isinstance(item, (int, float)) and not isinstance(item, bool):
157
+ return float(item)
158
+ return 0.0
159
+
160
+
161
+ def is_vector3(value: Any) -> bool:
162
+ return (
163
+ isinstance(value, list)
164
+ and len(value) == 3
165
+ and all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in value)
166
+ )
167
+
168
+
169
+ def has_number(value: Any) -> bool:
170
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
171
+
172
+
173
+ def component_requires_attachment(component: dict[str, Any]) -> bool:
174
+ if not component.get("parent"):
175
+ return False
176
+ role = str(component.get("role") or "").lower()
177
+ name = str(component.get("name") or component.get("id") or "").lower()
178
+ primitive = str(component.get("primitive") or "").lower()
179
+ action = component.get("actionProfile") if isinstance(component.get("actionProfile"), dict) else {}
180
+ animation_role = str(action.get("animationRole") or "").lower()
181
+ tokens = {role, animation_role} | set(re.findall(r"[a-z0-9]+", name))
182
+ return bool(tokens & ATTACHMENT_ROLES) or primitive in ATTACHMENT_PRIMITIVES
183
+
184
+
185
+ def attachment_complete(component: dict[str, Any]) -> bool:
186
+ attachment = component.get("attachment")
187
+ if not isinstance(attachment, dict):
188
+ return False
189
+ has_endpoint = is_vector3(attachment.get("localStart")) and is_vector3(attachment.get("localEnd"))
190
+ has_socket = has_non_empty(attachment.get("parentSocket")) or has_non_empty(attachment.get("parentId"))
191
+ has_contact = has_non_empty(attachment.get("contactType"))
192
+ has_overlap = (
193
+ number_from_layer(attachment.get("embedDepth"), ("base", "amount", "value")) > 0
194
+ or number_from_layer(attachment.get("overlap"), ("base", "amount", "value")) > 0
195
+ )
196
+ has_tolerance = has_number(attachment.get("gapTolerance"))
197
+ return has_endpoint and has_socket and has_contact and has_overlap and has_tolerance
198
+
199
+
200
+ def attachment_gaps(spec: dict[str, Any]) -> list[str]:
201
+ gaps: list[str] = []
202
+ for component in spec.get("componentTree", []):
203
+ if not isinstance(component, dict) or not component_requires_attachment(component):
204
+ continue
205
+ if attachment_complete(component):
206
+ continue
207
+ component_id = str(component.get("id") or component.get("name") or "(unnamed)")
208
+ gaps.append(
209
+ f"component {component_id!r} requires attachment.parentSocket/localStart/localEnd/"
210
+ "contactType/embedDepth(or overlap)/gapTolerance"
211
+ )
212
+ return gaps
213
+
214
+
215
+ def material_has_palette(material: dict[str, Any]) -> bool:
216
+ color_variation = material.get("colorVariation")
217
+ if isinstance(color_variation, dict) and len(color_variation.get("palette", [])) >= 2:
218
+ return True
219
+ albedo = material.get("albedo")
220
+ if isinstance(albedo, dict) and has_non_empty(albedo.get("secondary")):
221
+ return True
222
+ return has_non_empty(material.get("baseColor") or material.get("color"))
223
+
224
+
225
+ def material_has_response(material: dict[str, Any]) -> bool:
226
+ if number_from_layer(material.get("roughness"), ("variation", "base")) > 0:
227
+ return True
228
+ if number_from_layer(material.get("normal"), ("strength", "amplitude")) > 0:
229
+ return True
230
+ if number_from_layer(material.get("bump"), ("amplitude", "strength")) > 0:
231
+ return True
232
+ if number_from_layer(material.get("displacement"), ("amplitude", "strength")) > 0:
233
+ return True
234
+ return False
235
+
236
+
237
+ def material_has_locality(material: dict[str, Any]) -> bool:
238
+ if has_non_empty(material.get("localOverrides")):
239
+ return True
240
+ wear = material.get("wear")
241
+ if isinstance(wear, dict) and (
242
+ number_from_layer(wear.get("edgeWear"), ("base", "amount")) > 0
243
+ or has_non_empty(wear.get("scratches"))
244
+ or has_non_empty(wear.get("chips"))
245
+ ):
246
+ return True
247
+ dirt = material.get("dirt")
248
+ if isinstance(dirt, dict) and (
249
+ number_from_layer(dirt.get("amount"), ("base", "amount")) > 0
250
+ or number_from_layer(dirt.get("cavityBias"), ("base", "amount")) > 0
251
+ ):
252
+ return True
253
+ for field in ("moss", "stains", "scratches", "chips", "wetness", "patina", "soot"):
254
+ if has_non_empty(material.get(field)):
255
+ return True
256
+ return False
257
+
258
+
259
+ def quality_first_enabled(spec: dict[str, Any]) -> bool:
260
+ targets = spec.get("lookDevTargets")
261
+ return isinstance(targets, dict) and targets.get("qualityPriority") == "reference-fidelity"
262
+
263
+
264
+ def reference_pbr_usable(material: dict[str, Any], threshold: float) -> tuple[bool, str]:
265
+ material_id = str(material.get("id") or "(unnamed)")
266
+ reference = material.get("referencePbr")
267
+ if not isinstance(reference, dict):
268
+ return False, f"material {material_id!r} needs usable referencePbr extracted from source pixels"
269
+ if reference.get("usable") is not True:
270
+ return False, f"material {material_id!r} referencePbr.usable must be true"
271
+ confidence = reference.get("confidence", reference.get("estimatedFidelity"))
272
+ if not has_number(confidence) or float(confidence) < threshold:
273
+ return False, f"material {material_id!r} referencePbr confidence must be >= {threshold}"
274
+ maps = reference.get("maps")
275
+ if not isinstance(maps, dict):
276
+ return False, f"material {material_id!r} referencePbr needs maps"
277
+ for channel in ("albedo", "roughness", "height", "normal", "ao"):
278
+ entry = maps.get(channel)
279
+ if not isinstance(entry, dict) or not has_non_empty(entry.get("url") or entry.get("path")):
280
+ return False, f"material {material_id!r} referencePbr missing {channel} map path/url"
281
+ return True, ""
282
+
283
+
284
+ def quality_first_material_gaps(spec: dict[str, Any], material: dict[str, Any]) -> list[str]:
285
+ material_id = str(material.get("id") or "(unnamed)")
286
+ gaps: list[str] = []
287
+ targets = spec.get("lookDevTargets")
288
+ material_targets = targets.get("materialPass", {}) if isinstance(targets, dict) else {}
289
+ minimum_resolution = material_targets.get("minimumTextureResolution", 1024)
290
+ if not isinstance(minimum_resolution, int) or isinstance(minimum_resolution, bool):
291
+ minimum_resolution = 1024
292
+ extraction_targets = material_targets.get("referencePbrExtraction", {})
293
+ if not isinstance(extraction_targets, dict):
294
+ extraction_targets = {}
295
+ pbr_required = (
296
+ extraction_targets.get("requiredWhenSourceImagePresent") is True
297
+ and has_non_empty(spec.get("sourceImage"))
298
+ )
299
+ pbr_threshold = extraction_targets.get("targetThreshold", 0.7)
300
+ if not has_number(pbr_threshold):
301
+ pbr_threshold = 0.7
302
+ resolution = material.get("textureResolution")
303
+ if not isinstance(resolution, int) or isinstance(resolution, bool) or resolution < minimum_resolution:
304
+ gaps.append(f"material {material_id!r} textureResolution must be >= {minimum_resolution}")
305
+
306
+ projection = material.get("textureProjection")
307
+ if not isinstance(projection, dict) or not has_non_empty(projection.get("mode")):
308
+ gaps.append(f"material {material_id!r} needs textureProjection.mode and texel-density intent")
309
+
310
+ bands = material.get("surfaceFrequencyBands")
311
+ band_ids = {
312
+ str(item.get("id")).lower()
313
+ for item in bands
314
+ if isinstance(item, dict) and has_non_empty(item.get("id"))
315
+ } if isinstance(bands, list) else set()
316
+ missing_bands = {"macro", "meso", "micro"} - band_ids
317
+ if missing_bands:
318
+ gaps.append(
319
+ f"material {material_id!r} missing surface frequency bands: "
320
+ + ", ".join(sorted(missing_bands))
321
+ )
322
+
323
+ roughness = material.get("roughness")
324
+ roughness_map = roughness.get("map") if isinstance(roughness, dict) else None
325
+ if not has_non_empty(roughness_map) or "albedo" in str(roughness_map).lower():
326
+ gaps.append(f"material {material_id!r} needs an independent roughness map")
327
+ if not has_non_empty(material.get("normal")) and not has_non_empty(material.get("bump")):
328
+ gaps.append(f"material {material_id!r} needs an independent height/normal response")
329
+ if not has_non_empty(material.get("ambientOcclusion")):
330
+ gaps.append(f"material {material_id!r} needs an independent ambient-occlusion response")
331
+ if pbr_required:
332
+ ok, message = reference_pbr_usable(material, float(pbr_threshold))
333
+ if not ok:
334
+ gaps.append(message)
335
+ return gaps
336
+
337
+
338
+ def material_pass_gaps(spec: dict[str, Any]) -> list[str]:
339
+ materials = [item for item in spec.get("materials", []) if isinstance(item, dict)]
340
+ if not materials:
341
+ return ["materials array is empty"]
342
+ if not any(material_has_palette(item) for item in materials):
343
+ return ["no material has a reference-derived albedo palette or secondary color zones"]
344
+ gaps: list[str] = []
345
+ if not any(material_has_response(item) for item in materials):
346
+ gaps.append("no material defines roughness variation or normal/bump/displacement response")
347
+ if not any(material_has_locality(item) for item in materials):
348
+ gaps.append("no material defines local overrides, AO, dirt, wear, stains, moss, chips, or scratches")
349
+ if quality_first_enabled(spec):
350
+ for material in materials:
351
+ if material.get("qualityTier") == "utility":
352
+ continue
353
+ gaps.extend(quality_first_material_gaps(spec, material))
354
+ return gaps
355
+
356
+
357
+ def surface_pass_gaps(spec: dict[str, Any]) -> list[str]:
358
+ components = [item for item in spec.get("componentTree", []) if isinstance(item, dict)]
359
+ has_surface_detail = any(has_non_empty(item.get("surfaceDetail")) for item in components)
360
+ if not has_surface_detail:
361
+ return ["componentTree has no meaningful surfaceDetail for normal/bump/displacement/AO locality"]
362
+ return []
363
+
364
+
365
+ def lighting_pass_gaps(spec: dict[str, Any]) -> list[str]:
366
+ lighting = spec.get("lightingFromPhoto", [])
367
+ if not isinstance(lighting, list) or len([item for item in lighting if has_non_empty(item)]) < 3:
368
+ return ["lightingFromPhoto needs at least three concrete entries for key/fill/rim or environment lighting"]
369
+ text = " ".join(str(item).lower() for item in lighting)
370
+ required_groups = {
371
+ "key light": ("key", "sun", "main light"),
372
+ "fill light": ("fill", "ambient", "hemisphere"),
373
+ "rim/environment light": ("rim", "back light", "environment", "hdr", "reflection"),
374
+ "exposure/tone mapping": ("exposure", "tone", "aces", "filmic"),
375
+ "contact shadow": ("contact shadow", "ground shadow", "ambient occlusion", "ao"),
376
+ }
377
+ gaps = [
378
+ f"lightingFromPhoto missing {label}"
379
+ for label, terms in required_groups.items()
380
+ if not any(term in text for term in terms)
381
+ ]
382
+ return gaps
383
+
384
+
385
+ def pass_specific_evidence(pass_id: str) -> list[str]:
386
+ if pass_id in {"structural-pass", "form-refinement"}:
387
+ return [
388
+ "attachment contracts for child appendages/connectors",
389
+ "no floating child roots/joints in the browser screenshot",
390
+ ]
391
+ if pass_id == "material-pass":
392
+ return [
393
+ "reference-derived albedo palette with dominant, secondary, and accent colors",
394
+ "independent albedo, roughness, height/normal, and AO maps",
395
+ "macro, meso, and micro surface-frequency response at 1024px or higher",
396
+ "local material masks: AO, dirt, wear, stains, moss, chips, scratches, wetness, or equivalent",
397
+ "neutral, grazing-light close-up, and reference-matched browser screenshots",
398
+ "AI vision comparison sheet score meeting the visual acceptance threshold",
399
+ ]
400
+ if pass_id == "surface-pass":
401
+ return [
402
+ "component surfaceDetail for tactile normal/bump/displacement and locality",
403
+ ]
404
+ if pass_id == "lighting-pass":
405
+ return [
406
+ "lightingFromPhoto with key/fill/rim or environment light",
407
+ "exposure, tone mapping, background, shadow softness, and contact shadow behavior",
408
+ ]
409
+ return []
410
+
411
+
412
+ def pass_specific_gaps(spec: dict[str, Any], pass_id: str) -> list[str]:
413
+ if pass_id in {"structural-pass", "form-refinement"}:
414
+ return attachment_gaps(spec)
415
+ if pass_id == "material-pass":
416
+ return material_pass_gaps(spec)
417
+ if pass_id == "surface-pass":
418
+ return surface_pass_gaps(spec)
419
+ if pass_id == "lighting-pass":
420
+ return lighting_pass_gaps(spec)
421
+ return []
422
+
423
+
424
+ def sync_pipeline(spec: dict[str, Any]) -> dict[str, Any]:
425
+ ids = pass_order(spec)
426
+ completed = completed_passes(spec, ids)
427
+ current = current_pass(ids, completed)
428
+ pipeline = spec.setdefault("sculptPipeline", {})
429
+ if not isinstance(pipeline, dict):
430
+ pipeline = {}
431
+ spec["sculptPipeline"] = pipeline
432
+ pipeline.update(
433
+ {
434
+ "passGateMode": "locked-sequential",
435
+ "passOrder": ids,
436
+ "currentPass": current,
437
+ "completedPasses": completed,
438
+ "lastCompletedPass": completed[-1] if completed else "",
439
+ "blockedReason": "" if current != "complete" else "all build passes completed",
440
+ "nextRequiredEvidence": next_required_evidence(spec, current),
441
+ }
442
+ )
443
+ return pipeline
444
+
445
+
446
+ def check_pass(spec: dict[str, Any], requested_pass: str) -> tuple[bool, str, dict[str, Any]]:
447
+ pipeline = sync_pipeline(spec)
448
+ ids = list(pipeline["passOrder"])
449
+ if requested_pass not in ids:
450
+ return False, f"unknown build pass {requested_pass!r}", pipeline
451
+ current = str(pipeline["currentPass"])
452
+ completed = list(pipeline.get("completedPasses", []))
453
+ if requested_pass in completed or current == "complete":
454
+ gaps = pass_specific_gaps(spec, requested_pass)
455
+ if gaps:
456
+ return False, f"pass {requested_pass!r} needs spec refinement: {'; '.join(gaps)}", pipeline
457
+ return True, f"pass {requested_pass!r} is already completed and can be regenerated", pipeline
458
+ if requested_pass == current:
459
+ gaps = pass_specific_gaps(spec, requested_pass)
460
+ if gaps:
461
+ return False, f"pass {requested_pass!r} needs spec refinement: {'; '.join(gaps)}", pipeline
462
+ return True, f"pass {requested_pass!r} is the current unlocked pass", pipeline
463
+ previous_index = ids.index(requested_pass) - 1
464
+ previous = ids[previous_index] if previous_index >= 0 else ""
465
+ return (
466
+ False,
467
+ f"pass {requested_pass!r} is locked; complete {previous!r} with reviewHistory.action=continue and screenshot evidence first",
468
+ pipeline,
469
+ )
470
+
471
+
472
+ def status_payload(spec: dict[str, Any]) -> dict[str, Any]:
473
+ pipeline = sync_pipeline(spec)
474
+ return {
475
+ "targetName": spec.get("targetName"),
476
+ "passGateMode": pipeline.get("passGateMode"),
477
+ "currentPass": pipeline.get("currentPass"),
478
+ "completedPasses": pipeline.get("completedPasses", []),
479
+ "nextRequiredEvidence": pipeline.get("nextRequiredEvidence", []),
480
+ }
481
+
482
+
483
+ def main(argv: list[str]) -> int:
484
+ parser = argparse.ArgumentParser(description=__doc__)
485
+ subparsers = parser.add_subparsers(dest="command", required=True)
486
+
487
+ status_parser = subparsers.add_parser("status", help="Print current sculpt pipeline state")
488
+ status_parser.add_argument("spec", type=Path)
489
+ status_parser.add_argument("--json", action="store_true")
490
+
491
+ check_parser = subparsers.add_parser("check", help="Fail unless a build pass is unlocked")
492
+ check_parser.add_argument("spec", type=Path)
493
+ check_parser.add_argument("--pass-id", required=True)
494
+ check_parser.add_argument("--json", action="store_true")
495
+
496
+ sync_parser = subparsers.add_parser("sync", help="Refresh sculptPipeline from reviewHistory")
497
+ sync_parser.add_argument("spec", type=Path)
498
+ sync_parser.add_argument("--in-place", action="store_true")
499
+ sync_parser.add_argument("--out", type=Path)
500
+ sync_parser.add_argument("--json", action="store_true")
501
+
502
+ args = parser.parse_args(argv)
503
+ spec_path = args.spec.expanduser().resolve()
504
+ spec = load_spec(spec_path)
505
+
506
+ if args.command == "status":
507
+ payload = status_payload(spec)
508
+ if args.json:
509
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
510
+ else:
511
+ print(f"currentPass: {payload['currentPass']}")
512
+ print(f"completedPasses: {', '.join(payload['completedPasses']) or '(none)'}")
513
+ for item in payload["nextRequiredEvidence"]:
514
+ print(f"required: {item}")
515
+ return 0
516
+
517
+ if args.command == "check":
518
+ ok, message, pipeline = check_pass(spec, args.pass_id)
519
+ payload = {"ok": ok, "message": message, "pipeline": pipeline}
520
+ if args.json:
521
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
522
+ else:
523
+ print("PASS" if ok else "FAIL")
524
+ print(message)
525
+ return 0 if ok else 1
526
+
527
+ if args.command == "sync":
528
+ payload = status_payload(spec)
529
+ output = spec_path if args.in_place else (args.out.expanduser().resolve() if args.out else None)
530
+ if output:
531
+ write_spec(output, spec)
532
+ if args.json:
533
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
534
+ else:
535
+ print(output or json.dumps(spec, indent=2, ensure_ascii=False))
536
+ return 0
537
+
538
+ parser.error("unreachable command")
539
+ return 2
540
+
541
+
542
+ if __name__ == "__main__":
543
+ raise SystemExit(main(sys.argv[1:]))
forge/stage4_review/append_review.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Append a self-correction review entry to an ObjectSculptSpec JSON file."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "_shared"))
13
+ from feature_acceptance_policy import feature_gate_failures
14
+
15
+
16
+ VALID_ACTIONS = {"continue", "refine-spec", "refine-code", "request-input", "stop"}
17
+ VISUAL_PASS_IDS = {
18
+ "blockout",
19
+ "structural-pass",
20
+ "form-refinement",
21
+ "material-pass",
22
+ "surface-pass",
23
+ "lighting-pass",
24
+ "interaction-pass",
25
+ }
26
+ DEFAULT_PASS_ORDER = [
27
+ "blockout",
28
+ "structural-pass",
29
+ "form-refinement",
30
+ "material-pass",
31
+ "surface-pass",
32
+ "lighting-pass",
33
+ "interaction-pass",
34
+ "optimization-pass",
35
+ ]
36
+
37
+
38
+ def split_items(value: str | None) -> list[str]:
39
+ if not value:
40
+ return []
41
+ return [item.strip() for item in value.split(";") if item.strip()]
42
+
43
+
44
+ def load_spec(path: Path) -> dict:
45
+ payload = json.loads(path.read_text(encoding="utf-8"))
46
+ if not isinstance(payload, dict):
47
+ raise ValueError("spec must be a JSON object")
48
+ return payload
49
+
50
+
51
+ def load_json_argument(value: str | None, label: str) -> object | None:
52
+ if not value:
53
+ return None
54
+ candidate = Path(value).expanduser()
55
+ text = candidate.read_text(encoding="utf-8") if candidate.is_file() else value
56
+ try:
57
+ return json.loads(text)
58
+ except json.JSONDecodeError as exc:
59
+ raise ValueError(f"{label} must be valid inline JSON or a JSON file path") from exc
60
+
61
+
62
+ def clamp_score(value: float) -> float:
63
+ return max(0.0, min(1.0, float(value)))
64
+
65
+
66
+ def is_remote_or_virtual_path(value: str) -> bool:
67
+ return "://" in value or value.startswith("data:") or value.startswith("blob:")
68
+
69
+
70
+ def validate_optional_file(value: str | None, label: str) -> None:
71
+ if not value or is_remote_or_virtual_path(value):
72
+ return
73
+ if not Path(value).expanduser().exists():
74
+ raise FileNotFoundError(f"{label} does not exist: {value}")
75
+
76
+
77
+ def visual_acceptance_threshold(spec: dict) -> float:
78
+ loop = spec.get("selfCorrectLoop")
79
+ if isinstance(loop, dict):
80
+ acceptance = loop.get("visualAcceptance")
81
+ if isinstance(acceptance, dict) and isinstance(acceptance.get("threshold"), (int, float)):
82
+ return clamp_score(float(acceptance["threshold"]))
83
+ targets = spec.get("qualityTargets")
84
+ if isinstance(targets, dict) and isinstance(targets.get("targetFidelity"), (int, float)):
85
+ return clamp_score(float(targets["targetFidelity"]))
86
+ return 0.7
87
+
88
+
89
+ def visual_acceptance_config(spec: dict) -> dict:
90
+ loop = spec.get("selfCorrectLoop")
91
+ if not isinstance(loop, dict):
92
+ return {}
93
+ acceptance = loop.get("visualAcceptance")
94
+ return acceptance if isinstance(acceptance, dict) else {}
95
+
96
+
97
+ def pass_order(spec: dict) -> list[str]:
98
+ ids: list[str] = []
99
+ for item in spec.get("buildPasses", []):
100
+ if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].strip():
101
+ ids.append(item["id"])
102
+ return ids or DEFAULT_PASS_ORDER.copy()
103
+
104
+
105
+ def pass_acceptance(spec: dict, pass_id: str) -> list[str]:
106
+ for item in spec.get("buildPasses", []):
107
+ if isinstance(item, dict) and item.get("id") == pass_id:
108
+ acceptance = item.get("acceptance", [])
109
+ if isinstance(acceptance, list):
110
+ return [str(value) for value in acceptance if str(value).strip()]
111
+ return []
112
+
113
+
114
+ def pass_specific_evidence(pass_id: str) -> list[str]:
115
+ if pass_id in {"structural-pass", "form-refinement"}:
116
+ return [
117
+ "attachment contracts for child appendages/connectors",
118
+ "no floating child roots/joints in the browser screenshot",
119
+ ]
120
+ if pass_id == "material-pass":
121
+ return [
122
+ "reference-derived albedo palette with dominant, secondary, and accent colors",
123
+ "independent albedo, roughness, height/normal, and AO maps",
124
+ "macro, meso, and micro surface-frequency response at 1024px or higher",
125
+ "local material masks: AO, dirt, wear, stains, moss, chips, scratches, wetness, or equivalent",
126
+ "neutral, grazing-light close-up, and reference-matched browser screenshots",
127
+ ]
128
+ if pass_id == "surface-pass":
129
+ return [
130
+ "component surfaceDetail for tactile normal/bump/displacement and locality",
131
+ ]
132
+ if pass_id == "lighting-pass":
133
+ return [
134
+ "lightingFromPhoto with key/fill/rim or environment light",
135
+ "exposure, tone mapping, background, shadow softness, and contact shadow behavior",
136
+ ]
137
+ return []
138
+
139
+
140
+ def review_completes_pass(spec: dict, entry: dict, pass_id: str) -> bool:
141
+ if entry.get("passId") != pass_id or entry.get("action") != "continue":
142
+ return False
143
+ visual = entry.get("visualEvidence")
144
+ if pass_id in VISUAL_PASS_IDS and not (isinstance(visual, dict) and visual.get("renderScreenshot")):
145
+ return False
146
+ if pass_id in VISUAL_PASS_IDS:
147
+ score = entry.get("aiVisionScore")
148
+ threshold = entry.get("visualAcceptanceThreshold", 0.7)
149
+ if not isinstance(score, (int, float)) or not isinstance(threshold, (int, float)):
150
+ return False
151
+ if float(score) < float(threshold):
152
+ return False
153
+ if not (isinstance(visual, dict) and visual.get("comparisonImage")):
154
+ return False
155
+ if feature_gate_failures(spec, entry, pass_id):
156
+ return False
157
+ return True
158
+
159
+
160
+ def sync_pipeline(spec: dict) -> None:
161
+ ids = pass_order(spec)
162
+ history = spec.get("reviewHistory", [])
163
+ completed: list[str] = []
164
+ if isinstance(history, list):
165
+ for pass_id in ids:
166
+ if any(
167
+ isinstance(entry, dict) and review_completes_pass(spec, entry, pass_id)
168
+ for entry in history
169
+ ):
170
+ completed.append(pass_id)
171
+ else:
172
+ break
173
+ current = "complete" if len(completed) >= len(ids) else ids[len(completed)]
174
+ required = [] if current == "complete" else pass_acceptance(spec, current)
175
+ required.extend(pass_specific_evidence(current))
176
+ if current in VISUAL_PASS_IDS:
177
+ required.extend(
178
+ [
179
+ "browser render screenshot from your agent's browser/screenshot tool",
180
+ "single side-by-side full reference/render comparison sheet",
181
+ "all critical semantic feature scores at or above their thresholds",
182
+ "self-correction review appended with action=continue before the next pass",
183
+ ]
184
+ )
185
+ pipeline = spec.setdefault("sculptPipeline", {})
186
+ if not isinstance(pipeline, dict):
187
+ pipeline = {}
188
+ spec["sculptPipeline"] = pipeline
189
+ pipeline.update(
190
+ {
191
+ "passGateMode": "locked-sequential",
192
+ "passOrder": ids,
193
+ "currentPass": current,
194
+ "completedPasses": completed,
195
+ "lastCompletedPass": completed[-1] if completed else "",
196
+ "blockedReason": "" if current != "complete" else "all build passes completed",
197
+ "nextRequiredEvidence": required,
198
+ }
199
+ )
200
+
201
+
202
+ def main(argv: list[str]) -> int:
203
+ parser = argparse.ArgumentParser(description=__doc__)
204
+ parser.add_argument("spec", type=Path)
205
+ parser.add_argument("--pass-id", required=True, help="Build pass being reviewed")
206
+ parser.add_argument("--fidelity", type=float, required=True, help="Estimated match score from 0 to 1")
207
+ parser.add_argument("--action", choices=sorted(VALID_ACTIONS), required=True)
208
+ parser.add_argument("--summary", required=True, help="Short review summary")
209
+ parser.add_argument("--matched", help="Semicolon-separated matched criteria")
210
+ parser.add_argument("--mismatches", help="Semicolon-separated mismatches")
211
+ parser.add_argument("--spec-fixes", help="Semicolon-separated spec refinement tasks")
212
+ parser.add_argument("--code-fixes", help="Semicolon-separated code refinement tasks")
213
+ parser.add_argument("--evidence", help="Semicolon-separated screenshot/image/render paths or notes")
214
+ parser.add_argument("--reference-screenshot", help="Reference image/screenshot path or URL used for visual comparison")
215
+ parser.add_argument("--render-screenshot", help="Rendered browser screenshot path or URL for this pass")
216
+ parser.add_argument("--comparison-image", help="Side-by-side reference/render contact sheet reviewed by AI vision")
217
+ parser.add_argument("--ai-vision-score", type=float, help="AI vision visual match score from 0 to 1")
218
+ parser.add_argument("--layer-scores-json", help="JSON object with AI vision layer scores, e.g. silhouette/material/lighting")
219
+ parser.add_argument("--feature-reviews-json", help="JSON array or file path containing per-feature scores from the same full image pair")
220
+ parser.add_argument("--ai-vision-notes", help="AI vision critique explaining the score and mismatch root causes")
221
+ parser.add_argument("--visual-threshold", type=float, help="Override visual acceptance threshold for this review")
222
+ parser.add_argument("--camera-view", help="Camera/viewpoint label, e.g. front, three-quarter, side, close-up")
223
+ parser.add_argument("--visual-notes", help="Short notes from screenshot comparison")
224
+ parser.add_argument(
225
+ "--require-screenshot-files",
226
+ action="store_true",
227
+ help="Require local screenshot paths to exist before writing the review",
228
+ )
229
+ parser.add_argument("--in-place", action="store_true", help="Write back to the input spec")
230
+ parser.add_argument("--out", type=Path, help="Output JSON path when not using --in-place")
231
+ args = parser.parse_args(argv)
232
+
233
+ if args.require_screenshot_files:
234
+ validate_optional_file(args.reference_screenshot, "--reference-screenshot")
235
+ validate_optional_file(args.render_screenshot, "--render-screenshot")
236
+ validate_optional_file(args.comparison_image, "--comparison-image")
237
+ if args.pass_id in VISUAL_PASS_IDS and args.action == "continue" and not args.render_screenshot:
238
+ raise ValueError(
239
+ "visual pass cannot use action=continue without --render-screenshot; "
240
+ "capture a browser screenshot or choose refine-code/request-input"
241
+ )
242
+
243
+ spec_path = args.spec.expanduser().resolve()
244
+ spec = load_spec(spec_path)
245
+ history = spec.setdefault("reviewHistory", [])
246
+ if not isinstance(history, list):
247
+ raise ValueError("reviewHistory must be an array")
248
+ threshold = clamp_score(args.visual_threshold) if args.visual_threshold is not None else visual_acceptance_threshold(spec)
249
+ layer_scores = None
250
+ if args.layer_scores_json:
251
+ layer_scores = load_json_argument(args.layer_scores_json, "--layer-scores-json")
252
+ if not isinstance(layer_scores, dict):
253
+ raise ValueError("--layer-scores-json must be a JSON object")
254
+ for key, value in layer_scores.items():
255
+ if not isinstance(key, str) or not isinstance(value, (int, float)):
256
+ raise ValueError("--layer-scores-json values must be numeric scores")
257
+ if not 0 <= float(value) <= 1:
258
+ raise ValueError("--layer-scores-json values must be from 0 to 1")
259
+ if args.ai_vision_score is not None and not 0 <= args.ai_vision_score <= 1:
260
+ raise ValueError("--ai-vision-score must be from 0 to 1")
261
+ if args.visual_threshold is not None and not 0 <= args.visual_threshold <= 1:
262
+ raise ValueError("--visual-threshold must be from 0 to 1")
263
+ feature_reviews = load_json_argument(args.feature_reviews_json, "--feature-reviews-json")
264
+ if feature_reviews is None:
265
+ feature_reviews = []
266
+ if not isinstance(feature_reviews, list):
267
+ raise ValueError("--feature-reviews-json must be a JSON array")
268
+ for index, review in enumerate(feature_reviews):
269
+ if not isinstance(review, dict):
270
+ raise ValueError(f"feature review {index} must be an object")
271
+ if not isinstance(review.get("id"), str) or not review["id"].strip():
272
+ raise ValueError(f"feature review {index}.id is required")
273
+ score = review.get("score")
274
+ if score is not None and (
275
+ not isinstance(score, (int, float)) or not 0 <= float(score) <= 1
276
+ ):
277
+ raise ValueError(f"feature review {index}.score must be from 0 to 1")
278
+ if args.pass_id in VISUAL_PASS_IDS and args.action == "continue":
279
+ if not args.comparison_image:
280
+ raise ValueError(
281
+ "visual pass cannot use action=continue without --comparison-image; "
282
+ "create one with stage4_review/make_comparison_sheet.py"
283
+ )
284
+ if args.ai_vision_score is None:
285
+ raise ValueError(
286
+ "visual pass cannot use action=continue without --ai-vision-score; "
287
+ "AI vision must review the comparison sheet"
288
+ )
289
+ if clamp_score(args.ai_vision_score) < threshold:
290
+ raise ValueError(
291
+ f"AI vision score {clamp_score(args.ai_vision_score):.3f} is below threshold "
292
+ f"{threshold:.3f}; choose refine-spec/refine-code/request-input instead of continue"
293
+ )
294
+ acceptance = visual_acceptance_config(spec)
295
+ if acceptance.get("layerScoresRequired") is True and not layer_scores:
296
+ raise ValueError("visual pass cannot use action=continue without --layer-scores-json")
297
+ required_layers = acceptance.get("requiredLayerScores", [])
298
+ if isinstance(required_layers, list) and layer_scores:
299
+ missing_layers = [
300
+ layer
301
+ for layer in required_layers
302
+ if isinstance(layer, str) and layer not in layer_scores
303
+ ]
304
+ if missing_layers:
305
+ raise ValueError(
306
+ "--layer-scores-json is missing required layers: "
307
+ + ", ".join(missing_layers)
308
+ )
309
+
310
+ entry = {
311
+ "timestamp": datetime.now(timezone.utc).isoformat(),
312
+ "passId": args.pass_id,
313
+ "estimatedFidelity": clamp_score(args.fidelity),
314
+ "aiVisionScore": clamp_score(args.ai_vision_score) if args.ai_vision_score is not None else None,
315
+ "visualAcceptanceThreshold": threshold,
316
+ "layerScores": layer_scores or {},
317
+ "featureReviews": feature_reviews,
318
+ "action": args.action,
319
+ "summary": args.summary,
320
+ "matched": split_items(args.matched),
321
+ "mismatches": split_items(args.mismatches),
322
+ "specFixes": split_items(args.spec_fixes),
323
+ "codeFixes": split_items(args.code_fixes),
324
+ "evidence": split_items(args.evidence),
325
+ }
326
+ if args.pass_id in VISUAL_PASS_IDS and args.action == "continue":
327
+ feature_failures = feature_gate_failures(spec, entry, args.pass_id)
328
+ if feature_failures:
329
+ raise ValueError(
330
+ "feature-level AI vision gate failed: " + "; ".join(feature_failures)
331
+ )
332
+
333
+ has_visual_evidence = any(
334
+ [
335
+ args.reference_screenshot,
336
+ args.render_screenshot,
337
+ args.comparison_image,
338
+ args.camera_view,
339
+ args.visual_notes,
340
+ args.ai_vision_notes,
341
+ ]
342
+ )
343
+ if has_visual_evidence:
344
+ visual_evidence = {
345
+ "referenceScreenshot": args.reference_screenshot or spec.get("sourceImage", ""),
346
+ "renderScreenshot": args.render_screenshot or "",
347
+ "comparisonImage": args.comparison_image or "",
348
+ "cameraView": args.camera_view or "",
349
+ "notes": args.visual_notes or "",
350
+ "aiVisionNotes": args.ai_vision_notes or "",
351
+ }
352
+ entry["visualEvidence"] = visual_evidence
353
+
354
+ visual_history = spec.setdefault("visualEvidence", [])
355
+ if not isinstance(visual_history, list):
356
+ raise ValueError("visualEvidence must be an array")
357
+ visual_history.append(
358
+ {
359
+ "timestamp": entry["timestamp"],
360
+ "passId": args.pass_id,
361
+ "estimatedFidelity": entry["estimatedFidelity"],
362
+ "aiVisionScore": entry["aiVisionScore"],
363
+ "visualAcceptanceThreshold": entry["visualAcceptanceThreshold"],
364
+ "layerScores": entry["layerScores"],
365
+ "featureReviews": entry["featureReviews"],
366
+ **visual_evidence,
367
+ }
368
+ )
369
+ history.append(entry)
370
+ sync_pipeline(spec)
371
+
372
+ output = spec_path if args.in_place else (args.out.expanduser().resolve() if args.out else None)
373
+ payload = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
374
+ if output:
375
+ output.parent.mkdir(parents=True, exist_ok=True)
376
+ output.write_text(payload, encoding="utf-8")
377
+ print(output)
378
+ else:
379
+ print(payload, end="")
380
+ return 0
381
+
382
+
383
+ if __name__ == "__main__":
384
+ raise SystemExit(main(sys.argv[1:]))
forge/stage4_review/make_comparison_sheet.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create a side-by-side visual acceptance sheet for AI vision review.
3
+
4
+ The sheet is only evidence packaging. It does not score the images. the agent or
5
+ another AI vision reviewer should inspect the generated sheet and write the
6
+ score back with stage4_review/append_review.py.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import shutil
14
+ import struct
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import zlib
19
+ from pathlib import Path
20
+
21
+
22
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
23
+
24
+
25
+ def paeth_predictor(a: int, b: int, c: int) -> int:
26
+ p = a + b - c
27
+ pa = abs(p - a)
28
+ pb = abs(p - b)
29
+ pc = abs(p - c)
30
+ if pa <= pb and pa <= pc:
31
+ return a
32
+ if pb <= pc:
33
+ return b
34
+ return c
35
+
36
+
37
+ def read_png(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
38
+ data = path.read_bytes()
39
+ if not data.startswith(PNG_SIGNATURE):
40
+ raise ValueError("not a PNG file")
41
+ cursor = len(PNG_SIGNATURE)
42
+ width = height = bit_depth = color_type = interlace = None
43
+ idat = bytearray()
44
+ while cursor + 8 <= len(data):
45
+ length = struct.unpack(">I", data[cursor : cursor + 4])[0]
46
+ chunk_type = data[cursor + 4 : cursor + 8]
47
+ chunk_data = data[cursor + 8 : cursor + 8 + length]
48
+ cursor += 12 + length
49
+ if chunk_type == b"IHDR":
50
+ width, height, bit_depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", chunk_data)
51
+ elif chunk_type == b"IDAT":
52
+ idat.extend(chunk_data)
53
+ elif chunk_type == b"IEND":
54
+ break
55
+ if width is None or height is None or bit_depth != 8 or interlace != 0:
56
+ raise ValueError("unsupported PNG; expected 8-bit non-interlaced image")
57
+ channels_by_type = {0: 1, 2: 3, 4: 2, 6: 4}
58
+ if color_type not in channels_by_type:
59
+ raise ValueError("unsupported PNG color type; convert to RGB/RGBA first")
60
+ channels = channels_by_type[color_type]
61
+ row_bytes = width * channels
62
+ raw = zlib.decompress(bytes(idat))
63
+ rows: list[bytearray] = []
64
+ offset = 0
65
+ previous = bytearray(row_bytes)
66
+ for _ in range(height):
67
+ filter_type = raw[offset]
68
+ offset += 1
69
+ row = bytearray(raw[offset : offset + row_bytes])
70
+ offset += row_bytes
71
+ for index in range(row_bytes):
72
+ left = row[index - channels] if index >= channels else 0
73
+ up = previous[index]
74
+ up_left = previous[index - channels] if index >= channels else 0
75
+ if filter_type == 1:
76
+ row[index] = (row[index] + left) & 0xFF
77
+ elif filter_type == 2:
78
+ row[index] = (row[index] + up) & 0xFF
79
+ elif filter_type == 3:
80
+ row[index] = (row[index] + ((left + up) // 2)) & 0xFF
81
+ elif filter_type == 4:
82
+ row[index] = (row[index] + paeth_predictor(left, up, up_left)) & 0xFF
83
+ elif filter_type != 0:
84
+ raise ValueError(f"unsupported PNG filter {filter_type}")
85
+ rows.append(row)
86
+ previous = row
87
+ pixels: list[tuple[int, int, int, int]] = []
88
+ for row in rows:
89
+ for x in range(width):
90
+ base = x * channels
91
+ if color_type == 0:
92
+ gray = row[base]
93
+ pixels.append((gray, gray, gray, 255))
94
+ elif color_type == 2:
95
+ pixels.append((row[base], row[base + 1], row[base + 2], 255))
96
+ elif color_type == 4:
97
+ gray = row[base]
98
+ pixels.append((gray, gray, gray, row[base + 1]))
99
+ elif color_type == 6:
100
+ pixels.append((row[base], row[base + 1], row[base + 2], row[base + 3]))
101
+ return width, height, pixels
102
+
103
+
104
+ def write_png_rgb(path: Path, width: int, height: int, pixels: list[tuple[int, int, int]]) -> None:
105
+ if len(pixels) != width * height:
106
+ raise ValueError("pixel payload has the wrong size")
107
+
108
+ def chunk(kind: bytes, payload: bytes) -> bytes:
109
+ checksum = zlib.crc32(kind)
110
+ checksum = zlib.crc32(payload, checksum) & 0xFFFFFFFF
111
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum)
112
+
113
+ scanlines = bytearray()
114
+ for y in range(height):
115
+ scanlines.append(0)
116
+ for red, green, blue in pixels[y * width : (y + 1) * width]:
117
+ scanlines.extend((red, green, blue))
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ path.write_bytes(
120
+ PNG_SIGNATURE
121
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
122
+ + chunk(b"IDAT", zlib.compress(bytes(scanlines), level=6))
123
+ + chunk(b"IEND", b"")
124
+ )
125
+
126
+
127
+ def load_image(path: Path) -> tuple[int, int, list[tuple[int, int, int, int]]]:
128
+ try:
129
+ return read_png(path)
130
+ except Exception as direct_error:
131
+ sips = shutil.which("sips")
132
+ if not sips:
133
+ raise ValueError(f"could not decode {path.name} as PNG and sips is unavailable: {direct_error}") from direct_error
134
+ with tempfile.TemporaryDirectory() as tmpdir:
135
+ converted = Path(tmpdir) / "converted.png"
136
+ result = subprocess.run(
137
+ [sips, "-s", "format", "png", str(path), "--out", str(converted)],
138
+ capture_output=True,
139
+ text=True,
140
+ check=False,
141
+ )
142
+ if result.returncode != 0:
143
+ raise ValueError(result.stderr.strip() or result.stdout.strip() or "sips conversion failed")
144
+ return read_png(converted)
145
+
146
+
147
+ def composite_over_checker(pixel: tuple[int, int, int, int], x: int, y: int) -> tuple[int, int, int]:
148
+ red, green, blue, alpha = pixel
149
+ background = 238 if ((x // 12 + y // 12) % 2 == 0) else 210
150
+ mix = alpha / 255.0
151
+ return (
152
+ round(red * mix + background * (1 - mix)),
153
+ round(green * mix + background * (1 - mix)),
154
+ round(blue * mix + background * (1 - mix)),
155
+ )
156
+
157
+
158
+ def resize_cover(
159
+ width: int,
160
+ height: int,
161
+ pixels: list[tuple[int, int, int, int]],
162
+ target_w: int,
163
+ target_h: int,
164
+ ) -> list[tuple[int, int, int]]:
165
+ scale = max(target_w / width, target_h / height)
166
+ scaled_w = max(1, round(width * scale))
167
+ scaled_h = max(1, round(height * scale))
168
+ offset_x = max(0, (scaled_w - target_w) // 2)
169
+ offset_y = max(0, (scaled_h - target_h) // 2)
170
+ output: list[tuple[int, int, int]] = []
171
+ for y in range(target_h):
172
+ source_y = min(height - 1, max(0, int((y + offset_y) / scale)))
173
+ for x in range(target_w):
174
+ source_x = min(width - 1, max(0, int((x + offset_x) / scale)))
175
+ output.append(composite_over_checker(pixels[source_y * width + source_x], x, y))
176
+ return output
177
+
178
+
179
+ def fill_rect(
180
+ canvas: list[tuple[int, int, int]],
181
+ width: int,
182
+ x0: int,
183
+ y0: int,
184
+ rect_w: int,
185
+ rect_h: int,
186
+ color: tuple[int, int, int],
187
+ ) -> None:
188
+ height = len(canvas) // width
189
+ for y in range(max(0, y0), min(height, y0 + rect_h)):
190
+ row = y * width
191
+ for x in range(max(0, x0), min(width, x0 + rect_w)):
192
+ canvas[row + x] = color
193
+
194
+
195
+ def blit(
196
+ canvas: list[tuple[int, int, int]],
197
+ width: int,
198
+ image: list[tuple[int, int, int]],
199
+ image_w: int,
200
+ x0: int,
201
+ y0: int,
202
+ ) -> None:
203
+ image_h = len(image) // image_w
204
+ height = len(canvas) // width
205
+ for y in range(image_h):
206
+ target_y = y0 + y
207
+ if target_y < 0 or target_y >= height:
208
+ continue
209
+ for x in range(image_w):
210
+ target_x = x0 + x
211
+ if 0 <= target_x < width:
212
+ canvas[target_y * width + target_x] = image[y * image_w + x]
213
+
214
+
215
+ def create_sheet(
216
+ reference: Path,
217
+ render: Path,
218
+ out: Path,
219
+ width: int,
220
+ height: int,
221
+ gutter: int,
222
+ ) -> dict:
223
+ ref_w, ref_h, ref_pixels = load_image(reference)
224
+ ren_w, ren_h, ren_pixels = load_image(render)
225
+ panel_w = width
226
+ panel_h = height
227
+ canvas_w = panel_w * 2 + gutter * 3
228
+ header_h = 28
229
+ canvas_h = panel_h + gutter * 2 + header_h
230
+ canvas = [(246, 242, 236)] * (canvas_w * canvas_h)
231
+ fill_rect(canvas, canvas_w, gutter, gutter, panel_w, header_h, (40, 45, 48))
232
+ fill_rect(canvas, canvas_w, gutter * 2 + panel_w, gutter, panel_w, header_h, (40, 45, 48))
233
+ fill_rect(canvas, canvas_w, gutter, gutter + header_h, panel_w, panel_h, (230, 230, 230))
234
+ fill_rect(canvas, canvas_w, gutter * 2 + panel_w, gutter + header_h, panel_w, panel_h, (230, 230, 230))
235
+ ref_panel = resize_cover(ref_w, ref_h, ref_pixels, panel_w, panel_h)
236
+ ren_panel = resize_cover(ren_w, ren_h, ren_pixels, panel_w, panel_h)
237
+ blit(canvas, canvas_w, ref_panel, panel_w, gutter, gutter + header_h)
238
+ blit(canvas, canvas_w, ren_panel, panel_w, gutter * 2 + panel_w, gutter + header_h)
239
+ fill_rect(canvas, canvas_w, panel_w + gutter + gutter // 2, gutter, max(2, gutter // 5), canvas_h - gutter * 2, (170, 146, 92))
240
+ write_png_rgb(out, canvas_w, canvas_h, canvas)
241
+ return {
242
+ "comparisonImage": str(out.resolve()),
243
+ "referenceImage": str(reference.resolve()),
244
+ "renderScreenshot": str(render.resolve()),
245
+ "layout": "left=reference,right=render",
246
+ "panelWidth": panel_w,
247
+ "panelHeight": panel_h,
248
+ "note": "Send this image to AI vision for global, layer, and semantic feature scores; this script does not score.",
249
+ }
250
+
251
+
252
+ def main(argv: list[str]) -> int:
253
+ parser = argparse.ArgumentParser(description=__doc__)
254
+ parser.add_argument("--reference", type=Path, required=True)
255
+ parser.add_argument("--render", type=Path, required=True)
256
+ parser.add_argument("--out", type=Path, required=True)
257
+ parser.add_argument("--panel-width", type=int, default=720)
258
+ parser.add_argument("--panel-height", type=int, default=720)
259
+ parser.add_argument("--gutter", type=int, default=24)
260
+ parser.add_argument("--json", action="store_true")
261
+ args = parser.parse_args(argv)
262
+ try:
263
+ payload = create_sheet(
264
+ args.reference.expanduser().resolve(),
265
+ args.render.expanduser().resolve(),
266
+ args.out.expanduser().resolve(),
267
+ max(128, args.panel_width),
268
+ max(128, args.panel_height),
269
+ max(6, args.gutter),
270
+ )
271
+ except Exception as exc:
272
+ print(f"error: {exc}", file=sys.stderr)
273
+ return 1
274
+ print(json.dumps(payload, indent=2, ensure_ascii=False) if args.json else payload["comparisonImage"])
275
+ return 0
276
+
277
+
278
+ if __name__ == "__main__":
279
+ raise SystemExit(main(sys.argv[1:]))
forge/tests/test_pipeline.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """End-to-end integration tests for the Three.js Object Sculptor pipeline.
3
+
4
+ Pure stdlib. Runs each CLI script as a subprocess and asserts the gate behavior
5
+ described in SKILL.md / references. Also generates a tiny real PNG (struct+zlib)
6
+ to exercise the image-consuming scripts without any third-party deps.
7
+
8
+ Run: python3 forge/tests/test_pipeline.py (from skill root)
9
+ or: python3 -m unittest discover -s forge/tests
10
+ """
11
+ import json
12
+ import struct
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import unittest
17
+ import zlib
18
+ from pathlib import Path
19
+
20
+ SKILL = Path(__file__).resolve().parents[2]
21
+ SCRIPTS = SKILL / "forge"
22
+
23
+
24
+ def run(script, *args):
25
+ return subprocess.run(
26
+ [sys.executable, str(SCRIPTS / script), *map(str, args)],
27
+ capture_output=True, text=True,
28
+ )
29
+
30
+
31
+ def write_png(path, w=64, h=64):
32
+ """Write a minimal valid RGB PNG with a simple gradient (no PIL)."""
33
+ raw = bytearray()
34
+ for y in range(h):
35
+ raw.append(0) # filter type 0 per scanline
36
+ for x in range(w):
37
+ raw += bytes(((x * 4) % 256, (y * 4) % 256, ((x + y) * 2) % 256))
38
+
39
+ def chunk(tag, data):
40
+ c = struct.pack(">I", len(data)) + tag + data
41
+ return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
42
+
43
+ ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
44
+ png = (b"\x89PNG\r\n\x1a\n"
45
+ + chunk(b"IHDR", ihdr)
46
+ + chunk(b"IDAT", zlib.compress(bytes(raw), 9))
47
+ + chunk(b"IEND", b""))
48
+ Path(path).write_bytes(png)
49
+
50
+
51
+ class PipelineTest(unittest.TestCase):
52
+ def setUp(self):
53
+ self.dir = Path(tempfile.mkdtemp())
54
+ self.assessment = self.dir / "assessment.json"
55
+ self.spec = self.dir / "object-sculpt-spec.json"
56
+ self.ref = self.dir / "ref.png"
57
+ self.render = self.dir / "render.png"
58
+ write_png(self.ref)
59
+ write_png(self.render)
60
+
61
+ def test_probe_image(self):
62
+ r = run("stage1_intake/probe_image.py", self.ref)
63
+ self.assertEqual(r.returncode, 0, r.stderr)
64
+ self.assertIn("64", r.stdout) # reports dimensions
65
+
66
+ def test_assessment_and_spec(self):
67
+ r = run("stage2_spec/new_pre_spec_assessment.py", "Oak", "--complexity", "complex",
68
+ "--out", self.assessment)
69
+ self.assertEqual(r.returncode, 0, r.stderr)
70
+ self.assertTrue(self.assessment.exists())
71
+ self.assertIn("qualityContract", json.loads(self.assessment.read_text()))
72
+
73
+ r = run("stage2_spec/new_sculpt_spec.py", "Oak", "--assessment", self.assessment,
74
+ "--out", self.spec)
75
+ self.assertEqual(r.returncode, 0, r.stderr)
76
+ spec = json.loads(self.spec.read_text())
77
+ self.assertEqual(spec["schemaVersion"], "2.0")
78
+ self.assertEqual(spec["targetName"], "Oak")
79
+
80
+ def test_normal_validate_passes_strict_fails_on_shallow(self):
81
+ run("stage2_spec/new_pre_spec_assessment.py", "Oak", "--complexity", "complex",
82
+ "--out", self.assessment)
83
+ run("stage2_spec/new_sculpt_spec.py", "Oak", "--assessment", self.assessment,
84
+ "--out", self.spec)
85
+ # normal validation of a structurally-sound starter succeeds
86
+ self.assertEqual(run("stage2_spec/validate_sculpt_spec.py", self.spec).returncode, 0)
87
+ # strict quality gate must BLOCK a shallow starter spec
88
+ strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality")
89
+ self.assertNotEqual(strict.returncode, 0)
90
+ self.assertIn("strict quality failure", strict.stdout + strict.stderr)
91
+
92
+ def test_orchestrator_starts_at_blockout(self):
93
+ run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec)
94
+ r = run("stage3_build/orchestrate_passes.py", "status", self.spec)
95
+ self.assertEqual(r.returncode, 0, r.stderr)
96
+ self.assertIn("blockout", r.stdout)
97
+ # a future pass must be locked
98
+ locked = run("stage3_build/orchestrate_passes.py", "check", self.spec,
99
+ "--pass-id", "material-pass")
100
+ self.assertNotEqual(locked.returncode, 0)
101
+
102
+ def test_generate_factory_emits_typescript(self):
103
+ run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec)
104
+ out = self.dir / "createObjectModel.ts"
105
+ r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out)
106
+ self.assertEqual(r.returncode, 0, r.stderr)
107
+ ts = out.read_text()
108
+ self.assertIn("import * as THREE from 'three'", ts)
109
+ self.assertIn("sculptRuntime", ts)
110
+ # generating a locked future pass must fail
111
+ locked = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out,
112
+ "--pass-id", "lighting-pass")
113
+ self.assertNotEqual(locked.returncode, 0)
114
+
115
+ def test_comparison_sheet_packages_without_scoring(self):
116
+ cmp = self.dir / "cmp.png"
117
+ r = run("stage4_review/make_comparison_sheet.py", "--reference", self.ref,
118
+ "--render", self.render, "--out", cmp, "--json")
119
+ self.assertEqual(r.returncode, 0, r.stderr)
120
+ self.assertTrue(cmp.exists() and cmp.stat().st_size > 0)
121
+
122
+ def test_append_review_gate_and_record(self):
123
+ run("stage2_spec/new_sculpt_spec.py", "Oak", "--out", self.spec)
124
+ # GATE: continue on a visual pass WITHOUT screenshot evidence must be refused.
125
+ no_evidence = run("stage4_review/append_review.py", self.spec, "--pass-id", "blockout",
126
+ "--fidelity", "0.8", "--action", "continue",
127
+ "--summary", "no evidence", "--ai-vision-score", "0.8",
128
+ "--in-place")
129
+ self.assertNotEqual(no_evidence.returncode, 0)
130
+ self.assertIn("render-screenshot", no_evidence.stdout + no_evidence.stderr)
131
+ # WITH evidence: the review is recorded.
132
+ cmp = self.dir / "cmp.png"
133
+ run("stage4_review/make_comparison_sheet.py", "--reference", self.ref,
134
+ "--render", self.render, "--out", cmp)
135
+ layers = json.dumps({
136
+ "silhouetteProportion": 0.82, "componentStructure": 0.78,
137
+ "formDetail": 0.75, "materialSurface": 0.7, "lightingCamera": 0.8,
138
+ })
139
+ # every critical feature target of this pass needs an AI-vision review entry
140
+ spec = json.loads(self.spec.read_text())
141
+ targets = spec.get("selfCorrectLoop", {}).get("featureReviewTargets", [])
142
+ reviews = [
143
+ {"id": t.get("id"), "score": 0.8, "visible": True, "notes": "acceptable"}
144
+ for t in targets if t.get("tier") == "critical"
145
+ ] or [{"id": "overall-silhouette", "score": 0.8, "visible": True, "notes": "ok"}]
146
+ freviews = self.dir / "features.json"
147
+ freviews.write_text(json.dumps(reviews))
148
+ r = run("stage4_review/append_review.py", self.spec, "--pass-id", "blockout",
149
+ "--fidelity", "0.8", "--action", "continue",
150
+ "--summary", "Blockout silhouette acceptable.",
151
+ "--render-screenshot", self.render, "--comparison-image", cmp,
152
+ "--ai-vision-score", "0.8", "--layer-scores-json", layers,
153
+ "--feature-reviews-json", freviews,
154
+ "--camera-view", "front", "--in-place")
155
+ self.assertEqual(r.returncode, 0, r.stderr)
156
+ spec = json.loads(self.spec.read_text())
157
+ self.assertTrue(len(spec.get("reviewHistory", [])) >= 1)
158
+
159
+ def test_pbr_extraction_runs(self):
160
+ # low-detail synthetic image: either passes or refuses (non-zero) — both are valid,
161
+ # but it must not crash and must respect the confidence gate.
162
+ r = run("stage1_intake/extract_pbr_evidence.py", self.ref, "--out-dir", self.dir / "pbr",
163
+ "--material-id", "bark", "--target-threshold", "0.7",
164
+ "--report", self.dir / "pbr-report.json")
165
+ self.assertIn(r.returncode, (0, 1), r.stderr)
166
+ self.assertTrue((self.dir / "pbr-report.json").exists() or r.returncode == 1)
167
+
168
+ # ---- Track A / Track B upgrade coverage ----
169
+
170
+ def _fresh_spec(self, complexity="moderate"):
171
+ run("stage2_spec/new_pre_spec_assessment.py", "Widget", "--complexity", complexity,
172
+ "--out", self.assessment)
173
+ run("stage2_spec/new_sculpt_spec.py", "Widget", "--assessment", self.assessment,
174
+ "--out", self.spec)
175
+ return json.loads(self.spec.read_text())
176
+
177
+ def test_new_schema_fields_present(self):
178
+ spec = self._fresh_spec("complex")
179
+ pre = spec["preSpecAssessment"]
180
+ self.assertIn("detailInventory", pre)
181
+ self.assertIn("anatomy", pre)
182
+ self.assertIn("primaryDomain", pre["objectClass"])
183
+ self.assertIn("referenceCamera", spec)
184
+ # targetMinDetails scales with complexity
185
+ self.assertEqual(pre["detailInventory"]["targetMinDetails"], 10)
186
+
187
+ def test_detail_inventory_gate_fires_on_empty(self):
188
+ self._fresh_spec("moderate")
189
+ strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality")
190
+ self.assertNotEqual(strict.returncode, 0)
191
+ self.assertIn("detailInventory has 0 details", strict.stdout + strict.stderr)
192
+
193
+ def test_detail_inventory_backward_compatible(self):
194
+ # A spec with NO detailInventory (pre-upgrade shape) must not trigger the detail gate.
195
+ spec = self._fresh_spec("moderate")
196
+ spec["preSpecAssessment"].pop("detailInventory", None)
197
+ self.spec.write_text(json.dumps(spec))
198
+ strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality")
199
+ self.assertNotIn("detailInventory", strict.stdout + strict.stderr)
200
+
201
+ def test_character_gate_requires_anatomy(self):
202
+ spec = self._fresh_spec("moderate")
203
+ spec["preSpecAssessment"]["objectClass"]["primaryDomain"] = "character"
204
+ self.spec.write_text(json.dumps(spec))
205
+ strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality")
206
+ self.assertIn("anatomy.applies is not true", strict.stdout + strict.stderr)
207
+
208
+ def test_character_track_skipped_for_objects(self):
209
+ # primaryDomain unassessed/object must not trigger character warnings.
210
+ self._fresh_spec("moderate")
211
+ strict = run("stage2_spec/validate_sculpt_spec.py", self.spec, "--strict-quality")
212
+ self.assertNotIn("anatomy.applies", strict.stdout + strict.stderr)
213
+
214
+ def test_new_upgrade_scripts_help(self):
215
+ for script in ("stage1_intake/build_detail_inventory.py", "stage1_intake/extract_landmarks.py",
216
+ "stage1_intake/solve_camera_pose.py", "stage1_intake/delight_albedo.py",
217
+ "stage3_build/bake_projected_texture.py"):
218
+ r = run(script, "--help")
219
+ self.assertEqual(r.returncode, 0, f"{script}: {r.stderr}")
220
+
221
+ def test_build_detail_inventory_slices_zones(self):
222
+ out = self.dir / "di.json"
223
+ zones = self.dir / "zones"
224
+ r = run("stage1_intake/build_detail_inventory.py", self.ref, "--mode", "grid-3x3",
225
+ "--out-dir", zones, "--out", out)
226
+ self.assertEqual(r.returncode, 0, r.stderr)
227
+ self.assertTrue(out.exists())
228
+ crops = list(zones.glob("*.png")) if zones.exists() else []
229
+ self.assertGreaterEqual(len(crops), 1)
230
+
231
+ def test_delight_reference_writes_png(self):
232
+ out = self.dir / "albedo.png"
233
+ r = run("stage1_intake/delight_albedo.py", self.ref, "--out", out)
234
+ self.assertEqual(r.returncode, 0, r.stderr)
235
+ self.assertTrue(out.exists() and out.stat().st_size > 0)
236
+
237
+ # ---- v1.2 character generator ----
238
+
239
+ def test_character_flag_builds_humanoid_tree(self):
240
+ run("stage2_spec/new_sculpt_spec.py", "Person", "--character", "--out", self.spec)
241
+ spec = json.loads(self.spec.read_text())
242
+ ids = {c["id"] for c in spec["componentTree"]}
243
+ for part in ("root", "head", "torso", "neck", "hair", "glasses-frame-l", "arm-l"):
244
+ self.assertIn(part, ids)
245
+ # all parts flattened to root (no cascading non-uniform parent scale)
246
+ for c in spec["componentTree"]:
247
+ if c["id"] != "root":
248
+ self.assertEqual(c["parent"], "root")
249
+ # distinct per-part colors (skin vs hair vs shirt), not a single fallback
250
+ colors = {m["id"]: m.get("color") for m in spec["materials"] if m["id"] in ("skin", "hair", "shirt")}
251
+ self.assertEqual(len({colors["skin"], colors["hair"], colors["shirt"]}), 3)
252
+ # palette has >= 2 entries so the generator does not fall back to beige
253
+ for m in spec["materials"]:
254
+ if m["id"] in ("skin", "hair", "shirt"):
255
+ self.assertGreaterEqual(len(m.get("colorVariation", {}).get("palette", [])), 2)
256
+
257
+ def test_character_autodetect_from_domain(self):
258
+ run("stage2_spec/new_pre_spec_assessment.py", "Person", "--complexity", "complex", "--out", self.assessment)
259
+ a = json.loads(self.assessment.read_text())
260
+ a["preSpecAssessment"]["objectClass"]["primaryDomain"] = "character"
261
+ self.assessment.write_text(json.dumps(a))
262
+ run("stage2_spec/new_sculpt_spec.py", "Person", "--assessment", self.assessment, "--out", self.spec)
263
+ spec = json.loads(self.spec.read_text())
264
+ self.assertIn("head", {c["id"] for c in spec["componentTree"]})
265
+
266
+ def test_character_factory_generates(self):
267
+ run("stage2_spec/new_sculpt_spec.py", "Person", "--character", "--out", self.spec)
268
+ out = self.dir / "createCharacterModel.ts"
269
+ r = run("stage3_build/generate_threejs_factory.py", self.spec, "--out", out)
270
+ self.assertEqual(r.returncode, 0, r.stderr)
271
+ ts = out.read_text()
272
+ self.assertIn("createPersonModel", ts)
273
+ self.assertIn('meshes["head"]', ts)
274
+
275
+
276
+ if __name__ == "__main__":
277
+ unittest.main(verbosity=2)
grimoire/build/geometry_patterns.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Procedural Three.js Object Patterns
2
+
3
+ Use this reference only when implementing a model.
4
+
5
+ ## Geometry Choices
6
+
7
+ - box: flat machinery, furniture, panels, blockout masses
8
+ - sphere/ellipsoid: fruit, knobs, organic joints, rounded stones
9
+ - cylinder/cone/capsule: trunks, pipes, limbs, handles, bottles, rockets
10
+ - torus: rings, tires, loops, trim, cable coils
11
+ - shape extrude: logos, flat ornamental plates, blades, keys, leaves
12
+ - lathe: vases, bottles, bowls, lamps, wheels
13
+ - tube along curve: cables, roots, branches, straps, hoses
14
+ - instanced mesh: screws, rivets, leaves, needles, scales, pebbles, repeated ornaments
15
+ - plane cards: thin leaves, feathers, labels, cloth strips, decals
16
+
17
+ ## Material Recipes
18
+
19
+ - wood: brown base, vertical grain normal, roughness variation, darker creases, lighter worn edges
20
+ - stone: mottled albedo, high roughness, bump/normal noise, lichen/dirt patches
21
+ - metal: lower roughness, metalness, edge scratches, anisotropic-looking streaks via texture
22
+ - plastic: controlled roughness, subtle color variation, bevels to catch highlights
23
+ - leaf/plant: alpha cards or thin shape geometry, green hue variation, central vein, translucent-ish bright rim
24
+ - water/glass: transparent material only if needed; add environment/reflection cues or it reads as a flat sheet
25
+
26
+ ## Material Layer Fields
27
+
28
+ For each material, prefer a layered description:
29
+
30
+ - `baseColor`: dominant sampled color.
31
+ - `colorVariation`: palette, mottling pattern, amplitude, regional masks.
32
+ - `roughness`: base value, variation amount, map/pattern source.
33
+ - `metalness`: base value and local changes.
34
+ - `normal`: procedural pattern, strength, scale.
35
+ - `bump`: amplitude and scale for small tactile relief.
36
+ - `displacement`: only for silhouette-visible or close-up relief.
37
+ - `wear`: edge wear, scratches, chips, polish, exposed underlayer.
38
+ - `dirt`: amount, cavity bias, color, vertical streaking, contact staining.
39
+ - `localOverrides`: named regions where color/roughness/bump differs from the base.
40
+
41
+ Local overrides should answer: where, what changes, how strong, and which image evidence supports it.
42
+
43
+ ## Local Feature Types
44
+
45
+ Use `component.localFeatures` for details that matter to recognizability:
46
+
47
+ - raised ridge
48
+ - recessed groove
49
+ - seam line
50
+ - screw or rivet
51
+ - chip or dent
52
+ - scratch cluster
53
+ - stain or dirt patch
54
+ - decal or label area
55
+ - hole or socket
56
+ - bevel highlight
57
+ - fabric stitch
58
+ - leaf vein or serrated edge
59
+
60
+ Each feature should include placement, approximate size, orientation, material effect, geometry effect, and confidence.
61
+
62
+ ## Detail Recipes
63
+
64
+ Concrete Three.js material/geometry approach per `detailInventory` kind. Cross-reference
65
+ `grimoire/intake/detail_inventory.md` for the full taxonomy and the evidence/mapping rule.
66
+
67
+ - gloss: `MeshPhysicalMaterial` with a low-`roughness` localOverride (0.05-0.2) sized to the
68
+ hotspot region; use `clearcoat`/`clearcoatRoughness` for a lacquer layer over a rougher
69
+ base, `anisotropy`/`anisotropyRotation` for brushed/streaked highlights.
70
+ - bevel: real geometry, not a normal map - `edgeTreatment.type = chamfer`, `bevelRadius`
71
+ object-relative (0.02-0.08), `segments` 2-4 for a soft catch-light rim, 1 for a hard edge.
72
+ - fastener: `InstancedMesh` for the repeated part; `count` + spacing pattern (linear, radial,
73
+ grid) + head shape (hemisphere/flat/hex) + recess (raised vs countersunk); low-roughness
74
+ metal material on the head crown.
75
+ - linework: pick engraved groove (real recessed geometry along a path, catches shadow),
76
+ painted line/decal (canvas-texture localOverride, color contrast only, no relief), or
77
+ panel-line (thin dark AO/roughness localOverride along a seam, no depth) - match whichever
78
+ the reference evidence shows; do not default to decal for something that casts a shadow.
79
+ - stain: `material.localOverrides` region with `dirtAmount`, `cavityBias` (concentrate in
80
+ crevices), `streak` (directional, usually gravity-down), `patinaColor` for oxidation hue
81
+ shift, or a `fadedMask` (lighter, desaturated) for sun-bleaching - the inverse of dirt.
82
+
83
+ ## Character Geometry And Material Recipes
84
+
85
+ Use these when `objectClass.primaryDomain` is `character` or `hybrid`. Pair with
86
+ `grimoire/character/reconstruction.md` for proportion/landmark data.
87
+
88
+ - head: sphere or ellipsoid scaled to the measured head-unit, then displaced/tapered toward
89
+ the reference face shape (jaw width, chin point, cheek fullness) rather than left spherical.
90
+ - limbs: capsule or tapered cylinder per segment (upper arm, forearm, thigh, shin); taper
91
+ ratio and length come from `anatomy.proportions`; capsules keep joints visually continuous.
92
+ - hands: simplified capsule-cluster (palm block + finger capsules) at low segment count;
93
+ do not attempt per-knuckle detail unless the reference is close-up and complexity is ultra.
94
+ - hair: hair cards (alpha-mapped planes layered in clumps) for stylized/low-complexity, or a
95
+ tube-along-curve per lock for wavy/flowing hair with visible strand structure; prefer cards
96
+ by default - hair is the classic single-image failure mode, so favor legible clumps over
97
+ many thin strands that swim or alias.
98
+ - face feature placement: position eyes, brows, nose, mouth using `anatomy.faceLandmarks`
99
+ normalized coordinates (eyeLine, eyeSpacing, noseBase, mouthLine, hairline); never eyeball
100
+ placement freehand once landmarks exist.
101
+ - eyes: glossy sphere (low roughness, slight clearcoat) plus an iris decal/texture; a correct
102
+ catchlight (small bright localOverride matching the key light) sells more realism than
103
+ extra geometry.
104
+ - clothing: extrude or plane panels per garment piece, with fold normals (a normal-map or
105
+ displacement pattern following expected gravity/pose creases) rather than a flat shell;
106
+ reuse Track A detail machinery (seam, stitch, decal, stain) for prints, buttons, wear.
107
+ - skin: approximate subsurface scattering, not true SSS - warm base albedo, soft/lower
108
+ roughness variation (skin is not uniformly matte), and a rim or backlight to fake light
109
+ passing through thin tissue (ears, nose edge). Avoid pure-Lambertian flat skin.
110
+
111
+ ## Verification Cues
112
+
113
+ A procedural object is usually failing when:
114
+
115
+ - silhouette reads wrong even before material
116
+ - every edge is perfectly sharp or perfectly smooth
117
+ - material has one flat color and no roughness variation
118
+ - lighting hides the form instead of explaining it
119
+ - repeated details are too evenly spaced
120
+ - close-up details add triangles but not recognizability
grimoire/character/likeness_maximization.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Likeness Maximization (Projection-First Pipeline)
2
+
3
+ Use this reference when the goal is maximum resemblance to a specific person or character in a single reference image, not a generic stylized character. This is the default high-likeness path for the `character` domain; `character/reconstruction.md` covers the fallback stylized/freehand path when the input is weak or the user accepts approximation.
4
+
5
+ Read section 5.8-5.10 of `docs/UPGRADE_PLAN.md` for the full spec this reference implements.
6
+
7
+ ## Why Freehand Sculpting Cannot Reach High Likeness
8
+
9
+ Hand-authored primitives (capsules, spheres, blend shapes tuned by eye) can approximate proportions but cannot reproduce the exact geometry and surface information encoded in a photo. The two levers that actually move likeness are: (1) getting the mesh's shape and camera to align precisely with the photo, and (2) putting the photo's own pixels onto that mesh as texture. Everything else is secondary.
10
+
11
+ ## Pipeline
12
+
13
+ ### (a) Fit a parametric template to landmarks
14
+
15
+ Ship a lightweight, code-generated parametric humanoid/face template — a head-unit-parameterized body plus a morphable face, conceptually mirroring SMPL-X (body + hands + face, jaw/eye joints, linear blend skinning with corrective blendshapes) and FLAME (face-from-scans). This stays procedural: the template is code-generated and parameter-driven, never a downloaded art asset.
16
+
17
+ Fit template parameters (shape, pose, expression) by minimizing reprojection error between template landmarks and the observed 2D landmarks from `stage1_intake/extract_landmarks.py` — the SMPLify-X idea. Do not hand-place vertices; solve for parameters that make the template's projected landmarks match the image landmarks.
18
+
19
+ ### (b) Camera match
20
+
21
+ Estimate and store focal length, FOV, and orientation for the reference photo (`forge/stage1_intake/solve_camera_pose.py`, emits a `referenceCamera` spec block). The render camera must match this so:
22
+
23
+ - the review screenshot can be pixel-overlaid against the source photo
24
+ - the texture projection in step (d) lands correctly
25
+
26
+ Without a matched camera, projected texture will misalign the moment the model is viewed from any angle other than the accidental one it was authored at.
27
+
28
+ ### (c) De-light before treating the photo as albedo
29
+
30
+ A raw photo bakes in shadows, highlights, and ambient occlusion from whatever light was present when it was taken. Using it directly as albedo means the projected texture fights the new scene's lights. Run a de-lighting pass (`forge/stage1_intake/delight_albedo.py`) — high-pass/overlay neutralization at minimum, an AI delighter equivalent if available — to recover a neutral base color, then derive roughness/normal/AO independently. Treat "album must be free of baked lighting" as a hard requirement, not a nice-to-have.
31
+
32
+ ### (d) Project and bake
33
+
34
+ Solve projective/camera-projection texturing from the matched camera (Three.js `ShaderMaterial`, or the `three-projected-material` approach) to map the de-lit reference onto the fitted mesh, then bake the result into the mesh's UVs (`forge/stage3_build/bake_projected_texture.py`, stdlib PNG) for the visible (front) side.
35
+
36
+ ### (e) Infer unseen regions, flag confidence
37
+
38
+ Back/sides are not observed. Options, in order of preference:
39
+
40
+ 1. request an additional view (`request-input`: front/side/back) — always try this first for a real person
41
+ 2. mirror the front texture across the body's symmetry plane where anatomically valid (works reasonably for faces, poorly for asymmetric hair/clothing)
42
+ 3. palette-continue from the nearest observed edge as a last resort
43
+
44
+ Every inferred region gets its own confidence score and a note of which strategy produced it. Never silently present an inferred back as if it were observed.
45
+
46
+ ### (f) Rig for deformation
47
+
48
+ Emit a `SkinnedMesh` with a joint skeleton for the body plus morph targets/blend shapes for facial expression, exportable as glTF. Keep topology predictable (retopologized, evenly quaded around the face) so blendshapes deform cleanly. Expose skeleton and morph channels through `root.userData.sculptRuntime`.
49
+
50
+ ## Part-Specific Notes (stylized-to-realistic dial)
51
+
52
+ Same recipes as `character/reconstruction.md`, dialed toward realism: skin keeps the warm-base/soft-roughness/rim-light approximation (true SSS is out of scope); hair still prefers stylized clumps over strand geometry — a single image cannot supply real hair microstructure, so do not oversell hair likeness; eyes get the glossy-sphere-plus-iris-decal treatment with a correct catchlight, which reads as more "alive" than raw geometric accuracy.
53
+
54
+ ## Honesty Note
55
+
56
+ State plainly, every time this pipeline runs: a single image cannot yield a guaranteed 100 percent likeness. Back/sides, occluded geometry, and true skin/hair microstructure are not observable from one photo. This pipeline maximizes likeness through parametric fit + photo projection + de-lighting + camera match, reports per-region confidence, and requests additional views whenever the subject is a real person and fidelity matters. Never claim "100 percent match" as an output — report confidence per region instead.
57
+
58
+ An optional, explicitly-flagged `generativeAssist` mode (importing an external image-to-3D base mesh, e.g. TRELLIS/Tripo/Hunyuan3D/Rodin) sets the realistic ceiling higher (~80-95 percent front-face shape accuracy per current generators) but is non-procedural and never the silent default — see UPGRADE_PLAN.md section 5.9.
59
+
60
+ ## Sources
61
+
62
+ - [Expressive Body Capture: SMPL-X / SMPLify-X (arXiv 1904.05866)](https://arxiv.org/pdf/1904.05866)
63
+ - [SMPLify-X overview (EmergentMind)](https://www.emergentmind.com/topics/smplify-x)
64
+ - [Playing with Texture Projection in Three.js (Codrops)](https://tympanus.net/codrops/2020/01/07/playing-with-texture-projection-in-three-js/)
65
+ - [three-projected-material (GitHub)](https://github.com/marcofugaro/three-projected-material)
66
+ - [three.js morph targets - face example](https://threejs.org/examples/webgl_morphtargets_face.html)
67
+ - [TexDreamer: high-fidelity 3D human texture (arXiv 2403.12906)](https://arxiv.org/pdf/2403.12906)
68
+ - [Delight AI - Adobe Substance 3D Sampler](https://helpx.adobe.com/substance-3d-sampler/filters/tools/delight-ai-powered.html)
69
+ - [De-Lighting 3D Scans (Sketchfab community)](https://sketchfab.com/blogs/community/de-lighting-3d-scans-in-unity-by-pete-mcnally/)
70
+ - [Character Turnaround Guide (spines.com)](https://spines.com/character-turnaround/)
71
+ - [How to Create a 3D Character Model Reference (Coohom)](https://www.coohom.com/article/how-to-create-a-3d-character-model-reference)
72
+ - [Best AI 3D Model Generators 2026 (TRELLIS vs Meshy vs Tripo vs Hitem3D)](https://trellis2.app/blog/best-ai-3d-model-generator)
73
+ - [7 Image-to-3D AI Generators, July 2026 (Vitalify)](https://www.vitalify.asia/en/blog/generative-ai/ai-image-to-3d-generators-comparison)
74
+ - [How To Deploy Image-To-3D Models In Three.js (Threedium)](https://threedium.io/create/3d-models/platform/threejs)
grimoire/character/reconstruction.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Character Reconstruction
2
+
3
+ Use this reference when `objectClass.primaryDomain` is `character` or `hybrid`. It replaces guesswork proportions with a measured system so a generated humanoid actually resembles the reference pose and build.
4
+
5
+ ## Proportion System (head-units)
6
+
7
+ Measure everything in head-units (HU): total body height divided by head height. Pick the style axis from the image, do not assume realistic by default:
8
+
9
+ - realistic: ~7.5 HU (adult human)
10
+ - stylized / anime-adjacent: ~5-6 HU
11
+ - chibi / figurine: ~2-3 HU
12
+
13
+ Record measured ratios, not assumed ones:
14
+
15
+ - `headUnit`: head height as a fraction of total image height
16
+ - `torso`: crown-to-hip distance in HU
17
+ - `legs`: hip-to-floor distance in HU
18
+ - `shoulderWidth`: in HU (roughly 1.5-2 HU realistic, wider for stylized heroic builds)
19
+ - `hipWidth`: in HU
20
+
21
+ If the image crops the legs or feet, mark `legs` and `hipWidth` as inferred and lower confidence rather than guessing a stock adult ratio.
22
+
23
+ ## Facial Landmark Layout
24
+
25
+ Store landmarks as normalized coordinates (0-1) relative to head bounding box, not the full image, so they survive scale changes:
26
+
27
+ - `hairline`: ~0.0-0.15 from crown depending on hairstyle bulk
28
+ - `eyeLine`: ~0.45-0.55 (near vertical mid-head; lower for chibi, higher forehead for stylized)
29
+ - `eyeSpacing`: horizontal gap between inner eye corners, ~0.2-0.35 of head width (wider spacing reads as more stylized/cute)
30
+ - `noseBase`: ~0.6-0.7
31
+ - `mouthLine`: ~0.75-0.85
32
+ - `earTop` / `earBottom`: roughly bracket `eyeLine` to `noseBase`
33
+
34
+ Pull these from the actual image via `forge/stage1_intake/extract_landmarks.py` overlay, not from a generic face chart. A stylized face with huge eyes will violate realistic ratios on purpose — match what is observed.
35
+
36
+ ## Pose / Skeleton
37
+
38
+ Define joints as a minimal skeleton, matched to the reference silhouette and limb angles, not a default T-pose:
39
+
40
+ - root -> neck -> head
41
+ - neck -> left/right shoulder -> elbow -> wrist
42
+ - root -> left/right hip -> knee -> ankle
43
+
44
+ For each joint record an approximate angle (degrees, relative to rest pose) read off the silhouette. Prioritize matching:
45
+
46
+ 1. overall stance (weight distribution, contrapposto vs symmetric)
47
+ 2. limb angles at shoulders/hips (these dominate perceived pose match)
48
+ 3. hand/foot orientation only if clearly visible
49
+
50
+ If a joint is occluded, do not invent an angle — mark `confidence` low and default to a neutral rest angle for that joint only.
51
+
52
+ ## Character Materials (stylized default)
53
+
54
+ Reuse Track A detail machinery (`grimoire/intake/detail_inventory.md`) for accessories and trims. Base recipes:
55
+
56
+ - **Skin**: warm base albedo sampled from the image, low-to-mid roughness, no true subsurface scattering — approximate with a soft rim/backlight term and a slightly desaturated shadow tint. Avoid `MeshPhysicalMaterial.transmission` unless the reference clearly shows translucency (ears, fingers backlit).
57
+ - **Hair**: the single most common failure point for single-image reconstruction. Do NOT attempt strand-level geometry from one photo. Prefer stylized clumps — hair cards or short tube-along-curve locks grouped into 5-15 major masses matching the silhouette's hair shape, layered front-to-back with alpha or hard edges. Match the read silhouette (fringe, part line, volume) over any attempt at individual strands.
58
+ - **Eyes**: a glossy sphere (high specular, low roughness) plus a separate iris disc/decal with darker outline and a small bright catchlight quad or emissive dot offset toward the key light direction. The catchlight is disproportionately important for "looks alive."
59
+ - **Cloth**: extrude or plane panels following the silhouette's fold lines; add normal-map or geometry creasing at obvious fold zones (elbow, waist cinch, knee) rather than a flat plane. Local material overrides handle prints, seams, buttons via the Track A detail inventory.
60
+
61
+ ## Gate Notes
62
+
63
+ Proportion and landmark values feed `anatomy` block validation (section 5.3/5.6 of the upgrade plan) — every measured value needs an `evidenceRef` back to the source image region, same discipline as object detail inventory.
grimoire/feedback/render_capture.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Browser Screenshot Feedback
2
+
3
+ Use this reference when a procedural Three.js reconstruction has a browser-renderable preview.
4
+
5
+ ## Capture Rule
6
+
7
+ Each visual build pass should produce at least one rendered screenshot from a named review viewpoint. Use your agent's browser/screenshot tool (Claude Code browser MCP, your agent's in-app browser/preview, or the project's own preview) first. Do not install or download Playwright/Chromium just for this skill; use Playwright or another browser automation path only when the user explicitly allows it or the project already depends on it. If the in-app Browser is unavailable, ask for a screenshot path or use browser tooling that is already present in the target project.
8
+
9
+ Create a side-by-side review image after capture:
10
+
11
+ ```bash
12
+ ../../forge/stage4_review/make_comparison_sheet.py \
13
+ --reference reference.png \
14
+ --render render.png \
15
+ --out comparison.png \
16
+ --json
17
+ ```
18
+
19
+ The script only aligns and packages evidence. It must not calculate the acceptance score. your agent's vision must inspect `comparison.png`.
20
+
21
+ Use the same full image pair to score at most five critical semantic features per pass. A feature is a subsystem such as a hull, cabin system, roof system, limb assembly, face, control panel, or sail-and-rigging system. It is not an individual mesh and does not need a separate crop. Score up to three uncertain important features only when adaptive escalation is useful.
22
+
23
+ The starter spec contains generic review targets only as placeholders. Replace them with object-specific systems discovered during pre-spec assessment; otherwise strict quality validation should not pass a moderate or complex object.
24
+
25
+ ## Compare By Layer
26
+
27
+ Review screenshot evidence in this order:
28
+
29
+ 1. Silhouette and proportions: bounding shape, width/height/depth cues, taper, symmetry, negative space.
30
+ 2. Component structure: parent/child placement, joints, contact points, repeated systems, floating or detached parts.
31
+ 3. Form detail: bevels, chamfers, curvature, bends, dents, seams, raised ridges, holes, deformation scale.
32
+ 4. Surface response: albedo zones, roughness variation, metalness, clearcoat, transmission, normal/bump/displacement, ambient occlusion.
33
+ 5. Local features: scratches, chips, dirt accumulation, moss, stains, color patches, edge wear, contact wear.
34
+ 6. Lighting/camera: exposure, shadow softness, contact shadows, color temperature, rim light, reflection readability.
35
+ 7. Performance tradeoff: whether missing detail is intentional because of triangle, draw call, texture, or FPS budgets.
36
+
37
+ ## Decision Matrix
38
+
39
+ - If the screenshot reveals a missing or wrong component, choose `refine-spec`.
40
+ - If the spec describes the component but the render does not match it, choose `refine-code`.
41
+ - If the screenshot is too dark, too close, too far, or from the wrong viewpoint, choose `refine-code` for camera/lighting before judging model fidelity.
42
+ - If the source image does not reveal enough geometry or material information, choose `request-input`.
43
+ - If the screenshot matches the pass acceptance criteria and does not hide future risk, choose `continue`.
44
+
45
+ `continue` is allowed only when the global AI vision score meets `selfCorrectLoop.visualAcceptance.threshold`, normally `0.7`, and every critical semantic feature meets its own threshold. A numeric or pixel-difference script may help diagnose alignment, but it cannot approve the pass.
46
+
47
+ ## AI Vision Scorecard
48
+
49
+ Score each applicable layer from `0` to `1`, then assign one overall score based on the pass goal:
50
+
51
+ - `silhouetteProportion`: outer contour, mass distribution, negative space, camera-normalized proportions.
52
+ - `componentStructure`: hierarchy, placement, attachment, repeated systems, floating or disconnected parts.
53
+ - `formDetail`: taper, bend, bevel, deformation, secondary forms, local geometry.
54
+ - `materialSurface`: albedo, roughness, reflectance, normal/displacement, AO, local wear, tactile frequency.
55
+ - `lightingCamera`: camera match, exposure, key/fill/rim balance, shadow/contact response, background.
56
+
57
+ Do not hide a critical failed layer inside a high average. If a layer is essential to the current pass and remains visibly wrong, choose `refine-spec` or `refine-code` even when the arithmetic mean is above threshold.
58
+
59
+ ## Feature Tiers
60
+
61
+ - `critical`: identity-defining, user-prioritized, visually salient, or high-risk subsystem. Must be visible in the full pair and pass independently.
62
+ - `important`: useful secondary subsystem. Review only suspicious items; the reviewed average must meet the configured threshold.
63
+ - `detail`: micro detail. Record mismatch notes and defer to refinement unless the user promotes it.
64
+
65
+ Repeated parts should be one target when they form one recognizable system. For example, review three cabins as `cabin-system`, not three separate cabin targets.
66
+
67
+ ## Evidence Format
68
+
69
+ Record screenshot evidence with:
70
+
71
+ - `referenceScreenshot`: source image, crop, or marked-up reference path.
72
+ - `renderScreenshot`: browser-rendered screenshot path.
73
+ - `comparisonImage`: side-by-side evidence image reviewed by AI vision.
74
+ - `cameraView`: named viewpoint such as `front`, `three-quarter`, `side`, `top`, or `close-up-material`.
75
+ - `notes`: concise mismatch summary using 3D graphics terms.
76
+ - `aiVisionScore`: overall score from `0` to `1`.
77
+ - `layerScores`: per-layer scores from the scorecard.
78
+ - `aiVisionNotes`: concrete matched features, mismatches, root causes, and next correction.
79
+ - `featureReviews`: feature ID, score, visibility in the shared pair, and focused notes.
80
+
81
+ Never use screenshots as decoration only. They are the ground truth for the self-correction loop.
grimoire/feedback/shading_realism.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Material And Lighting Realism
2
+
3
+ Use this reference whenever the model silhouette is acceptable but the render still looks unlike the source image.
4
+
5
+ ## Common Failure Pattern
6
+
7
+ A procedural object often fails after the shape pass because the render has:
8
+
9
+ - one flat albedo color per material
10
+ - no roughness variation or cavity response
11
+ - no normal/bump/displacement response on surfaces that should be tactile
12
+ - missing local overrides such as moss, stains, edge wear, dirt, sap, rust, dust, scorch, or faded zones
13
+ - lighting that is only ambient or too evenly exposed
14
+ - weak contact shadows, no rim separation, and no tone mapping/exposure target
15
+
16
+ Treat this as a `LookDev Reset`, not a geometry problem.
17
+
18
+ ## Material-Pass Requirements
19
+
20
+ Before implementing or accepting `material-pass`, the spec must contain:
21
+
22
+ - `albedo` palette: dominant, secondary, accent colors, and where they appear on the object.
23
+ - `roughness` response: base value, variation, and local response such as smoother worn edges or rougher cavities.
24
+ - tactile response: at least one of `normal`, `bump`, or `displacement` with scale/amplitude/strength.
25
+ - locality: `localOverrides`, dirt, wear, scratches, chips, stains, moss, patina, wetness, soot, or cavity masks tied to `viewEvidence`.
26
+ - material-specific behavior: alpha/transmission/translucency for thin or transparent parts, metalness/clearcoat for reflective parts, cloth/fiber grain for fabric-like parts.
27
+ - independent PBR channels: albedo, roughness, height/normal, and AO must be generated or authored separately; never reuse albedo as a roughness, height, normal, or AO map.
28
+ - reference-derived PBR extraction: when a source image is available and fidelity matters, run `../../forge/stage1_intake/extract_pbr_evidence.py` for each important material or crop before accepting material-pass. The default target threshold is `0.7`; below that, stop or request better references unless the user explicitly accepts a lower-fidelity approximation.
29
+ - scale hierarchy: close-up materials must describe macro, meso, and micro surface-frequency bands with object-relative frequency and amplitude.
30
+ - projection/UV intent: state UV, triplanar, cylindrical, planar, or another projection strategy, plus repeat/texel-density intent so detail does not stretch across scaled components.
31
+ - quality-first resolution: use at least 1024px procedural maps for important close-up materials and prefer 2048px when reference fidelity is the priority.
32
+ - geometric relief: if a ridge, crack, seam, chip, bark plate, fold, or dent affects the visible silhouette, represent it with geometry or displacement-capable topology instead of texture alone.
33
+
34
+ Do not accept "brown bark", "gold leaves", "dark metal", or "rough stone" as sufficient. Translate it into PBR terms: albedo palette, roughness, normal/bump, AO, dirt/wear, and local masks.
35
+
36
+ Do not claim exact PBR recovery from a single image. Pixels include baked lighting, exposure, shadow, view angle, and camera response. Treat extracted maps as reference-derived material evidence that still needs neutral/grazing/reference screenshot review.
37
+
38
+ Do not accept a material merely because all required fields are present. The browser render must prove that:
39
+
40
+ - roughness breaks highlights independently from albedo color
41
+ - normal/height detail remains readable under grazing light
42
+ - cavities and contacts have coherent AO rather than uniformly dark noise
43
+ - referencePbr maps, when present, are loaded by the generated Three.js material and have confidence at or above the configured threshold
44
+ - micro detail does not visibly tile or swim when the object is scaled
45
+ - local overrides appear in the same regions supported by `viewEvidence`
46
+
47
+ ## Lighting-Pass Requirements
48
+
49
+ Before accepting `lighting-pass`, the spec must contain:
50
+
51
+ - key light direction, color temperature, intensity, and shadow softness
52
+ - fill light color/intensity, or explicit reason for no fill
53
+ - rim/back light or environment reflection cue when the silhouette needs separation
54
+ - ambient/hemisphere/environment color
55
+ - exposure and tone-mapping intent
56
+ - background color or gradient
57
+ - contact shadow / ground shadow behavior
58
+
59
+ Separate object material from photo lighting: a material should still read correctly in neutral turntable lighting, then a reference-matching lighting setup can be added.
60
+
61
+ ## Screenshot Review
62
+
63
+ For material and lighting screenshots, compare in this order:
64
+
65
+ 1. Albedo palette: are dominant and accent colors close to the reference?
66
+ 2. Value range: are dark cavities and bright highlights in the right places?
67
+ 3. Surface response: does roughness/normal/bump catch light?
68
+ 4. Locality: are moss, stains, dirt, wear, chips, or color patches placed where the reference shows them?
69
+ 5. Light structure: can you identify key, fill, rim/environment, contact shadow, and exposure?
70
+ 6. Material-vs-light split: if the scene is relit neutrally, does the object still have believable material detail?
71
+
72
+ For quality-first work, capture three deliberate look-dev views before choosing `continue`:
73
+
74
+ 1. `neutral`: broad soft key/fill lighting for honest albedo and form reading.
75
+ 2. `grazing`: a low-angle hard or semi-hard key close-up that exposes smooth-plastic highlights, weak normals, uniform roughness, and texture tiling.
76
+ 3. `reference-match`: the source camera and lighting direction as closely as the available evidence allows.
77
+
78
+ A material that only looks convincing in the reference-matched light has not passed. Fix its PBR response first, then tune the reference lighting.
79
+
80
+ If the mismatch is mostly color/texture/lighting, choose `refine-code` only when the spec already has the above details. Otherwise choose `refine-spec` first.
grimoire/glossary/3d_vocabulary.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 3D Graphics Terminology For Object Sculpt Specs
2
+
3
+ Use this reference when writing or reviewing an `ObjectSculptSpec`. The goal is to describe what the agent should build in terms a Three.js/technical-artist workflow can act on.
4
+
5
+ ## Geometry And Topology
6
+
7
+ - `silhouette`: the outer read of the object from a given camera angle.
8
+ - `primitive family`: box, sphere, ellipsoid, cylinder, cone, capsule, torus, lathe, extrusion, tube, plane card, instanced cluster.
9
+ - `topology intent`: hard-surface blockout, subdivision-ready surface, low-poly prop, organic deformed mesh, alpha-card cluster.
10
+ - `bevel radius`: size of rounded edge transition in object-relative units.
11
+ - `bevel segments`: number of edge-rounding subdivisions.
12
+ - `chamfer`: flat angled edge cut, usually cheaper than a rounded bevel.
13
+ - `taper`: gradual scale change along an axis.
14
+ - `bend`: curvature deformation along an axis or spline.
15
+ - `twist`: rotational deformation along an axis.
16
+ - `boolean cut`: subtractive shape such as hole, notch, slot, recess, or carved opening.
17
+ - `edge loop`: repeated ring/path of vertices used to support shape or deformation.
18
+ - `local deformation`: localized dent, swelling, pinch, warp, sag, or buckle.
19
+ - `displacement`: geometry-level height movement that can affect silhouette.
20
+ - `normal strategy`: vertex normals, weighted normals, flat shading, generated tangent-space normal.
21
+ - `UV strategy`: generated procedural coordinates, cylindrical projection, triplanar-like mapping, atlas-ready unwrapped regions.
22
+
23
+ ## Material And PBR
24
+
25
+ - `albedo` / `baseColor`: diffuse color independent of lighting.
26
+ - `roughness`: microfacet scatter; high roughness is matte, low roughness is glossy.
27
+ - `metalness`: whether material behaves as conductive metal in PBR.
28
+ - `normal map`: tangent-space normal detail that changes lighting without changing silhouette.
29
+ - `bump map`: height-derived normal detail, usually procedural and cheaper than displacement.
30
+ - `displacement map`: geometry displacement; use only when relief should affect silhouette or close-up shape.
31
+ - `ambient occlusion`: darkening in creases, contact zones, and cavities.
32
+ - `cavity dirt`: localized dark/dusty buildup in recessed areas.
33
+ - `edge wear`: exposed lighter/polished/damaged material on protruding edges.
34
+ - `clearcoat`: secondary glossy layer over base material.
35
+ - `transmission`: light passing through transparent/translucent material.
36
+ - `alpha`: opacity or cutout transparency.
37
+ - `anisotropy`: directional highlight stretch, useful for brushed metal or fibers.
38
+ - `procedural noise scale`: spatial frequency of generated variation.
39
+ - `local mask`: region-specific control for color, roughness, dirt, wear, or bump.
40
+
41
+ ## Surface Local Features
42
+
43
+ - `raised ridge`: geometry or normal detail protruding from surface.
44
+ - `recessed groove`: carved or shadowed line cut into the surface.
45
+ - `seam line`: boundary between joined pieces or material panels.
46
+ - `scratch cluster`: group of thin directional marks affecting albedo/roughness/normal.
47
+ - `chip`: broken missing piece, often with exposed underlayer.
48
+ - `dent`: inward local deformation.
49
+ - `stain`: local albedo/roughness color change without strong geometry change.
50
+ - `contact wear`: abrasion where object touches ground, hands, joints, or other parts.
51
+ - `decal region`: image/text/logo-like area; approximate with colored planes or generated texture unless exact fidelity is required.
52
+
53
+ ## Lighting And Rendering
54
+
55
+ - `key light`: dominant light source.
56
+ - `fill light`: softer light used to lift shadows.
57
+ - `rim light`: back/side light that outlines silhouette.
58
+ - `environment reflection`: skybox/HDRI-like reflection source.
59
+ - `contact shadow`: near-surface shadow grounding the object.
60
+ - `shadow softness`: blur/spread of shadow edge.
61
+ - `color temperature`: warm/cool light tint.
62
+ - `exposure`: scene brightness scale.
63
+ - `tone mapping`: output transform affecting contrast and highlights.
64
+
65
+ ## Animation, Physics, And Destruction
66
+
67
+ - `pivot`: local rotation origin.
68
+ - `hinge`: constrained rotation joint.
69
+ - `socket`: attachment point for a child part.
70
+ - `collider`: simplified physics shape.
71
+ - `rigid body`: simulated physical body.
72
+ - `fracture seam`: planned break line.
73
+ - `detachable fragment`: piece that can separate during destruction.
74
+ - `impulse direction`: force vector used to trigger movement or breakage.
75
+
76
+ ## Writing Rule
77
+
78
+ Bad: `the surface is ugly and too smooth`.
79
+
80
+ Better: `increase microRoughness to 0.45, add tangent-space fine-noise normal with strength 0.2 and scale 32, add low-frequency albedo mottling at amplitude 0.12, and add cavity dirt local masks in recessed grooves`.
81
+
82
+ Bad: `make the edges realistic`.
83
+
84
+ Better: `add 0.025 relative bevel radius with 3 segments on exposed hard-surface edges, add edge-wear local overrides on bevel crests, and keep internal seams sharper with 1-segment chamfers`.
grimoire/intake/detail_inventory.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Detail Inventory
2
+
3
+ Use this reference during analysis, before the spec is authored. It exists because small
4
+ identity-defining marks (a bevel highlight, a row of rivets, a stain) get skipped when the
5
+ agent only eyeballs the whole image once. Scan zone by zone and record every mark as a
6
+ structured `detail`, not as prose.
7
+
8
+ ## The Rule
9
+
10
+ Every `detail` entry records: where (`region`, normalized), what changes (`kind` + `affects`),
11
+ how strong (`scale`, intensity implied by the recipe below), evidence region (`evidenceRef`),
12
+ and confidence. It MUST set `mapsTo` a real `component.localFeatures[]` entry or
13
+ `material.localOverrides[]` entry. A detail described only in prose is a gate failure - if it
14
+ does not map to a field the generator reads, it will not reach the render.
15
+
16
+ ## Taxonomy - kind to graphics terms
17
+
18
+ ### gloss (do bong)
19
+ Localized low-roughness zone or specular hotspot, not a global material change.
20
+ - `material.localOverrides`: `roughness` low value (0.05-0.2) over the region, or
21
+ `clearcoat` + `clearcoatRoughness` on `MeshPhysicalMaterial` for a lacquer/wet look.
22
+ - Streaked highlights (brushed metal, hair) -> `anisotropy` + `anisotropyRotation`.
23
+ - Record hotspot position relative to the key light direction; a gloss detail with no
24
+ matching light direction will not render visibly.
25
+
26
+ ### bevel (bo goc)
27
+ Edge treatment, not a texture trick - light catches a real chamfer.
28
+ - `component.localFeatures` geometry effect: `edgeTreatment.type = chamfer`,
29
+ `bevelRadius` (object-relative, e.g. 0.02-0.08), `segments` (2-4 for a soft rim, 1 for hard).
30
+ - Note whether it reads as a bright rim highlight under grazing light; if the reference
31
+ shows a crisp bright line along an edge, the bevel must be real geometry, not a normal map.
32
+
33
+ ### fastener (screw / rivet / bolt)
34
+ Repeated small parts - always an instanced system, never one-off meshes.
35
+ - `InstancedMesh`, `count`, spacing/distribution (linear, radial, grid), head shape
36
+ (hemisphere, flat, hex), recess (raised vs countersunk), material (usually metal, low
37
+ roughness at the head crown).
38
+ - Confidence should reflect whether every instance is visible or only a legible subset
39
+ (partial rows behind occlusion still count if spacing is inferable).
40
+
41
+ ### linework (engraving / painted line / panel-line)
42
+ Three distinct techniques - pick the one the evidence supports, they read differently:
43
+ - Engraved groove: geometry effect, a recessed `groove` (see below) following a path;
44
+ catches shadow, no geometry it will look flat under any light.
45
+ - Painted line / decal: `material.localOverrides` with a canvas-texture decal region;
46
+ color contrast only, no relief.
47
+ - Panel-line: dark AO seam - a thin `localOverride` darkening roughness/AO along a seam
48
+ without true depth; use when the reference shows a soft dark line, not a hard groove.
49
+ - State a legibility target: line must remain readable at the review's grazing-light shot.
50
+
51
+ ### contour (edge outline / toon rim)
52
+ Stylized outline, usually a rim-light or a backface-outline technique.
53
+ - `material.localOverrides` or a dedicated outline pass (inverted-hull or shader rim).
54
+ - Record which silhouette edges carry it; partial outlines (only the top edge) are common.
55
+
56
+ ### seam
57
+ Construction line where two surfaces meet (molded parts, fabric panels, armor plates).
58
+ - Geometry effect: a thin recessed `groove` or a raised `ridge` (whichever the reference
59
+ shows) plus a slightly darker AO localOverride in the crevice.
60
+
61
+ ### stitch (fabric stitch)
62
+ - `component.localFeatures`: small repeated bumps or a dashed groove along a seam path;
63
+ usually paired with a `linework: painted line` for the thread color contrast.
64
+ - Instance or repeat along a curve like a fastener row, but finer spacing.
65
+
66
+ ### stain (dirt / patina / discolour / faded)
67
+ Always a `material.localOverrides` region, described with these sub-fields:
68
+ - `dirtAmount`: 0-1, how much darker/desaturated.
69
+ - `cavityBias`: whether it concentrates in crevices/cavities (usually yes for dirt/grime).
70
+ - `streak`: vertical/directional streaking flag + direction (gravity-fed dirt runs down).
71
+ - `patinaColor`: hex or named hue shift for oxidation/verdigris/rust bloom.
72
+ - `fadedMask`: a lighter, desaturated region for sun-bleaching - opposite of dirt, still a
73
+ localOverride.
74
+ - `region`: where on the object, tied to `evidenceRef`.
75
+
76
+ ### scratch
77
+ Thin localized roughness/normal perturbation, optionally exposing an underlayer color.
78
+ - `material.localOverrides`: scratch cluster with orientation (usually radial or directional
79
+ from handling), width, and whether it exposes a different base color underneath.
80
+
81
+ ### chip
82
+ Small area of missing surface material, usually at an edge or corner.
83
+ - Geometry effect if it changes silhouette (a notch); otherwise a localOverride exposing
84
+ an underlayer color/roughness at a corner/edge component.
85
+
86
+ ### decal
87
+ Printed/applied graphic or label, flat against the surface.
88
+ - `material.localOverrides` with a canvas-texture region; record placement, approximate
89
+ size, and rotation. Decals do not add geometry unless they have physical thickness
90
+ (a sticker edge) - if so, add a thin raised `component.localFeatures` plate.
91
+
92
+ ### emissive
93
+ Self-lit region (LED, glow, screen, ember).
94
+ - `material.localOverrides`: `emissive` color + `emissiveIntensity`, and whether it should
95
+ bloom under the renderer's tone mapping. Record whether it is constant or should read as
96
+ a light source affecting nearby surfaces (may need a matching point/area light).
97
+
98
+ ### hole
99
+ Actual opening or socket, changes silhouette/topology.
100
+ - `component.localFeatures` geometry effect: a real cut or socket, not a dark texture patch.
101
+ Record depth and whether the interior needs its own material (visible cavity).
102
+
103
+ ### groove
104
+ Recessed linear or curved channel.
105
+ - Geometry effect: negative relief along a path, width/depth object-relative, plus AO
106
+ darkening in the channel. Shares mechanics with engraved linework and seams.
107
+
108
+ ### ridge
109
+ Raised linear or curved feature, the geometric inverse of a groove.
110
+ - Geometry effect: positive relief along a path, width/height object-relative, catches
111
+ highlight along its top edge (pair with a gloss or bevel note if the reference shows a
112
+ highlight line on the ridge crest).
113
+
114
+ ## Scan Method
115
+
116
+ Pick one and record it as `scanMethod`:
117
+ - `component-zones`: walk each planned component's bounding region; best when component
118
+ boundaries are already known.
119
+ - `grid-3x3` / `grid-4x4`: divide the image into a uniform grid and inspect every cell;
120
+ use when components are not yet decided or the object has no obvious part boundaries.
121
+
122
+ Set `targetMinDetails` from complexity tier: simple 3, moderate 6, complex 10, ultra 16
123
+ (starting values, tune after runs). Scanning zone by zone against a minimum count is what
124
+ prevents a single-glance miss of small marks.
125
+
126
+ ## Confidence
127
+
128
+ Score 0-1 per detail. Lower confidence for: partially occluded regions, marks inferred by
129
+ symmetry rather than seen, or ambiguous kind classification (e.g. scratch vs. panel-line).
130
+ Do not inflate confidence to pad `targetMinDetails` - an unlinked or low-confidence detail
131
+ that fails the `mapsTo` check still blocks the gate.
grimoire/intake/quality_contract.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pre-Spec Assessment And Quality Contract
2
+
3
+ Use this reference before authoring an `ObjectSculptSpec`. The purpose is to prevent shallow specs that are technically valid but too vague to recreate the reference object.
4
+
5
+ Do not use fixed domain profiles. Assess the object from observed traits, complexity, and target fidelity.
6
+
7
+ ## Soft Object Classification
8
+
9
+ Describe the object using multiple axes:
10
+
11
+ - form language: organic, hard-surface, mechanical, architectural, botanical-like, character-like, amorphous, sculptural, fabric-like, transparent-like
12
+ - structure kind: single body, compound object, branching hierarchy, repeated modules, layered shell, articulated assembly, deformable surface
13
+ - motion potential: static prop, whole-object transform, articulated, bendable, detachable, destructible, effect-emitter
14
+ - material families: wood, bark, leaf, metal, stone, ceramic, plastic, rubber, cloth, glass-like, liquid-like, skin-like, mixed
15
+
16
+ These are descriptors, not domain templates. Use only what the image supports.
17
+
18
+ ## Complexity Scoring
19
+
20
+ Score each axis from 0 to 3:
21
+
22
+ - silhouette complexity: simple outline to heavily interrupted/organic silhouette
23
+ - component count: one piece to many visible subparts
24
+ - hierarchy depth: flat object to deep parent-child structure
25
+ - repetition density: none to thousands of repeated marks/leaves/scales/rivets
26
+ - material layer count: one material to many layered local material responses
27
+ - local detail density: plain surface to dense scratches, bumps, moss, seams, chips, pores, or grain
28
+ - occlusion risk: fully visible to many hidden/inferred parts
29
+ - action readiness need: static to many pivots/sockets/colliders/destruction seams
30
+
31
+ Map total judgment to:
32
+
33
+ - `simple`: few parts, low detail, one or two materials
34
+ - `moderate`: several parts, visible local detail, shallow hierarchy
35
+ - `complex`: many parts, repeated systems, multiple materials, several hierarchy levels
36
+ - `ultra-complex`: dense organic/mechanical/architectural structure where fidelity depends on deep hierarchy and repeated microstructure
37
+
38
+ ## Quality Contract
39
+
40
+ Before generating code, define exactly what makes the model good enough:
41
+
42
+ - definition of done for this object
43
+ - minimum macro, meso, and micro feature counts
44
+ - required repeated systems and their distribution rules
45
+ - required material layers and local overrides
46
+ - screenshot viewpoints required for visual comparison
47
+ - failure modes that should block `continue`
48
+
49
+ Good feature groups are specific to the image:
50
+
51
+ - weak: `make leaves look good`
52
+ - strong: `leaf clusters must form irregular overlapping canopy masses, with varied card size/orientation/color and gaps exposing secondary branches`
53
+
54
+ - weak: `add bark texture`
55
+ - strong: `trunk and primary branches need vertical ridges, cavity-darkened cracks, moss/lichen patches near roots and inner forks, roughness variation, and nonuniform displacement/bump`
56
+
57
+ ## Strict Quality Gate
58
+
59
+ Run `../../forge/stage2_spec/validate_sculpt_spec.py spec.json --strict-quality` before code generation. The script path is relative to the skill folder.
60
+
61
+ If strict validation fails:
62
+
63
+ - refine `preSpecAssessment` if complexity was underestimated
64
+ - refine `qualityContract` if definition of done is too generic
65
+ - add missing components, material layers, repetition systems, evidence refs, or local features
66
+ - only lower the quality bar if the user explicitly accepts a simpler approximation
67
+
68
+ The gate should block code generation when the spec could describe many different objects instead of the provided reference.
grimoire/intake/validation_rubric.md ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Object Image Validation Rubric
2
+
3
+ Use this reference when the suitability decision is unclear.
4
+
5
+ ## Pass
6
+
7
+ - one obvious target object
8
+ - object occupies enough of the frame
9
+ - at least one strong silhouette
10
+ - major materials are visible
11
+ - hidden side can be reasonably inferred
12
+ - target can be approximated with procedural primitives
13
+
14
+ ## Conditional
15
+
16
+ - one view only but object has rotational symmetry
17
+ - some occlusion but macro shape is clear
18
+ - fine surface detail can be represented with procedural texture
19
+ - target is organic but user accepts stylization
20
+ - exact brand/logo/text fidelity is not required
21
+
22
+ ## Reject
23
+
24
+ - target object is ambiguous
25
+ - photo is a scene, not an object reference
26
+ - important shape is hidden, cropped, blurred, or transparent
27
+ - request demands exact mesh extraction or manufacturing-grade dimensions
28
+ - object relies primarily on smoke, liquid, glass caustics, or lace (no reconstruction path exists for these)
29
+
30
+ ## Character / Human Suitability
31
+
32
+ Do not blanket-reject a subject for being hair- or cloth-fold-dominant. If the form language is character-like (humanoid silhouette, skin/cloth/hair materials), classify it `character-conditional -> stylized` instead of `reject`. Route through `grimoire/character/reconstruction.md` (proportions, landmarks, pose, stylized materials) by default.
33
+
34
+ - **character-conditional -> stylized**: humanoid subject, at least one clear frontal view, pose readable, hair/cloth is present but the user accepts the stylized-clump/fold-normal treatment rather than photoreal strands or drape simulation. Proceed with the standard character pipeline.
35
+ - **character-conditional -> maximum likeness**: user explicitly wants the closest possible match to a specific person/character. Confirm this intent before starting, then route through `grimoire/character/likeness_maximization.md` (projection-first: template fit, camera match, de-lighting, texture projection). State up front that a single image cannot guarantee 100 percent likeness; report per-region confidence instead of claiming an exact match.
36
+ - **still reject**: no humanoid silhouette is discernible at all, the figure is fully occluded/cropped below usable proportions, or the request demands photoreal skin/hair microstructure from a single low-resolution image with no willingness to provide more views or accept stylization.
37
+
38
+ Before committing to a character spec:
39
+
40
+ - confirm which stylization level the user accepts (realistic ~7.5 heads / stylized 5-6 / chibi 2-3) — do not assume realistic by default
41
+ - request front, side, and back (or full-body) views whenever the visible view cannot support pose, proportion, or back-of-head/body inference
42
+ - if maximum likeness is requested but only one low-quality view is available, say so explicitly and offer the stylized fallback as the practical alternative
43
+
44
+ ## Ask For Better Input
45
+
46
+ Ask for:
47
+
48
+ - front, side, and back views
49
+ - a neutral background
50
+ - higher resolution
51
+ - close-ups of material/detail
52
+ - desired style: realistic, stylized, low-poly, game prop, hero render
53
+
54
+ ## Complex Object Detail Standard
55
+
56
+ For objects with many details, require:
57
+
58
+ - macro components for the overall mass
59
+ - meso components for visible sub-assemblies
60
+ - micro components or local features for repeated/tiny details
61
+ - material layer stack for every visually distinct surface
62
+ - local overrides for stains, scratches, dirt, color changes, wear, bumps, and roughness shifts
63
+ - confidence per component or feature
64
+ - evidence refs to image regions
65
+
66
+ If these cannot be inferred from the image, mark the spec `conditional` and list missing views or close-ups.
grimoire/readiness/action_rigging.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Action-Ready Procedural Models
2
+
3
+ Use this reference when a procedural Three.js model may later need animation, transformation, physics, or destruction.
4
+
5
+ ## Design Goal
6
+
7
+ The generated model should be a runtime-ready hierarchy, not a single decorative mesh. Future actions should be added by targeting named nodes, sockets, colliders, and destruction groups instead of rewriting the reconstruction.
8
+
9
+ ## Hierarchy Pattern
10
+
11
+ Use this structure:
12
+
13
+ - `root`: whole-object motion, visibility, global scale, runtime metadata.
14
+ - `component pivot Group`: stable transform node for each macro/meso component.
15
+ - `visual mesh`: child of the component pivot; holds geometry and material.
16
+ - `socket Object3D`: child of the relevant pivot; marks attachment, effect, grip, or joint positions.
17
+ - collider metadata/proxy: simplified runtime shape, not necessarily a visible mesh.
18
+ - destruction group metadata: semantic grouping for detach/break logic.
19
+
20
+ ## Pivot Rules
21
+
22
+ - Use center pivots only when the object rotates around its center of mass.
23
+ - Use base pivots for trees, signs, bottles, poles, legs, and upright props.
24
+ - Use hinge pivots for lids, doors, handles, flaps, jaws, levers, and wings.
25
+ - Use branch/root pivots for organic appendages that bend from one end.
26
+ - Use custom pivots when the reference clearly implies a mechanical joint or socket.
27
+
28
+ ## Collider Rules
29
+
30
+ - Use primitive proxies first: box, sphere, capsule, cylinder.
31
+ - Use compound proxies for complex silhouettes.
32
+ - Avoid visual mesh colliders unless the user explicitly asks for high-precision collision.
33
+ - Mark triggers separately from solid colliders.
34
+ - Store collider intent even when no physics engine is installed.
35
+
36
+ ## Destruction Rules
37
+
38
+ - Break along existing seams, joints, material boundaries, weak points, or branch roots.
39
+ - Use detachable component groups for large fragments.
40
+ - Use procedural small fragments only where they improve readability.
41
+ - Attach impact, spark, dust, liquid, or debris effect sockets when destruction is expected.
42
+ - Preserve material continuity on exposed fracture faces where possible.
43
+
44
+ ## Acceptance Criteria
45
+
46
+ An action-ready model passes when:
47
+
48
+ - Every major part has a stable ID and pivot node.
49
+ - Movable or breakable parts are not merged into unrelated geometry.
50
+ - Sockets are named and placed in local coordinates.
51
+ - Collider proxies exist for physics-relevant parts.
52
+ - Destruction groups and fracture seams are explicit.
53
+ - `root.userData.sculptRuntime` exposes maps that later code can target.