jasondo OpenAI Codex commited on
Commit
a0540e9
·
1 Parent(s): ca9bbeb

Add confidence threshold control

Browse files

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

Files changed (5) hide show
  1. AGENTS.md +8 -0
  2. FEATURE.md +194 -0
  3. README.md +7 -2
  4. index.html +85 -3
  5. snap2sim/schema.py +3 -2
AGENTS.md CHANGED
@@ -261,6 +261,14 @@ technical cutaway animation.
261
  reveal. Local browser verification confirmed the scene still renders, orbit
262
  drag still works, the canvas remains the pointer target, the source preview
263
  remains contained, and the mobile layout has no horizontal overflow.
 
 
 
 
 
 
 
 
264
 
265
  ## Next Work
266
 
 
261
  reveal. Local browser verification confirmed the scene still renders, orbit
262
  drag still works, the canvas remains the pointer target, the source preview
263
  remains contained, and the mobile layout has no horizontal overflow.
264
+ - Implemented the `FEATURE.md` confidence-threshold control on June 14, 2026:
265
+ added a toolbar slider defaulting to `0.5`, made the browser recompute render
266
+ mode from cached analysis plus the current slider value, and kept slider
267
+ changes local with no extra `/analyze_image` or `/generate_scene` calls.
268
+ Added `DEFAULT_CONFIDENCE_THRESHOLD = 0.5` for the server's advisory fallback.
269
+ Local verification passed for schema/parser checks, FastAPI `TestClient`,
270
+ slider downgrade/promote behavior, annotated-photo fallback, keyboard slider
271
+ operation, mobile no-overflow layout, and canvas pointer targeting.
272
 
273
  ## Next Work
274
 
