jasondo OpenAI Codex commited on
Commit
51992f4
·
1 Parent(s): e91ca3c

Use real Modal analysis path

Browse files

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

Files changed (9) hide show
  1. .env.example +2 -1
  2. AGENTS.md +44 -0
  3. README.md +30 -19
  4. index.html +296 -6
  5. modal_app.py +77 -7
  6. snap2sim/backend.py +7 -5
  7. snap2sim/model_io.py +113 -17
  8. snap2sim/prompts.py +21 -9
  9. snap2sim/schema.py +121 -23
.env.example CHANGED
@@ -4,6 +4,7 @@
4
 
5
  INFERENCE_BACKEND=modal
6
  MODAL_ANALYZE_URL=
 
7
  MODAL_GENERATE_URL=
8
- INFERENCE_TIMEOUT_SECONDS=240
9
  SNAP2SIM_API_TOKEN=
 
4
 
5
  INFERENCE_BACKEND=modal
6
  MODAL_ANALYZE_URL=
7
+ # Optional compatibility value; scene descriptors are generated locally.
8
  MODAL_GENERATE_URL=
9
+ INFERENCE_TIMEOUT_SECONDS=300
10
  SNAP2SIM_API_TOKEN=
AGENTS.md CHANGED
@@ -209,9 +209,53 @@ technical cutaway animation.
209
  `/analyze_image` returned a validated mechanism payload through the secured
210
  Modal bearer-token flow, and `/generate_scene` returned
211
  `{ "renderer": "three", "analysis": ... }` with no HTML field.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  ## Next Work
214
 
 
 
 
 
 
 
 
215
  - Optional polish only after private deployment verification: component
216
  watermark, noise texture, vignette, then scan-line reveal refinements.
217
  - Keep the Hugging Face Space private until the user explicitly approves making
 
209
  `/analyze_image` returned a validated mechanism payload through the secured
210
  Modal bearer-token flow, and `/generate_scene` returned
211
  `{ "renderer": "three", "analysis": ... }` with no HTML field.
212
+ - Implemented the `REVIEW2.md` local code pass on June 14, 2026: fixed the
213
+ label-overlay pointer-events bug that blocked OrbitControls, added bounded
214
+ camera controls plus Reset view and orbit/zoom hint, changed the source-photo
215
+ preview to a stable contained image box, added optional normalized per-part
216
+ `annotation` fields, added `render_mode` scene descriptors, and added a
217
+ browser-side annotated-photo fallback plus an honest unavailable state.
218
+ - Updated model prompting/parsing for longer reasoning: prompts may allow
219
+ reasoning before the final JSON, parser strips `<think>` blocks and prefers
220
+ the last complete JSON object, and llama.cpp analysis now uses larger
221
+ token/context/timeout budgets (`4096` output tokens, `8192` context, `300s`
222
+ timeout at the analysis call site).
223
+ - Local verification after the `REVIEW2.md` pass: schema/parser checks passed,
224
+ FastAPI `TestClient` confirmed `/`, `/analyze_image`, and `/generate_scene`,
225
+ and browser verification confirmed left-drag orbit, right-drag pan, wheel
226
+ zoom, mobile canvas event targeting, contained source preview, annotated-photo
227
+ fallback, and unavailable retry state.
228
+ - Modal reasoning-budget follow-up on June 14, 2026: an initial
229
+ `run_analysis_endpoint_check` returned a generic fallback, so a local-only
230
+ `run_analysis_raw_check` diagnostic was added. The raw diagnostic showed the
231
+ model used schema-adjacent fields (`radius`, `height`, string axes). The
232
+ prompt now explicitly requires `size` vectors and numeric axis vectors, and
233
+ coercion preserves common radius/height/length/axis-string mistakes instead
234
+ of accepting nested JSON fragments as generic fallbacks. After the fix,
235
+ `run_analysis_raw_check` parsed strict JSON successfully in `35.12s`, and
236
+ `run_analysis_endpoint_check` returned a parsed per-image `targeting reticle`
237
+ payload at `0.7` confidence.
238
+ - User decision after the `REVIEW2.md` pass: use the real Modal
239
+ `analyze_image_llamacpp` analysis path for the demo. `INFERENCE_BACKEND` now
240
+ defaults to `modal`; `INFERENCE_BACKEND=local` is an explicit UI-development
241
+ sample mode only and should not be used for public submission.
242
+ - Deployed the updated Modal app after the real-analysis decision on June 14,
243
+ 2026. The stable deployment exposes `analyze_image_llamacpp` with the latest
244
+ prompt/parsing/coercion fixes. A lightweight deployed `runtime_probe` request
245
+ without credentials returned `401 Unauthorized`. Authenticated deployed web
246
+ verification could not be run from the local shell because
247
+ `SNAP2SIM_API_TOKEN` was not set locally; use the Hugging Face Space secret or
248
+ a local secret store for the normal authenticated check.
249
 
250
  ## Next Work
251
 
252
+ - Deploy/sync the latest GitHub changes through the normal GitHub-to-Hugging
253
+ Face workflow, then re-run authenticated private Space verification. Do not
254
+ edit files directly on the Hugging Face Space.
255
+ - Measure the larger llama.cpp reasoning budget with representative real photos;
256
+ tune token/context/timeout values down if valid JSON remains reliable with
257
+ lower latency. The synthetic Modal check now succeeds with the current
258
+ `4096` output token, `8192` context, `300s` timeout settings.
259
  - Optional polish only after private deployment verification: component
260
  watermark, noise texture, vignette, then scan-line reveal refinements.
261
  - Keep the Hugging Face Space private until the user explicitly approves making
README.md CHANGED
@@ -18,9 +18,10 @@ structured mechanism analysis, and renders an animated technical cutaway
18
  visualization.
19
 
20
  The local app serves a trusted `index.html` shell through `gradio.Server` and
21
- exposes `/analyze_image` plus a compatibility `/generate_scene` route. It still
22
- defaults to the placeholder backend locally, while the Modal llama.cpp analysis
23
- path has been smoke-tested with the selected Nemotron GGUF and projector.
 
24
 
25
  ## Run Locally
26
 
@@ -29,9 +30,14 @@ pip install -r requirements.txt
29
  python app.py
30
  ```
31
 
32
- Set `INFERENCE_BACKEND=modal`, `MODAL_ANALYZE_URL`, and `MODAL_GENERATE_URL`
33
- to point the Gradio app at deployed Modal `/analyze_image` and
34
- `/generate_scene` endpoints.
 
 
 
 
 
35
 
36
  For the deployed demo configuration, copy `.env.example` into local environment
37
  variables or Hugging Face Space variables. Do not put tokens in `.env.example`;
@@ -117,10 +123,13 @@ Runtime flow:
117
 
118
  1. Browser encodes the uploaded photo and posts it to `/analyze_image`.
119
  2. Backend returns the validated mechanism JSON.
120
- 3. Browser renders the cutaway directly from that JSON with deterministic
121
- Three.js primitives.
122
- 4. `/generate_scene` remains as a compatibility endpoint, but it returns a
123
- validated `{ "renderer": "three", "analysis": ... }` scene descriptor
 
 
 
124
  instead of model-authored HTML.
125
 
126
  The shell uses Chakra Petch and Fira Code from Bunny Fonts, an asymmetric
@@ -133,8 +142,8 @@ job is limited to the structured analysis JSON contract in `snap2sim/schema.py`.
133
 
134
  ## Modal Deployment Path
135
 
136
- The Modal app defaults to placeholder inference so endpoint wiring can be tested
137
- without loading a 30B model:
138
 
139
  ```powershell
140
  python -m modal deploy modal_app.py
@@ -160,8 +169,9 @@ Useful deployment functions/endpoints:
160
  - `run_analysis_endpoint_check` calls the real llama.cpp analysis task with a
161
  synthetic image and confirms it returns a validated mechanism payload.
162
  - `runtime_probe` reports the configured model repo, quant, projector file, and
163
- whether placeholder inference is still active.
164
- - `analyze_image` preserves the HTTP contract for the Gradio app.
 
165
  - `generate_scene` and `generate_scene_llamacpp` are compatibility endpoints
166
  that return the deterministic Three.js scene descriptor.
167
  - `analyze_image_llamacpp` is the experimental GPU endpoint for the llama.cpp
@@ -177,9 +187,10 @@ Runtime environment knobs:
177
  - `SNAP2SIM_SMOKE_GPU`, default `L40S`
178
  - `SNAP2SIM_RUNTIME_GPU`, default `L40S`
179
 
180
- Keep `SNAP2SIM_RUNTIME_MODE=placeholder` for the public demo unless you point
181
- `MODAL_ANALYZE_URL` at the validated `analyze_image_llamacpp` endpoint. The
182
- browser renders scenes deterministically from the validated analysis JSON.
 
