Spaces:
Running
Running
Deploy verified img2threejs Docker Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +28 -0
- .gitignore +13 -0
- Dockerfile +81 -0
- LICENSE +21 -0
- README.md +165 -5
- SKILL.md +136 -0
- app/__init__.py +1 -0
- app/config.py +202 -0
- app/exemplar_spec.json +485 -0
- app/forge_bridge.py +314 -0
- app/image_guard.py +184 -0
- app/llm.py +322 -0
- app/main.py +478 -0
- app/pipeline.py +530 -0
- app/prompt.py +140 -0
- app/ratelimit.py +53 -0
- app/static/app.js +367 -0
- app/static/index.html +109 -0
- app/static/logo.svg +33 -0
- app/static/styles.css +135 -0
- app/static/viewer-core.js +152 -0
- app/static/viewer.html +90 -0
- docs/SECURITY.md +116 -0
- forge/_shared/feature_acceptance_policy.py +113 -0
- forge/requirements.txt +6 -0
- forge/stage1_intake/build_detail_inventory.py +336 -0
- forge/stage1_intake/delight_albedo.py +343 -0
- forge/stage1_intake/extract_landmarks.py +447 -0
- forge/stage1_intake/extract_pbr_evidence.py +834 -0
- forge/stage1_intake/probe_image.py +168 -0
- forge/stage1_intake/solve_camera_pose.py +202 -0
- forge/stage2_spec/new_pre_spec_assessment.py +113 -0
- forge/stage2_spec/new_sculpt_spec.py +1129 -0
- forge/stage2_spec/validate_sculpt_spec.py +1730 -0
- forge/stage3_build/bake_projected_texture.py +163 -0
- forge/stage3_build/generate_threejs_factory.py +986 -0
- forge/stage3_build/orchestrate_passes.py +543 -0
- forge/stage4_review/append_review.py +384 -0
- forge/stage4_review/make_comparison_sheet.py +279 -0
- forge/tests/test_pipeline.py +277 -0
- grimoire/build/geometry_patterns.md +120 -0
- grimoire/character/likeness_maximization.md +74 -0
- grimoire/character/reconstruction.md +63 -0
- grimoire/feedback/render_capture.md +81 -0
- grimoire/feedback/shading_realism.md +80 -0
- grimoire/glossary/3d_vocabulary.md +84 -0
- grimoire/intake/detail_inventory.md +131 -0
- grimoire/intake/quality_contract.md +68 -0
- grimoire/intake/validation_rubric.md +66 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|