jasondo OpenAI Codex commited on
Commit
36f68cc
·
1 Parent(s): 4c22d01

Build Snap2Sim demo deployment scaffold

Browse files

Co-authored-by: OpenAI Codex <codex@openai.com>

.env.example ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Snap2Sim runtime configuration template.
2
+ # Copy these keys into Hugging Face Space variables/secrets or a local .env file.
3
+ # Do not put real tokens, passwords, or private endpoint URLs in this file.
4
+
5
+ INFERENCE_BACKEND=modal
6
+ MODAL_ANALYZE_URL=
7
+ MODAL_GENERATE_URL=
8
+ INFERENCE_TIMEOUT_SECONDS=240
9
+ SNAP2SIM_API_TOKEN=
.github/workflows/sync_to_hf.yml ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sync this GitHub repository to an existing Hugging Face Space.
2
+ #
3
+ # Configure before use:
4
+ # - Create the Hugging Face Space first; this workflow does not create it.
5
+ # - Add a GitHub Actions secret named HF_TOKEN with write access to the target
6
+ # Hugging Face Space repository.
7
+ # - Keep GitHub as the single source of truth. Do not edit files directly on
8
+ # the Hugging Face Space, because this workflow force-syncs GitHub to HF.
9
+ # - Make sure any required Space variables/secrets, such as MODAL_ANALYZE_URL,
10
+ # MODAL_GENERATE_URL, and SNAP2SIM_API_TOKEN, are configured in the Space UI.
11
+
12
+ name: Sync to Hugging Face Space
13
+
14
+ on:
15
+ push:
16
+ branches:
17
+ - main
18
+
19
+ permissions:
20
+ contents: read
21
+
22
+ concurrency:
23
+ group: sync-to-hugging-face-space
24
+ cancel-in-progress: false
25
+
26
+ jobs:
27
+ sync:
28
+ name: Push GitHub main to Hugging Face
29
+ runs-on: ubuntu-latest
30
+ env:
31
+ HF_SPACE_URL: https://huggingface.co/spaces/jasondo111/Snap2Sim
32
+
33
+ steps:
34
+ - name: Checkout full history with LFS
35
+ uses: actions/checkout@v4
36
+ with:
37
+ fetch-depth: 0
38
+ lfs: true
39
+
40
+ - name: Install Git LFS
41
+ run: |
42
+ git lfs install
43
+ git lfs fetch --all
44
+
45
+ - name: Configure Hugging Face remote
46
+ env:
47
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
48
+ run: |
49
+ if [ -z "$HF_TOKEN" ]; then
50
+ echo "GitHub secret HF_TOKEN is not configured." >&2
51
+ exit 1
52
+ fi
53
+ git remote remove huggingface 2>/dev/null || true
54
+ git remote add huggingface "https://hf:${HF_TOKEN}@${HF_SPACE_URL#https://}"
55
+
56
+ - name: Push LFS objects to Hugging Face
57
+ run: git lfs push huggingface --all
58
+
59
+ - name: Force-sync main to Hugging Face
60
+ run: git push --force huggingface HEAD:main
.gitignore CHANGED
@@ -149,6 +149,8 @@ activemq-data/
149
 
150
  # Environments
151
  .env
 
 
152
  .envrc
153
  .venv
154
  env/
@@ -216,3 +218,14 @@ __marimo__/
216
 
217
  # Streamlit
218
  .streamlit/secrets.toml
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  # Environments
151
  .env
152
+ .env.*
153
+ !.env.example
154
  .envrc
155
  .venv
156
  env/
 
218
 
219
  # Streamlit
220
  .streamlit/secrets.toml
221
+
222
+ # Local credentials and runtime caches
223
+ .modal.toml
224
+ .huggingface/
225
+ hf_cache/
226
+ model_cache/
227
+ *.gguf
228
+
229
+ # Local verification artifacts
230
+ .playwright-mcp/
231
+ snap2sim-*-scene.png
.hfignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git/
2
+ .venv/
3
+ env/
4
+ venv/
5
+ __pycache__/
6
+ *.py[codz]
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .playwright-mcp/
10
+
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ .envrc
15
+ .modal.toml
16
+ .huggingface/
17
+ .netrc
18
+
19
+ hf_cache/
20
+ model_cache/
21
+ *.gguf
22
+ snap2sim-*-scene.png
AGENTS.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md
2
+
3
+ ## Project Overview
4
+
5
+ Snap2Sim / Inside the Machine is a Gradio-based Hugging Face Space scaffold for
6
+ the Build Small Hackathon Backyard AI track. The app is intended to accept a
7
+ photo of a hardware component, infer its internal mechanism, and render an
8
+ annotated technical cutaway animation.
9
+
10
+ ## Runtime Notes
11
+
12
+ - Preferred model path: `unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF`
13
+ through llama.cpp.
14
+ - Verification note from June 13, 2026: the Unsloth GGUF repo lists `mmproj`
15
+ files and llama.cpp usage instructions. Modal GPU smoke testing confirms the
16
+ selected `UD-Q4_K_M` GGUF and `mmproj-F16.gguf` work with `llama-mtmd-cli`
17
+ image input.
18
+ - Fallback model path: `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-NVFP4`
19
+ through Transformers/custom code for the vision step if GGUF endpoint quality
20
+ is not reliable enough for the demo.
21
+ - Current code uses placeholder inference so the UI, schema, and endpoint
22
+ contract can run without downloading large model weights.
23
+
24
+ ## Project Structure
25
+
26
+ - `PROMPT.md` - original product, model, design, and delivery requirements.
27
+ - `README.md` - quickstart, runtime decision, and repo map.
28
+ - `SECURITY.md` - public/private data handling rules for humans and agents.
29
+ - `.env.example` - public Hugging Face Space / local demo variables.
30
+ - `.hfignore` - excludes local credentials, caches, GGUF files, and artifacts
31
+ from Hugging Face uploads.
32
+ - `.github/workflows/sync_to_hf.yml` - GitHub Actions workflow that syncs
33
+ GitHub `main` to an existing Hugging Face Space.
34
+ - `requirements.txt` - Python dependencies for the Gradio Space and Modal
35
+ scaffold.
36
+ - `app.py` - Hugging Face Space entry point; launches the Gradio app.
37
+ - `modal_app.py` - Modal app scaffold with runtime asset caching, a
38
+ llama.cpp GPU smoke-test function, a `runtime_probe` diagnostic endpoint, and
39
+ placeholder plus experimental llama.cpp `analyze_image` / `generate_threejs`
40
+ web endpoints.
41
+ - `scripts/verify_runtime_assets.py` - Hugging Face metadata preflight for the
42
+ selected GGUF quant and `mmproj` file.
43
+ - `snap2sim/__init__.py` - package marker.
44
+ - `snap2sim/backend.py` - backend config, local placeholder inference, Modal
45
+ HTTP client, and image base64 encoding.
46
+ - `snap2sim/fallback_scene.py` - themed animated SVG/CSS fallback shown when
47
+ generated Three.js is unavailable.
48
+ - `snap2sim/prompts.py` - prompt templates for the vision analysis and Three.js
49
+ code-generation steps.
50
+ - `snap2sim/schema.py` - structured JSON schema plus a sample mechanism payload.
51
+ - `snap2sim/three_scene.py` - deterministic Three.js scene generation from a
52
+ validated mechanism payload.
53
+ - `snap2sim/ui.py` - Gradio Blocks UI, blueprint field-manual CSS theme, and
54
+ pipeline orchestration.
55
+
56
+ ## What Has Been Done
57
+
58
+ - Read `PROMPT.md` and implemented the requested initial scaffold.
59
+ - Added a modular Gradio app with a two-pane technical cutaway / field manual
60
+ visual direction.
61
+ - Added loading/status language and a styled fallback visualization path so the
62
+ viewport is not blank when Three.js generation is unavailable.
63
+ - Added a backend abstraction controlled by `INFERENCE_BACKEND`.
64
+ - Added placeholder Modal endpoints for the two required inference steps.
65
+ - Added a Modal Volume-based runtime asset cache helper and runtime probe.
66
+ - Added `smoke_test_llamacpp_image`, which builds llama.cpp with CUDA on Modal
67
+ and runs one image prompt through `llama-mtmd-cli` using the selected GGUF and
68
+ `mmproj-F16.gguf`.
69
+ - Confirmed `smoke_test_llamacpp_image` returns `"ok": true`.
70
+ - Confirmed `run_analysis_endpoint_check` returns a validated mechanism payload
71
+ from the real llama.cpp analysis task.
72
+ - Changed runtime asset resolution so endpoint calls use cached Modal Volume
73
+ files directly; repeated `Fetching 3 files` logs were cached Hugging Face
74
+ metadata checks, not full model downloads.
75
+ - Added `run_runtime_preflight` local entrypoint to cache assets and run the GPU
76
+ smoke test in one Modal command.
77
+ - Added a local Hugging Face metadata preflight script and confirmed it passes
78
+ for `UD-Q4_K_M` plus `mmproj-F16.gguf`.
79
+ - Modal is authenticated under the `bigstonks1` workspace; use
80
+ `$env:PYTHONIOENCODING='utf-8'; python -m modal ...` on Windows to avoid
81
+ CLI Unicode/charmap errors.
82
+ - Defined the vision-to-scene JSON schema for parts, geometry hints, and motion.
83
+ - Added JSON-schema validation before scene generation.
84
+ - Added deterministic Three.js scene generation for local and Modal placeholder
85
+ mode, so validated payloads render as an animated 3D cutaway before model
86
+ code-generation is wired in.
87
+ - Added model response parsing helpers for JSON analysis payloads and complete
88
+ HTML documents.
89
+ - Added a local coercion fallback for verbose/partial model JSON so the
90
+ analysis endpoint does not launch a second slow repair generation.
91
+ - Added experimental `analyze_image_llamacpp` and `generate_threejs_llamacpp`
92
+ Modal GPU endpoints. They are not wired into the Gradio app by default; point
93
+ `MODAL_ANALYZE_URL` / `MODAL_GENERATE_URL` at them only after the smoke test
94
+ passes.
95
+ - Fixed Modal packaging for local `snap2sim` imports by adding
96
+ `add_local_python_source("snap2sim")` as the final image step for both Modal
97
+ images.
98
+ - Added `check_remote_imports`; `python -m modal run
99
+ modal_app.py::check_remote_imports` now creates the `PythonPackage:snap2sim`
100
+ mount and hydrates the web functions without `ModuleNotFoundError`.
101
+ - Added prompt templates for image analysis and Three.js scene generation.
102
+ - Deployed `modal_app.py` to Modal at
103
+ `https://modal.com/apps/bigstonks1/main/deployed/snap2sim-inside-the-machine`.
104
+ - Production endpoint checks passed for `runtime_probe`,
105
+ `analyze_image_llamacpp`, and deterministic `generate_threejs`.
106
+ - Modal web endpoints are secured with `Authorization: Bearer
107
+ SNAP2SIM_API_TOKEN`; the token lives in Modal secret `snap2sim-api-auth` and
108
+ Hugging Face Space secret `SNAP2SIM_API_TOKEN`.
109
+ - Verified unauthenticated Modal requests return `401 Unauthorized`.
110
+ - Added `SECURITY.md`, `.env.example`, and `.hfignore` so public deployment
111
+ config is explicit while credentials and runtime caches stay out of uploads.
112
+ - Added Hugging Face Space metadata to `README.md`.
113
+ - Hugging Face auth is now valid for user `jasondo111`.
114
+ - Uploaded the Space to `jasondo111/Snap2Sim`; the Space is `RUNNING` on
115
+ `cpu-basic` at `https://jasondo111-snap2sim.hf.space`.
116
+ - The Space remains private as of the latest deployment.
117
+ - User decision on June 13, 2026: keep the Hugging Face Space private for now.
118
+ Do not make it public unless the user explicitly asks.
119
+ - Removed accidentally uploaded Playwright artifacts/screenshots from the Space
120
+ and expanded `.gitignore` so future `hf upload` runs skip them.
121
+ - Verified the private Space with an authenticated `gradio_client` call using
122
+ `handle_file`; the `/run_pipeline` API returned `CUTAWAY READY`.
123
+ - Re-verified `/run_pipeline` after endpoint auth was added; it still returns
124
+ `CUTAWAY READY`.
125
+ - Added `.github/workflows/sync_to_hf.yml` for one-way GitHub to Hugging Face
126
+ Space sync on pushes to `main`. The workflow targets `jasondo111/Snap2Sim`
127
+ and requires GitHub secret `HF_TOKEN`.
128
+ - User reported the GitHub Actions `HF_TOKEN` secret has been added.
129
+ - Documented GitHub as the source of truth:
130
+ `https://github.com/Bigstonks1/Snap2Sim`. Do not edit files directly on the
131
+ Hugging Face Space; they will be overwritten by the sync workflow.
132
+ - Updated `README.md` with local run instructions, runtime preflight, Modal
133
+ deployment path, and the current runtime decision.
134
+
135
+ ## Next Work
136
+
137
+ - Point the Gradio app at `analyze_image_llamacpp` for analysis and the
138
+ deterministic `generate_threejs` endpoint for scene rendering.
139
+ - Keep the Hugging Face Space private until the user explicitly approves making
140
+ it public for submission.
141
+ - Commit and push the workflow to GitHub `main`, then verify the GitHub Actions
142
+ sync run succeeds.
143
+ - If Three.js model generation is too brittle, keep using the deterministic
144
+ local scene generator for the demo while still using Nemotron for analysis.
145
+ - Document final submission links and bonus claims.
GITHUB_PROMPT.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Codex Prompt: Sync GitHub Repo to Hugging Face Space
2
+
3
+ Create a GitHub Actions workflow file that automatically syncs this existing GitHub repository to an already-created Hugging Face Space whenever code is pushed to the `main` branch.
4
+
5
+ ## Important Context
6
+
7
+ - The Hugging Face Space already exists — do not include any steps to create or initialize it
8
+ - This is a **one-way sync only** (GitHub → Hugging Face). Nothing should ever push back from HF to GitHub
9
+ - GitHub is the single source of truth — all edits should happen here, never directly on the HF Space
10
+
11
+ ## Requirements
12
+
13
+ - Workflow file at `.github/workflows/sync_to_hf.yml`
14
+ - Triggers on every push to `main`
15
+ - Uses a GitHub secret called `HF_TOKEN` for authentication
16
+ - Pushes full git history (no shallow clone)
17
+ - Includes LFS support
18
+ - Uses placeholders `YOUR_HF_USERNAME` and `YOUR_SPACE_NAME` in the HF Space URL
19
+ - Adds a comment block at the top of the workflow file listing everything that needs to be configured before use
20
+
21
+ ## Additional Output
22
+
23
+ Also create a short markdown snippet I can paste into my existing README explaining the deployment pipeline and warning contributors not to edit files directly on the Hugging Face Space.
PROMPT.md ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PROJECT: "Inside the Machine" — AI-powered teardown visualizer for the
2
+ Build Small Hackathon (Backyard AI track, huggingface.co/build-small-hackathon)
3
+
4
+ GOAL
5
+ Build a Gradio app, deployed as a Hugging Face Space, that takes a photo of
6
+ a hardware component (e.g. a gear, valve, hinge, pump, lock, engine part)
7
+ and produces an animated 3D visualization showing how that component works
8
+ internally at a finer-grained scale than the photo shows — i.e. "open it up
9
+ and show me the moving parts and the physics/mechanism."
10
+
11
+ HARD CONSTRAINTS
12
+ - Total model parameters across the entire pipeline must be ≤ 32B.
13
+ - Must be a Gradio app, hosted as a Hugging Face Space.
14
+ - Prefer local/open-weight models (target the "Off the Grid" bonus: no
15
+ cloud APIs at inference time, where deployment allows).
16
+
17
+ MODEL STACK
18
+ Primary model: NVIDIA Nemotron 3 Nano Omni (30B-A3B, MoE, ~3B active
19
+ params), used for both the vision-understanding step AND the Three.js
20
+ code generation step (two prompts/turns against the same model). This
21
+ targets the NVIDIA Nemotron Quest sponsor award and stays comfortably
22
+ under the 32B cap as a single model.
23
+
24
+ Fallback split pipeline (use only if the primary path's code-gen quality
25
+ is too weak, or if the multimodal runtime issue below can't be resolved):
26
+ - NVIDIA Nemotron Nano V2 VL (12B) for image understanding/analysis.
27
+ - Qwen2.5-Coder-14B for Three.js/animation code generation.
28
+ - Total ~26B, still leaves headroom and still qualifies for the Nemotron
29
+ Quest award via the vision step.
30
+
31
+ MODEL RUNTIME
32
+ - Primary path (targets "Llama Champion" bonus): use
33
+ unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF via llama.cpp.
34
+ Start with a mid-size quant (e.g. UD-Q4_K_M, ~24 GB) for quality, and
35
+ have a smaller quant (e.g. UD-IQ2_M, ~18.5 GB) ready as a fallback for
36
+ constrained hardware.
37
+ - IMPORTANT — verify multimodal support first: as a setup/diagnostic step,
38
+ confirm whether this GGUF repo includes a vision-encoder ("mmproj") file
39
+ for the CRADIO v4-H encoder, and whether llama.cpp's current build
40
+ actually accepts image input for this architecture (nemotron_h_moe).
41
+ Multimodal GGUF conversions often ship the LLM only, with vision support
42
+ lagging behind.
43
+ - If image input works end-to-end via llama.cpp: use this single model
44
+ for both the vision-analysis step and the Three.js code-gen step —
45
+ this is the ideal "Llama Champion" + Nemotron Quest combo.
46
+ - If image input is NOT yet supported in the GGUF/llama.cpp path: use
47
+ the NVFP4 safetensors checkpoint (nvidia/Nemotron-3-Nano-Omni-30B-A3B-
48
+ Reasoning-NVFP4, ~20.9 GB) via vLLM or transformers for the vision-
49
+ analysis step only, and use the GGUF + llama.cpp build for the
50
+ Three.js code-gen step (text-only, so GGUF support is solid). Note
51
+ in the README that "Llama Champion" applies to the code-gen stage.
52
+ - Either way, total parameters stay at ~31B (single model), comfortably
53
+ under the 32B cap, and the project remains eligible for the NVIDIA
54
+ Nemotron Quest award.
55
+
56
+ DEPLOYMENT ARCHITECTURE — two-tier with Modal
57
+ - Tier 1 (frontend): the Gradio app, hosted as a HF Space on the standard
58
+ CPU tier. Handles UI, image upload, calling the inference backend, and
59
+ rendering the returned JSON + Three.js HTML.
60
+ - Tier 2 (inference backend): a Modal app exposing two functions as web
61
+ endpoints, running on a Modal GPU container:
62
+ - analyze_image(image) -> structured JSON (vision step, per the
63
+ pipeline below)
64
+ - generate_threejs(json) -> Three.js scene HTML/JS (code-gen step)
65
+ Both load Nemotron 3 Nano Omni once at container start (use a Modal
66
+ Volume to cache model weights across cold starts so they aren't
67
+ re-downloaded every time). Use whichever runtime (llama.cpp/GGUF or
68
+ vLLM/NVFP4) was determined in the MODEL RUNTIME verification step.
69
+ - The Gradio app calls these endpoints over HTTP (e.g. via `requests`),
70
+ passing the image as base64 and receiving JSON/HTML back.
71
+ - Make the inference backend swappable via a config flag (e.g.
72
+ INFERENCE_BACKEND=modal | zerogpu | local), so the same Gradio code can
73
+ run against:
74
+ - a Modal endpoint (primary path — generous credits, no VRAM
75
+ constraints, targets the Modal Award)
76
+ - HF Spaces ZeroGPU (alternative — model loads inside the Space itself
77
+ on an A10G; try the smallest GGUF quant here)
78
+ - a local GPU (for development/testing)
79
+ - Surface Modal cold-start latency in the UI — e.g. a "WAKING THE
80
+ WORKSHOP..." loading message in the established visual theme, since
81
+ cold starts on a 30B model can take tens of seconds.
82
+ - README must state which backend the submitted Space actually uses, and
83
+ adjust bonus-quest claims accordingly: if Modal is the deployed backend,
84
+ "Off the Grid" is not claimed (inference happens on Modal's cloud GPUs),
85
+ but "Llama Champion", "Nemotron Quest", and the Modal Award still apply.
86
+ If ZeroGPU ends up sufficient, "Off the Grid" can additionally be claimed.
87
+
88
+ PIPELINE / APPLICATION FLOW
89
+ 1. User uploads a photo of a hardware component via gr.Image.
90
+ 2. Vision step (Nemotron VL): identify the component, enumerate its
91
+ internal parts, and describe its operating mechanism in structured
92
+ terms — e.g. part names, how each part moves (rotate/translate/
93
+ oscillate), axes/pivots, sequence of motion, and what triggers it
94
+ (e.g. "input shaft rotates → cam lobe pushes follower → valve opens").
95
+ Output this as structured JSON (part list with geometry hints: shape,
96
+ approximate size/position, motion type, motion parameters).
97
+ 3. Code-gen step: feed that JSON into the second prompt/model with
98
+ instructions to generate a single self-contained Three.js scene (HTML
99
+ + inline JS/CSS) that:
100
+ - builds simple primitive-based 3D representations of each part
101
+ (boxes, cylinders, gears via extruded shapes, etc.)
102
+ - animates them according to the described motion (rotation speed,
103
+ translation range, timing/sequence)
104
+ - includes basic camera controls (OrbitControls) and a play/pause toggle
105
+ - includes on-screen labels for each part
106
+ 4. Gradio renders the generated HTML via gr.HTML (sandboxed iframe).
107
+ 5. Below the visualization, display the structured explanation text from
108
+ step 2 (plain-language "how it works" writeup) so the app is useful
109
+ even if the 3D fails to render.
110
+
111
+ VISUAL DESIGN DIRECTION — "Technical Cutaway / Field Manual" (Off-Brand bonus)
112
+ Commit fully to this aesthetic; do not fall back to default Gradio styling.
113
+
114
+ - Concept: the app should feel like a page from a vintage engineering
115
+ service manual or a reverse-engineering field notebook — the kind of
116
+ diagram you'd find annotating a cutaway drawing of an engine.
117
+ - Theme: dark "blueprint" canvas as the dominant surface — deep navy/
118
+ charcoal background (not pure black), with fine 1px grid lines at low
119
+ opacity to evoke graph/blueprint paper.
120
+ - Color system (define as CSS variables, used consistently):
121
+ - Background: deep charcoal-navy (e.g. #14181F)
122
+ - Primary accent: warm amber/safety-orange (e.g. #E8A33D) for
123
+ annotations, active states, and the "how it works" highlights
124
+ - Secondary accent: cool cyan (e.g. #5FD4D0) for part labels and
125
+ motion-path indicators in the 3D scene
126
+ - Text: off-white / warm gray, never pure white
127
+ - Typography: pair a condensed, slightly industrial display/grotesk font
128
+ (e.g. for headings/labels — something like a stencil-adjacent or
129
+ technical condensed sans) with a monospace font (e.g. for the part
130
+ list, JSON-derived data, and annotations) — avoid Inter, Roboto, Arial,
131
+ and system-default fonts entirely.
132
+ - Layout: two-pane, asymmetric split (3D viewport larger/left, analysis
133
+ panel narrower/right or as an overlay drawer). Part labels in the 3D
134
+ scene should look like annotation callouts (thin leader lines + small
135
+ monospace tags), echoing real exploded-diagram conventions.
136
+ - Motion: one signature moment — when the model finishes analysis and
137
+ the 3D scene loads, animate a "power-on" reveal (e.g. parts fade/slide
138
+ into place sequentially, or a scanning-line sweep across the blueprint
139
+ grid before the model appears). Avoid scattered micro-animations
140
+ elsewhere; spend the motion budget on this one moment.
141
+ - Texture/atmosphere: subtle grid-paper background, faint scanline or
142
+ vignette effect on the canvas edges — restrained, not noisy.
143
+ - Loading states should reinforce the theme (e.g. "ANALYZING ASSEMBLY...",
144
+ "RENDERING CUTAWAY...", "WAKING THE WORKSHOP...") in the monospace font
145
+ rather than generic spinners.
146
+
147
+ UI / UX REQUIREMENTS
148
+ - Clean two-pane layout per the design direction above: 3D visualization
149
+ + part-by-part "how it works" panel.
150
+ - Loading states for both inference steps (vision analysis can be slow,
151
+ Modal cold starts add latency), styled per the theme above.
152
+ - Graceful fallback: if Three.js code generation fails or errors, show
153
+ the textual explanation plus a simple 2D animated SVG/CSS fallback
154
+ instead of a blank pane — keep this fallback in the same visual theme.
155
+ - Investigate gr.Server if needed to achieve full styling control beyond
156
+ what gr.HTML / custom CSS in Blocks allows.
157
+
158
+ DELIVERABLES
159
+ - Working Gradio app code (app.py + requirements.txt) ready to push to a
160
+ Hugging Face Space under the build-small-hackathon org.
161
+ - Modal app code (modal_app.py or similar) implementing the two inference
162
+ endpoints described above.
163
+ - README explaining: the problem this solves and for whom (Backyard AI
164
+ track requires a real person/use case — e.g. a hobbyist mechanic, a
165
+ shop teacher, a repair-cafe volunteer who wants to explain how parts
166
+ work to customers/students), the model stack and parameter count
167
+ (must total ≤32B, state the exact breakdown), the deployment
168
+ architecture, and which bonus quests/sponsor awards are targeted
169
+ (Llama Champion, Off-Brand, NVIDIA Nemotron Quest, Modal Award, Field
170
+ Notes, and "Off the Grid" if applicable to the final deployment).
171
+ - Keep code modular: separate files/functions for (1) vision analysis,
172
+ (2) code generation, (3) Gradio UI/styling, (4) inference backend
173
+ client/config, so each piece can be swapped or debugged independently.
174
+
175
+ START by:
176
+ 1. Verifying the llama.cpp multimodal support question above for the
177
+ Nemotron 3 Nano Omni GGUF — this determines the runtime path for the
178
+ rest of the build.
179
+ 2. Scaffolding the Modal app with the two inference endpoints (using
180
+ placeholder/echo logic initially), and the Gradio app structure with
181
+ the backend-config flag and CSS theme/variables for the visual design
182
+ direction above.
183
+ 3. Designing the JSON schema for step 2 of the pipeline — this is the
184
+ critical interface between the vision and code-gen steps.
185
+ 4. Iterating on prompt templates for steps 2 and 3.
README.md CHANGED
@@ -1 +1,207 @@
1
- # Snap2Sim
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Snap2Sim Inside The Machine
3
+ sdk: gradio
4
+ app_file: app.py
5
+ license: mit
6
+ colorFrom: blue
7
+ colorTo: yellow
8
+ ---
9
+
10
+ # Snap2Sim
11
+
12
+ Inside the Machine is a Gradio app scaffold for the Build Small Hackathon
13
+ Backyard AI track. It takes a hardware component photo, produces a structured
14
+ mechanism analysis, and renders a technical cutaway visualization.
15
+
16
+ The local app still defaults to the placeholder backend, but the Modal
17
+ llama.cpp path has been smoke-tested with the selected Nemotron GGUF and
18
+ projector.
19
+
20
+ ## Run Locally
21
+
22
+ ```powershell
23
+ pip install -r requirements.txt
24
+ python app.py
25
+ ```
26
+
27
+ Set `INFERENCE_BACKEND=modal`, `MODAL_ANALYZE_URL`, and `MODAL_GENERATE_URL`
28
+ to point the Gradio app at deployed Modal endpoints.
29
+
30
+ For the deployed demo configuration, copy `.env.example` into local environment
31
+ variables or Hugging Face Space variables. Do not put tokens in `.env.example`;
32
+ see `SECURITY.md`.
33
+
34
+ ## Runtime Preflight
35
+
36
+ Run the metadata preflight before spending GPU time:
37
+
38
+ ```powershell
39
+ python scripts/verify_runtime_assets.py
40
+ ```
41
+
42
+ This confirms the selected GGUF quant and `mmproj` projector are present in the
43
+ Hugging Face repo without downloading the model. It does not prove image input
44
+ works in llama.cpp.
45
+
46
+ ## Runtime Decision
47
+
48
+ Verification on June 13, 2026 found:
49
+
50
+ - `unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF` includes GGUF
51
+ quant files, `mmproj-BF16.gguf`, `mmproj-F16.gguf`, and `mmproj-F32.gguf`.
52
+ - The same model card includes llama.cpp launch instructions.
53
+ - llama.cpp source includes the `nemotron_h_moe` text architecture.
54
+ - llama.cpp public docs list multimodal server support, but the visible
55
+ supported-model list does not explicitly name Nemotron Omni or CRADIO v4-H.
56
+
57
+ So the preferred path is single-model GGUF via llama.cpp. The Modal GPU smoke
58
+ test passed on June 13, 2026 with `UD_Q4_K_M` and `mmproj-F16.gguf`, confirming
59
+ that `llama-mtmd-cli` accepts image input for this pairing.
60
+
61
+ If endpoint quality is not strong enough for the demo, use
62
+ `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-NVFP4` through vLLM or
63
+ Transformers/custom code for vision analysis, and keep the GGUF path for
64
+ text-only Three.js generation.
65
+
66
+ Primary references:
67
+
68
+ - https://huggingface.co/unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF
69
+ - https://github.com/ggml-org/llama.cpp
70
+
71
+ ## Project Structure
72
+
73
+ - `.env.example` - public runtime variables for the deployed Modal demo.
74
+ - `SECURITY.md` - public/private data handling guidance for humans and agents.
75
+ - `app.py` - Hugging Face Space entry point.
76
+ - `modal_app.py` - Modal web endpoint scaffold.
77
+ - `scripts/verify_runtime_assets.py` - GGUF/mmproj metadata preflight.
78
+ - `snap2sim/backend.py` - backend selection and HTTP client.
79
+ - `snap2sim/fallback_scene.py` - themed 2D fallback animation.
80
+ - `snap2sim/prompts.py` - prompt templates for vision and Three.js generation.
81
+ - `snap2sim/schema.py` - JSON schema and sample analysis payload.
82
+ - `snap2sim/three_scene.py` - deterministic Three.js scene generation from the
83
+ validated mechanism JSON.
84
+ - `snap2sim/ui.py` - Gradio Blocks UI and visual theme.
85
+
86
+ ## Modal Deployment Path
87
+
88
+ The Modal app defaults to placeholder inference so endpoint wiring can be tested
89
+ without loading a 30B model:
90
+
91
+ ```powershell
92
+ python -m modal deploy modal_app.py
93
+ ```
94
+
95
+ If Modal is not authenticated on the machine, run `python -m modal token new`
96
+ first.
97
+
98
+ Current production deployment:
99
+
100
+ - Deployment: https://modal.com/apps/bigstonks1/main/deployed/snap2sim-inside-the-machine
101
+ - Web endpoint URLs are configured in Hugging Face Space variables and protected
102
+ by the `SNAP2SIM_API_TOKEN` bearer-token secret.
103
+
104
+ Useful deployment functions/endpoints:
105
+
106
+ - `check_remote_imports` verifies Modal can import the local `snap2sim` package
107
+ before running expensive GPU/model work.
108
+ - `download_runtime_assets` caches the selected GGUF quant and `mmproj` file in
109
+ the `snap2sim-hf-cache` Modal Volume.
110
+ - `smoke_test_llamacpp_image` builds llama.cpp with CUDA and runs one image
111
+ prompt through `llama-mtmd-cli` using the cached GGUF and `mmproj`.
112
+ - `run_analysis_endpoint_check` calls the real llama.cpp analysis task with a
113
+ synthetic image and confirms it returns a validated mechanism payload.
114
+ - `runtime_probe` reports the configured model repo, quant, projector file, and
115
+ whether placeholder inference is still active.
116
+ - `analyze_image` and `generate_threejs` preserve the current HTTP contract for
117
+ the Gradio app.
118
+ - `analyze_image_llamacpp` and `generate_threejs_llamacpp` are experimental GPU
119
+ endpoints for the llama.cpp runtime path after the smoke test passes.
120
+
121
+ Runtime environment knobs:
122
+
123
+ - `SNAP2SIM_MODEL_REPO`, default
124
+ `unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF`
125
+ - `SNAP2SIM_GGUF_QUANT`, default `UD-Q4_K_M`
126
+ - `SNAP2SIM_MMPROJ_FILE`, default `mmproj-F16.gguf`
127
+ - `SNAP2SIM_RUNTIME_MODE`, default `placeholder`
128
+ - `SNAP2SIM_SMOKE_GPU`, default `L40S`
129
+ - `SNAP2SIM_RUNTIME_GPU`, default `L40S`
130
+
131
+ Keep `SNAP2SIM_RUNTIME_MODE=placeholder` for the public demo unless you point
132
+ `MODAL_ANALYZE_URL` at the validated `analyze_image_llamacpp` endpoint and keep
133
+ `MODAL_GENERATE_URL` on deterministic `generate_threejs`.
134
+
135
+ Run the deployment preflight in this order:
136
+
137
+ ```powershell
138
+ python -m modal run modal_app.py::check_remote_imports
139
+ python -m modal run modal_app.py::download_runtime_assets
140
+ python -m modal run modal_app.py::smoke_test_llamacpp_image
141
+ python -m modal run modal_app.py::run_analysis_endpoint_check
142
+ ```
143
+
144
+ Or run both steps together:
145
+
146
+ ```powershell
147
+ python -m modal run modal_app.py::run_runtime_preflight
148
+ ```
149
+
150
+ The smoke test returns a JSON object. Treat `"ok": true` as evidence that
151
+ llama.cpp accepted image input with the selected GGUF and `mmproj`. The analysis
152
+ endpoint has a bounded generation path and a local coercion fallback because the
153
+ reasoning model can emit verbose `<think>` text before JSON.
154
+
155
+ ## Hugging Face Space Configuration
156
+
157
+ Current Space:
158
+
159
+ - Hub repo: https://huggingface.co/spaces/jasondo111/Snap2Sim
160
+ - App host: https://jasondo111-snap2sim.hf.space
161
+ - Visibility: private as of the latest deployment
162
+ - Runtime: `RUNNING` on `cpu-basic`
163
+
164
+ The Space is configured as a Gradio SDK app with these variables:
165
+
166
+ ```text
167
+ INFERENCE_BACKEND=modal
168
+ MODAL_ANALYZE_URL=<stored in Hugging Face Space variables>
169
+ MODAL_GENERATE_URL=<stored in Hugging Face Space variables>
170
+ INFERENCE_TIMEOUT_SECONDS=240
171
+ ```
172
+
173
+ The Space also needs `SNAP2SIM_API_TOKEN` as a Hugging Face Space secret. The
174
+ same value must be present in the Modal `snap2sim-api-auth` secret. Do not put
175
+ that token in `.env.example`, README, logs, or prompts.
176
+
177
+ From this repo, deploy after authenticating with Hugging Face:
178
+
179
+ ```powershell
180
+ hf auth login --force
181
+ hf repos create jasondo111/Snap2Sim --type space --space-sdk gradio --exist-ok --env-file .env.example
182
+ hf upload jasondo111/Snap2Sim . . --repo-type space --commit-message "Deploy Snap2Sim demo"
183
+ ```
184
+
185
+ The `.hfignore` file excludes local credentials, caches, downloaded model
186
+ weights, and Playwright artifacts from uploads.
187
+
188
+ For an authenticated API check against this private Space, use `handle_file`
189
+ with `gradio_client`; plain string file paths are rejected by the Gradio 6 image
190
+ input schema.
191
+
192
+ ## GitHub to Hugging Face Sync
193
+
194
+ GitHub is the source of truth for this project:
195
+
196
+ - GitHub repo: https://github.com/Bigstonks1/Snap2Sim
197
+ - Hugging Face Space: configured separately as the sync target
198
+
199
+ The workflow at `.github/workflows/sync_to_hf.yml` syncs pushes to the `main`
200
+ branch into `jasondo111/Snap2Sim`. It requires a GitHub Actions secret named
201
+ `HF_TOKEN` with write access to that Space.
202
+
203
+ Do not edit files directly in the Hugging Face Space. Those changes are not the
204
+ source of truth and will be overwritten by the next GitHub-to-Hugging Face sync.
205
+ Configure runtime values such as `MODAL_ANALYZE_URL`, `MODAL_GENERATE_URL`, and
206
+ `SNAP2SIM_API_TOKEN` through Hugging Face Space variables/secrets instead of
207
+ committing them.
SECURITY.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Notes
2
+
3
+ This project is intended to become a public hackathon demo, but the deployed
4
+ Hugging Face Space is private right now by user decision. Treat the repo,
5
+ README, screenshots, and Codex-visible files as public unless explicitly told
6
+ otherwise, and do not make the Space public until the user explicitly approves
7
+ that change.
8
+
9
+ ## Do Not Commit Or Expose
10
+
11
+ - Modal tokens, Hugging Face tokens, API keys, passwords, OAuth credentials, or
12
+ private SSH keys.
13
+ - Local credential files such as `.modal.toml`, `.huggingface/token`,
14
+ `.netrc`, `.env`, `.envrc`, or shell profile exports containing secrets.
15
+ - Modal or Hugging Face dashboard pages that include account-private billing,
16
+ workspace settings, tokens, or secret values.
17
+ - Raw user-uploaded photos unless the user explicitly asks to add sample images
18
+ to the repo.
19
+ - Full model outputs if they include private user content, uploaded image
20
+ details, or long traces that are not needed for debugging.
21
+ - Modal Volume contents, downloaded GGUF files, Hugging Face cache directories,
22
+ or any other large runtime assets.
23
+
24
+ ## Endpoint Access
25
+
26
+ The deployed Modal endpoint URLs are public HTTP entry points at the network
27
+ level, but every Modal web endpoint must require:
28
+
29
+ ```text
30
+ Authorization: Bearer <SNAP2SIM_API_TOKEN>
31
+ ```
32
+
33
+ `SNAP2SIM_API_TOKEN` must be stored only as:
34
+
35
+ - Modal secret: `snap2sim-api-auth`
36
+ - Hugging Face Space secret: `SNAP2SIM_API_TOKEN`
37
+
38
+ Do not document the live Modal endpoint URLs in public files unless the user
39
+ explicitly asks. The URLs are not credentials, but keeping them out of public
40
+ docs reduces casual discovery, and the bearer token is the real protection
41
+ against credit-spending spam.
42
+
43
+ The Hugging Face Space at `jasondo111/Snap2Sim` is private as of the latest
44
+ deployment. Future agents must not change the Space visibility to public unless
45
+ the user explicitly asks for that action.
46
+
47
+ ## Environment Variables
48
+
49
+ Safe to expose in public docs or `.env.example`:
50
+
51
+ - `INFERENCE_BACKEND`
52
+ - `MODAL_ANALYZE_URL`
53
+ - `MODAL_GENERATE_URL`
54
+ - `INFERENCE_TIMEOUT_SECONDS`
55
+ - `SNAP2SIM_MODEL_REPO`
56
+ - `SNAP2SIM_GGUF_QUANT`
57
+ - `SNAP2SIM_MMPROJ_FILE`
58
+ - `SNAP2SIM_RUNTIME_GPU`
59
+
60
+ Keep private and configure only through local secret stores, Modal secrets, or
61
+ Hugging Face Space secrets:
62
+
63
+ - `MODAL_TOKEN_ID`
64
+ - `MODAL_TOKEN_SECRET`
65
+ - `SNAP2SIM_API_TOKEN`
66
+ - `HF_TOKEN`
67
+ - `HUGGING_FACE_HUB_TOKEN`
68
+ - Any future provider key such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or
69
+ hosted inference credentials.
70
+
71
+ ## GitHub Actions
72
+
73
+ The GitHub-to-Hugging Face sync workflow uses a GitHub Actions secret named
74
+ `HF_TOKEN`. Store that token only in GitHub repository or organization secrets.
75
+ Do not hardcode it in `.github/workflows/sync_to_hf.yml`, README files,
76
+ environment templates, prompts, or local scripts.
77
+
78
+ The workflow is one-way only: GitHub `main` pushes to the Hugging Face Space.
79
+ Do not configure jobs that pull changes back from Hugging Face into GitHub.
80
+
81
+ ## Agent Guidance
82
+
83
+ - Prefer Hugging Face Space variables for public configuration and Space secrets
84
+ for credentials.
85
+ - Do not print secret-bearing environment variables in logs.
86
+ - Do not paste full Modal or Hugging Face auth files into issues, docs, prompts,
87
+ or Codex summaries.
88
+ - When sharing logs, trim them to the failing stack trace and remove uploaded
89
+ image payloads, base64 strings, tokens, cookies, and account-private URLs.
90
+ - Before adding new dependencies or services, document whether they require
91
+ credentials and where those credentials should live.
92
+
93
+ ## Reporting
94
+
95
+ For this hackathon repo, report suspected leaks directly to the repository owner
96
+ or workspace owner. If a token was exposed, rotate it immediately in the
97
+ provider dashboard and remove the leaked value from git history before making
98
+ the repository public.
app.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Space entry point."""
2
+
3
+ from snap2sim.ui import build_app
4
+
5
+
6
+ demo = build_app()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ demo.launch()
11
+
modal_app.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal inference service scaffold for Snap2Sim.
2
+
3
+ The endpoints intentionally keep placeholder inference as the default until the
4
+ Nemotron runtime passes a GPU smoke test. The deployment helpers here pin the
5
+ target model assets and make that smoke test explicit instead of silently
6
+ claiming multimodal GGUF support before image input has been proven.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+ import secrets as token_secrets
15
+
16
+ import modal
17
+ from fastapi import Header, HTTPException
18
+
19
+ from snap2sim.model_io import coerce_analysis_response, parse_analysis_response, parse_html_response
20
+ from snap2sim.prompts import build_threejs_prompt, build_vision_prompt
21
+ from snap2sim.schema import EXAMPLE_ANALYSIS, validate_analysis
22
+ from snap2sim.three_scene import build_threejs_html
23
+
24
+
25
+ DEFAULT_MODEL_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF"
26
+ DEFAULT_GGUF_QUANT = "UD-Q4_K_M"
27
+ DEFAULT_MMPROJ_FILE = "mmproj-F16.gguf"
28
+ DEFAULT_RUNTIME_MODE = "placeholder"
29
+ CACHE_DIR = "/cache"
30
+ HF_CACHE_DIR = f"{CACHE_DIR}/huggingface"
31
+ MODEL_ASSET_DIR = f"{CACHE_DIR}/models"
32
+
33
+
34
+ model_cache = modal.Volume.from_name("snap2sim-hf-cache", create_if_missing=True)
35
+ api_auth_secret = modal.Secret.from_name("snap2sim-api-auth")
36
+
37
+ image = (
38
+ modal.Image.debian_slim(python_version="3.11")
39
+ .pip_install(
40
+ "fastapi[standard]",
41
+ "huggingface_hub[hf_xet]",
42
+ "pillow",
43
+ "requests",
44
+ )
45
+ .env(
46
+ {
47
+ "HF_HUB_CACHE": HF_CACHE_DIR,
48
+ "HF_XET_HIGH_PERFORMANCE": "1",
49
+ }
50
+ )
51
+ .add_local_python_source("snap2sim")
52
+ )
53
+
54
+ llamacpp_image = (
55
+ modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.11")
56
+ .entrypoint([])
57
+ .apt_install("build-essential", "cmake", "curl", "git", "libcurl4-openssl-dev")
58
+ .pip_install("fastapi[standard]", "huggingface_hub[hf_xet]", "pillow")
59
+ .env(
60
+ {
61
+ "HF_HUB_CACHE": HF_CACHE_DIR,
62
+ "HF_XET_HIGH_PERFORMANCE": "1",
63
+ "LIBRARY_PATH": "/usr/local/cuda/lib64/stubs:/usr/local/cuda/targets/x86_64-linux/lib/stubs",
64
+ }
65
+ )
66
+ .run_commands(
67
+ "if [ -f /usr/local/cuda/lib64/stubs/libcuda.so ] && [ ! -f /usr/local/cuda/lib64/stubs/libcuda.so.1 ]; then ln -s /usr/local/cuda/lib64/stubs/libcuda.so /usr/local/cuda/lib64/stubs/libcuda.so.1; fi",
68
+ "if [ -f /usr/local/cuda/targets/x86_64-linux/lib/stubs/libcuda.so ] && [ ! -f /usr/local/cuda/targets/x86_64-linux/lib/stubs/libcuda.so.1 ]; then ln -s /usr/local/cuda/targets/x86_64-linux/lib/stubs/libcuda.so /usr/local/cuda/targets/x86_64-linux/lib/stubs/libcuda.so.1; fi",
69
+ "git clone --depth 1 https://github.com/ggml-org/llama.cpp.git /opt/llama.cpp",
70
+ "cmake -S /opt/llama.cpp -B /opt/llama.cpp/build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXE_LINKER_FLAGS='-L/usr/local/cuda/lib64/stubs -L/usr/local/cuda/targets/x86_64-linux/lib/stubs -Wl,-rpath-link,/usr/local/cuda/lib64/stubs -Wl,-rpath-link,/usr/local/cuda/targets/x86_64-linux/lib/stubs'",
71
+ "cmake --build /opt/llama.cpp/build --target llama-mtmd-cli llama-cli -j",
72
+ )
73
+ .add_local_python_source("snap2sim")
74
+ )
75
+
76
+ app = modal.App("snap2sim-inside-the-machine")
77
+
78
+
79
+ @app.function(image=image, timeout=120)
80
+ def check_remote_imports() -> dict[str, Any]:
81
+ """Lightweight Modal check that local project modules are packaged."""
82
+ import snap2sim.model_io
83
+ import snap2sim.schema
84
+ import snap2sim.three_scene
85
+
86
+ return {
87
+ "ok": True,
88
+ "modules": [
89
+ snap2sim.model_io.__name__,
90
+ snap2sim.schema.__name__,
91
+ snap2sim.three_scene.__name__,
92
+ ],
93
+ }
94
+
95
+
96
+ def runtime_config() -> dict[str, str]:
97
+ return {
98
+ "model_repo": os.getenv("SNAP2SIM_MODEL_REPO", DEFAULT_MODEL_REPO),
99
+ "gguf_quant": os.getenv("SNAP2SIM_GGUF_QUANT", DEFAULT_GGUF_QUANT),
100
+ "mmproj_file": os.getenv("SNAP2SIM_MMPROJ_FILE", DEFAULT_MMPROJ_FILE),
101
+ "runtime_mode": os.getenv("SNAP2SIM_RUNTIME_MODE", DEFAULT_RUNTIME_MODE),
102
+ }
103
+
104
+
105
+ def require_authorization(authorization: str) -> None:
106
+ expected_token = os.getenv("SNAP2SIM_API_TOKEN", "")
107
+ if not expected_token:
108
+ raise HTTPException(status_code=503, detail="API authentication is not configured.")
109
+ scheme, separator, provided_token = authorization.partition(" ")
110
+ if (
111
+ not separator
112
+ or scheme.lower() != "bearer"
113
+ or not token_secrets.compare_digest(provided_token, expected_token)
114
+ ):
115
+ raise HTTPException(status_code=401, detail="Unauthorized")
116
+
117
+
118
+ def asset_patterns(config: dict[str, str]) -> list[str]:
119
+ """Return the repo file patterns needed by the llama.cpp runtime."""
120
+ return [
121
+ f"*{config['gguf_quant']}*.gguf",
122
+ config["mmproj_file"],
123
+ "README.md",
124
+ ]
125
+
126
+
127
+ def cached_asset_paths(config: dict[str, str]) -> tuple[Path, Path] | None:
128
+ """Return cached model paths when both required runtime files exist."""
129
+ local_dir = Path(MODEL_ASSET_DIR) / config["model_repo"]
130
+ model_matches = sorted(local_dir.glob(f"*{config['gguf_quant']}*.gguf"))
131
+ mmproj_path = local_dir / config["mmproj_file"]
132
+ if model_matches and mmproj_path.exists():
133
+ return model_matches[0], mmproj_path
134
+ return None
135
+
136
+
137
+ @app.function(
138
+ image=image,
139
+ volumes={CACHE_DIR: model_cache},
140
+ timeout=60 * 60,
141
+ )
142
+ def download_runtime_assets() -> dict[str, Any]:
143
+ """Cache the selected GGUF quant and projector on the Modal Volume."""
144
+ from huggingface_hub import snapshot_download
145
+
146
+ config = runtime_config()
147
+ patterns = asset_patterns(config)
148
+ path = snapshot_download(
149
+ repo_id=config["model_repo"],
150
+ local_dir=f"{MODEL_ASSET_DIR}/{config['model_repo']}",
151
+ allow_patterns=patterns,
152
+ )
153
+ model_cache.commit()
154
+ return {
155
+ "repo": config["model_repo"],
156
+ "quant": config["gguf_quant"],
157
+ "mmproj": config["mmproj_file"],
158
+ "path": path,
159
+ "patterns": patterns,
160
+ }
161
+
162
+
163
+ def ensure_runtime_assets() -> tuple[Path, Path]:
164
+ """Download configured model assets if needed and return local paths."""
165
+ from huggingface_hub import snapshot_download
166
+
167
+ config = runtime_config()
168
+ cached_paths = cached_asset_paths(config)
169
+ if cached_paths:
170
+ return cached_paths
171
+
172
+ local_dir = Path(MODEL_ASSET_DIR) / config["model_repo"]
173
+ snapshot_download(
174
+ repo_id=config["model_repo"],
175
+ local_dir=local_dir,
176
+ allow_patterns=asset_patterns(config),
177
+ )
178
+ cached_paths = cached_asset_paths(config)
179
+ if not cached_paths:
180
+ raise FileNotFoundError(f"No GGUF file matched {config['gguf_quant']} in {local_dir}")
181
+ return cached_paths
182
+
183
+
184
+ @app.function(
185
+ image=llamacpp_image,
186
+ gpu=os.getenv("SNAP2SIM_SMOKE_GPU", "L40S"),
187
+ volumes={CACHE_DIR: model_cache},
188
+ timeout=60 * 60,
189
+ )
190
+ def smoke_test_llamacpp_image() -> dict[str, Any]:
191
+ """Run one image prompt through llama.cpp's multimodal CLI on a GPU."""
192
+ import base64
193
+ import subprocess
194
+ import time
195
+
196
+ from PIL import Image, ImageDraw
197
+
198
+ model_path, mmproj_path = ensure_runtime_assets()
199
+ test_image = Path("/tmp/snap2sim-smoke-input.jpg")
200
+ img = Image.new("RGB", (512, 384), "#d8d0bd")
201
+ draw = ImageDraw.Draw(img)
202
+ draw.rectangle((82, 96, 430, 288), outline="#2c3138", width=8)
203
+ draw.ellipse((178, 112, 334, 268), outline="#b06c23", width=14)
204
+ draw.line((256, 112, 256, 268), fill="#2c3138", width=6)
205
+ draw.line((178, 190, 334, 190), fill="#2c3138", width=6)
206
+ img.save(test_image, format="JPEG", quality=92)
207
+
208
+ prompt = """Answer with only this compact JSON shape. Do not include markdown.
209
+ {
210
+ "component": "short component name",
211
+ "confidence": 0.7,
212
+ "summary": "one sentence about the visible test image mechanism",
213
+ "trigger": "manual alignment",
214
+ "motion_sequence": ["first motion", "second motion"],
215
+ "parts": [
216
+ {
217
+ "id": "ring",
218
+ "name": "outer ring",
219
+ "role": "frames the mechanism",
220
+ "geometry": {"shape": "cylinder", "size": [1, 0.1, 1], "position": [0, 0, 0]},
221
+ "motion": {"type": "rotate", "axis": [0, 1, 0], "speed": 0.2}
222
+ },
223
+ {
224
+ "id": "crossbar",
225
+ "name": "crossbar",
226
+ "role": "shows alignment",
227
+ "geometry": {"shape": "rod", "size": [0.05, 0.05, 1], "position": [0, 0.05, 0]},
228
+ "motion": {"type": "static"}
229
+ }
230
+ ]
231
+ }"""
232
+ cmd = [
233
+ "/opt/llama.cpp/build/bin/llama-mtmd-cli",
234
+ "-m",
235
+ str(model_path),
236
+ "--mmproj",
237
+ str(mmproj_path),
238
+ "--image",
239
+ str(test_image),
240
+ "-p",
241
+ prompt,
242
+ "-n",
243
+ "1024",
244
+ "--temp",
245
+ "0.2",
246
+ ]
247
+ start = time.monotonic()
248
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=45 * 60)
249
+ elapsed_seconds = round(time.monotonic() - start, 2)
250
+ stdout = proc.stdout.strip()
251
+ stderr = proc.stderr.strip()
252
+ combined_output = "\n".join(part for part in [stdout, stderr] if part).strip()
253
+ parsed_component = ""
254
+ valid_json = False
255
+ parse_error = ""
256
+ try:
257
+ parsed_component = parse_analysis_response(stdout)["component"]
258
+ valid_json = True
259
+ except Exception as exc:
260
+ parse_error = str(exc)
261
+
262
+ image_supported = (
263
+ proc.returncode == 0
264
+ and bool(stdout)
265
+ and "image input is not supported" not in combined_output.lower()
266
+ and "failed to load projector" not in combined_output.lower()
267
+ )
268
+ return {
269
+ "ok": image_supported and valid_json,
270
+ "image_supported": image_supported,
271
+ "valid_json": valid_json,
272
+ "parsed_component": parsed_component,
273
+ "parse_error": parse_error,
274
+ "returncode": proc.returncode,
275
+ "elapsed_seconds": elapsed_seconds,
276
+ "model_path": str(model_path),
277
+ "mmproj_path": str(mmproj_path),
278
+ "image_base64_prefix": base64.b64encode(test_image.read_bytes()).decode("ascii")[:80],
279
+ "stdout_tail": stdout[-4000:],
280
+ "stderr_tail": stderr[-4000:],
281
+ }
282
+
283
+
284
+ def run_llamacpp_prompt(
285
+ prompt: str,
286
+ image_path: Path | None = None,
287
+ max_tokens: int = 1024,
288
+ timeout_seconds: int = 180,
289
+ ) -> str:
290
+ """Run one prompt through the llama.cpp multimodal CLI."""
291
+ import subprocess
292
+
293
+ model_path, mmproj_path = ensure_runtime_assets()
294
+ cmd = [
295
+ "/opt/llama.cpp/build/bin/llama-mtmd-cli",
296
+ "-m",
297
+ str(model_path),
298
+ "--mmproj",
299
+ str(mmproj_path),
300
+ "-p",
301
+ prompt,
302
+ "-n",
303
+ str(max_tokens),
304
+ "--temp",
305
+ "0.2",
306
+ ]
307
+ if image_path is not None:
308
+ cmd.extend(["--image", str(image_path)])
309
+
310
+ try:
311
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds)
312
+ except subprocess.TimeoutExpired as exc:
313
+ partial_output = "\n".join(
314
+ part.decode("utf-8", errors="replace") if isinstance(part, bytes) else part
315
+ for part in [exc.stdout, exc.stderr]
316
+ if part
317
+ ).strip()
318
+ raise TimeoutError(
319
+ f"llama.cpp timed out after {timeout_seconds}s: {partial_output[-2000:]}"
320
+ ) from exc
321
+ output = "\n".join(part for part in [proc.stdout, proc.stderr] if part).strip()
322
+ if proc.returncode != 0:
323
+ raise RuntimeError(f"llama.cpp exited with {proc.returncode}: {output[-2000:]}")
324
+ return output
325
+
326
+
327
+ def write_payload_image(payload: dict[str, Any]) -> Path:
328
+ """Decode an image_base64 payload into a temporary RGB JPEG."""
329
+ import base64
330
+ from io import BytesIO
331
+
332
+ from PIL import Image
333
+
334
+ image_base64 = payload.get("image_base64")
335
+ if not isinstance(image_base64, str) or not image_base64:
336
+ raise ValueError("Request payload must include image_base64.")
337
+ if "," in image_base64 and image_base64.lstrip().startswith("data:"):
338
+ image_base64 = image_base64.split(",", 1)[1]
339
+
340
+ raw = base64.b64decode(image_base64)
341
+ image = Image.open(BytesIO(raw)).convert("RGB")
342
+ path = Path("/tmp/snap2sim-request-image.jpg")
343
+ image.save(path, format="JPEG", quality=92)
344
+ return path
345
+
346
+
347
+ def analyze_image_llamacpp_payload(payload: dict[str, Any]) -> dict[str, Any]:
348
+ image_path = write_payload_image(payload)
349
+ response = run_llamacpp_prompt(
350
+ build_vision_prompt(),
351
+ image_path=image_path,
352
+ max_tokens=1536,
353
+ timeout_seconds=180,
354
+ )
355
+ try:
356
+ return parse_analysis_response(response)
357
+ except Exception:
358
+ return coerce_analysis_response(response)
359
+
360
+
361
+ @app.local_entrypoint()
362
+ def run_runtime_preflight() -> None:
363
+ """Cache assets, then run the Modal GPU llama.cpp image smoke test."""
364
+ print(download_runtime_assets.remote())
365
+ print(smoke_test_llamacpp_image.remote())
366
+
367
+
368
+ @app.local_entrypoint()
369
+ def run_smoke_test() -> None:
370
+ """Run and print the Modal GPU llama.cpp image smoke test result."""
371
+ import json
372
+
373
+ print(json.dumps(smoke_test_llamacpp_image.remote(), indent=2))
374
+
375
+
376
+ @app.local_entrypoint()
377
+ def run_analysis_endpoint_check() -> None:
378
+ """Run the experimental image-analysis endpoint logic with a test image."""
379
+ import base64
380
+ import json
381
+ from io import BytesIO
382
+
383
+ from PIL import Image, ImageDraw
384
+
385
+ img = Image.new("RGB", (512, 384), "#d8d0bd")
386
+ draw = ImageDraw.Draw(img)
387
+ draw.rectangle((82, 96, 430, 288), outline="#2c3138", width=8)
388
+ draw.ellipse((178, 112, 334, 268), outline="#b06c23", width=14)
389
+ draw.line((256, 112, 256, 268), fill="#2c3138", width=6)
390
+ draw.line((178, 190, 334, 190), fill="#2c3138", width=6)
391
+ buffer = BytesIO()
392
+ img.save(buffer, format="JPEG", quality=92)
393
+ result = analyze_image_llamacpp_task.remote(
394
+ {"image_base64": base64.b64encode(buffer.getvalue()).decode("ascii")}
395
+ )
396
+ print(json.dumps(result, indent=2))
397
+
398
+
399
+ @app.function(image=image, timeout=120, secrets=[api_auth_secret])
400
+ @modal.fastapi_endpoint(method="GET")
401
+ def runtime_probe(authorization: str = Header(default="")) -> dict[str, Any]:
402
+ """Expose the currently selected runtime path for deployment diagnostics."""
403
+ require_authorization(authorization)
404
+ config = runtime_config()
405
+ return {
406
+ "runtime_mode": config["runtime_mode"],
407
+ "model_repo": config["model_repo"],
408
+ "gguf_quant": config["gguf_quant"],
409
+ "mmproj_file": config["mmproj_file"],
410
+ "status": (
411
+ "placeholder endpoint active; llama.cpp endpoint verified separately"
412
+ if config["runtime_mode"] == "placeholder"
413
+ else "runtime selected"
414
+ ),
415
+ "verified_endpoint": "analyze_image_llamacpp",
416
+ "recommended_generate_endpoint": "generate_threejs",
417
+ }
418
+
419
+
420
+ @app.function(image=image, volumes={CACHE_DIR: model_cache}, timeout=600, secrets=[api_auth_secret])
421
+ @modal.fastapi_endpoint(method="POST")
422
+ def analyze_image(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
423
+ require_authorization(authorization)
424
+ _ = payload.get("image_base64", "")
425
+ _prompt = build_vision_prompt()
426
+ if runtime_config()["runtime_mode"] != "placeholder":
427
+ raise NotImplementedError(
428
+ "Use the analyze_image_llamacpp endpoint for the verified Nemotron "
429
+ "llama.cpp runtime path."
430
+ )
431
+ return validate_analysis(dict(EXAMPLE_ANALYSIS))
432
+
433
+
434
+ @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60)
435
+ def analyze_image_llamacpp_task(payload: dict[str, Any]) -> dict[str, Any]:
436
+ """Remote-callable task for testing the llama.cpp image analysis path."""
437
+ return analyze_image_llamacpp_payload(payload)
438
+
439
+
440
+ @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
441
+ @modal.fastapi_endpoint(method="POST")
442
+ def analyze_image_llamacpp(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
443
+ """Experimental GPU endpoint for llama.cpp multimodal image analysis."""
444
+ require_authorization(authorization)
445
+ return analyze_image_llamacpp_payload(payload)
446
+
447
+
448
+ @app.function(image=image, volumes={CACHE_DIR: model_cache}, timeout=600, secrets=[api_auth_secret])
449
+ @modal.fastapi_endpoint(method="POST")
450
+ def generate_threejs(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, str]:
451
+ require_authorization(authorization)
452
+ analysis = validate_analysis(payload.get("analysis") or EXAMPLE_ANALYSIS)
453
+ _prompt = build_threejs_prompt(analysis)
454
+ if runtime_config()["runtime_mode"] != "placeholder":
455
+ raise NotImplementedError(
456
+ "Nemotron runtime is selected but not wired yet. Keep placeholder "
457
+ "mode active until the llama.cpp/vLLM adapter is implemented."
458
+ )
459
+ return {"html": build_threejs_html(analysis)}
460
+
461
+
462
+ @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
463
+ @modal.fastapi_endpoint(method="POST")
464
+ def generate_threejs_llamacpp(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, str]:
465
+ """Experimental GPU endpoint for llama.cpp Three.js code generation."""
466
+ require_authorization(authorization)
467
+ analysis = validate_analysis(payload.get("analysis") or EXAMPLE_ANALYSIS)
468
+ response = run_llamacpp_prompt(build_threejs_prompt(analysis), max_tokens=4096)
469
+ return {"html": parse_html_response(response)}
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.44
2
+ huggingface_hub[hf_xet]>=0.36
3
+ requests>=2.32
4
+ pillow>=10.4
5
+ modal>=0.64
scripts/verify_runtime_assets.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Preflight checks for the preferred Nemotron GGUF runtime assets.
2
+
3
+ This script only lists Hugging Face repository metadata. It does not download
4
+ model weights and it does not prove image input works in llama.cpp.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+ from huggingface_hub import HfApi
13
+
14
+
15
+ MODEL_REPO = os.getenv(
16
+ "SNAP2SIM_MODEL_REPO",
17
+ "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF",
18
+ )
19
+ GGUF_QUANT = os.getenv("SNAP2SIM_GGUF_QUANT", "UD-Q4_K_M")
20
+ MMPROJ_FILE = os.getenv("SNAP2SIM_MMPROJ_FILE", "mmproj-F16.gguf")
21
+
22
+
23
+ def main() -> int:
24
+ files = HfApi().list_repo_files(MODEL_REPO)
25
+ quant_matches = [name for name in files if name.endswith(".gguf") and GGUF_QUANT in name]
26
+ mmproj_matches = [name for name in files if name.startswith("mmproj") and name.endswith(".gguf")]
27
+
28
+ print(f"repo: {MODEL_REPO}")
29
+ print(f"quant selector: {GGUF_QUANT}")
30
+ print(f"quant files: {', '.join(quant_matches) or 'NONE'}")
31
+ print(f"mmproj files: {', '.join(mmproj_matches) or 'NONE'}")
32
+ print(f"selected mmproj: {MMPROJ_FILE}")
33
+
34
+ missing = []
35
+ if not quant_matches:
36
+ missing.append(f"GGUF quant containing {GGUF_QUANT}")
37
+ if MMPROJ_FILE not in files:
38
+ missing.append(MMPROJ_FILE)
39
+
40
+ if missing:
41
+ print(f"missing: {', '.join(missing)}", file=sys.stderr)
42
+ return 1
43
+
44
+ print("preflight: PASS")
45
+ print(
46
+ "next: run a GPU smoke test with llama.cpp using the selected GGUF and "
47
+ "mmproj, then send one image prompt through the server."
48
+ )
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
snap2sim/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Snap2Sim application package."""
2
+
snap2sim/backend.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend client and local placeholder inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ from dataclasses import dataclass
8
+ from io import BytesIO
9
+ from typing import Any
10
+
11
+ import requests
12
+ from PIL import Image
13
+
14
+ from snap2sim.schema import EXAMPLE_ANALYSIS, validate_analysis
15
+ from snap2sim.three_scene import build_threejs_html
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Settings:
20
+ backend: str = os.getenv("INFERENCE_BACKEND", "local")
21
+ analyze_url: str = os.getenv("MODAL_ANALYZE_URL", "")
22
+ generate_url: str = os.getenv("MODAL_GENERATE_URL", "")
23
+ api_token: str = os.getenv("SNAP2SIM_API_TOKEN", "")
24
+ timeout_seconds: int = int(os.getenv("INFERENCE_TIMEOUT_SECONDS", "180"))
25
+
26
+
27
+ def encode_image(image: Image.Image) -> str:
28
+ buffer = BytesIO()
29
+ image.convert("RGB").save(buffer, format="JPEG", quality=92)
30
+ return base64.b64encode(buffer.getvalue()).decode("ascii")
31
+
32
+
33
+ class InferenceClient:
34
+ def __init__(self, settings: Settings | None = None) -> None:
35
+ self.settings = settings or Settings()
36
+
37
+ def analyze_image(self, image: Image.Image | None) -> dict[str, Any]:
38
+ if self.settings.backend == "modal":
39
+ if image is None:
40
+ raise ValueError("Upload an image before analysis.")
41
+ return self._post_json(
42
+ self.settings.analyze_url,
43
+ {"image_base64": encode_image(image)},
44
+ )
45
+
46
+ return validate_analysis(dict(EXAMPLE_ANALYSIS))
47
+
48
+ def generate_threejs(self, analysis: dict[str, Any]) -> str:
49
+ valid_analysis = validate_analysis(analysis)
50
+ if self.settings.backend == "modal":
51
+ response = self._post_json(self.settings.generate_url, {"analysis": valid_analysis})
52
+ html = response.get("html", "")
53
+ if not html:
54
+ raise RuntimeError("Modal response did not include generated HTML.")
55
+ return str(html)
56
+
57
+ return build_threejs_html(valid_analysis)
58
+
59
+ def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
60
+ if not url:
61
+ raise RuntimeError("Modal backend selected but endpoint URL is not configured.")
62
+ if not self.settings.api_token:
63
+ raise RuntimeError("Modal backend selected but SNAP2SIM_API_TOKEN is not configured.")
64
+ response = requests.post(
65
+ url,
66
+ json=payload,
67
+ headers={"Authorization": f"Bearer {self.settings.api_token}"},
68
+ timeout=self.settings.timeout_seconds,
69
+ )
70
+ response.raise_for_status()
71
+ data = response.json()
72
+ if not isinstance(data, dict):
73
+ raise RuntimeError("Inference backend returned a non-object JSON payload.")
74
+ return data
snap2sim/fallback_scene.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Themed fallback visualization used when generated Three.js is unavailable."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def build_fallback_html(component: str) -> str:
7
+ title = component.upper()
8
+ return f"""
9
+ <div class="fallback-cutaway" role="img" aria-label="Fallback mechanism animation for {component}">
10
+ <div class="scan"></div>
11
+ <svg viewBox="0 0 760 430" xmlns="http://www.w3.org/2000/svg">
12
+ <defs>
13
+ <pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
14
+ <path d="M 24 0 L 0 0 0 24" fill="none" stroke="rgba(95,212,208,.14)" stroke-width="1"/>
15
+ </pattern>
16
+ </defs>
17
+ <rect width="760" height="430" fill="#14181F"/>
18
+ <rect width="760" height="430" fill="url(#grid)"/>
19
+ <g transform="translate(380 220)">
20
+ <circle r="104" fill="none" stroke="#5FD4D0" stroke-width="2" opacity=".8"/>
21
+ <circle class="fallback-gear" r="72" fill="none" stroke="#E8A33D" stroke-width="18"
22
+ stroke-dasharray="18 10"/>
23
+ <rect class="fallback-pawl" x="72" y="-16" width="132" height="32" rx="2"
24
+ fill="#E8A33D" opacity=".9"/>
25
+ <line x1="108" y1="-54" x2="215" y2="-112" stroke="#5FD4D0" stroke-width="1"/>
26
+ <text x="224" y="-116" fill="#5FD4D0" font-family="monospace" font-size="15">LOCKING PAWL</text>
27
+ <line x1="-54" y1="52" x2="-198" y2="122" stroke="#5FD4D0" stroke-width="1"/>
28
+ <text x="-342" y="132" fill="#5FD4D0" font-family="monospace" font-size="15">DRIVE GEAR</text>
29
+ </g>
30
+ <text x="32" y="44" fill="#D9D3C7" font-family="monospace" font-size="18">{title}</text>
31
+ <text x="32" y="394" fill="#E8A33D" font-family="monospace" font-size="13">2D FALLBACK: GENERATED 3D SCENE UNAVAILABLE</text>
32
+ </svg>
33
+ </div>
34
+ <style>
35
+ .fallback-cutaway {{
36
+ position: relative;
37
+ overflow: hidden;
38
+ border: 1px solid rgba(95, 212, 208, .28);
39
+ background: #14181F;
40
+ min-height: 430px;
41
+ }}
42
+ .fallback-cutaway svg {{
43
+ display: block;
44
+ width: 100%;
45
+ height: min(58vh, 560px);
46
+ }}
47
+ .fallback-gear {{
48
+ transform-origin: center;
49
+ animation: fallback-spin 3.8s linear infinite;
50
+ }}
51
+ .fallback-pawl {{
52
+ transform-origin: 74px 0;
53
+ animation: fallback-pawl 1.05s steps(2, end) infinite;
54
+ }}
55
+ .scan {{
56
+ position: absolute;
57
+ inset: 0;
58
+ background: linear-gradient(180deg, transparent, rgba(95, 212, 208, .16), transparent);
59
+ height: 26%;
60
+ animation: fallback-scan 2.6s ease-in-out infinite;
61
+ pointer-events: none;
62
+ }}
63
+ @keyframes fallback-spin {{ to {{ transform: rotate(360deg); }} }}
64
+ @keyframes fallback-pawl {{ 50% {{ transform: rotate(-10deg); }} }}
65
+ @keyframes fallback-scan {{
66
+ 0% {{ transform: translateY(-120%); opacity: 0; }}
67
+ 20%, 80% {{ opacity: 1; }}
68
+ 100% {{ transform: translateY(430%); opacity: 0; }}
69
+ }}
70
+ </style>
71
+ """
72
+
snap2sim/model_io.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parsing helpers for model responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ from snap2sim.schema import validate_analysis
10
+
11
+
12
+ _FENCE_RE = re.compile(r"^```(?:json|html)?\s*|\s*```$", re.IGNORECASE)
13
+
14
+
15
+ def parse_analysis_response(text: str) -> dict[str, Any]:
16
+ """Extract and validate a JSON object from a model response."""
17
+ raw = _strip_fences(text)
18
+ errors: list[str] = []
19
+ for start, json_text in _json_object_candidates(raw):
20
+ try:
21
+ payload = json.loads(json_text)
22
+ return validate_analysis(payload)
23
+ except (json.JSONDecodeError, ValueError) as exc:
24
+ errors.append(f"object at {start}: {exc}")
25
+
26
+ if errors:
27
+ raise ValueError("Model response did not contain a valid analysis JSON object. " + errors[-1])
28
+ raise ValueError("Model response did not contain a complete JSON object.")
29
+
30
+
31
+ def coerce_analysis_response(text: str) -> dict[str, Any]:
32
+ """Best-effort conversion of partial model output into a valid analysis."""
33
+ raw = _strip_fences(text)
34
+ fallback_component = _infer_component(raw)
35
+ for _, json_text in _json_object_candidates(raw):
36
+ try:
37
+ payload = json.loads(json_text)
38
+ except json.JSONDecodeError:
39
+ continue
40
+ if not isinstance(payload, dict):
41
+ continue
42
+ try:
43
+ return validate_analysis(_coerce_analysis_payload(payload, fallback_component))
44
+ except ValueError:
45
+ continue
46
+ return validate_analysis(_generic_analysis(fallback_component))
47
+
48
+
49
+ def parse_html_response(text: str) -> str:
50
+ """Extract a complete HTML document from a model response."""
51
+ raw = _strip_fences(text).strip()
52
+ html_start = _find_html_start(raw)
53
+ html_end = raw.lower().rfind("</html>")
54
+ if html_start < 0 or html_end < 0:
55
+ raise ValueError("Model response did not contain a complete HTML document.")
56
+ return raw[html_start : html_end + len("</html>")]
57
+
58
+
59
+ def _strip_fences(text: str) -> str:
60
+ return _FENCE_RE.sub("", text.strip()).strip()
61
+
62
+
63
+ def _find_html_start(text: str) -> int:
64
+ lowered = text.lower()
65
+ starts = [index for index in [lowered.find("<!doctype"), lowered.find("<html")] if index >= 0]
66
+ return min(starts) if starts else -1
67
+
68
+
69
+ def _json_object_candidates(text: str) -> list[tuple[int, str]]:
70
+ candidates = []
71
+ for index, char in enumerate(text):
72
+ if char != "{":
73
+ continue
74
+ try:
75
+ candidates.append((index, _balanced_json_object(text, index)))
76
+ except ValueError:
77
+ continue
78
+ return candidates
79
+
80
+
81
+ def _balanced_json_object(text: str, start: int) -> str:
82
+ depth = 0
83
+ in_string = False
84
+ escaped = False
85
+ for index in range(start, len(text)):
86
+ char = text[index]
87
+ if in_string:
88
+ if escaped:
89
+ escaped = False
90
+ elif char == "\\":
91
+ escaped = True
92
+ elif char == '"':
93
+ in_string = False
94
+ continue
95
+
96
+ if char == '"':
97
+ in_string = True
98
+ elif char == "{":
99
+ depth += 1
100
+ elif char == "}":
101
+ depth -= 1
102
+ if depth == 0:
103
+ return text[start : index + 1]
104
+
105
+ raise ValueError("Model response contained an unterminated JSON object.")
106
+
107
+
108
+ def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -> dict[str, Any]:
109
+ parts = payload.get("parts")
110
+ if not isinstance(parts, list):
111
+ parts = []
112
+ coerced_parts = [_coerce_part(part, index) for index, part in enumerate(parts[:4]) if isinstance(part, dict)]
113
+ coerced_parts = [part for part in coerced_parts if part is not None]
114
+ if not coerced_parts:
115
+ coerced_parts = _generic_analysis(fallback_component)["parts"]
116
+
117
+ confidence = payload.get("confidence", 0.55)
118
+ if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
119
+ confidence = 0.55
120
+
121
+ return {
122
+ "component": _non_empty_string(payload.get("component"), fallback_component),
123
+ "confidence": max(0.0, min(1.0, float(confidence))),
124
+ "summary": _non_empty_string(
125
+ payload.get("summary"),
126
+ f"{fallback_component.title()} approximated as primitive cutaway parts.",
127
+ ),
128
+ "trigger": _non_empty_string(payload.get("trigger"), "manual input"),
129
+ "motion_sequence": _string_list(
130
+ payload.get("motion_sequence"),
131
+ ["input is applied", "internal parts move through the inferred mechanism"],
132
+ ),
133
+ "parts": coerced_parts,
134
+ }
135
+
136
+
137
+ def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
138
+ geometry = part.get("geometry")
139
+ if not isinstance(geometry, dict):
140
+ geometry = {}
141
+ motion = part.get("motion")
142
+ if not isinstance(motion, dict):
143
+ motion = {}
144
+
145
+ shape = geometry.get("shape")
146
+ if shape not in {"box", "cylinder", "sphere", "gear", "rod"}:
147
+ shape = "box"
148
+ motion_type = motion.get("type")
149
+ if motion_type not in {"rotate", "translate", "oscillate", "static"}:
150
+ motion_type = "static"
151
+
152
+ coerced_motion: dict[str, Any] = {"type": motion_type}
153
+ for key in ["axis"]:
154
+ values = _number_list(motion.get(key), 3)
155
+ if values:
156
+ coerced_motion[key] = values
157
+ for key in ["speed", "amplitude", "phase"]:
158
+ if isinstance(motion.get(key), (int, float)) and not isinstance(motion.get(key), bool):
159
+ coerced_motion[key] = float(motion[key])
160
+ values = _number_list(motion.get("range"), 2)
161
+ if values:
162
+ coerced_motion["range"] = values
163
+
164
+ coerced_geometry: dict[str, Any] = {
165
+ "shape": shape,
166
+ "size": _number_list(geometry.get("size"), 3) or [1.0, 0.4, 1.0],
167
+ "position": _number_list(geometry.get("position"), 3) or [float(index) - 1.0, 0.0, 0.0],
168
+ }
169
+ values = _number_list(geometry.get("rotation"), 3)
170
+ if values:
171
+ coerced_geometry["rotation"] = values
172
+ if isinstance(geometry.get("teeth"), int):
173
+ coerced_geometry["teeth"] = geometry["teeth"]
174
+ if isinstance(geometry.get("color"), str) and geometry["color"].strip():
175
+ coerced_geometry["color"] = geometry["color"].strip()
176
+
177
+ return {
178
+ "id": _identifier(part.get("id"), f"part_{index + 1}"),
179
+ "name": _non_empty_string(part.get("name"), f"part {index + 1}"),
180
+ "role": _non_empty_string(part.get("role"), "inferred mechanical element"),
181
+ "geometry": coerced_geometry,
182
+ "motion": coerced_motion,
183
+ }
184
+
185
+
186
+ def _generic_analysis(component: str) -> dict[str, Any]:
187
+ return {
188
+ "component": component,
189
+ "confidence": 0.45,
190
+ "summary": f"{component.title()} rendered as a conservative generic cutaway.",
191
+ "trigger": "manual input",
192
+ "motion_sequence": [
193
+ "input is applied to the housing",
194
+ "central rotor transfers motion",
195
+ "guide elements hold alignment",
196
+ ],
197
+ "parts": [
198
+ {
199
+ "id": "housing",
200
+ "name": "outer housing",
201
+ "role": "supports the internal mechanism",
202
+ "geometry": {"shape": "box", "size": [2.4, 0.45, 1.4], "position": [0, 0, 0]},
203
+ "motion": {"type": "static"},
204
+ },
205
+ {
206
+ "id": "rotor",
207
+ "name": "central rotor",
208
+ "role": "transfers motion through the assembly",
209
+ "geometry": {"shape": "cylinder", "size": [0.9, 0.35, 0.9], "position": [0, 0.18, 0]},
210
+ "motion": {"type": "rotate", "axis": [0, 1, 0], "speed": 0.55},
211
+ },
212
+ {
213
+ "id": "guide",
214
+ "name": "guide rail",
215
+ "role": "keeps the moving part aligned",
216
+ "geometry": {"shape": "rod", "size": [1.6, 0.12, 0.12], "position": [0, 0.42, 0.48]},
217
+ "motion": {"type": "static"},
218
+ },
219
+ ],
220
+ }
221
+
222
+
223
+ def _infer_component(text: str) -> str:
224
+ match = re.search(r'"component"\s*:\s*"([^"]+)"', text)
225
+ if match and match.group(1).strip():
226
+ return match.group(1).strip()
227
+ lowered = text.lower()
228
+ for name in ["ratchet", "gear", "hinge", "motor", "lens", "target", "switch", "bearing"]:
229
+ if name in lowered:
230
+ return name
231
+ return "observed component"
232
+
233
+
234
+ def _non_empty_string(value: Any, fallback: str) -> str:
235
+ return value.strip() if isinstance(value, str) and value.strip() else fallback
236
+
237
+
238
+ def _string_list(value: Any, fallback: list[str]) -> list[str]:
239
+ if isinstance(value, list):
240
+ values = [item.strip() for item in value if isinstance(item, str) and item.strip()]
241
+ if values:
242
+ return values
243
+ return fallback
244
+
245
+
246
+ def _number_list(value: Any, length: int) -> list[float] | None:
247
+ if not isinstance(value, list) or len(value) != length:
248
+ return None
249
+ if not all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in value):
250
+ return None
251
+ return [float(item) for item in value]
252
+
253
+
254
+ def _identifier(value: Any, fallback: str) -> str:
255
+ text = _non_empty_string(value, fallback).lower()
256
+ text = re.sub(r"[^a-z0-9_]+", "_", text).strip("_")
257
+ return text or fallback
snap2sim/prompts.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt templates for the two model calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ VISION_SYSTEM_PROMPT = """You are a mechanical teardown analyst.
10
+ Given an image of a hardware component, infer the most likely internal
11
+ mechanism and return only JSON matching the provided schema. Prefer clear,
12
+ physically plausible primitive geometry over speculative detail. If the photo is
13
+ ambiguous, state lower confidence and model the most likely mechanism."""
14
+
15
+
16
+ def build_vision_prompt() -> str:
17
+ return (
18
+ "Analyze the uploaded hardware component as a cutaway mechanism. "
19
+ "Answer with only one JSON object. Do not include markdown. Do not "
20
+ "include a reasoning trace or <think> tags. Keep the payload compact and physically "
21
+ "plausible for primitive Three.js rendering.\n\n"
22
+ "Required top-level keys: component, confidence, summary, trigger, "
23
+ "motion_sequence, parts.\n"
24
+ "Each part requires: id, name, role, geometry, motion.\n"
25
+ "Allowed geometry.shape values: box, cylinder, sphere, gear, rod.\n"
26
+ "Allowed motion.type values: rotate, translate, oscillate, static.\n\n"
27
+ "Use 2 to 4 parts. Keep names and descriptions short.\n\n"
28
+ "Use this shape:\n"
29
+ "{\n"
30
+ ' "component": "short component name",\n'
31
+ ' "confidence": 0.7,\n'
32
+ ' "summary": "one or two sentences",\n'
33
+ ' "trigger": "what starts the mechanism",\n'
34
+ ' "motion_sequence": ["step one", "step two"],\n'
35
+ ' "parts": [\n'
36
+ " {\n"
37
+ ' "id": "part_id",\n'
38
+ ' "name": "part name",\n'
39
+ ' "role": "mechanical role",\n'
40
+ ' "geometry": {"shape": "box", "size": [1, 1, 1], "position": [0, 0, 0]},\n'
41
+ ' "motion": {"type": "static"}\n'
42
+ " }\n"
43
+ " ]\n"
44
+ "}\n\n"
45
+ "Optional geometry fields: rotation, teeth, color. Optional motion "
46
+ "fields: axis, speed, amplitude, phase, range. Include optional fields "
47
+ "only when useful."
48
+ )
49
+
50
+
51
+ THREEJS_SYSTEM_PROMPT = """You generate self-contained Three.js cutaway scenes.
52
+ Return a complete HTML document with inline CSS and JavaScript only. Use
53
+ primitive geometry, labels, OrbitControls, a play/pause control, and one
54
+ power-on reveal animation. Do not include markdown fences."""
55
+
56
+
57
+ def build_threejs_prompt(analysis: dict[str, Any]) -> str:
58
+ return (
59
+ "Build a technical cutaway / field manual Three.js animation for this "
60
+ "mechanism analysis. Use a deep navy blueprint surface, warm amber "
61
+ "annotations, cool cyan motion paths, and monospace labels. Include "
62
+ "a robust fallback message inside the HTML if WebGL fails.\n\n"
63
+ f"Mechanism JSON:\n{json.dumps(analysis, indent=2)}"
64
+ )
snap2sim/schema.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared data contract between vision analysis and scene generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ MotionType = Literal["rotate", "translate", "oscillate", "static"]
8
+
9
+
10
+ EXAMPLE_ANALYSIS: dict[str, Any] = {
11
+ "component": "ratchet wrench head",
12
+ "confidence": 0.72,
13
+ "summary": (
14
+ "A ratchet head converts a back-and-forth handle motion into one-way "
15
+ "socket rotation. A pawl locks against the gear teeth during the drive "
16
+ "stroke and slips over the teeth during the return stroke."
17
+ ),
18
+ "trigger": "handle swings clockwise and counterclockwise",
19
+ "motion_sequence": [
20
+ "handle applies torque to the outer head",
21
+ "pawl tooth locks into the ratchet gear",
22
+ "gear and socket rotate on the drive stroke",
23
+ "pawl rides over gear teeth on the return stroke",
24
+ ],
25
+ "parts": [
26
+ {
27
+ "id": "housing",
28
+ "name": "head housing",
29
+ "role": "keeps the mechanism aligned",
30
+ "geometry": {
31
+ "shape": "cylinder",
32
+ "size": [3.2, 0.55, 3.2],
33
+ "position": [0, 0, 0],
34
+ "rotation": [1.5708, 0, 0],
35
+ "color": "steel",
36
+ },
37
+ "motion": {"type": "static"},
38
+ },
39
+ {
40
+ "id": "ratchet_gear",
41
+ "name": "ratchet gear",
42
+ "role": "carries the socket and teeth",
43
+ "geometry": {
44
+ "shape": "gear",
45
+ "teeth": 24,
46
+ "size": [1.45, 0.4, 1.45],
47
+ "position": [0, 0.15, 0],
48
+ "color": "cyan",
49
+ },
50
+ "motion": {
51
+ "type": "rotate",
52
+ "axis": [0, 1, 0],
53
+ "speed": 0.95,
54
+ "phase": 0,
55
+ },
56
+ },
57
+ {
58
+ "id": "pawl",
59
+ "name": "spring pawl",
60
+ "role": "locks and releases against gear teeth",
61
+ "geometry": {
62
+ "shape": "box",
63
+ "size": [0.32, 0.35, 1.1],
64
+ "position": [1.25, 0.3, 0.18],
65
+ "rotation": [0, 0.35, 0],
66
+ "color": "amber",
67
+ },
68
+ "motion": {
69
+ "type": "oscillate",
70
+ "axis": [0, 1, 0],
71
+ "amplitude": 0.28,
72
+ "speed": 3.2,
73
+ "phase": 0.6,
74
+ },
75
+ },
76
+ {
77
+ "id": "selector",
78
+ "name": "direction selector",
79
+ "role": "flips the pawl angle for reverse drive",
80
+ "geometry": {
81
+ "shape": "box",
82
+ "size": [0.9, 0.2, 0.32],
83
+ "position": [0, 0.68, -1.05],
84
+ "color": "orange",
85
+ },
86
+ "motion": {"type": "static"},
87
+ },
88
+ ],
89
+ }
90
+
91
+
92
+ ANALYSIS_SCHEMA: dict[str, Any] = {
93
+ "type": "object",
94
+ "required": ["component", "summary", "trigger", "motion_sequence", "parts"],
95
+ "properties": {
96
+ "component": {"type": "string"},
97
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1},
98
+ "summary": {"type": "string"},
99
+ "trigger": {"type": "string"},
100
+ "motion_sequence": {"type": "array", "items": {"type": "string"}},
101
+ "parts": {
102
+ "type": "array",
103
+ "items": {
104
+ "type": "object",
105
+ "required": ["id", "name", "role", "geometry", "motion"],
106
+ "properties": {
107
+ "id": {"type": "string"},
108
+ "name": {"type": "string"},
109
+ "role": {"type": "string"},
110
+ "geometry": {
111
+ "type": "object",
112
+ "required": ["shape", "size", "position"],
113
+ "properties": {
114
+ "shape": {
115
+ "type": "string",
116
+ "enum": ["box", "cylinder", "sphere", "gear", "rod"],
117
+ },
118
+ "size": {
119
+ "type": "array",
120
+ "items": {"type": "number"},
121
+ "minItems": 3,
122
+ "maxItems": 3,
123
+ },
124
+ "position": {
125
+ "type": "array",
126
+ "items": {"type": "number"},
127
+ "minItems": 3,
128
+ "maxItems": 3,
129
+ },
130
+ "rotation": {
131
+ "type": "array",
132
+ "items": {"type": "number"},
133
+ "minItems": 3,
134
+ "maxItems": 3,
135
+ },
136
+ "teeth": {"type": "integer", "minimum": 6, "maximum": 80},
137
+ "color": {"type": "string"},
138
+ },
139
+ },
140
+ "motion": {
141
+ "type": "object",
142
+ "required": ["type"],
143
+ "properties": {
144
+ "type": {
145
+ "type": "string",
146
+ "enum": ["rotate", "translate", "oscillate", "static"],
147
+ },
148
+ "axis": {
149
+ "type": "array",
150
+ "items": {"type": "number"},
151
+ "minItems": 3,
152
+ "maxItems": 3,
153
+ },
154
+ "speed": {"type": "number"},
155
+ "amplitude": {"type": "number"},
156
+ "phase": {"type": "number"},
157
+ "range": {
158
+ "type": "array",
159
+ "items": {"type": "number"},
160
+ "minItems": 2,
161
+ "maxItems": 2,
162
+ },
163
+ },
164
+ },
165
+ },
166
+ },
167
+ },
168
+ },
169
+ }
170
+
171
+
172
+ _SHAPES = {"box", "cylinder", "sphere", "gear", "rod"}
173
+ _MOTIONS = {"rotate", "translate", "oscillate", "static"}
174
+
175
+
176
+ def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
177
+ """Validate a model analysis payload against the scene-generation contract."""
178
+ if not isinstance(payload, dict):
179
+ raise ValueError("Analysis payload must be a JSON object.")
180
+
181
+ for key in ["component", "summary", "trigger"]:
182
+ _require_string(payload, key, key)
183
+ _require_string_list(payload, "motion_sequence", "motion_sequence")
184
+
185
+ parts = payload.get("parts")
186
+ if not isinstance(parts, list) or not parts:
187
+ raise ValueError("Invalid analysis payload at parts: expected a non-empty list")
188
+
189
+ for index, part in enumerate(parts):
190
+ path = f"parts.{index}"
191
+ if not isinstance(part, dict):
192
+ raise ValueError(f"Invalid analysis payload at {path}: expected an object")
193
+ for key in ["id", "name", "role"]:
194
+ _require_string(part, key, f"{path}.{key}")
195
+
196
+ geometry = part.get("geometry")
197
+ if not isinstance(geometry, dict):
198
+ raise ValueError(f"Invalid analysis payload at {path}.geometry: expected an object")
199
+ shape = geometry.get("shape")
200
+ if shape not in _SHAPES:
201
+ raise ValueError(f"Invalid analysis payload at {path}.geometry.shape: unsupported shape")
202
+ _require_number_list(geometry, "size", f"{path}.geometry.size", 3)
203
+ _require_number_list(geometry, "position", f"{path}.geometry.position", 3)
204
+ if "rotation" in geometry:
205
+ _require_number_list(geometry, "rotation", f"{path}.geometry.rotation", 3)
206
+ if "teeth" in geometry and not isinstance(geometry["teeth"], int):
207
+ raise ValueError(f"Invalid analysis payload at {path}.geometry.teeth: expected an integer")
208
+
209
+ motion = part.get("motion")
210
+ if not isinstance(motion, dict):
211
+ raise ValueError(f"Invalid analysis payload at {path}.motion: expected an object")
212
+ motion_type = motion.get("type")
213
+ if motion_type not in _MOTIONS:
214
+ raise ValueError(f"Invalid analysis payload at {path}.motion.type: unsupported motion")
215
+ if "axis" in motion:
216
+ _require_number_list(motion, "axis", f"{path}.motion.axis", 3)
217
+ if "range" in motion:
218
+ _require_number_list(motion, "range", f"{path}.motion.range", 2)
219
+ for key in ["speed", "amplitude", "phase"]:
220
+ if key in motion and not _is_number(motion[key]):
221
+ raise ValueError(f"Invalid analysis payload at {path}.motion.{key}: expected a number")
222
+
223
+ return payload
224
+
225
+
226
+ def _require_string(payload: dict[str, Any], key: str, path: str) -> None:
227
+ if not isinstance(payload.get(key), str) or not payload[key].strip():
228
+ raise ValueError(f"Invalid analysis payload at {path}: expected a non-empty string")
229
+
230
+
231
+ def _require_string_list(payload: dict[str, Any], key: str, path: str) -> None:
232
+ value = payload.get(key)
233
+ if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value):
234
+ raise ValueError(f"Invalid analysis payload at {path}: expected a non-empty list of strings")
235
+
236
+
237
+ def _require_number_list(payload: dict[str, Any], key: str, path: str, length: int) -> None:
238
+ value = payload.get(key)
239
+ if not isinstance(value, list) or len(value) != length or not all(_is_number(item) for item in value):
240
+ raise ValueError(f"Invalid analysis payload at {path}: expected {length} numbers")
241
+
242
+
243
+ def _is_number(value: Any) -> bool:
244
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
snap2sim/three_scene.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic Three.js scene generation from a validated mechanism payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from typing import Any
8
+
9
+ from snap2sim.schema import validate_analysis
10
+
11
+
12
+ def build_threejs_html(analysis: dict[str, Any]) -> str:
13
+ """Return a sandboxed iframe containing a self-contained scene document."""
14
+ valid_analysis = validate_analysis(analysis)
15
+ document = _build_scene_document(valid_analysis)
16
+ escaped = html.escape(document, quote=True)
17
+ title = html.escape(str(valid_analysis.get("component", "mechanism")), quote=True)
18
+ return (
19
+ f'<iframe class="snap-scene-frame" title="Animated cutaway of {title}" '
20
+ 'sandbox="allow-scripts allow-same-origin" '
21
+ f'srcdoc="{escaped}"></iframe>'
22
+ )
23
+
24
+
25
+ def _build_scene_document(analysis: dict[str, Any]) -> str:
26
+ analysis_json = json.dumps(analysis)
27
+ component = html.escape(str(analysis.get("component", "mechanism")).upper())
28
+ return f"""<!doctype html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <meta name="viewport" content="width=device-width, initial-scale=1">
33
+ <style>
34
+ :root {{
35
+ --bg: #14181F;
36
+ --panel: #1B222B;
37
+ --amber: #E8A33D;
38
+ --cyan: #5FD4D0;
39
+ --text: #D9D3C7;
40
+ --muted: #8F968F;
41
+ }}
42
+ * {{ box-sizing: border-box; }}
43
+ html, body {{
44
+ width: 100%;
45
+ height: 100%;
46
+ margin: 0;
47
+ overflow: hidden;
48
+ color: var(--text);
49
+ background:
50
+ linear-gradient(rgba(95, 212, 208, .07) 1px, transparent 1px),
51
+ linear-gradient(90deg, rgba(95, 212, 208, .07) 1px, transparent 1px),
52
+ var(--bg);
53
+ background-size: 28px 28px;
54
+ font-family: "IBM Plex Mono", "Consolas", monospace;
55
+ }}
56
+ #scene {{
57
+ position: fixed;
58
+ inset: 0;
59
+ }}
60
+ .hud {{
61
+ position: fixed;
62
+ left: 18px;
63
+ right: 18px;
64
+ top: 16px;
65
+ z-index: 3;
66
+ display: flex;
67
+ align-items: flex-start;
68
+ justify-content: space-between;
69
+ gap: 16px;
70
+ pointer-events: none;
71
+ }}
72
+ .title {{
73
+ max-width: min(680px, 70vw);
74
+ }}
75
+ .title b {{
76
+ display: block;
77
+ color: var(--text);
78
+ font-size: 14px;
79
+ letter-spacing: 0;
80
+ text-transform: uppercase;
81
+ }}
82
+ .title span {{
83
+ display: block;
84
+ margin-top: 6px;
85
+ color: var(--muted);
86
+ font-size: 12px;
87
+ line-height: 1.45;
88
+ }}
89
+ #toggle {{
90
+ pointer-events: auto;
91
+ min-width: 92px;
92
+ border: 1px solid rgba(232, 163, 61, .8);
93
+ border-radius: 2px;
94
+ padding: 8px 12px;
95
+ color: #16130E;
96
+ background: linear-gradient(180deg, #F0B65B, #C97923);
97
+ font: 700 12px "IBM Plex Mono", monospace;
98
+ text-transform: uppercase;
99
+ cursor: pointer;
100
+ }}
101
+ #labels {{
102
+ position: fixed;
103
+ inset: 0;
104
+ z-index: 2;
105
+ pointer-events: none;
106
+ }}
107
+ .tag {{
108
+ position: absolute;
109
+ max-width: min(190px, 42vw);
110
+ transform: translate(-50%, -50%);
111
+ border-left: 1px solid var(--cyan);
112
+ padding: 4px 0 4px 8px;
113
+ color: var(--cyan);
114
+ background: rgba(20, 24, 31, .76);
115
+ font-size: 11px;
116
+ line-height: 1.25;
117
+ text-transform: uppercase;
118
+ white-space: normal;
119
+ }}
120
+ .tag::before {{
121
+ content: "";
122
+ position: absolute;
123
+ left: -42px;
124
+ top: 50%;
125
+ width: 40px;
126
+ border-top: 1px solid rgba(95, 212, 208, .7);
127
+ }}
128
+ .scan {{
129
+ position: fixed;
130
+ inset: -30% 0 auto;
131
+ height: 28%;
132
+ z-index: 4;
133
+ background: linear-gradient(180deg, transparent, rgba(95, 212, 208, .18), transparent);
134
+ animation: scan 2.2s ease-in-out 1;
135
+ pointer-events: none;
136
+ }}
137
+ .failure {{
138
+ position: fixed;
139
+ left: 50%;
140
+ top: 50%;
141
+ width: min(520px, calc(100vw - 40px));
142
+ transform: translate(-50%, -50%);
143
+ border: 1px solid rgba(232, 163, 61, .62);
144
+ padding: 18px;
145
+ color: var(--amber);
146
+ background: rgba(20, 24, 31, .92);
147
+ font-size: 13px;
148
+ line-height: 1.55;
149
+ }}
150
+ .hidden {{ display: none; }}
151
+ @keyframes scan {{
152
+ 0% {{ transform: translateY(-100%); opacity: 0; }}
153
+ 20%, 75% {{ opacity: 1; }}
154
+ 100% {{ transform: translateY(430%); opacity: 0; }}
155
+ }}
156
+ </style>
157
+ </head>
158
+ <body>
159
+ <div id="scene"></div>
160
+ <div id="labels"></div>
161
+ <div class="scan"></div>
162
+ <div class="hud">
163
+ <div class="title">
164
+ <b>{component}</b>
165
+ <span>Primitive cutaway generated from the validated mechanism schema.</span>
166
+ </div>
167
+ <button id="toggle" type="button">Pause</button>
168
+ </div>
169
+ <div id="failure" class="failure hidden">WebGL scene failed to initialize.</div>
170
+ <script type="module">
171
+ const analysis = {analysis_json};
172
+ const failure = document.getElementById("failure");
173
+
174
+ function fail(error) {{
175
+ failure.classList.remove("hidden");
176
+ failure.textContent = "3D renderer unavailable: " + (error && error.message ? error.message : error);
177
+ }}
178
+
179
+ try {{
180
+ const [threeModule, controlsModule] = await Promise.all([
181
+ import("https://esm.sh/three@0.166.1"),
182
+ import("https://esm.sh/three@0.166.1/examples/jsm/controls/OrbitControls.js?deps=three@0.166.1")
183
+ ]);
184
+ initialize(threeModule, controlsModule.OrbitControls);
185
+ }} catch (error) {{
186
+ fail(error);
187
+ }}
188
+
189
+ function initialize(THREE, OrbitControls) {{
190
+ const root = document.getElementById("scene");
191
+ const labelsRoot = document.getElementById("labels");
192
+ const renderer = new THREE.WebGLRenderer({{ antialias: true, alpha: true, preserveDrawingBuffer: true }});
193
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
194
+ renderer.setSize(window.innerWidth, window.innerHeight);
195
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
196
+ root.appendChild(renderer.domElement);
197
+
198
+ const scene = new THREE.Scene();
199
+ scene.fog = new THREE.Fog(0x14181f, 9, 22);
200
+
201
+ const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 100);
202
+ camera.position.set(5.8, 4.6, 6.8);
203
+
204
+ const controls = new OrbitControls(camera, renderer.domElement);
205
+ controls.enableDamping = true;
206
+ controls.target.set(0, 0.15, 0);
207
+
208
+ scene.add(new THREE.HemisphereLight(0x5fd4d0, 0x14181f, 1.35));
209
+ const key = new THREE.DirectionalLight(0xffc36a, 2.15);
210
+ key.position.set(4, 7, 5);
211
+ scene.add(key);
212
+
213
+ const grid = new THREE.GridHelper(8, 32, 0x5fd4d0, 0x24505a);
214
+ grid.material.transparent = true;
215
+ grid.material.opacity = 0.42;
216
+ scene.add(grid);
217
+
218
+ const motionLineMaterial = new THREE.LineBasicMaterial({{ color: 0x5fd4d0, transparent: true, opacity: 0.56 }});
219
+ const parts = [];
220
+
221
+ for (const [index, part] of analysis.parts.entries()) {{
222
+ const mesh = buildMesh(THREE, part, index);
223
+ mesh.position.fromArray(part.geometry.position);
224
+ if (Array.isArray(part.geometry.rotation)) mesh.rotation.fromArray(part.geometry.rotation);
225
+ mesh.userData.basePosition = mesh.position.clone();
226
+ mesh.userData.baseRotation = mesh.rotation.clone();
227
+ mesh.userData.part = part;
228
+ mesh.scale.setScalar(0.001);
229
+ scene.add(mesh);
230
+ parts.push(mesh);
231
+
232
+ if (part.motion && part.motion.type !== "static") {{
233
+ const curve = motionCurve(THREE, part);
234
+ const line = new THREE.Line(
235
+ new THREE.BufferGeometry().setFromPoints(curve),
236
+ motionLineMaterial
237
+ );
238
+ scene.add(line);
239
+ }}
240
+ }}
241
+
242
+ let playing = true;
243
+ document.getElementById("toggle").addEventListener("click", (event) => {{
244
+ playing = !playing;
245
+ event.currentTarget.textContent = playing ? "Pause" : "Play";
246
+ }});
247
+
248
+ const clock = new THREE.Clock();
249
+ function animate() {{
250
+ requestAnimationFrame(animate);
251
+ const elapsed = clock.getElapsedTime();
252
+ const t = playing ? elapsed : 0;
253
+ for (const [index, mesh] of parts.entries()) {{
254
+ reveal(mesh, elapsed, index);
255
+ applyMotion(THREE, mesh, t);
256
+ }}
257
+ controls.update();
258
+ renderer.render(scene, camera);
259
+ updateLabels(THREE, camera, labelsRoot, parts);
260
+ }}
261
+ animate();
262
+
263
+ window.addEventListener("resize", () => {{
264
+ camera.aspect = window.innerWidth / window.innerHeight;
265
+ camera.updateProjectionMatrix();
266
+ renderer.setSize(window.innerWidth, window.innerHeight);
267
+ }});
268
+ }}
269
+
270
+ function buildMesh(THREE, part, index) {{
271
+ const g = part.geometry || {{}};
272
+ const size = Array.isArray(g.size) ? g.size : [1, 1, 1];
273
+ let geometry;
274
+ if (g.shape === "cylinder") {{
275
+ geometry = new THREE.CylinderGeometry(size[0] / 2, size[2] / 2, size[1], 48);
276
+ }} else if (g.shape === "sphere") {{
277
+ geometry = new THREE.SphereGeometry(Math.max(size[0], size[1], size[2]) / 2, 36, 18);
278
+ }} else if (g.shape === "rod") {{
279
+ geometry = new THREE.CylinderGeometry(size[0] / 2, size[0] / 2, size[2], 24);
280
+ geometry.rotateX(Math.PI / 2);
281
+ }} else if (g.shape === "gear") {{
282
+ geometry = gearGeometry(THREE, Math.max(size[0], size[2]) / 2, size[1], g.teeth || 18);
283
+ }} else {{
284
+ geometry = new THREE.BoxGeometry(size[0], size[1], size[2]);
285
+ }}
286
+ const color = colorFor(g.color, index);
287
+ const material = new THREE.MeshStandardMaterial({{
288
+ color,
289
+ metalness: 0.55,
290
+ roughness: 0.36,
291
+ emissive: color,
292
+ emissiveIntensity: 0.05
293
+ }});
294
+ return new THREE.Mesh(geometry, material);
295
+ }}
296
+
297
+ function gearGeometry(THREE, radius, depth, teeth) {{
298
+ const shape = new THREE.Shape();
299
+ const steps = teeth * 2;
300
+ for (let i = 0; i <= steps; i++) {{
301
+ const angle = (i / steps) * Math.PI * 2;
302
+ const r = radius * (i % 2 === 0 ? 1.0 : 0.84);
303
+ const x = Math.cos(angle) * r;
304
+ const y = Math.sin(angle) * r;
305
+ if (i === 0) shape.moveTo(x, y);
306
+ else shape.lineTo(x, y);
307
+ }}
308
+ const hole = new THREE.Path();
309
+ hole.absarc(0, 0, radius * 0.28, 0, Math.PI * 2, true);
310
+ shape.holes.push(hole);
311
+ const geometry = new THREE.ExtrudeGeometry(shape, {{ depth, bevelEnabled: false }});
312
+ geometry.center();
313
+ geometry.rotateX(Math.PI / 2);
314
+ return geometry;
315
+ }}
316
+
317
+ function colorFor(name, index) {{
318
+ const key = String(name || "").toLowerCase();
319
+ if (key.includes("amber") || key.includes("orange")) return 0xe8a33d;
320
+ if (key.includes("cyan")) return 0x5fd4d0;
321
+ if (key.includes("steel")) return 0x9aa4a6;
322
+ const palette = [0x5fd4d0, 0xe8a33d, 0xd9d3c7, 0x80b8ff, 0xf07f5a];
323
+ return palette[index % palette.length];
324
+ }}
325
+
326
+ function motionCurve(THREE, part) {{
327
+ const p = part.geometry.position || [0, 0, 0];
328
+ const points = [];
329
+ for (let i = 0; i < 48; i++) {{
330
+ const a = (i / 47) * Math.PI * 2;
331
+ points.push(new THREE.Vector3(p[0] + Math.cos(a) * 0.52, p[1] + 0.04, p[2] + Math.sin(a) * 0.52));
332
+ }}
333
+ return points;
334
+ }}
335
+
336
+ function reveal(mesh, elapsed, index) {{
337
+ const local = Math.max(0, Math.min(1, (elapsed - index * 0.12) / 0.75));
338
+ const eased = 1 - Math.pow(1 - local, 3);
339
+ mesh.scale.setScalar(eased);
340
+ }}
341
+
342
+ function applyMotion(THREE, mesh, elapsed) {{
343
+ const motion = mesh.userData.part.motion || {{ type: "static" }};
344
+ const speed = Number(motion.speed ?? 1);
345
+ const phase = Number(motion.phase ?? 0);
346
+ const axis = new THREE.Vector3(...(motion.axis || [0, 1, 0])).normalize();
347
+ mesh.position.copy(mesh.userData.basePosition);
348
+ mesh.rotation.copy(mesh.userData.baseRotation);
349
+ if (motion.type === "rotate") {{
350
+ mesh.rotateOnAxis(axis, elapsed * speed + phase);
351
+ }} else if (motion.type === "oscillate") {{
352
+ mesh.rotateOnAxis(axis, Math.sin(elapsed * speed + phase) * Number(motion.amplitude ?? 0.25));
353
+ }} else if (motion.type === "translate") {{
354
+ const range = motion.range || [-0.35, 0.35];
355
+ const offset = range[0] + (range[1] - range[0]) * ((Math.sin(elapsed * speed + phase) + 1) / 2);
356
+ mesh.position.add(axis.multiplyScalar(offset));
357
+ }}
358
+ }}
359
+
360
+ function updateLabels(THREE, camera, labelsRoot, parts) {{
361
+ labelsRoot.replaceChildren();
362
+ const width = window.innerWidth;
363
+ const height = window.innerHeight;
364
+ const entries = [];
365
+ for (const mesh of parts) {{
366
+ const pos = mesh.position.clone().project(camera);
367
+ if (pos.z < -1 || pos.z > 1) continue;
368
+ entries.push({{
369
+ x: clamp((pos.x * 0.5 + 0.5) * width, 76, Math.max(76, width - 76)),
370
+ y: clamp((-pos.y * 0.5 + 0.5) * height, 72, Math.max(72, height - 32)),
371
+ text: mesh.userData.part.name || mesh.userData.part.id || "part"
372
+ }});
373
+ }}
374
+ entries.sort((a, b) => a.y - b.y);
375
+ for (let i = 1; i < entries.length; i++) {{
376
+ entries[i].y = Math.max(entries[i].y, entries[i - 1].y + 38);
377
+ }}
378
+ const overflow = entries.length ? entries[entries.length - 1].y - (height - 32) : 0;
379
+ if (overflow > 0) {{
380
+ for (const entry of entries) entry.y -= overflow;
381
+ }}
382
+ for (const entry of entries) {{
383
+ const label = document.createElement("div");
384
+ label.className = "tag";
385
+ label.style.left = entry.x + "px";
386
+ label.style.top = entry.y + "px";
387
+ label.textContent = entry.text;
388
+ labelsRoot.appendChild(label);
389
+ }}
390
+ }}
391
+
392
+ function clamp(value, min, max) {{
393
+ return Math.min(max, Math.max(min, value));
394
+ }}
395
+ </script>
396
+ </body>
397
+ </html>"""
snap2sim/ui.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio interface for Snap2Sim."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import gradio as gr
9
+ from PIL import Image
10
+
11
+ from snap2sim.backend import InferenceClient, Settings
12
+ from snap2sim.fallback_scene import build_fallback_html
13
+ from snap2sim.schema import EXAMPLE_ANALYSIS
14
+
15
+
16
+ CSS = """
17
+ :root {
18
+ --snap-bg: #14181F;
19
+ --snap-panel: #1B222B;
20
+ --snap-line: rgba(95, 212, 208, 0.22);
21
+ --snap-amber: #E8A33D;
22
+ --snap-cyan: #5FD4D0;
23
+ --snap-text: #D9D3C7;
24
+ --snap-muted: #8F968F;
25
+ }
26
+
27
+ .gradio-container {
28
+ min-height: 100vh;
29
+ color: var(--snap-text);
30
+ background:
31
+ linear-gradient(rgba(95, 212, 208, .055) 1px, transparent 1px),
32
+ linear-gradient(90deg, rgba(95, 212, 208, .055) 1px, transparent 1px),
33
+ radial-gradient(circle at 85% 15%, rgba(232, 163, 61, .12), transparent 30%),
34
+ var(--snap-bg) !important;
35
+ background-size: 28px 28px, 28px 28px, auto, auto !important;
36
+ font-family: 'IBM Plex Mono', monospace !important;
37
+ }
38
+
39
+ .snap-shell {
40
+ max-width: 1540px;
41
+ margin: 0 auto;
42
+ }
43
+
44
+ .snap-title h1 {
45
+ margin: 0 0 4px;
46
+ color: var(--snap-text);
47
+ font-family: 'Archivo Narrow', sans-serif;
48
+ font-size: clamp(38px, 5vw, 76px);
49
+ line-height: .9;
50
+ letter-spacing: 0;
51
+ text-transform: uppercase;
52
+ }
53
+
54
+ .snap-title p {
55
+ max-width: 860px;
56
+ color: var(--snap-muted);
57
+ font-size: 14px;
58
+ line-height: 1.6;
59
+ }
60
+
61
+ .snap-frame {
62
+ border: 1px solid var(--snap-line);
63
+ background: rgba(20, 24, 31, .8);
64
+ box-shadow: inset 0 0 0 1px rgba(232, 163, 61, .06);
65
+ }
66
+
67
+ .snap-frame label,
68
+ .snap-frame .label-wrap span {
69
+ color: var(--snap-cyan) !important;
70
+ font-family: 'IBM Plex Mono', monospace !important;
71
+ text-transform: uppercase;
72
+ }
73
+
74
+ .snap-frame textarea,
75
+ .snap-frame input {
76
+ background: #10141A !important;
77
+ color: var(--snap-text) !important;
78
+ border-color: var(--snap-line) !important;
79
+ font-family: 'IBM Plex Mono', monospace !important;
80
+ }
81
+
82
+ .snap-run {
83
+ border: 1px solid rgba(232, 163, 61, .7) !important;
84
+ background: linear-gradient(180deg, #F0B65B, #C97923) !important;
85
+ color: #16130E !important;
86
+ font-family: 'Archivo Narrow', sans-serif !important;
87
+ font-size: 18px !important;
88
+ font-weight: 700 !important;
89
+ text-transform: uppercase;
90
+ border-radius: 2px !important;
91
+ }
92
+
93
+ .snap-run:hover {
94
+ filter: brightness(1.08);
95
+ }
96
+
97
+ .snap-status {
98
+ min-height: 42px;
99
+ color: var(--snap-amber);
100
+ font-size: 13px;
101
+ text-transform: uppercase;
102
+ }
103
+
104
+ .snap-html iframe,
105
+ .snap-html > div {
106
+ min-height: 560px;
107
+ }
108
+
109
+ .snap-scene-frame {
110
+ display: block;
111
+ width: 100%;
112
+ min-height: 560px;
113
+ border: 0;
114
+ background: #14181F;
115
+ }
116
+
117
+ .snap-json pre {
118
+ color: var(--snap-text) !important;
119
+ background: #10141A !important;
120
+ }
121
+
122
+ .snap-explain {
123
+ color: var(--snap-text);
124
+ line-height: 1.65;
125
+ }
126
+ """
127
+
128
+
129
+ def format_explanation(analysis: dict[str, Any]) -> str:
130
+ parts = analysis.get("parts", [])
131
+ part_lines = "\n".join(
132
+ f"- **{part.get('name', 'part')}**: {part.get('role', 'role unknown')}"
133
+ for part in parts
134
+ )
135
+ sequence = "\n".join(f"{index + 1}. {step}" for index, step in enumerate(analysis.get("motion_sequence", [])))
136
+ return (
137
+ f"### {analysis.get('component', 'Component')}\n\n"
138
+ f"{analysis.get('summary', '')}\n\n"
139
+ f"**Trigger:** {analysis.get('trigger', 'unknown')}\n\n"
140
+ f"**Motion sequence**\n{sequence}\n\n"
141
+ f"**Parts**\n{part_lines}"
142
+ )
143
+
144
+
145
+ def initial_status() -> str:
146
+ settings = Settings()
147
+ if settings.backend == "modal":
148
+ configured = bool(settings.analyze_url and settings.generate_url)
149
+ return "STANDBY: MODAL BACKEND CONFIGURED" if configured else "STANDBY: MODAL BACKEND MISSING URLS"
150
+ return "STANDBY: LOCAL PLACEHOLDER BACKEND"
151
+
152
+
153
+ def run_pipeline(image: Image.Image | None) -> tuple[str, str, str, str]:
154
+ client = InferenceClient(Settings())
155
+ try:
156
+ analysis = client.analyze_image(image)
157
+ html = client.generate_threejs(analysis)
158
+ status = "CUTAWAY READY"
159
+ except Exception as exc:
160
+ analysis = dict(EXAMPLE_ANALYSIS)
161
+ html = build_fallback_html(str(analysis.get("component", "mechanism")))
162
+ status = f"FALLBACK RENDER ACTIVE: {exc}"
163
+
164
+ return (
165
+ html,
166
+ format_explanation(analysis),
167
+ json.dumps(analysis, indent=2),
168
+ status,
169
+ )
170
+
171
+
172
+ def build_app() -> gr.Blocks:
173
+ with gr.Blocks(css=CSS, title="Inside the Machine") as demo:
174
+ with gr.Column(elem_classes=["snap-shell"]):
175
+ gr.HTML(
176
+ """
177
+ <link rel="preconnect" href="https://fonts.googleapis.com">
178
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
179
+ <link href="https://fonts.googleapis.com/css2?family=Archivo+Narrow:wght@500;700&family=IBM+Plex+Mono:wght@400;500;700&display=swap" rel="stylesheet">
180
+ <section class="snap-title">
181
+ <h1>Inside the Machine</h1>
182
+ <p>Upload a component photo. Snap2Sim drafts a mechanical teardown,
183
+ then renders an annotated cutaway animation for repair cafes,
184
+ shop classrooms, and curious builders.</p>
185
+ </section>
186
+ """
187
+ )
188
+
189
+ with gr.Row(equal_height=False):
190
+ with gr.Column(scale=5, elem_classes=["snap-frame"]):
191
+ scene = gr.HTML(
192
+ build_fallback_html("waiting for upload"),
193
+ label="Cutaway viewport",
194
+ elem_classes=["snap-html"],
195
+ )
196
+ with gr.Column(scale=3, elem_classes=["snap-frame"]):
197
+ image = gr.Image(type="pil", label="Component photo")
198
+ run = gr.Button("Analyze Assembly", elem_classes=["snap-run"])
199
+ status = gr.Textbox(
200
+ value=initial_status(),
201
+ label="Workshop status",
202
+ interactive=False,
203
+ elem_classes=["snap-status"],
204
+ )
205
+ explanation = gr.Markdown(
206
+ "Upload a photo and run the analyzer.",
207
+ elem_classes=["snap-explain"],
208
+ )
209
+
210
+ with gr.Accordion("Structured mechanism JSON", open=False):
211
+ raw_json = gr.Code(
212
+ value="{}",
213
+ language="json",
214
+ label="Vision-to-scene contract",
215
+ elem_classes=["snap-json"],
216
+ )
217
+
218
+ run.click(
219
+ fn=run_pipeline,
220
+ inputs=[image],
221
+ outputs=[scene, explanation, raw_json, status],
222
+ show_progress="full",
223
+ )
224
+
225
+ return demo