183
 
184
  Run the deployment preflight in this order:
185
 
@@ -215,8 +226,8 @@ The Space is configured as a Gradio SDK app with these variables:
215
  ```text
216
  INFERENCE_BACKEND=modal
217
  MODAL_ANALYZE_URL=<stored in Hugging Face Space variables>
218
- MODAL_GENERATE_URL=<stored in Hugging Face Space variables>
219
- INFERENCE_TIMEOUT_SECONDS=240
220
  ```
221
 
222
  The Space also needs `SNAP2SIM_API_TOKEN` as a Hugging Face Space secret. The
 
18
  visualization.
19
 
20
  The local app serves a trusted `index.html` shell through `gradio.Server` and
21
+ exposes `/analyze_image` plus a compatibility `/generate_scene` route. The demo
22
+ path uses the real Modal llama.cpp analysis endpoint by default; the fixed local
23
+ sample backend is available only when `INFERENCE_BACKEND=local` is explicitly
24
+ set for development.
25
 
26
  ## Run Locally
27
 
 
30
  python app.py
31
  ```
32
 
33
+ Set `INFERENCE_BACKEND=modal`, `MODAL_ANALYZE_URL`, and `SNAP2SIM_API_TOKEN`
34
+ to point the Gradio app at the deployed Modal `analyze_image_llamacpp`
35
+ endpoint. `/generate_scene` is deterministic in the Space process and returns a
36
+ validated scene descriptor.
37
+
38
+ For quick UI-only development without Modal, explicitly set
39
+ `INFERENCE_BACKEND=local`. That mode returns a sample mechanism and should not
40
+ be used for the public demo.
41
 
42
  For the deployed demo configuration, copy `.env.example` into local environment
43
  variables or Hugging Face Space variables. Do not put tokens in `.env.example`;
 
123
 
124
  1. Browser encodes the uploaded photo and posts it to `/analyze_image`.
125
  2. Backend returns the validated mechanism JSON.
126
+ 3. Browser posts the analysis to `/generate_scene` for a validated scene
127
+ descriptor.
128
+ 4. Browser renders deterministic Three.js primitives when geometry is usable,
129
+ or overlays text-only callouts on the uploaded photo when the model only has
130
+ image-space annotations.
131
+ 5. `/generate_scene` returns a validated
132
+ `{ "renderer": "...", "render_mode": "...", "analysis": ... }` descriptor
133
  instead of model-authored HTML.
134
 
135
  The shell uses Chakra Petch and Fira Code from Bunny Fonts, an asymmetric
 
142
 
143
  ## Modal Deployment Path
144
 
145
+ Deploy the Modal app before wiring a public demo so the llama.cpp endpoint uses
146
+ the current prompt, parsing, and coercion code:
147
 
148
  ```powershell
149
  python -m modal deploy modal_app.py
 
169
  - `run_analysis_endpoint_check` calls the real llama.cpp analysis task with a
170
  synthetic image and confirms it returns a validated mechanism payload.
171
  - `runtime_probe` reports the configured model repo, quant, projector file, and
172
+ whether placeholder inference is active on the compatibility endpoint.
173
+ - `analyze_image` preserves the HTTP contract for old wiring, but the demo
174
+ should use `analyze_image_llamacpp`.
175
  - `generate_scene` and `generate_scene_llamacpp` are compatibility endpoints
176
  that return the deterministic Three.js scene descriptor.
177
  - `analyze_image_llamacpp` is the experimental GPU endpoint for the llama.cpp
 
187
  - `SNAP2SIM_SMOKE_GPU`, default `L40S`
188
  - `SNAP2SIM_RUNTIME_GPU`, default `L40S`
189
 
190
+ For the public demo, point `MODAL_ANALYZE_URL` at the validated
191
+ `analyze_image_llamacpp` endpoint. `SNAP2SIM_RUNTIME_MODE=placeholder` can stay
192
+ set because only the legacy `analyze_image` compatibility endpoint reads it.
193
+ The browser renders scenes deterministically from the validated analysis JSON.
194
 
195
  Run the deployment preflight in this order:
196
 
 
226
  ```text
227
  INFERENCE_BACKEND=modal
228
  MODAL_ANALYZE_URL=<stored in Hugging Face Space variables>
229
+ MODAL_GENERATE_URL=<optional compatibility value>
230
+ INFERENCE_TIMEOUT_SECONDS=300
231
  ```
232
 
233
  The Space also needs `SNAP2SIM_API_TOKEN` as a Hugging Face Space secret. The
index.html CHANGED
@@ -166,6 +166,7 @@
166
  top: 18px;
167
  z-index: 10;
168
  display: flex;
 
169
  gap: 8px;
170
  }
171
 
@@ -193,6 +194,25 @@
193
  cursor: default;
194
  }
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  .panel {
197
  min-width: 0;
198
  overflow: auto;
@@ -274,8 +294,8 @@
274
 
275
  .source-image {
276
  width: 100%;
277
- max-height: 150px;
278
- object-fit: cover;
279
  border: 1px solid rgba(122, 84, 32, 0.72);
280
  background: var(--bg);
281
  }
@@ -374,6 +394,64 @@
374
  pointer-events: none;
375
  }
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  @keyframes loading {
378
  from { transform: translateX(-100%); }
379
  to { transform: translateX(300%); }
@@ -401,6 +479,10 @@
401
  border-left: 0;
402
  border-top: 1px solid var(--amber-dim);
403
  }
 
 
 
 
404
  }
405
  </style>
406
  </head>
@@ -411,8 +493,10 @@
411
  <div class="toolbar">
412
  <button id="uploadButton" class="tool-button" type="button">Load</button>
413
  <button id="playButton" class="tool-button" type="button" disabled>Play</button>
 
414
  </div>
415
  <div id="viewport" aria-hidden="true"></div>
 
416
  <label id="dropZone" class="drop-zone" for="fileInput" tabindex="0">Drop component photo</label>
417
  <div id="scanLine" class="scan-line"></div>
418
  <input id="fileInput" type="file" accept="image/*" capture="environment" hidden>
@@ -461,8 +545,10 @@
461
  const fileInput = document.getElementById("fileInput");
462
  const uploadButton = document.getElementById("uploadButton");
463
  const playButton = document.getElementById("playButton");
 
464
  const dropZone = document.getElementById("dropZone");
465
  const viewport = document.getElementById("viewport");
 
466
  const progress = document.getElementById("progress");
467
  const scanLine = document.getElementById("scanLine");
468
  const statusEl = document.getElementById("status");
@@ -522,6 +608,9 @@
522
  playButton.textContent = paused ? "Resume" : "Pause";
523
  if (fallbackRuntime) fallbackRuntime.playing = !paused;
524
  });
 
 
 
525
 
526
  async function runPipeline(file) {
527
  const validationError = validateFile(file);
@@ -545,13 +634,13 @@
545
  populateAnalysis(analysis);
546
 
547
  setStatus("RENDERING CUTAWAY...");
548
- buildDeterministicScene(analysis);
 
549
  } catch (error) {
550
  window.clearTimeout(coldStartTimer);
551
  setStatus(error.message || String(error), true);
552
  retryButton.hidden = false;
553
  dropZone.classList.remove("hidden");
554
- if (window.lastAnalysis) buildDeterministicScene(window.lastAnalysis);
555
  } finally {
556
  setBusy(false);
557
  }
@@ -564,6 +653,8 @@
564
  paused = false;
565
  playButton.textContent = "Play";
566
  playButton.disabled = true;
 
 
567
  retryButton.hidden = true;
568
  viewport.replaceChildren();
569
  dropZone.classList.add("hidden");
@@ -594,6 +685,55 @@
594
  }));