FEATURE.md ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FEATURE: Confidence Threshold Control for Cutaway Generation
2
+
3
+ Status: Implemented locally by Codex, June 14, 2026.
4
+ Author of spec: codebase review pass, June 14, 2026.
5
+
6
+ ## Summary
7
+
8
+ Add a user-facing **confidence threshold slider** that controls how high the
9
+ model's analysis `confidence` must be before the deterministic Three.js cutaway
10
+ is rendered. Today this threshold exists but is a hardcoded `0.5` magic number,
11
+ duplicated across Python and JavaScript, and not user-controllable.
12
+
13
+ ## Findings (current behavior)
14
+
15
+ **Q: Does the generation of the Three.js parts have a confidence threshold to
16
+ pass before sending back to the user?**
17
+
18
+ Partially yes, but it is implicit, hardcoded, and not a true block:
19
+
20
+ 1. The vision model returns a `confidence` value (`0`–`1`) in the analysis JSON.
21
+ See `snap2sim/schema.py` (`ANALYSIS_SCHEMA`, `confidence` is
22
+ `number, minimum 0, maximum 1`), `snap2sim/prompts.py`, and
23
+ `snap2sim/model_io.py` (defaults: coerced `0.55`, generic fallback `0.45`).
24
+
25
+ 2. Confidence gates the **render mode**, not whether output is returned. The
26
+ threshold is a hardcoded `0.5` and lives in **two** places:
27
+ - **Python** `snap2sim/schema.py:218` `select_render_mode()`:
28
+ `low_confidence = _is_number(confidence) and float(confidence) < 0.5`.
29
+ This is called by `snap2sim/backend.py:47` `InferenceClient.generate_scene()`,
30
+ which returns `render_mode` in the `/generate_scene` payload.
31
+ - **JavaScript** `index.html:760` `chooseRenderMode()`:
32
+ `confidence < 0.5` / `confidence >= 0.5`. Used as a client-side fallback
33
+ only when the server payload omits `render_mode`.
34
+
35
+ 3. The threshold **downgrades** rather than blocks. The chain is:
36
+ `three` (3D cutaway) -> `annotate` (annotated source photo overlay) ->
37
+ `unavailable` (honest "cannot render" state). A low-confidence analysis is
38
+ still returned to the user, just not as 3D parts. See
39
+ `index.html:748` `renderScenePayload()` and `index.html:760`
40
+ `chooseRenderMode()`; mirrored in `schema.py:218` `select_render_mode()`.
41
+
42
+ 4. Confidence is currently only **displayed** (as a percentage) at
43
+ `index.html:726`; there is no control to change the cutoff.
44
+
45
+ 5. There is **no server-side hard gate**. `modal_app.py` / `app.py` validate and
46
+ coerce the payload but never reject on low confidence.
47
+
48
+ **Conclusion:** an implicit confidence threshold (`0.5`) already governs whether
49
+ Three.js parts render, but it is a duplicated magic number with no UI control.
50
+ This feature exposes it as a slider and removes the magic-number duplication.
51
+
52
+ ## Product decisions (confirmed with user, June 14, 2026)
53
+
54
+ 1. **Gate behavior: keep the downgrade chain.** Below threshold, skip the 3D
55
+ Three.js render and fall back to annotated photo, then unavailable. The
56
+ slider only moves the cutoff; it does not introduce a new hard-block state.
57
+ 2. **Slider apply: live re-render from cached analysis.** Moving the slider after
58
+ an analysis returns must re-evaluate the render mode against the
59
+ already-returned analysis JSON and re-render immediately. No new
60
+ `/analyze_image` or model call — zero added latency or Modal cost.
61
+ 3. **Source of truth: client authoritative.** The browser owns the threshold.
62
+ The client recomputes the render mode from the raw `confidence` plus the
63
+ slider value, overriding any `render_mode` in the server payload. Rendering is
64
+ already deterministic browser-side, so this is the minimal change. Per
65
+ `SECURITY.md`, the threshold is a non-credential UX control, so client
66
+ authority is acceptable; it is a quality gate, not a security gate.
67
+
68
+ ## Implementation plan (for Codex)
69
+
70
+ All primary changes are in `index.html`. No backend signature change is required.
71
+
72
+ ### 1. Add the slider UI
73
+
74
+ - Add a labeled `range` input to the viewport toolbar
75
+ (`index.html:550` `<div class="toolbar">`) or the readout panel near the
76
+ confidence metric (`index.html:577` `metric-row`). Recommended: toolbar, next
77
+ to `resetViewButton`, so it sits with the other live viewport controls.
78
+ - Suggested markup:
79
+ ```html
80
+ <label class="threshold-control" for="confidenceThreshold">
81
+ Min confidence
82
+ <input id="confidenceThreshold" class="threshold-slider" type="range"
83
+ min="0" max="1" step="0.05" value="0.5"
84
+ aria-describedby="thresholdValue">
85
+ <span id="thresholdValue" aria-live="polite">50%</span>
86
+ </label>
87
+ ```
88
+ - Default value `0.5` to preserve current behavior exactly.
89
+ - Style consistent with existing `.tool-button` / toolbar aesthetic; keep it
90
+ keyboard-accessible (range inputs are by default) and screen-reader labeled,
91
+ matching the accessibility work already done in this repo (live status,
92
+ keyboard drop zone).
93
+
94
+ ### 2. Wire the threshold into render-mode selection
95
+
96
+ - Introduce a single source for the current threshold, e.g.
97
+ `let confidenceThreshold = 0.5;`, updated from the slider's `input` event.
98
+ - Update the slider value label (`#thresholdValue`) on `input`.
99
+ - Replace the hardcoded `0.5` comparisons in `chooseRenderMode()`
100
+ (`index.html:760-772`) with `confidenceThreshold`:
101
+ - `confidence < confidenceThreshold` for the low-confidence branch.
102
+ - `confidence >= confidenceThreshold` for the `three` branch.
103
+ - Make `renderScenePayload()` (`index.html:748`) **client authoritative**:
104
+ always compute the render mode via `chooseRenderMode(analysis)` using the
105
+ current threshold, instead of trusting `payload.render_mode`. Keep the
106
+ geometry/annotation capability checks (`hasUsableGeometry`, `hasAnnotations`)
107
+ so a high threshold never forces a 3D render that lacks geometry.
108
+
109
+ ### 3. Live re-render on slider change
110
+
111
+ - Cache the last analysis (already stored at `index.html:722`
112
+ `window.lastAnalysis`) and the last scene payload.
113
+ - On slider `change` (or debounced `input`), if an analysis is present and the
114
+ pipeline is not mid-request, call `renderScenePayload(lastScenePayload)` (or a
115
+ small `reevaluateRender()` helper that reads `window.lastAnalysis`) to rebuild
116
+ the scene from cached data. Do **not** call `/analyze_image` or
117
+ `/generate_scene` again — re-use the cached analysis JSON.
118
+ - Guard against re-render while `setBusy(true)` is active to avoid racing an
119
+ in-flight request.
120
+ - Reset/standby state should disable or ignore the slider re-render until an
121
+ analysis exists.
122
+
123
+ ### 4. Remove the magic-number duplication (recommended cleanup)
124
+
125
+ Because decision #3 makes the client authoritative, the server's
126
+ `select_render_mode()` `0.5` is now advisory only. Two acceptable options:
127
+
128
+ - **Minimal:** leave `schema.py` `select_render_mode()` as-is (still returns a
129
+ reasonable default `render_mode`); the client overrides it. Add a code comment
130
+ noting the client is authoritative for the user-facing threshold.
131
+ - **Cleaner (preferred if time allows):** extract the `0.5` default into a single
132
+ named constant, e.g. `DEFAULT_CONFIDENCE_THRESHOLD = 0.5` in `schema.py`, and a
133
+ matching JS constant in `index.html`, so the default lives in one obvious place
134
+ per layer. Do **not** add a backend signature change to pass the slider value
135
+ to the server (decision #3 keeps the threshold client-side).
136
+
137
+ ## Out of scope / explicitly NOT doing
138
+
139
+ - No new server endpoint or `/generate_scene` signature change (client
140
+ authoritative per decision #3).
141
+ - No hard-block "confidence too low" state — the downgrade chain stays
142
+ (decision #1).
143
+ - No re-running model inference when the slider moves (decision #2).
144
+ - No model-authored HTML/JS/markup injection; rendering stays deterministic
145
+ Three.js from validated JSON (`SECURITY.md` Agent Guidance).
146
+
147
+ ## Implementation result
148
+
149
+ - Added a toolbar confidence threshold slider in `index.html`, defaulting to
150
+ `0.5` / `50%`.
151
+ - The browser now recomputes render mode from the cached analysis confidence and
152
+ current slider value, overriding advisory server `render_mode`.
153
+ - Slider input re-renders from `window.lastScenePayload` only; it does not call
154
+ `/analyze_image` or `/generate_scene`.
155
+ - Added `DEFAULT_CONFIDENCE_THRESHOLD = 0.5` in `snap2sim/schema.py` for the
156
+ server's advisory fallback.
157
+ - Local verification passed: schema/parser check, FastAPI `TestClient` root /
158
+ `/analyze_image` / `/generate_scene`, browser slider downgrade/promote with
159
+ no network calls, annotated-photo fallback, keyboard slider operation, mobile
160
+ no horizontal overflow, and canvas pointer targeting after normal overlay
161
+ hiding.
162
+
163
+ ## Verification checklist
164
+
165
+ - Slider defaults to `0.5`; with default value, behavior matches current
166
+ production exactly (regression check).
167
+ - Raising the threshold above a returned analysis's confidence downgrades a
168
+ previously-3D render to annotated photo, then to unavailable, live, with no
169
+ network call (confirm via browser devtools Network tab — no new
170
+ `/analyze_image` or `/generate_scene` requests on slider move).
171
+ - Lowering the threshold promotes an annotated/unavailable result back to the 3D
172
+ cutaway, provided usable geometry exists.
173
+ - Slider value label updates and is announced (`aria-live`).
174
+ - Slider is keyboard-operable and does not steal focus from / block the canvas
175
+ OrbitControls (watch for the pointer-events class of bug fixed in the
176
+ REVIEW2.md pass).
177
+ - Mobile layout: slider does not introduce horizontal overflow; toolbar still
178
+ fits.
179
+ - `INFERENCE_BACKEND=local` sample mode still renders the example analysis with
180
+ the slider present.
181
+ - Run existing local checks: schema/parser checks and the FastAPI `TestClient`
182
+ pass for `/`, `/analyze_image`, `/generate_scene`.
183
+
184
+ ## Touch points (file/line reference)
185
+
186
+ - `index.html:550` toolbar — add slider markup.
187
+ - `index.html:577` metric-row / `index.html:726` confidence display — optional
188
+ co-location with confidence readout.
189
+ - `index.html:748` `renderScenePayload()` — make client authoritative.
190
+ - `index.html:760` `chooseRenderMode()` — replace `0.5` with slider value.
191
+ - `index.html:602-622` element refs + `index.html:624` state vars — add slider
192
+ element ref and `confidenceThreshold` state.
193
+ - `snap2sim/schema.py:218` `select_render_mode()` — optional constant extraction.
194
+ - `snap2sim/backend.py:47` `generate_scene()` — no change required.
README.md CHANGED
@@ -128,14 +128,19 @@ Runtime flow:
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
136
  63/37 viewport/readout split, a blueprint grid, amber/cyan instrument-panel
137
  colors, explicit Modal cold-start messaging, source-photo preview, and a
138
- play/pause control.
 
 
139
 
140
  The browser no longer injects model-authored HTML into the DOM. The model's
141
  job is limited to the structured analysis JSON contract in `snap2sim/schema.py`.
 
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. A browser-side confidence slider controls the minimum analysis confidence
132
+ needed for the 3D cutaway. Moving it re-renders from cached analysis data
133
+ and does not call the model again.
134
+ 6. `/generate_scene` returns a validated
135
  `{ "renderer": "...", "render_mode": "...", "analysis": ... }` descriptor
136
  instead of model-authored HTML.
137
 
138
  The shell uses Chakra Petch and Fira Code from Bunny Fonts, an asymmetric
139
  63/37 viewport/readout split, a blueprint grid, amber/cyan instrument-panel
140
  colors, explicit Modal cold-start messaging, source-photo preview, and a
141
+ play/pause control. The confidence slider defaults to 50%, matching the
142
+ server's advisory fallback threshold, and the browser is authoritative for the
143
+ visible render mode.
144
 
145
  The browser no longer injects model-authored HTML into the DOM. The model's
146
  job is limited to the structured analysis JSON contract in `snap2sim/schema.py`.
index.html CHANGED
@@ -210,11 +210,13 @@
210
  .toolbar {
211
  position: absolute;
212
  left: 18px;
 
213
  top: 18px;
214
  z-index: 10;
215
  display: flex;
216
  flex-wrap: wrap;
217
  gap: 8px;
 
218
  }
219
 
220
  .tool-button {
@@ -241,6 +243,43 @@
241
  cursor: default;
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  .viewport-hint {
245
  position: absolute;
246
  left: 18px;
@@ -551,6 +590,11 @@
551
  <button id="uploadButton" class="tool-button" type="button">Load</button>
552
  <button id="playButton" class="tool-button" type="button" disabled>Play</button>
553
  <button id="resetViewButton" class="tool-button" type="button" disabled>Reset view</button>
 
 
 
 
 
554
  </div>
555
  <div id="viewport" aria-hidden="true"></div>
556
  <div id="viewportHint" class="viewport-hint" hidden>Drag to orbit / scroll to zoom</div>
@@ -603,6 +647,8 @@
603
  const uploadButton = document.getElementById("uploadButton");
604
  const playButton = document.getElementById("playButton");
605
  const resetViewButton = document.getElementById("resetViewButton");
 
 
606
  const dropZone = document.getElementById("dropZone");
607
  const viewport = document.getElementById("viewport");
608
  const viewportHint = document.getElementById("viewportHint");
@@ -621,11 +667,15 @@
621
  const partsEl = document.getElementById("parts");
622
  const rawJsonEl = document.getElementById("rawJson");
623
 
 
624
  let activeMode = "idle";
625
  let paused = false;
 
626
  let coldStartTimer = 0;
 
627
  let fallbackRuntime = null;
628
  let currentPreviewUrl = "";
 
629
  const MAX_CLIENT_IMAGE_BYTES = 8 * 1024 * 1024;
630
 
631
  uploadButton.addEventListener("click", () => fileInput.click());
@@ -669,6 +719,10 @@
669
  resetViewButton.addEventListener("click", () => {
670
  if (fallbackRuntime && fallbackRuntime.resetView) fallbackRuntime.resetView();
671
  });
 
 
 
 
672
 
673
  async function runPipeline(file) {
674
  const validationError = validateFile(file);
@@ -712,8 +766,11 @@
712
  playButton.textContent = "Play";
713
  playButton.disabled = true;
714
  resetViewButton.disabled = true;
 
715
  viewportHint.hidden = true;
716
  retryButton.hidden = true;
 
 
717
  viewport.replaceChildren();
718
  dropZone.classList.add("hidden");
719
  }
@@ -747,7 +804,11 @@
747
 
748
  function renderScenePayload(payload) {
749
  const analysis = payload && payload.analysis ? payload.analysis : payload;
750
- const renderMode = payload && payload.render_mode ? payload.render_mode : chooseRenderMode(analysis);
 
 
 
 
751
  if (renderMode === "three" && hasUsableGeometry(analysis)) {
752
  buildDeterministicScene(analysis);
753
  } else if (renderMode === "annotate" && hasAnnotations(analysis)) {
@@ -759,10 +820,10 @@
759
 
760
  function chooseRenderMode(analysis) {
761
  const confidence = typeof analysis.confidence === "number" ? analysis.confidence : 1;
762
- if (hasAnnotations(analysis) && (confidence < 0.5 || !hasUsableGeometry(analysis))) {
763
  return "annotate";
764
  }
765
- if (hasUsableGeometry(analysis) && confidence >= 0.5) {
766
  return "three";
767
  }
768
  if (hasAnnotations(analysis)) {
@@ -808,6 +869,7 @@
808
  playButton.textContent = "Pause";
809
  playButton.disabled = false;
810
  resetViewButton.disabled = false;
 
811
  viewportHint.hidden = false;
812
  viewport.replaceChildren();
813
  const stage = document.createElement("div");
@@ -939,6 +1001,7 @@
939
  playButton.textContent = "Play";
940
  playButton.disabled = true;
941
  resetViewButton.disabled = true;
 
942
  viewportHint.hidden = true;
943
  viewport.replaceChildren();
944
 
@@ -1194,8 +1257,10 @@
1194
  }
1195
 
1196
  function setBusy(active) {
 
1197
  progress.classList.toggle("active", active);
1198
  uploadButton.disabled = active;
 
1199
  }
1200
 
1201
  function setStatus(message, error) {
@@ -1203,6 +1268,23 @@
1203
  statusEl.classList.toggle("error", Boolean(error));
1204
  }
1205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1206
  function revealScan() {
1207
  scanLine.classList.remove("active");
1208
  void scanLine.offsetWidth;
 
210
  .toolbar {
211
  position: absolute;
212
  left: 18px;
213
+ right: 18px;
214
  top: 18px;
215
  z-index: 10;
216
  display: flex;
217
  flex-wrap: wrap;
218
  gap: 8px;
219
+ max-width: calc(100% - 36px);
220
  }
221
 
222
  .tool-button {
 
243
  cursor: default;
244
  }
245
 
246
+ .threshold-control {
247
+ min-height: 34px;
248
+ display: flex;
249
+ align-items: center;
250
+ gap: 8px;
251
+ border: 1px solid var(--amber-dim);
252
+ padding: 0 9px;
253
+ color: var(--text-muted);
254
+ background: rgba(15, 19, 24, 0.86);
255
+ font-size: 0.68rem;
256
+ font-weight: 600;
257
+ text-transform: uppercase;
258
+ white-space: nowrap;
259
+ }
260
+
261
+ .threshold-control:focus-within {
262
+ border-color: var(--amber);
263
+ }
264
+
265
+ .threshold-slider {
266
+ width: 104px;
267
+ accent-color: var(--amber);
268
+ cursor: pointer;
269
+ }
270
+
271
+ .threshold-slider:disabled {
272
+ cursor: default;
273
+ opacity: 0.45;
274
+ }
275
+
276
+ .threshold-value {
277
+ min-width: 32px;
278
+ color: var(--amber);
279
+ font-family: "Fira Code", monospace;
280
+ text-align: right;
281
+ }
282
+
283
  .viewport-hint {
284
  position: absolute;
285
  left: 18px;
 
590
  <button id="uploadButton" class="tool-button" type="button">Load</button>
591
  <button id="playButton" class="tool-button" type="button" disabled>Play</button>
592
  <button id="resetViewButton" class="tool-button" type="button" disabled>Reset view</button>
593
+ <label class="threshold-control" for="confidenceThreshold">
594
+ <span>Min confidence</span>
595
+ <input id="confidenceThreshold" class="threshold-slider" type="range" min="0" max="1" step="0.05" value="0.5" aria-describedby="thresholdValue" disabled>
596
+ <span id="thresholdValue" class="threshold-value" aria-live="polite">50%</span>
597
+ </label>
598
  </div>
599
  <div id="viewport" aria-hidden="true"></div>
600
  <div id="viewportHint" class="viewport-hint" hidden>Drag to orbit / scroll to zoom</div>
 
647
  const uploadButton = document.getElementById("uploadButton");
648
  const playButton = document.getElementById("playButton");
649
  const resetViewButton = document.getElementById("resetViewButton");
650
+ const confidenceThresholdInput = document.getElementById("confidenceThreshold");
651
+ const thresholdValue = document.getElementById("thresholdValue");
652
  const dropZone = document.getElementById("dropZone");
653
  const viewport = document.getElementById("viewport");
654
  const viewportHint = document.getElementById("viewportHint");
 
667
  const partsEl = document.getElementById("parts");
668
  const rawJsonEl = document.getElementById("rawJson");
669
 
670
+ const DEFAULT_CONFIDENCE_THRESHOLD = 0.5;
671
  let activeMode = "idle";
672
  let paused = false;
673
+ let requestBusy = false;
674
  let coldStartTimer = 0;
675
+ let thresholdRenderTimer = 0;
676
  let fallbackRuntime = null;
677
  let currentPreviewUrl = "";
678
+ let confidenceThreshold = DEFAULT_CONFIDENCE_THRESHOLD;
679
  const MAX_CLIENT_IMAGE_BYTES = 8 * 1024 * 1024;
680
 
681
  uploadButton.addEventListener("click", () => fileInput.click());
 
719
  resetViewButton.addEventListener("click", () => {
720
  if (fallbackRuntime && fallbackRuntime.resetView) fallbackRuntime.resetView();
721
  });
722
+ confidenceThresholdInput.addEventListener("input", () => {
723
+ updateConfidenceThreshold();
724
+ scheduleThresholdRender();
725
+ });
726
 
727
  async function runPipeline(file) {
728
  const validationError = validateFile(file);
 
766
  playButton.textContent = "Play";
767
  playButton.disabled = true;
768
  resetViewButton.disabled = true;
769
+ confidenceThresholdInput.disabled = true;
770
  viewportHint.hidden = true;
771
  retryButton.hidden = true;
772
+ window.lastAnalysis = null;
773
+ window.lastScenePayload = null;
774
  viewport.replaceChildren();
775
  dropZone.classList.add("hidden");
776
  }
 
804
 
805
  function renderScenePayload(payload) {
806
  const analysis = payload && payload.analysis ? payload.analysis : payload;
807
+ if (analysis) {
808
+ window.lastAnalysis = analysis;
809
+ window.lastScenePayload = payload;
810
+ }
811
+ const renderMode = chooseRenderMode(analysis);
812
  if (renderMode === "three" && hasUsableGeometry(analysis)) {
813
  buildDeterministicScene(analysis);
814
  } else if (renderMode === "annotate" && hasAnnotations(analysis)) {
 
820
 
821
  function chooseRenderMode(analysis) {
822
  const confidence = typeof analysis.confidence === "number" ? analysis.confidence : 1;
823
+ if (hasAnnotations(analysis) && (confidence < confidenceThreshold || !hasUsableGeometry(analysis))) {
824
  return "annotate";
825
  }
826
+ if (hasUsableGeometry(analysis) && confidence >= confidenceThreshold) {
827
  return "three";
828
  }
829
  if (hasAnnotations(analysis)) {
 
869
  playButton.textContent = "Pause";
870
  playButton.disabled = false;
871
  resetViewButton.disabled = false;
872
+ retryButton.hidden = true;
873
  viewportHint.hidden = false;
874
  viewport.replaceChildren();
875
  const stage = document.createElement("div");
 
1001
  playButton.textContent = "Play";
1002
  playButton.disabled = true;
1003
  resetViewButton.disabled = true;
1004
+ retryButton.hidden = true;
1005
  viewportHint.hidden = true;
1006
  viewport.replaceChildren();
1007
 
 
1257
  }
1258
 
1259
  function setBusy(active) {
1260
+ requestBusy = active;
1261
  progress.classList.toggle("active", active);
1262
  uploadButton.disabled = active;
1263
+ confidenceThresholdInput.disabled = active || !window.lastScenePayload;
1264
  }
1265
 
1266
  function setStatus(message, error) {
 
1268
  statusEl.classList.toggle("error", Boolean(error));
1269
  }
1270
 
1271
+ function updateConfidenceThreshold() {
1272
+ const nextThreshold = Number(confidenceThresholdInput.value);
1273
+ confidenceThreshold = Number.isFinite(nextThreshold)
1274
+ ? nextThreshold
1275
+ : DEFAULT_CONFIDENCE_THRESHOLD;
1276
+ thresholdValue.textContent = Math.round(confidenceThreshold * 100) + "%";
1277
+ }
1278
+
1279
+ function scheduleThresholdRender() {
1280
+ window.clearTimeout(thresholdRenderTimer);
1281
+ thresholdRenderTimer = window.setTimeout(() => {
1282
+ if (!requestBusy && window.lastScenePayload) {
1283
+ renderScenePayload(window.lastScenePayload);
1284
+ }
1285
+ }, 90);
1286
+ }
1287
+
1288
  function revealScan() {
1289
  scanLine.classList.remove("active");
1290
  void scanLine.offsetWidth;
snap2sim/schema.py CHANGED
@@ -213,10 +213,11 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
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)
@@ -225,7 +226,7 @@ def select_render_mode(analysis: dict[str, Any]) -> str:
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:
 
213
  _SHAPES = {"box", "cylinder", "sphere", "gear", "rod"}
214
  _MOTIONS = {"rotate", "translate", "oscillate", "static"}
215
  _RENDER_MODES = {"three", "annotate", "unavailable"}
216
+ DEFAULT_CONFIDENCE_THRESHOLD = 0.5
217
 
218
 
219
  def select_render_mode(analysis: dict[str, Any]) -> str:
220
+ """Pick the safest advisory renderer for a validated analysis payload."""
221
  explicit_mode = analysis.get("render_mode")
222
  if explicit_mode in _RENDER_MODES:
223
  return str(explicit_mode)
 
226
  has_geometry = any(isinstance(part, dict) and isinstance(part.get("geometry"), dict) for part in parts)
227
  has_annotation = any(isinstance(part, dict) and isinstance(part.get("annotation"), dict) for part in parts)
228
  confidence = analysis.get("confidence", 1)
229
+ low_confidence = _is_number(confidence) and float(confidence) < DEFAULT_CONFIDENCE_THRESHOLD
230
  if has_annotation and (low_confidence or not has_geometry):
231
  return "annotate"
232
  if has_geometry and not low_confidence: