Spaces:
Running
Running
jasondo OpenAI Codex commited on
Commit ·
36f68cc
1
Parent(s): 4c22d01
Build Snap2Sim demo deployment scaffold
Browse filesCo-authored-by: OpenAI Codex <codex@openai.com>
- .env.example +9 -0
- .github/workflows/sync_to_hf.yml +60 -0
- .gitignore +13 -0
- .hfignore +22 -0
- AGENTS.md +145 -0
- GITHUB_PROMPT.md +23 -0
- PROMPT.md +185 -0
- README.md +207 -1
- SECURITY.md +98 -0
- app.py +11 -0
- modal_app.py +469 -0
- requirements.txt +5 -0
- scripts/verify_runtime_assets.py +53 -0
- snap2sim/__init__.py +2 -0
- snap2sim/backend.py +74 -0
- snap2sim/fallback_scene.py +72 -0
- snap2sim/model_io.py +257 -0
- snap2sim/prompts.py +64 -0
- snap2sim/schema.py +244 -0
- snap2sim/three_scene.py +397 -0
- snap2sim/ui.py +225 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|