595
  }
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  function buildDeterministicScene(analysis) {
598
  const threeRuntime = window.THREE;
599
  if (!threeRuntime) {
@@ -607,6 +747,8 @@
607
  paused = false;
608
  playButton.textContent = "Pause";
609
  playButton.disabled = false;
 
 
610
  viewport.replaceChildren();
611
  const stage = document.createElement("div");
612
  stage.className = "fallback-stage";
@@ -619,20 +761,34 @@
619
 
620
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
621
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
 
622
  mount.appendChild(renderer.domElement);
623
 
624
  const scene = new THREE.Scene();
625
  scene.fog = new THREE.Fog(0x0f1318, 8, 20);
626
 
627
  const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
628
- camera.position.set(5.4, 3.9, 6.2);
 
 
629
 
630
  const controls = window.THREE.OrbitControls
631
  ? new THREE.OrbitControls(camera, renderer.domElement)
632
  : null;
633
  if (controls) {
634
  controls.enableDamping = true;
635
- controls.target.set(0, 0.1, 0);
 
 
 
 
 
 
 
 
 
 
 
636
  }
637
 
638
  scene.add(new THREE.HemisphereLight(0x5fd4d0, 0x0f1318, 1.2));
@@ -665,6 +821,13 @@
665
  let disposed = false;
666
  fallbackRuntime = {
667
  playing: true,
 
 
 
 
 
 
 
668
  cleanup() {
669
  disposed = true;
670
  cancelAnimationFrame(frameId);
@@ -705,6 +868,133 @@
705
  setStatus("CUTAWAY READY");
706
  }
707
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  function validateFile(file) {
709
  if (!file.type || !file.type.startsWith("image/")) {
710
  return "Upload an image file.";
 
166
  top: 18px;
167
  z-index: 10;
168
  display: flex;
169
+ flex-wrap: wrap;
170
  gap: 8px;
171
  }
172
 
 
194
  cursor: default;
195
  }
196
 
197
+ .viewport-hint {
198
+ position: absolute;
199
+ left: 18px;
200
+ bottom: 18px;
201
+ z-index: 10;
202
+ max-width: min(320px, calc(100% - 36px));
203
+ border-left: 1px solid var(--cyan-dim);
204
+ padding: 6px 0 6px 10px;
205
+ color: var(--text-muted);
206
+ background: rgba(15, 19, 24, 0.78);
207
+ font: 0.78rem/1.3 "Fira Code", monospace;
208
+ text-transform: uppercase;
209
+ pointer-events: none;
210
+ }
211
+
212
+ .viewport-hint[hidden] {
213
+ display: none;
214
+ }
215
+
216
  .panel {
217
  min-width: 0;
218
  overflow: auto;
 
294
 
295
  .source-image {
296
  width: 100%;
297
+ height: 180px;
298
+ object-fit: contain;
299
  border: 1px solid rgba(122, 84, 32, 0.72);
300
  background: var(--bg);
301
  }
 
394
  pointer-events: none;
395
  }
396
 
397
+ .label-layer {
398
+ pointer-events: none;
399
+ }
400
+
401
+ .annotated-stage {
402
+ display: grid;
403
+ place-items: center;
404
+ padding: 58px 18px 44px;
405
+ background: rgba(15, 19, 24, 0.72);
406
+ }
407
+
408
+ .annotation-frame {
409
+ position: relative;
410
+ width: 100%;
411
+ height: 100%;
412
+ display: grid;
413
+ place-items: center;
414
+ }
415
+
416
+ .annotation-image {
417
+ width: 100%;
418
+ height: 100%;
419
+ object-fit: contain;
420
+ border: 1px solid rgba(122, 84, 32, 0.72);
421
+ background: var(--bg);
422
+ }
423
+
424
+ .photo-label {
425
+ max-width: min(220px, 42vw);
426
+ white-space: normal;
427
+ text-transform: none;
428
+ }
429
+
430
+ .label-title {
431
+ display: block;
432
+ color: var(--cyan);
433
+ text-transform: uppercase;
434
+ }
435
+
436
+ .label-note {
437
+ display: block;
438
+ color: var(--text);
439
+ margin-top: 2px;
440
+ }
441
+
442
+ .empty-state {
443
+ position: absolute;
444
+ inset: 18px;
445
+ display: grid;
446
+ place-items: center;
447
+ border: 1px dashed var(--danger);
448
+ color: var(--danger);
449
+ background: rgba(15, 19, 24, 0.72);
450
+ text-align: center;
451
+ text-transform: uppercase;
452
+ font-weight: 600;
453
+ }
454
+
455
  @keyframes loading {
456
  from { transform: translateX(-100%); }
457
  to { transform: translateX(300%); }
 
479
  border-left: 0;
480
  border-top: 1px solid var(--amber-dim);
481
  }
482
+
483
+ .source-image {
484
+ height: 220px;
485
+ }
486
  }
487
  </style>
488
  </head>
 
493
  <div class="toolbar">
494
  <button id="uploadButton" class="tool-button" type="button">Load</button>
495
  <button id="playButton" class="tool-button" type="button" disabled>Play</button>
496
+ <button id="resetViewButton" class="tool-button" type="button" disabled>Reset view</button>
497
  </div>
498
  <div id="viewport" aria-hidden="true"></div>
499
+ <div id="viewportHint" class="viewport-hint" hidden>Drag to orbit / scroll to zoom</div>
500
  <label id="dropZone" class="drop-zone" for="fileInput" tabindex="0">Drop component photo</label>
501
  <div id="scanLine" class="scan-line"></div>
502
  <input id="fileInput" type="file" accept="image/*" capture="environment" hidden>
 
545
  const fileInput = document.getElementById("fileInput");
546
  const uploadButton = document.getElementById("uploadButton");
547
  const playButton = document.getElementById("playButton");
548
+ const resetViewButton = document.getElementById("resetViewButton");
549
  const dropZone = document.getElementById("dropZone");
550
  const viewport = document.getElementById("viewport");
551
+ const viewportHint = document.getElementById("viewportHint");
552
  const progress = document.getElementById("progress");
553
  const scanLine = document.getElementById("scanLine");
554
  const statusEl = document.getElementById("status");
 
608
  playButton.textContent = paused ? "Resume" : "Pause";
609
  if (fallbackRuntime) fallbackRuntime.playing = !paused;
610
  });
611
+ resetViewButton.addEventListener("click", () => {
612
+ if (fallbackRuntime && fallbackRuntime.resetView) fallbackRuntime.resetView();
613
+ });
614
 
615
  async function runPipeline(file) {
616
  const validationError = validateFile(file);
 
634
  populateAnalysis(analysis);
635
 
636
  setStatus("RENDERING CUTAWAY...");
637
+ const scenePayload = await postJson("/generate_scene", { analysis });
638
+ renderScenePayload(scenePayload);
639
  } catch (error) {
640
  window.clearTimeout(coldStartTimer);
641
  setStatus(error.message || String(error), true);
642
  retryButton.hidden = false;
643
  dropZone.classList.remove("hidden");
 
644
  } finally {
645
  setBusy(false);
646
  }
 
653
  paused = false;
654
  playButton.textContent = "Play";
655
  playButton.disabled = true;
656
+ resetViewButton.disabled = true;
657
+ viewportHint.hidden = true;
658
  retryButton.hidden = true;
659
  viewport.replaceChildren();
660
  dropZone.classList.add("hidden");
 
685
  }));
686
  }
687
 
688
+ function renderScenePayload(payload) {
689
+ const analysis = payload && payload.analysis ? payload.analysis : payload;
690
+ const renderMode = payload && payload.render_mode ? payload.render_mode : chooseRenderMode(analysis);
691
+ if (renderMode === "three" && hasUsableGeometry(analysis)) {
692
+ buildDeterministicScene(analysis);
693
+ } else if (renderMode === "annotate" && hasAnnotations(analysis)) {
694
+ buildAnnotatedPhoto(analysis);
695
+ } else {
696
+ buildUnavailableScene();
697
+ }
698
+ }
699
+
700
+ function chooseRenderMode(analysis) {
701
+ const confidence = typeof analysis.confidence === "number" ? analysis.confidence : 1;
702
+ if (hasAnnotations(analysis) && (confidence < 0.5 || !hasUsableGeometry(analysis))) {
703
+ return "annotate";
704
+ }
705
+ if (hasUsableGeometry(analysis) && confidence >= 0.5) {
706
+ return "three";
707
+ }
708
+ if (hasAnnotations(analysis)) {
709
+ return "annotate";
710
+ }
711
+ return "unavailable";
712
+ }
713
+
714
+ function hasUsableGeometry(analysis) {
715
+ return Boolean(analysis && Array.isArray(analysis.parts) && analysis.parts.some((part) => {
716
+ const geometry = part && part.geometry;
717
+ return geometry
718
+ && ["box", "cylinder", "sphere", "gear", "rod"].includes(geometry.shape)
719
+ && isNumberList(geometry.size, 3)
720
+ && isNumberList(geometry.position, 3);
721
+ }));
722
+ }
723
+
724
+ function hasAnnotations(analysis) {
725
+ return Boolean(analysis && Array.isArray(analysis.parts) && analysis.parts.some((part) => {
726
+ const point = part && part.annotation && part.annotation.point;
727
+ return isNumberList(point, 2);
728
+ }));
729
+ }
730
+
731
+ function isNumberList(value, length) {
732
+ return Array.isArray(value)
733
+ && value.length === length
734
+ && value.every((item) => typeof item === "number" && Number.isFinite(item));
735
+ }
736
+
737
  function buildDeterministicScene(analysis) {
738
  const threeRuntime = window.THREE;
739
  if (!threeRuntime) {
 
747
  paused = false;
748
  playButton.textContent = "Pause";
749
  playButton.disabled = false;
750
+ resetViewButton.disabled = false;
751
+ viewportHint.hidden = false;
752
  viewport.replaceChildren();
753
  const stage = document.createElement("div");
754
  stage.className = "fallback-stage";
 
761
 
762
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
763
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
764
+ renderer.domElement.style.touchAction = "none";
765
  mount.appendChild(renderer.domElement);
766
 
767
  const scene = new THREE.Scene();
768
  scene.fog = new THREE.Fog(0x0f1318, 8, 20);
769
 
770
  const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
771
+ const defaultCameraPosition = new THREE.Vector3(5.4, 3.9, 6.2);
772
+ const defaultTarget = new THREE.Vector3(0, 0.1, 0);
773
+ camera.position.copy(defaultCameraPosition);
774
 
775
  const controls = window.THREE.OrbitControls
776
  ? new THREE.OrbitControls(camera, renderer.domElement)
777
  : null;
778
  if (controls) {
779
  controls.enableDamping = true;
780
+ controls.minDistance = 2.4;
781
+ controls.maxDistance = 12;
782
+ controls.maxPolarAngle = Math.PI * 0.48;
783
+ controls.target.copy(defaultTarget);
784
+ if (window.THREE.TOUCH) {
785
+ controls.touches = {
786
+ ONE: THREE.TOUCH.ROTATE,
787
+ TWO: THREE.TOUCH.DOLLY_PAN
788
+ };
789
+ }
790
+ controls.update();
791
+ controls.saveState();
792
  }
793
 
794
  scene.add(new THREE.HemisphereLight(0x5fd4d0, 0x0f1318, 1.2));
 
821
  let disposed = false;
822
  fallbackRuntime = {
823
  playing: true,
824
+ resetView() {
825
+ camera.position.copy(defaultCameraPosition);
826
+ if (controls) {
827
+ controls.target.copy(defaultTarget);
828
+ controls.update();
829
+ }
830
+ },
831
  cleanup() {
832
  disposed = true;
833
  cancelAnimationFrame(frameId);
 
868
  setStatus("CUTAWAY READY");
869
  }
870
 
871
+ function buildAnnotatedPhoto(analysis) {
872
+ if (!currentPreviewUrl) {
873
+ buildUnavailableScene();
874
+ return;
875
+ }
876
+ if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
877
+ activeMode = "annotate";
878
+ paused = true;
879
+ playButton.textContent = "Play";
880
+ playButton.disabled = true;
881
+ resetViewButton.disabled = true;
882
+ viewportHint.hidden = true;
883
+ viewport.replaceChildren();
884
+
885
+ const stage = document.createElement("div");
886
+ stage.className = "fallback-stage annotated-stage";
887
+ const frame = document.createElement("div");
888
+ frame.className = "annotation-frame";
889
+ const image = document.createElement("img");
890
+ image.className = "annotation-image";
891
+ image.alt = "";
892
+ image.src = currentPreviewUrl;
893
+ const labelLayer = document.createElement("div");
894
+ labelLayer.className = "label-layer";
895
+ frame.append(image, labelLayer);
896
+ stage.append(frame);
897
+ viewport.append(stage);
898
+
899
+ let disposed = false;
900
+ const annotatedParts = (analysis.parts || []).filter((part) => {
901
+ const point = part && part.annotation && part.annotation.point;
902
+ return isNumberList(point, 2);
903
+ });
904
+
905
+ function updatePhotoLabels() {
906
+ if (disposed) return;
907
+ const imageRect = containedImageRect(image);
908
+ const layerRect = labelLayer.getBoundingClientRect();
909
+ labelLayer.replaceChildren(...annotatedParts.map((part, index) => {
910
+ const annotation = part.annotation || {};
911
+ const point = annotation.point || [0.5, 0.5];
912
+ const label = document.createElement("div");
913
+ label.className = "scene-label photo-label";
914
+ label.style.left = Math.min(
915
+ layerRect.width - 72,
916
+ Math.max(72, imageRect.left - layerRect.left + clamp01(point[0]) * imageRect.width)
917
+ ) + "px";
918
+ label.style.top = Math.min(
919
+ layerRect.height - 36,
920
+ Math.max(56, imageRect.top - layerRect.top + clamp01(point[1]) * imageRect.height)
921
+ ) + "px";
922
+
923
+ const title = document.createElement("span");
924
+ title.className = "label-title";
925
+ title.textContent = (index + 1) + ". " + (annotation.label || part.name || part.id || "part");
926
+ label.append(title);
927
+ const noteText = annotation.note || part.role || "";
928
+ if (noteText) {
929
+ const note = document.createElement("span");
930
+ note.className = "label-note";
931
+ note.textContent = noteText;
932
+ label.append(note);
933
+ }
934
+ return label;
935
+ }));
936
+ }
937
+
938
+ image.addEventListener("load", updatePhotoLabels, { once: true });
939
+ window.addEventListener("resize", updatePhotoLabels);
940
+ fallbackRuntime = {
941
+ playing: false,
942
+ cleanup() {
943
+ disposed = true;
944
+ window.removeEventListener("resize", updatePhotoLabels);
945
+ }
946
+ };
947
+ window.requestAnimationFrame(updatePhotoLabels);
948
+ revealScan();
949
+ setStatus("ANNOTATED PHOTO READY");
950
+ }
951
+
952
+ function buildUnavailableScene() {
953
+ if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
954
+ fallbackRuntime = null;
955
+ activeMode = "unavailable";
956
+ paused = true;
957
+ playButton.textContent = "Play";
958
+ playButton.disabled = true;
959
+ resetViewButton.disabled = true;
960
+ viewportHint.hidden = true;
961
+ viewport.replaceChildren();
962
+ const empty = document.createElement("div");
963
+ empty.className = "empty-state";
964
+ empty.textContent = "Could not analyze this photo. Try another angle.";
965
+ viewport.append(empty);
966
+ retryButton.hidden = false;
967
+ setStatus("COULD NOT ANALYZE", true);
968
+ }
969
+
970
+ function containedImageRect(image) {
971
+ const rect = image.getBoundingClientRect();
972
+ const naturalWidth = image.naturalWidth || rect.width || 1;
973
+ const naturalHeight = image.naturalHeight || rect.height || 1;
974
+ const rectAspect = rect.width / Math.max(1, rect.height);
975
+ const imageAspect = naturalWidth / Math.max(1, naturalHeight);
976
+ if (rectAspect > imageAspect) {
977
+ const width = rect.height * imageAspect;
978
+ return {
979
+ left: rect.left + (rect.width - width) / 2,
980
+ top: rect.top,
981
+ width,
982
+ height: rect.height
983
+ };
984
+ }
985
+ const height = rect.width / imageAspect;
986
+ return {
987
+ left: rect.left,
988
+ top: rect.top + (rect.height - height) / 2,
989
+ width: rect.width,
990
+ height
991
+ };
992
+ }
993
+
994
+ function clamp01(value) {
995
+ return Math.max(0, Math.min(1, Number(value) || 0));
996
+ }
997
+
998
  function validateFile(file) {
999
  if (!file.type || !file.type.startsWith("image/")) {
1000
  return "Upload an image file.";
modal_app.py CHANGED
@@ -18,7 +18,7 @@ from fastapi import Header, HTTPException
18
 
19
  from snap2sim.model_io import coerce_analysis_response, parse_analysis_response
20
  from snap2sim.prompts import build_vision_prompt
21
- from snap2sim.schema import EXAMPLE_ANALYSIS, validate_analysis
22
 
23
 
24
  DEFAULT_MODEL_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF"
@@ -244,6 +244,8 @@ def smoke_test_llamacpp_image() -> dict[str, Any]:
244
  prompt,
245
  "-n",
246
  "1024",
 
 
247
  "--temp",
248
  "0.2",
249
  ]
@@ -289,8 +291,9 @@ def smoke_test_llamacpp_image() -> dict[str, Any]:
289
  def run_llamacpp_prompt(
290
  prompt: str,
291
  image_path: Path | None = None,
292
- max_tokens: int = 1024,
293
- timeout_seconds: int = 180,
 
294
  ) -> str:
295
  """Run one prompt through the llama.cpp multimodal CLI."""
296
  import subprocess
@@ -306,6 +309,8 @@ def run_llamacpp_prompt(
306
  prompt,
307
  "-n",
308
  str(max_tokens),
 
 
309
  "--temp",
310
  "0.2",
311
  ]
@@ -383,8 +388,9 @@ def analyze_image_llamacpp_payload(payload: dict[str, Any]) -> dict[str, Any]:
383
  response = run_llamacpp_prompt(
384
  build_vision_prompt(),
385
  image_path=image_path,
386
- max_tokens=1536,
387
- timeout_seconds=180,
 
388
  )
389
  try:
390
  return parse_analysis_response(response)
@@ -432,6 +438,28 @@ def run_analysis_endpoint_check() -> None:
432
  print(json.dumps(result, indent=2))
433
 
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  @app.function(image=image, timeout=120, secrets=[api_auth_secret])
436
  @modal.fastapi_endpoint(method="GET")
437
  def runtime_probe(authorization: str = Header(default="")) -> dict[str, Any]:
@@ -473,6 +501,44 @@ def analyze_image_llamacpp_task(payload: dict[str, Any]) -> dict[str, Any]:
473
  return analyze_image_llamacpp_payload(payload)
474
 
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
477
  @modal.fastapi_endpoint(method="POST")
478
  def analyze_image_llamacpp(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
@@ -490,7 +556,9 @@ def generate_scene(payload: dict[str, Any], authorization: str = Header(default=
490
  raise NotImplementedError(
491
  "Scene generation is deterministic in the browser from validated JSON."
492
  )
493
- return {"renderer": "three", "analysis": analysis}
 
 
494
 
495
 
496
  @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
@@ -499,4 +567,6 @@ def generate_scene_llamacpp(payload: dict[str, Any], authorization: str = Header
499
  """Compatibility endpoint; scene rendering is deterministic browser-side."""
500
  require_authorization(authorization)
501
  analysis = validate_analysis(payload.get("analysis") or EXAMPLE_ANALYSIS)
502
- return {"renderer": "three", "analysis": analysis}
 
 
 
18
 
19
  from snap2sim.model_io import coerce_analysis_response, parse_analysis_response
20
  from snap2sim.prompts import build_vision_prompt
21
+ from snap2sim.schema import EXAMPLE_ANALYSIS, select_render_mode, validate_analysis
22
 
23
 
24
  DEFAULT_MODEL_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF"
 
244
  prompt,
245
  "-n",
246
  "1024",
247
+ "-c",
248
+ "4096",
249
  "--temp",
250
  "0.2",
251
  ]
 
291
  def run_llamacpp_prompt(
292
  prompt: str,
293
  image_path: Path | None = None,
294
+ max_tokens: int = 3072,
295
+ timeout_seconds: int = 300,
296
+ ctx_size: int = 8192,
297
  ) -> str:
298
  """Run one prompt through the llama.cpp multimodal CLI."""
299
  import subprocess
 
309
  prompt,
310
  "-n",
311
  str(max_tokens),
312
+ "-c",
313
+ str(ctx_size),
314
  "--temp",
315
  "0.2",
316
  ]
 
388
  response = run_llamacpp_prompt(
389
  build_vision_prompt(),
390
  image_path=image_path,
391
+ max_tokens=4096,
392
+ timeout_seconds=300,
393
+ ctx_size=8192,
394
  )
395
  try:
396
  return parse_analysis_response(response)
 
438
  print(json.dumps(result, indent=2))
439
 
440
 
441
+ @app.local_entrypoint()
442
+ def run_analysis_raw_check() -> None:
443
+ """Print raw llama.cpp analysis output diagnostics for prompt tuning."""
444
+ import base64
445
+ import json
446
+ from io import BytesIO
447
+
448
+ from PIL import Image, ImageDraw
449
+
450
+ img = Image.new("RGB", (512, 384), "#d8d0bd")
451
+ draw = ImageDraw.Draw(img)
452
+ draw.rectangle((82, 96, 430, 288), outline="#2c3138", width=8)
453
+ draw.ellipse((178, 112, 334, 268), outline="#b06c23", width=14)
454
+ draw.line((256, 112, 256, 268), fill="#2c3138", width=6)
455
+ draw.line((178, 190, 334, 190), fill="#2c3138", width=6)
456
+ buffer = BytesIO()
457
+ img.save(buffer, format="JPEG", quality=92)
458
+ payload = {"image_base64": base64.b64encode(buffer.getvalue()).decode("ascii")}
459
+ result = analyze_image_llamacpp_raw_task.remote(payload)
460
+ print(json.dumps(result, indent=2))
461
+
462
+
463
  @app.function(image=image, timeout=120, secrets=[api_auth_secret])
464
  @modal.fastapi_endpoint(method="GET")
465
  def runtime_probe(authorization: str = Header(default="")) -> dict[str, Any]:
 
501
  return analyze_image_llamacpp_payload(payload)
502
 
503
 
504
+ @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60)
505
+ def analyze_image_llamacpp_raw_task(payload: dict[str, Any]) -> dict[str, Any]:
506
+ """Remote-callable diagnostic task for prompt and budget tuning."""
507
+ import time
508
+
509
+ image_path = write_payload_image(payload)
510
+ start = time.monotonic()
511
+ try:
512
+ response = run_llamacpp_prompt(
513
+ build_vision_prompt(),
514
+ image_path=image_path,
515
+ max_tokens=4096,
516
+ timeout_seconds=300,
517
+ ctx_size=8192,
518
+ )
519
+ finally:
520
+ image_path.unlink(missing_ok=True)
521
+
522
+ elapsed_seconds = round(time.monotonic() - start, 2)
523
+ parse_error = ""
524
+ parsed: dict[str, Any] | None = None
525
+ try:
526
+ parsed = parse_analysis_response(response)
527
+ except Exception as exc:
528
+ parse_error = str(exc)
529
+ coerced = coerce_analysis_response(response)
530
+ return {
531
+ "elapsed_seconds": elapsed_seconds,
532
+ "parse_ok": parsed is not None,
533
+ "parse_error": parse_error,
534
+ "parsed_component": parsed["component"] if parsed else "",
535
+ "coerced_component": coerced["component"],
536
+ "coerced_render_mode": select_render_mode(coerced),
537
+ "coerced_confidence": coerced.get("confidence"),
538
+ "stdout_tail": response[-4000:],
539
+ }
540
+
541
+
542
  @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
543
  @modal.fastapi_endpoint(method="POST")
544
  def analyze_image_llamacpp(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
 
556
  raise NotImplementedError(
557
  "Scene generation is deterministic in the browser from validated JSON."
558
  )
559
+ render_mode = select_render_mode(analysis)
560
+ renderer = "three" if render_mode == "three" else "photo"
561
+ return {"renderer": renderer, "render_mode": render_mode, "analysis": analysis}
562
 
563
 
564
  @app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
 
567
  """Compatibility endpoint; scene rendering is deterministic browser-side."""
568
  require_authorization(authorization)
569
  analysis = validate_analysis(payload.get("analysis") or EXAMPLE_ANALYSIS)
570
+ render_mode = select_render_mode(analysis)
571
+ renderer = "three" if render_mode == "three" else "photo"
572
+ return {"renderer": renderer, "render_mode": render_mode, "analysis": analysis}
snap2sim/backend.py CHANGED
@@ -1,4 +1,4 @@
1
- """Backend client and local placeholder inference."""
2
 
3
  from __future__ import annotations
4
 
@@ -11,16 +11,16 @@ from typing import Any
11
  import requests
12
  from PIL import Image
13
 
14
- from snap2sim.schema import EXAMPLE_ANALYSIS, validate_analysis
15
 
16
 
17
  @dataclass(frozen=True)
18
  class Settings:
19
- backend: str = os.getenv("INFERENCE_BACKEND", "local")
20
  analyze_url: str = os.getenv("MODAL_ANALYZE_URL", "")
21
  generate_url: str = os.getenv("MODAL_GENERATE_URL", "")
22
  api_token: str = os.getenv("SNAP2SIM_API_TOKEN", "")
23
- timeout_seconds: int = int(os.getenv("INFERENCE_TIMEOUT_SECONDS", "180"))
24
 
25
 
26
  def encode_image(image: Image.Image) -> str:
@@ -46,7 +46,9 @@ class InferenceClient:
46
 
47
  def generate_scene(self, analysis: dict[str, Any]) -> dict[str, Any]:
48
  valid_analysis = validate_analysis(analysis)
49
- return {"renderer": "three", "analysis": valid_analysis}
 
 
50
 
51
  def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
52
  if not url:
 
1
+ """Backend client for Modal inference with explicit local sample mode."""
2
 
3
  from __future__ import annotations
4
 
 
11
  import requests
12
  from PIL import Image
13
 
14
+ from snap2sim.schema import EXAMPLE_ANALYSIS, select_render_mode, validate_analysis
15
 
16
 
17
  @dataclass(frozen=True)
18
  class Settings:
19
+ backend: str = os.getenv("INFERENCE_BACKEND", "modal")
20
  analyze_url: str = os.getenv("MODAL_ANALYZE_URL", "")
21
  generate_url: str = os.getenv("MODAL_GENERATE_URL", "")
22
  api_token: str = os.getenv("SNAP2SIM_API_TOKEN", "")
23
+ timeout_seconds: int = int(os.getenv("INFERENCE_TIMEOUT_SECONDS", "300"))
24
 
25
 
26
  def encode_image(image: Image.Image) -> str:
 
46
 
47
  def generate_scene(self, analysis: dict[str, Any]) -> dict[str, Any]:
48
  valid_analysis = validate_analysis(analysis)
49
+ render_mode = select_render_mode(valid_analysis)
50
+ renderer = "three" if render_mode == "three" else "photo"
51
+ return {"renderer": renderer, "render_mode": render_mode, "analysis": valid_analysis}
52
 
53
  def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
54
  if not url:
snap2sim/model_io.py CHANGED
@@ -10,13 +10,14 @@ 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)
@@ -32,13 +33,15 @@ 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:
@@ -47,7 +50,21 @@ def coerce_analysis_response(text: str) -> dict[str, Any]:
47
 
48
 
49
  def _strip_fences(text: str) -> str:
50
- return _FENCE_RE.sub("", text.strip()).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
  def _json_object_candidates(text: str) -> list[tuple[int, str]]:
@@ -89,6 +106,10 @@ def _balanced_json_object(text: str, start: int) -> str:
89
  raise ValueError("Model response contained an unterminated JSON object.")
90
 
91
 
 
 
 
 
92
  def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -> dict[str, Any]:
93
  parts = payload.get("parts")
94
  if not isinstance(parts, list):
@@ -102,7 +123,7 @@ def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -
102
  if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
103
  confidence = 0.55
104
 
105
- return {
106
  "component": _non_empty_string(payload.get("component"), fallback_component),
107
  "confidence": max(0.0, min(1.0, float(confidence))),
108
  "summary": _non_empty_string(
@@ -116,16 +137,31 @@ def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -
116
  ),
117
  "parts": coerced_parts,
118
  }
 
 
 
119
 
120
 
121
  def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
 
122
  geometry = part.get("geometry")
123
- if not isinstance(geometry, dict):
 
124
  geometry = {}
125
  motion = part.get("motion")
126
  if not isinstance(motion, dict):
127
  motion = {}
128
 
 
 
 
 
 
 
 
 
 
 
129
  shape = geometry.get("shape")
130
  if shape not in {"box", "cylinder", "sphere", "gear", "rod"}:
131
  shape = "box"
@@ -134,10 +170,9 @@ def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
134
  motion_type = "static"
135
 
136
  coerced_motion: dict[str, Any] = {"type": motion_type}
137
- for key in ["axis"]:
138
- values = _number_list(motion.get(key), 3)
139
- if values:
140
- coerced_motion[key] = values
141
  for key in ["speed", "amplitude", "phase"]:
142
  if isinstance(motion.get(key), (int, float)) and not isinstance(motion.get(key), bool):
143
  coerced_motion[key] = float(motion[key])
@@ -147,7 +182,7 @@ def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
147
 
148
  coerced_geometry: dict[str, Any] = {
149
  "shape": shape,
150
- "size": _number_list(geometry.get("size"), 3) or [1.0, 0.4, 1.0],
151
  "position": _number_list(geometry.get("position"), 3) or [float(index) - 1.0, 0.0, 0.0],
152
  }
153
  values = _number_list(geometry.get("rotation"), 3)
@@ -158,13 +193,9 @@ def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
158
  if isinstance(geometry.get("color"), str) and geometry["color"].strip():
159
  coerced_geometry["color"] = geometry["color"].strip()
160
 
161
- return {
162
- "id": _identifier(part.get("id"), f"part_{index + 1}"),
163
- "name": _non_empty_string(part.get("name"), f"part {index + 1}"),
164
- "role": _non_empty_string(part.get("role"), "inferred mechanical element"),
165
- "geometry": coerced_geometry,
166
- "motion": coerced_motion,
167
- }
168
 
169
 
170
  def _generic_analysis(component: str) -> dict[str, Any]:
@@ -235,6 +266,71 @@ def _number_list(value: Any, length: int) -> list[float] | None:
235
  return [float(item) for item in value]
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  def _identifier(value: Any, fallback: str) -> str:
239
  text = _non_empty_string(value, fallback).lower()
240
  text = re.sub(r"[^a-z0-9_]+", "_", text).strip("_")
 
10
 
11
 
12
  _FENCE_RE = re.compile(r"^```(?:json|html)?\s*|\s*```$", re.IGNORECASE)
13
+ _THINK_RE = re.compile(r"<think\b[^>]*>.*?</think>", re.IGNORECASE | re.DOTALL)
14
 
15
 
16
  def parse_analysis_response(text: str) -> dict[str, Any]:
17
  """Extract and validate a JSON object from a model response."""
18
  raw = _strip_fences(text)
19
  errors: list[str] = []
20
+ for start, json_text in reversed(_json_object_candidates(raw)):
21
  try:
22
  payload = json.loads(json_text)
23
  return validate_analysis(payload)
 
33
  """Best-effort conversion of partial model output into a valid analysis."""
34
  raw = _strip_fences(text)
35
  fallback_component = _infer_component(raw)
36
+ for _, json_text in reversed(_json_object_candidates(raw)):
37
  try:
38
  payload = json.loads(json_text)
39
  except json.JSONDecodeError:
40
  continue
41
  if not isinstance(payload, dict):
42
  continue
43
+ if not _looks_like_analysis_payload(payload):
44
+ continue
45
  try:
46
  return validate_analysis(_coerce_analysis_payload(payload, fallback_component))
47
  except ValueError:
 
50
 
51
 
52
  def _strip_fences(text: str) -> str:
53
+ raw = _FENCE_RE.sub("", text.strip()).strip()
54
+ return _strip_reasoning(raw)
55
+
56
+
57
+ def _strip_reasoning(text: str) -> str:
58
+ cleaned = _THINK_RE.sub("", text)
59
+ unclosed = re.search(r"<think\b[^>]*>", cleaned, re.IGNORECASE)
60
+ if unclosed:
61
+ after = cleaned[unclosed.end() :]
62
+ json_start = after.find("{")
63
+ if json_start >= 0:
64
+ cleaned = cleaned[: unclosed.start()] + after[json_start:]
65
+ else:
66
+ cleaned = cleaned[: unclosed.start()]
67
+ return cleaned.strip()
68
 
69
 
70
  def _json_object_candidates(text: str) -> list[tuple[int, str]]:
 
106
  raise ValueError("Model response contained an unterminated JSON object.")
107
 
108
 
109
+ def _looks_like_analysis_payload(payload: dict[str, Any]) -> bool:
110
+ return any(key in payload for key in ["component", "summary", "trigger", "motion_sequence", "parts"])
111
+
112
+
113
  def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -> dict[str, Any]:
114
  parts = payload.get("parts")
115
  if not isinstance(parts, list):
 
123
  if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
124
  confidence = 0.55
125
 
126
+ result = {
127
  "component": _non_empty_string(payload.get("component"), fallback_component),
128
  "confidence": max(0.0, min(1.0, float(confidence))),
129
  "summary": _non_empty_string(
 
137
  ),
138
  "parts": coerced_parts,
139
  }
140
+ if payload.get("render_mode") in {"three", "annotate", "unavailable"}:
141
+ result["render_mode"] = payload["render_mode"]
142
+ return result
143
 
144
 
145
  def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
146
+ annotation = _coerce_annotation(part.get("annotation"))
147
  geometry = part.get("geometry")
148
+ has_geometry_input = isinstance(geometry, dict) and bool(geometry)
149
+ if not has_geometry_input:
150
  geometry = {}
151
  motion = part.get("motion")
152
  if not isinstance(motion, dict):
153
  motion = {}
154
 
155
+ base_part: dict[str, Any] = {
156
+ "id": _identifier(part.get("id"), f"part_{index + 1}"),
157
+ "name": _non_empty_string(part.get("name"), f"part {index + 1}"),
158
+ "role": _non_empty_string(part.get("role"), "inferred mechanical element"),
159
+ }
160
+ if annotation:
161
+ base_part["annotation"] = annotation
162
+ if annotation and not has_geometry_input:
163
+ return base_part
164
+
165
  shape = geometry.get("shape")
166
  if shape not in {"box", "cylinder", "sphere", "gear", "rod"}:
167
  shape = "box"
 
170
  motion_type = "static"
171
 
172
  coerced_motion: dict[str, Any] = {"type": motion_type}
173
+ axis = _axis_vector(motion.get("axis"))
174
+ if axis:
175
+ coerced_motion["axis"] = axis
 
176
  for key in ["speed", "amplitude", "phase"]:
177
  if isinstance(motion.get(key), (int, float)) and not isinstance(motion.get(key), bool):
178
  coerced_motion[key] = float(motion[key])
 
182
 
183
  coerced_geometry: dict[str, Any] = {
184
  "shape": shape,
185
+ "size": _geometry_size(geometry, shape),
186
  "position": _number_list(geometry.get("position"), 3) or [float(index) - 1.0, 0.0, 0.0],
187
  }
188
  values = _number_list(geometry.get("rotation"), 3)
 
193
  if isinstance(geometry.get("color"), str) and geometry["color"].strip():
194
  coerced_geometry["color"] = geometry["color"].strip()
195
 
196
+ base_part["geometry"] = coerced_geometry
197
+ base_part["motion"] = coerced_motion
198
+ return base_part
 
 
 
 
199
 
200
 
201
  def _generic_analysis(component: str) -> dict[str, Any]:
 
266
  return [float(item) for item in value]
267
 
268
 
269
+ def _geometry_size(geometry: dict[str, Any], shape: str) -> list[float]:
270
+ size = _number_list(geometry.get("size"), 3)
271
+ if size:
272
+ return size
273
+
274
+ radius = _number_value(geometry.get("radius"))
275
+ height = _number_value(geometry.get("height"))
276
+ length = _number_value(geometry.get("length"))
277
+ width = _number_value(geometry.get("width"))
278
+ depth = _number_value(geometry.get("depth"))
279
+ if shape in {"cylinder", "gear"} and radius and height:
280
+ diameter = radius * 2
281
+ return [diameter, height, diameter]
282
+ if shape == "rod" and (length or height) and radius:
283
+ diameter = radius * 2
284
+ return [diameter, diameter, length or height or 1.0]
285
+ if width and height and depth:
286
+ return [width, height, depth]
287
+ return [1.0, 0.4, 1.0]
288
+
289
+
290
+ def _axis_vector(value: Any) -> list[float] | None:
291
+ vector = _number_list(value, 3)
292
+ if vector:
293
+ return vector
294
+ if isinstance(value, str):
295
+ key = value.strip().lower()
296
+ if key == "x":
297
+ return [1.0, 0.0, 0.0]
298
+ if key == "y":
299
+ return [0.0, 1.0, 0.0]
300
+ if key == "z":
301
+ return [0.0, 0.0, 1.0]
302
+ return None
303
+
304
+
305
+ def _number_value(value: Any) -> float | None:
306
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
307
+ return float(value)
308
+ return None
309
+
310
+
311
+ def _unit_number_list(value: Any, length: int) -> list[float] | None:
312
+ values = _number_list(value, length)
313
+ if not values or not all(0 <= item <= 1 for item in values):
314
+ return None
315
+ return values
316
+
317
+
318
+ def _coerce_annotation(value: Any) -> dict[str, Any] | None:
319
+ if not isinstance(value, dict):
320
+ return None
321
+ point = _unit_number_list(value.get("point"), 2)
322
+ if not point:
323
+ return None
324
+ annotation: dict[str, Any] = {"point": point}
325
+ box = _unit_number_list(value.get("box"), 4)
326
+ if box:
327
+ annotation["box"] = box
328
+ for key in ["label", "note"]:
329
+ if isinstance(value.get(key), str) and value[key].strip():
330
+ annotation[key] = value[key].strip()
331
+ return annotation
332
+
333
+
334
  def _identifier(value: Any, fallback: str) -> str:
335
  text = _non_empty_string(value, fallback).lower()
336
  text = re.sub(r"[^a-z0-9_]+", "_", text).strip("_")
snap2sim/prompts.py CHANGED
@@ -6,21 +6,32 @@ VISION_SYSTEM_PROMPT = """You are a mechanical teardown analyst.
6
  Given an image of a hardware component, infer the most likely internal
7
  mechanism and return only JSON matching the provided schema. Prefer clear,
8
  physically plausible primitive geometry over speculative detail. If the photo is
9
- ambiguous, state lower confidence and model the most likely mechanism."""
 
10
 
11
 
12
  def build_vision_prompt() -> str:
13
  return (
14
  "Analyze the uploaded hardware component as a cutaway mechanism. "
15
- "Answer with only one JSON object. Do not include markdown. Do not "
16
- "include a reasoning trace or <think> tags. Keep the payload compact and physically "
17
- "plausible for primitive Three.js rendering.\n\n"
18
  "Required top-level keys: component, confidence, summary, trigger, "
19
- "motion_sequence, parts.\n"
20
- "Each part requires: id, name, role, geometry, motion.\n"
 
 
 
21
  "Allowed geometry.shape values: box, cylinder, sphere, gear, rod.\n"
22
- "Allowed motion.type values: rotate, translate, oscillate, static.\n\n"
23
- "Use 2 to 4 parts. Keep names and descriptions short.\n\n"
 
 
 
 
 
 
 
24
  "Use this shape:\n"
25
  "{\n"
26
  ' "component": "short component name",\n'
@@ -34,7 +45,8 @@ def build_vision_prompt() -> str:
34
  ' "name": "part name",\n'
35
  ' "role": "mechanical role",\n'
36
  ' "geometry": {"shape": "box", "size": [1, 1, 1], "position": [0, 0, 0]},\n'
37
- ' "motion": {"type": "static"}\n'
 
38
  " }\n"
39
  " ]\n"
40
  "}\n\n"
 
6
  Given an image of a hardware component, infer the most likely internal
7
  mechanism and return only JSON matching the provided schema. Prefer clear,
8
  physically plausible primitive geometry over speculative detail. If the photo is
9
+ ambiguous, state lower confidence and identify visible components with photo
10
+ annotations instead of forcing a speculative 3D mechanism."""
11
 
12
 
13
  def build_vision_prompt() -> str:
14
  return (
15
  "Analyze the uploaded hardware component as a cutaway mechanism. "
16
+ "You may reason first if needed, but the final answer must be one JSON "
17
+ "object. Do not include markdown. Keep the final payload compact and "
18
+ "physically plausible for primitive Three.js rendering.\n\n"
19
  "Required top-level keys: component, confidence, summary, trigger, "
20
+ "motion_sequence, parts. Optional top-level render_mode is three, "
21
+ "annotate, or unavailable.\n"
22
+ "Each part requires: id, name, role, and either geometry plus motion, "
23
+ "or annotation when the visible component can be located but 3D "
24
+ "geometry is uncertain.\n"
25
  "Allowed geometry.shape values: box, cylinder, sphere, gear, rod.\n"
26
+ "Allowed motion.type values: rotate, translate, oscillate, static.\n"
27
+ "Every geometry must use size: [x, y, z] and position: [x, y, z]. "
28
+ "Do not use radius, height, length, width, or depth fields. Every "
29
+ "motion axis must be a numeric vector such as [0, 1, 0], never a string "
30
+ "like x, y, or z.\n\n"
31
+ "Use 2 to 4 parts. Keep names and descriptions short. When possible, "
32
+ "include annotation.point in normalized image coordinates [x, y] with "
33
+ "origin at top-left, plus a short annotation.note. Optional "
34
+ "annotation.box is [x, y, width, height], also normalized from 0 to 1.\n\n"
35
  "Use this shape:\n"
36
  "{\n"
37
  ' "component": "short component name",\n'
 
45
  ' "name": "part name",\n'
46
  ' "role": "mechanical role",\n'
47
  ' "geometry": {"shape": "box", "size": [1, 1, 1], "position": [0, 0, 0]},\n'
48
+ ' "motion": {"type": "static"},\n'
49
+ ' "annotation": {"point": [0.5, 0.5], "label": "visible label", "note": "short visible clue"}\n'
50
  " }\n"
51
  " ]\n"
52
  "}\n\n"
snap2sim/schema.py CHANGED
@@ -35,6 +35,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
35
  "color": "steel",
36
  },
37
  "motion": {"type": "static"},
 
 
 
 
 
38
  },
39
  {
40
  "id": "ratchet_gear",
@@ -53,6 +58,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
53
  "speed": 0.95,
54
  "phase": 0,
55
  },
 
 
 
 
 
56
  },
57
  {
58
  "id": "pawl",
@@ -72,6 +82,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
72
  "speed": 3.2,
73
  "phase": 0.6,
74
  },
 
 
 
 
 
75
  },
76
  {
77
  "id": "selector",
@@ -84,6 +99,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
84
  "color": "orange",
85
  },
86
  "motion": {"type": "static"},
 
 
 
 
 
87
  },
88
  ],
89
  }
@@ -95,6 +115,7 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
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"}},
@@ -102,7 +123,7 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
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"},
@@ -162,6 +183,26 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
162
  },
163
  },
164
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  },
166
  },
167
  },
@@ -171,6 +212,27 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
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]:
@@ -181,6 +243,8 @@ def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
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:
@@ -193,32 +257,56 @@ def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
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
 
@@ -240,5 +328,15 @@ def _require_number_list(payload: dict[str, Any], key: str, path: str, length: i
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)
 
35
  "color": "steel",
36
  },
37
  "motion": {"type": "static"},
38
+ "annotation": {
39
+ "point": [0.5, 0.55],
40
+ "label": "head housing",
41
+ "note": "visible outer shell around the ratchet mechanism",
42
+ },
43
  },
44
  {
45
  "id": "ratchet_gear",
 
58
  "speed": 0.95,
59
  "phase": 0,
60
  },
61
+ "annotation": {
62
+ "point": [0.5, 0.48],
63
+ "label": "ratchet gear",
64
+ "note": "central rotating socket carrier",
65
+ },
66
  },
67
  {
68
  "id": "pawl",
 
82
  "speed": 3.2,
83
  "phase": 0.6,
84
  },
85
+ "annotation": {
86
+ "point": [0.67, 0.44],
87
+ "label": "spring pawl",
88
+ "note": "locking tooth that alternates grip and slip",
89
+ },
90
  },
91
  {
92
  "id": "selector",
 
99
  "color": "orange",
100
  },
101
  "motion": {"type": "static"},
102
+ "annotation": {
103
+ "point": [0.5, 0.28],
104
+ "label": "selector",
105
+ "note": "external control that reverses drive direction",
106
+ },
107
  },
108
  ],
109
  }
 
115
  "properties": {
116
  "component": {"type": "string"},
117
  "confidence": {"type": "number", "minimum": 0, "maximum": 1},
118
+ "render_mode": {"type": "string", "enum": ["three", "annotate", "unavailable"]},
119
  "summary": {"type": "string"},
120
  "trigger": {"type": "string"},
121
  "motion_sequence": {"type": "array", "items": {"type": "string"}},
 
123
  "type": "array",
124
  "items": {
125
  "type": "object",
126
+ "required": ["id", "name", "role"],
127
  "properties": {
128
  "id": {"type": "string"},
129
  "name": {"type": "string"},
 
183
  },
184
  },
185
  },
186
+ "annotation": {
187
+ "type": "object",
188
+ "required": ["point"],
189
+ "properties": {
190
+ "point": {
191
+ "type": "array",
192
+ "items": {"type": "number", "minimum": 0, "maximum": 1},
193
+ "minItems": 2,
194
+ "maxItems": 2,
195
+ },
196
+ "box": {
197
+ "type": "array",
198
+ "items": {"type": "number", "minimum": 0, "maximum": 1},
199
+ "minItems": 4,
200
+ "maxItems": 4,
201
+ },
202
+ "label": {"type": "string"},
203
+ "note": {"type": "string"},
204
+ },
205
+ },
206
  },
207
  },
208
  },
 
212
 
213
  _SHAPES = {"box", "cylinder", "sphere", "gear", "rod"}
214
  _MOTIONS = {"rotate", "translate", "oscillate", "static"}
215
+ _RENDER_MODES = {"three", "annotate", "unavailable"}
216
+
217
+
218
+ def select_render_mode(analysis: dict[str, Any]) -> str:
219
+ """Pick the safest renderer for a validated analysis payload."""
220
+ explicit_mode = analysis.get("render_mode")
221
+ if explicit_mode in _RENDER_MODES:
222
+ return str(explicit_mode)
223
+
224
+ parts = analysis.get("parts") if isinstance(analysis, dict) else []
225
+ has_geometry = any(isinstance(part, dict) and isinstance(part.get("geometry"), dict) for part in parts)
226
+ has_annotation = any(isinstance(part, dict) and isinstance(part.get("annotation"), dict) for part in parts)
227
+ confidence = analysis.get("confidence", 1)
228
+ low_confidence = _is_number(confidence) and float(confidence) < 0.5
229
+ if has_annotation and (low_confidence or not has_geometry):
230
+ return "annotate"
231
+ if has_geometry and not low_confidence:
232
+ return "three"
233
+ if has_annotation:
234
+ return "annotate"
235
+ return "unavailable"
236
 
237
 
238
  def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
 
243
  for key in ["component", "summary", "trigger"]:
244
  _require_string(payload, key, key)
245
  _require_string_list(payload, "motion_sequence", "motion_sequence")
246
+ if "render_mode" in payload and payload["render_mode"] not in _RENDER_MODES:
247
+ raise ValueError("Invalid analysis payload at render_mode: unsupported mode")
248
 
249
  parts = payload.get("parts")
250
  if not isinstance(parts, list) or not parts:
 
257
  for key in ["id", "name", "role"]:
258
  _require_string(part, key, f"{path}.{key}")
259
 
260
+ has_geometry = False
261
  geometry = part.get("geometry")
262
+ if geometry is not None:
263
+ if not isinstance(geometry, dict):
264
+ raise ValueError(f"Invalid analysis payload at {path}.geometry: expected an object")
265
+ shape = geometry.get("shape")
266
+ if shape not in _SHAPES:
267
+ raise ValueError(f"Invalid analysis payload at {path}.geometry.shape: unsupported shape")
268
+ _require_number_list(geometry, "size", f"{path}.geometry.size", 3)
269
+ _require_number_list(geometry, "position", f"{path}.geometry.position", 3)
270
+ if "rotation" in geometry:
271
+ _require_number_list(geometry, "rotation", f"{path}.geometry.rotation", 3)
272
+ if "teeth" in geometry and not isinstance(geometry["teeth"], int):
273
+ raise ValueError(f"Invalid analysis payload at {path}.geometry.teeth: expected an integer")
274
+ has_geometry = True
275
+
276
+ has_annotation = False
277
+ annotation = part.get("annotation")
278
+ if annotation is not None:
279
+ if not isinstance(annotation, dict):
280
+ raise ValueError(f"Invalid analysis payload at {path}.annotation: expected an object")
281
+ _require_unit_number_list(annotation, "point", f"{path}.annotation.point", 2)
282
+ if "box" in annotation:
283
+ _require_unit_number_list(annotation, "box", f"{path}.annotation.box", 4)
284
+ for key in ["label", "note"]:
285
+ if key in annotation and not isinstance(annotation[key], str):
286
+ raise ValueError(f"Invalid analysis payload at {path}.annotation.{key}: expected a string")
287
+ has_annotation = True
288
+
289
+ if not has_geometry and not has_annotation:
290
+ raise ValueError(
291
+ f"Invalid analysis payload at {path}: expected geometry or annotation"
292
+ )
293
 
294
  motion = part.get("motion")
295
+ if motion is not None:
296
+ if not isinstance(motion, dict):
297
+ raise ValueError(f"Invalid analysis payload at {path}.motion: expected an object")
298
+ motion_type = motion.get("type")
299
+ if motion_type not in _MOTIONS:
300
+ raise ValueError(f"Invalid analysis payload at {path}.motion.type: unsupported motion")
301
+ if "axis" in motion:
302
+ _require_number_list(motion, "axis", f"{path}.motion.axis", 3)
303
+ if "range" in motion:
304
+ _require_number_list(motion, "range", f"{path}.motion.range", 2)
305
+ for key in ["speed", "amplitude", "phase"]:
306
+ if key in motion and not _is_number(motion[key]):
307
+ raise ValueError(f"Invalid analysis payload at {path}.motion.{key}: expected a number")
308
+ elif has_geometry:
309
  raise ValueError(f"Invalid analysis payload at {path}.motion: expected an object")
 
 
 
 
 
 
 
 
 
 
310
 
311
  return payload
312
 
 
328
  raise ValueError(f"Invalid analysis payload at {path}: expected {length} numbers")
329
 
330
 
331
+ def _require_unit_number_list(payload: dict[str, Any], key: str, path: str, length: int) -> None:
332
+ value = payload.get(key)
333
+ if (
334
+ not isinstance(value, list)
335
+ or len(value) != length
336
+ or not all(_is_number(item) and 0 <= item <= 1 for item in value)
337
+ ):
338
+ raise ValueError(f"Invalid analysis payload at {path}: expected {length} numbers from 0 to 1")
339
+
340
+
341
  def _is_number(value: Any) -> bool:
342
  return isinstance(value, (int, float)) and not isinstance(value, bool)