Spaces:
Sleeping
Sleeping
jasondo OpenAI Codex commited on
Commit ·
a0540e9
1
Parent(s): ca9bbeb
Add confidence threshold control
Browse filesCo-authored-by: OpenAI Codex <codex@openai.com>
- AGENTS.md +8 -0
- FEATURE.md +194 -0
- README.md +7 -2
- index.html +85 -3
- 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.
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 <
|
| 763 |
return "annotate";
|
| 764 |
}
|
| 765 |
-
if (hasUsableGeometry(analysis) && confidence >=
|
| 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) <
|
| 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:
|