Snap2Sim / index.html
jasondo
Reduce motion axis bias
5e65d06
Raw
History Blame Contribute Delete
51.1 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inside the Machine</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=chakra-petch:400,500,600,700|fira-code:400,500,600" rel="stylesheet">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.1/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
window.THREE = { ...THREE, OrbitControls };
</script>
<style>
:root {
--bg: #0F1318;
--bg-panel: #161B22;
--bg-lift: #1E2530;
--amber: #E8A33D;
--amber-dim: #7A5420;
--cyan: #5FD4D0;
--cyan-dim: #2A5E5C;
--text: #C8C0AC;
--text-muted: #9CA3AF;
--grid: rgba(255,255,255,0.04);
--danger: #F07F5A;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
color: var(--text);
background:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
var(--bg);
background-size: 32px 32px;
font-family: "Chakra Petch", "Fira Code", monospace;
}
button,
input {
font: inherit;
}
.shell {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: minmax(0, 63fr) minmax(340px, 37fr);
width: 100vw;
height: 100vh;
}
.shell::after {
content: "";
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
opacity: 0.11;
mix-blend-mode: screen;
background-image:
radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.28) 0 1px, transparent 1px),
radial-gradient(circle at 75% 65%, rgba(0, 0, 0, 0.42) 0 1px, transparent 1px);
background-size: 13px 17px, 19px 23px;
}
.component-watermark {
position: absolute;
left: clamp(-10px, 2vw, 28px);
bottom: clamp(-18px, -1.2vw, -8px);
z-index: 2;
max-width: 92vw;
color: var(--bg-lift);
opacity: 0.34;
font-size: clamp(3.6rem, 8vw, 9rem);
line-height: 0.76;
font-weight: 700;
text-transform: uppercase;
overflow-wrap: anywhere;
pointer-events: none;
user-select: none;
}
.viewport-pane {
position: relative;
min-width: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 40%, rgba(95, 212, 208, 0.06), transparent 42%),
var(--bg);
}
.viewport-pane::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(0, 0, 0, 0.18), transparent 18%, transparent 76%, rgba(0, 0, 0, 0.22)),
radial-gradient(circle at center, transparent 52%, rgba(0, 0, 0, 0.44));
z-index: 7;
}
#viewport {
position: absolute;
inset: 0;
z-index: 1;
}
.drop-zone {
position: absolute;
inset: 18px;
z-index: 5;
display: grid;
place-items: center;
border: 1px dashed var(--amber-dim);
color: var(--text-muted);
background: rgba(15, 19, 24, 0.72);
text-transform: uppercase;
letter-spacing: 0;
font-size: clamp(1.15rem, 2vw, 2.1rem);
font-weight: 600;
transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
}
.drop-zone.dragging {
color: var(--amber);
border-color: var(--amber);
background: rgba(122, 84, 32, 0.16);
}
.drop-zone.hidden {
display: none;
}
.scan-line {
position: absolute;
left: 0;
right: 0;
top: -2px;
z-index: 8;
height: 3px;
opacity: 0;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
box-shadow:
0 0 18px rgba(95, 212, 208, 0.8),
0 18px 42px rgba(95, 212, 208, 0.16);
pointer-events: none;
}
.scan-line::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 3px;
height: 52px;
background: linear-gradient(180deg, rgba(95, 212, 208, 0.13), transparent);
}
.scan-line.active {
animation: scan 820ms cubic-bezier(0.16, 1, 0.3, 1);
}
.progress {
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 9;
height: 2px;
overflow: hidden;
opacity: 0;
background: rgba(122, 84, 32, 0.35);
transition: opacity 180ms ease;
}
.progress.active {
opacity: 1;
}
.progress::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 34%;
background: var(--amber);
animation: loading 1050ms linear infinite;
}
.toolbar {
position: absolute;
left: 18px;
right: 18px;
top: 18px;
z-index: 10;
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: calc(100% - 36px);
}
.tool-button {
min-width: 82px;
min-height: 34px;
border: 1px solid var(--amber-dim);
border-radius: 0;
color: var(--amber);
background: rgba(15, 19, 24, 0.86);
text-transform: uppercase;
letter-spacing: 0;
cursor: pointer;
}
.tool-button:hover,
.tool-button:focus-visible {
border-color: var(--amber);
outline: none;
}
.tool-button[disabled] {
color: var(--text-muted);
border-color: var(--cyan-dim);
cursor: default;
}
.threshold-control {
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--amber-dim);
padding: 0 9px;
color: var(--text-muted);
background: rgba(15, 19, 24, 0.86);
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.threshold-control:focus-within {
border-color: var(--amber);
}
.threshold-slider {
width: 104px;
accent-color: var(--amber);
cursor: pointer;
}
.threshold-slider:disabled {
cursor: default;
opacity: 0.45;
}
.threshold-value {
min-width: 32px;
color: var(--amber);
font-family: "Fira Code", monospace;
text-align: right;
}
.viewport-hint {
position: absolute;
left: 18px;
bottom: 18px;
z-index: 11;
max-width: min(320px, calc(100% - 36px));
border-left: 1px solid var(--cyan-dim);
padding: 6px 0 6px 10px;
color: var(--text-muted);
background: rgba(15, 19, 24, 0.78);
font: 0.78rem/1.3 "Fira Code", monospace;
text-transform: uppercase;
pointer-events: none;
}
.viewport-hint[hidden] {
display: none;
}
.panel {
position: relative;
min-width: 0;
overflow: auto;
border-left: 1px solid var(--amber-dim);
background: linear-gradient(180deg, rgba(30, 37, 48, 0.92), var(--bg-panel));
}
.panel-inner {
position: relative;
z-index: 1;
min-height: 100%;
padding: 24px;
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 22px;
}
.brand {
display: grid;
gap: 8px;
border-bottom: 1px solid rgba(122, 84, 32, 0.7);
padding-bottom: 18px;
}
.kicker,
.label {
color: var(--amber);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
}
h1 {
margin: 0;
color: var(--text);
font-size: clamp(2rem, 4vw, 4.4rem);
line-height: 0.9;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.status {
min-height: 24px;
color: var(--amber);
font-size: 0.92rem;
font-weight: 600;
text-transform: uppercase;
}
.status.error {
color: var(--danger);
}
.retry-button {
justify-self: start;
min-height: 32px;
border: 1px solid var(--danger);
border-radius: 0;
color: var(--danger);
background: transparent;
text-transform: uppercase;
letter-spacing: 0;
cursor: pointer;
}
.retry-button[hidden] {
display: none;
}
.source-card {
display: grid;
gap: 8px;
border-bottom: 1px solid rgba(95, 212, 208, 0.16);
padding-bottom: 14px;
}
.source-card[hidden] {
display: none;
}
.source-image {
width: 100%;
height: 180px;
object-fit: contain;
border: 1px solid rgba(122, 84, 32, 0.72);
background: var(--bg);
}
.readout {
display: grid;
gap: 18px;
}
.metric-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: baseline;
border-bottom: 1px solid rgba(95, 212, 208, 0.16);
padding-bottom: 10px;
}
.component {
color: var(--cyan);
font-size: clamp(1.4rem, 2.2vw, 2.4rem);
font-weight: 700;
line-height: 1;
text-transform: uppercase;
}
.confidence {
color: var(--amber);
font-family: "Fira Code", monospace;
font-size: 0.95rem;
}
.summary,
.trigger,
.sequence,
.parts {
margin: 0;
color: var(--text);
font-size: 0.98rem;
line-height: 1.5;
}
.trigger {
color: var(--text-muted);
}
.sequence,
.parts {
display: grid;
gap: 8px;
padding: 0;
list-style: none;
}
.sequence li,
.parts li {
border-left: 1px solid var(--cyan-dim);
padding-left: 10px;
}
.parts strong {
display: block;
color: var(--cyan);
font-weight: 600;
text-transform: uppercase;
}
.json-box {
max-height: 240px;
overflow: auto;
margin: 0;
border-top: 1px solid rgba(122, 84, 32, 0.7);
padding-top: 14px;
color: var(--text-muted);
font: 0.78rem/1.45 "Fira Code", monospace;
white-space: pre-wrap;
}
.fallback-stage,
.scene-mount,
.label-layer {
position: absolute;
inset: 0;
}
.scene-label {
position: absolute;
max-width: min(180px, 34vw);
transform: translate(-50%, -50%);
border-left: 1px solid var(--cyan);
padding: 4px 0 4px 8px;
color: var(--cyan);
background: rgba(15, 19, 24, 0.72);
font: 0.72rem/1.2 "Fira Code", monospace;
text-transform: uppercase;
pointer-events: none;
}
.label-layer {
pointer-events: none;
}
.annotated-stage {
background: rgba(15, 19, 24, 0.72);
}
.annotation-frame {
position: absolute;
inset: 58px 18px 44px;
}
.annotation-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
outline: 1px solid rgba(122, 84, 32, 0.72);
background: var(--bg);
}
.photo-marker {
position: absolute;
width: 22px;
height: 22px;
transform: translate(-50%, -50%);
border: 1px solid var(--cyan);
border-radius: 50%;
color: var(--bg);
background: var(--cyan);
box-shadow: 0 0 16px rgba(95, 212, 208, 0.45);
display: grid;
place-items: center;
font: 0.68rem/1 "Fira Code", monospace;
pointer-events: none;
}
.photo-leader {
position: absolute;
height: 1px;
transform-origin: 0 50%;
background: rgba(95, 212, 208, 0.72);
pointer-events: none;
}
.photo-box {
position: absolute;
border: 1px solid rgba(95, 212, 208, 0.86);
background: rgba(95, 212, 208, 0.08);
box-shadow: inset 0 0 0 1px rgba(15, 19, 24, 0.45);
pointer-events: none;
}
.photo-label {
max-width: min(220px, 42vw);
transform: none;
white-space: normal;
text-transform: none;
}
.label-title {
display: block;
color: var(--cyan);
text-transform: uppercase;
}
.label-note {
display: block;
color: var(--text);
margin-top: 2px;
}
.empty-state {
position: absolute;
inset: 18px;
display: grid;
place-items: center;
border: 1px dashed var(--danger);
color: var(--danger);
background: rgba(15, 19, 24, 0.72);
text-align: center;
text-transform: uppercase;
font-weight: 600;
}
@keyframes loading {
from { transform: translateX(-100%); }
to { transform: translateX(300%); }
}
@keyframes scan {
0% { transform: translateY(0); opacity: 0; }
8%, 72% { opacity: 1; }
100% { transform: translateY(calc(100vh + 60px)); opacity: 0; }
}
@media (max-width: 860px) {
body {
overflow: auto;
}
.shell {
overflow: hidden;
grid-template-columns: 1fr;
grid-template-rows: minmax(420px, 62vh) auto;
min-height: 100vh;
height: auto;
}
.component-watermark {
max-width: 120vw;
font-size: clamp(3rem, 14vw, 6rem);
}
.panel {
border-left: 0;
border-top: 1px solid var(--amber-dim);
}
.source-image {
height: 220px;
}
}
</style>
</head>
<body>
<main class="shell">
<div id="componentWatermark" class="component-watermark" aria-hidden="true">Inside the Machine</div>
<section class="viewport-pane" aria-label="Cutaway viewport">
<div id="progress" class="progress"></div>
<div class="toolbar">
<button id="uploadButton" class="tool-button" type="button">Load</button>
<button id="playButton" class="tool-button" type="button" disabled>Play</button>
<button id="resetViewButton" class="tool-button" type="button" disabled>Reset view</button>
<label class="threshold-control" for="confidenceThreshold">
<span>Min confidence</span>
<input id="confidenceThreshold" class="threshold-slider" type="range" min="0" max="1" step="0.05" value="0.5" aria-describedby="thresholdValue">
<span id="thresholdValue" class="threshold-value" aria-live="polite">50%</span>
</label>
</div>
<div id="viewport" aria-hidden="true"></div>
<div id="viewportHint" class="viewport-hint" hidden>Drag to orbit / scroll to zoom</div>
<label id="dropZone" class="drop-zone" for="fileInput" tabindex="0">Drop component photo</label>
<div id="scanLine" class="scan-line"></div>
<input id="fileInput" type="file" accept="image/*" capture="environment" hidden>
</section>
<aside class="panel">
<div class="panel-inner">
<header class="brand">
<div class="kicker">Snap2Sim</div>
<h1>Inside the Machine</h1>
<div id="status" class="status" role="status" aria-live="polite">Standby</div>
<button id="retryButton" class="retry-button" type="button" hidden>Try another photo</button>
</header>
<section id="sourceCard" class="source-card" hidden>
<div class="label">Source Photo</div>
<img id="sourceImage" class="source-image" alt="Uploaded component photo preview">
</section>
<section class="readout">
<div class="metric-row">
<div id="component" class="component">Awaiting Photo</div>
<div id="confidence" class="confidence">--</div>
</div>
<p id="summary" class="summary"></p>
<p id="trigger" class="trigger"></p>
</section>
<section class="readout">
<div>
<div class="label">Motion Sequence</div>
<ol id="sequence" class="sequence"></ol>
</div>
<div>
<div class="label">Parts</div>
<ul id="parts" class="parts"></ul>
</div>
</section>
<pre id="rawJson" class="json-box">{}</pre>
</div>
</aside>
</main>
<script>
const fileInput = document.getElementById("fileInput");
const uploadButton = document.getElementById("uploadButton");
const playButton = document.getElementById("playButton");
const resetViewButton = document.getElementById("resetViewButton");
const confidenceThresholdInput = document.getElementById("confidenceThreshold");
const thresholdValue = document.getElementById("thresholdValue");
const dropZone = document.getElementById("dropZone");
const viewport = document.getElementById("viewport");
const viewportHint = document.getElementById("viewportHint");
const progress = document.getElementById("progress");
const scanLine = document.getElementById("scanLine");
const statusEl = document.getElementById("status");
const retryButton = document.getElementById("retryButton");
const sourceCard = document.getElementById("sourceCard");
const sourceImage = document.getElementById("sourceImage");
const componentWatermark = document.getElementById("componentWatermark");
const componentEl = document.getElementById("component");
const confidenceEl = document.getElementById("confidence");
const summaryEl = document.getElementById("summary");
const triggerEl = document.getElementById("trigger");
const sequenceEl = document.getElementById("sequence");
const partsEl = document.getElementById("parts");
const rawJsonEl = document.getElementById("rawJson");
const DEFAULT_CONFIDENCE_THRESHOLD = 0.5;
let activeMode = "idle";
let paused = false;
let requestBusy = false;
let coldStartTimer = 0;
let fallbackRuntime = null;
let currentPreviewUrl = "";
let confidenceThreshold = DEFAULT_CONFIDENCE_THRESHOLD;
const MAX_CLIENT_IMAGE_BYTES = 8 * 1024 * 1024;
uploadButton.addEventListener("click", () => fileInput.click());
retryButton.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
const file = fileInput.files && fileInput.files[0];
if (file) runPipeline(file);
});
for (const eventName of ["dragenter", "dragover"]) {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add("dragging");
});
}
for (const eventName of ["dragleave", "drop"]) {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove("dragging");
});
}
dropZone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
fileInput.click();
}
});
dropZone.addEventListener("drop", (event) => {
const file = event.dataTransfer.files && event.dataTransfer.files[0];
if (file) runPipeline(file);
});
playButton.addEventListener("click", () => {
paused = !paused;
playButton.textContent = paused ? "Resume" : "Pause";
if (fallbackRuntime) fallbackRuntime.playing = !paused;
});
resetViewButton.addEventListener("click", () => {
if (fallbackRuntime && fallbackRuntime.resetView) fallbackRuntime.resetView();
});
confidenceThresholdInput.addEventListener("input", () => {
updateConfidenceThreshold();
});
async function runPipeline(file) {
const validationError = validateFile(file);
if (validationError) {
setStatus(validationError, true);
retryButton.hidden = false;
dropZone.classList.remove("hidden");
return;
}
resetScene();
showSourcePreview(file);
setBusy(true);
setStatus("ANALYZING ASSEMBLY...");
coldStartTimer = window.setTimeout(() => setStatus("WAKING THE WORKSHOP..."), 6500);
try {
const imageBase64 = await fileToDataUrl(file);
const analysis = await postJson("/analyze_image", { image_base64: imageBase64 });
window.clearTimeout(coldStartTimer);
populateAnalysis(analysis);
setStatus("RENDERING CUTAWAY...");
const scenePayload = await postJson("/generate_scene", {
analysis,
confidence_threshold: confidenceThreshold
});
renderScenePayload(scenePayload);
} catch (error) {
window.clearTimeout(coldStartTimer);
setStatus(error.message || String(error), true);
retryButton.hidden = false;
dropZone.classList.remove("hidden");
} finally {
setBusy(false);
}
}
function resetScene() {
if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
fallbackRuntime = null;
activeMode = "idle";
paused = false;
playButton.textContent = "Play";
playButton.disabled = true;
resetViewButton.disabled = true;
viewportHint.hidden = true;
retryButton.hidden = true;
window.lastAnalysis = null;
window.lastScenePayload = null;
viewport.replaceChildren();
dropZone.classList.add("hidden");
}
function populateAnalysis(analysis) {
window.lastAnalysis = analysis;
const componentName = String(analysis.component || "Component");
componentEl.textContent = componentName.toUpperCase();
componentWatermark.textContent = componentName;
confidenceEl.textContent = typeof analysis.confidence === "number"
? Math.round(analysis.confidence * 100) + "%"
: "--";
summaryEl.textContent = analysis.summary || "";
triggerEl.textContent = analysis.trigger ? "Trigger: " + analysis.trigger : "";
rawJsonEl.textContent = JSON.stringify(analysis, null, 2);
sequenceEl.replaceChildren(...(analysis.motion_sequence || []).map((step) => {
const item = document.createElement("li");
item.textContent = step;
return item;
}));
partsEl.replaceChildren(...(analysis.parts || []).map((part) => {
const item = document.createElement("li");
const name = document.createElement("strong");
name.textContent = part.name || part.id || "part";
const role = document.createElement("span");
role.textContent = part.role || "";
item.append(name, role);
return item;
}));
}
function renderScenePayload(payload) {
const analysis = payload && payload.analysis ? payload.analysis : payload;
if (analysis) {
window.lastAnalysis = analysis;
window.lastScenePayload = payload;
}
const renderMode = chooseRenderMode(payload, analysis);
if (renderMode === "three" && hasUsableGeometry(analysis)) {
buildDeterministicScene(analysis);
} else if (renderMode === "annotate" && hasAnnotations(analysis)) {
buildAnnotatedPhoto(analysis);
} else {
buildUnavailableScene();
}
}
function chooseRenderMode(payload, analysis) {
const serverMode = payload && typeof payload.render_mode === "string"
? payload.render_mode
: analysis && typeof analysis.render_mode === "string"
? analysis.render_mode
: "unavailable";
if (serverMode === "three") {
if (hasUsableGeometry(analysis)) return "three";
if (hasAnnotations(analysis)) return "annotate";
return "unavailable";
}
if (serverMode === "annotate" && hasAnnotations(analysis)) {
return "annotate";
}
return "unavailable";
}
function hasUsableGeometry(analysis) {
return Boolean(analysis && Array.isArray(analysis.parts) && analysis.parts.some((part) => {
const geometry = part && part.geometry;
return geometry
&& ["box", "cylinder", "sphere", "gear", "rod", "cone", "capsule", "torus", "spring"].includes(geometry.shape)
&& isNumberList(geometry.size, 3)
&& isNumberList(geometry.position, 3);
}));
}
function hasAnnotations(analysis) {
return Boolean(analysis && Array.isArray(analysis.parts) && analysis.parts.some((part) => {
const point = part && part.annotation && part.annotation.point;
return isNumberList(point, 2);
}));
}
function isNumberList(value, length) {
return Array.isArray(value)
&& value.length === length
&& value.every((item) => typeof item === "number" && Number.isFinite(item));
}
function buildDeterministicScene(analysis) {
const threeRuntime = window.THREE;
if (!threeRuntime) {
setStatus("3D RUNTIME UNAVAILABLE", true);
return;
}
window.THREE = threeRuntime;
if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
activeMode = "three";
paused = false;
playButton.textContent = "Pause";
playButton.disabled = false;
resetViewButton.disabled = false;
retryButton.hidden = true;
viewportHint.hidden = false;
viewport.replaceChildren();
const stage = document.createElement("div");
stage.className = "fallback-stage";
const mount = document.createElement("div");
mount.className = "scene-mount";
const labelLayer = document.createElement("div");
labelLayer.className = "label-layer";
stage.append(mount, labelLayer);
viewport.append(stage);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.domElement.style.touchAction = "none";
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0f1318, 8, 20);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
const defaultViewDirection = new THREE.Vector3(5.4, 3.9, 6.2).normalize();
const defaultCameraPosition = new THREE.Vector3(5.4, 3.9, 6.2);
const defaultTarget = new THREE.Vector3(0, 0, 0);
camera.position.copy(defaultCameraPosition);
const controls = window.THREE.OrbitControls
? new THREE.OrbitControls(camera, renderer.domElement)
: null;
if (controls) {
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.48;
controls.target.copy(defaultTarget);
if (window.THREE.TOUCH) {
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN
};
}
controls.update();
controls.saveState();
}
scene.add(new THREE.HemisphereLight(0x5fd4d0, 0x0f1318, 1.2));
const key = new THREE.DirectionalLight(0xe8a33d, 1.9);
key.position.set(4, 6, 5);
scene.add(key);
const grid = new THREE.GridHelper(8, 32, 0x2a5e5c, 0x1e2530);
grid.material.transparent = true;
grid.material.opacity = 0.75;
scene.add(grid);
const partGroup = new THREE.Group();
scene.add(partGroup);
const meshes = (analysis.parts || []).slice(0, 6).map((part, index) => {
const mesh = buildPartMesh(part, index);
const position = part.geometry && part.geometry.position || [0, 0, 0];
mesh.position.set(position[0], position[1], position[2]);
if (part.geometry && Array.isArray(part.geometry.rotation)) {
mesh.rotation.fromArray(part.geometry.rotation);
}
mesh.userData.basePosition = mesh.position.clone();
mesh.userData.baseRotation = mesh.rotation.clone();
mesh.userData.part = part;
const pivot = part.motion && isNumberList(part.motion.pivot, 3)
? part.motion.pivot
: [0, 0, 0];
mesh.userData.pivot = new THREE.Vector3(pivot[0], pivot[1], pivot[2]);
partGroup.add(mesh);
return mesh;
});
const fitBounds = fitAssembly(partGroup, grid);
for (const mesh of meshes) {
mesh.scale.setScalar(0.001);
}
const clock = new THREE.Clock();
let frameId = 0;
let disposed = false;
fallbackRuntime = {
playing: true,
resetView() {
camera.position.copy(defaultCameraPosition);
if (controls) {
controls.target.copy(defaultTarget);
controls.minDistance = Math.max(0.05, fitBounds.radius * 0.6);
controls.maxDistance = Math.max(controls.minDistance * 2, fitBounds.radius * 6);
controls.update();
}
},
cleanup() {
disposed = true;
cancelAnimationFrame(frameId);
renderer.dispose();
}
};
function resize() {
const rect = viewport.getBoundingClientRect();
camera.aspect = Math.max(1, rect.width) / Math.max(1, rect.height);
renderer.setSize(Math.max(1, rect.width), Math.max(1, rect.height));
fitCameraToBounds(camera, controls, fitBounds, defaultViewDirection, defaultCameraPosition, defaultTarget);
}
function animate() {
if (disposed) return;
frameId = requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
for (const [index, mesh] of meshes.entries()) {
revealMesh(mesh, elapsed, index);
if (fallbackRuntime.playing) applyMotion(mesh, elapsed);
}
if (controls) controls.update();
renderer.render(scene, camera);
updateLabels(camera, labelLayer, meshes);
}
window.addEventListener("resize", resize);
const originalCleanup = fallbackRuntime.cleanup;
fallbackRuntime.cleanup = () => {
window.removeEventListener("resize", resize);
originalCleanup();
};
resize();
animate();
revealScan();
setStatus("CUTAWAY READY");
}
function buildAnnotatedPhoto(analysis) {
if (!currentPreviewUrl) {
buildUnavailableScene();
return;
}
if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
activeMode = "annotate";
paused = true;
playButton.textContent = "Play";
playButton.disabled = true;
resetViewButton.disabled = true;
retryButton.hidden = true;
viewportHint.hidden = true;
viewport.replaceChildren();
const stage = document.createElement("div");
stage.className = "fallback-stage annotated-stage";
const frame = document.createElement("div");
frame.className = "annotation-frame";
const image = document.createElement("img");
image.className = "annotation-image";
image.alt = "";
image.src = currentPreviewUrl;
const labelLayer = document.createElement("div");
labelLayer.className = "label-layer";
frame.append(image, labelLayer);
stage.append(frame);
viewport.append(stage);
let disposed = false;
const annotatedParts = (analysis.parts || []).filter((part) => {
const point = part && part.annotation && part.annotation.point;
return isNumberList(point, 2);
});
function updatePhotoLabels() {
if (disposed) return;
const imageRect = containedImageRect(image);
const layerRect = labelLayer.getBoundingClientRect();
if (imageRect.width < 1 || imageRect.height < 1 || layerRect.width < 1 || layerRect.height < 1) return;
const nodes = [];
for (const [index, part] of annotatedParts.entries()) {
const annotation = part.annotation || {};
const point = annotation.point || [0.5, 0.5];
const pointX = imageRect.left - layerRect.left + clamp01(point[0]) * imageRect.width;
const pointY = imageRect.top - layerRect.top + clamp01(point[1]) * imageRect.height;
if (isNumberList(annotation.box, 4)) {
const [boxX, boxY, boxW, boxH] = annotation.box.map(clamp01);
const box = document.createElement("div");
box.className = "photo-box";
box.style.left = (imageRect.left - layerRect.left + boxX * imageRect.width) + "px";
box.style.top = (imageRect.top - layerRect.top + boxY * imageRect.height) + "px";
box.style.width = (Math.min(boxW, 1 - boxX) * imageRect.width) + "px";
box.style.height = (Math.min(boxH, 1 - boxY) * imageRect.height) + "px";
nodes.push(box);
}
const marker = document.createElement("div");
marker.className = "photo-marker";
marker.style.left = pointX + "px";
marker.style.top = pointY + "px";
marker.textContent = String(index + 1);
nodes.push(marker);
const labelWidth = Math.min(220, Math.max(120, layerRect.width * 0.42), Math.max(80, layerRect.width - 24));
const preferLeft = pointX > layerRect.width * 0.62;
const maxLabelX = Math.max(12, layerRect.width - labelWidth - 12);
const maxLabelY = Math.max(12, layerRect.height - 82);
const labelX = Math.min(
maxLabelX,
Math.max(12, pointX + (preferLeft ? -labelWidth - 28 : 28))
);
const labelY = Math.min(
maxLabelY,
Math.max(12, pointY - 42)
);
const label = document.createElement("div");
label.className = "scene-label photo-label";
label.style.left = labelX + "px";
label.style.top = labelY + "px";
label.style.width = labelWidth + "px";
const title = document.createElement("span");
title.className = "label-title";
title.textContent = (index + 1) + ". " + (annotation.label || part.name || part.id || "part");
label.append(title);
const noteText = annotation.note || part.role || "";
if (noteText) {
const note = document.createElement("span");
note.className = "label-note";
note.textContent = noteText;
label.append(note);
}
const labelAnchorX = preferLeft ? labelX + labelWidth : labelX;
const labelAnchorY = labelY + 24;
const dx = labelAnchorX - pointX;
const dy = labelAnchorY - pointY;
const leader = document.createElement("div");
leader.className = "photo-leader";
leader.style.left = pointX + "px";
leader.style.top = pointY + "px";
leader.style.width = Math.max(0, Math.hypot(dx, dy) - 13) + "px";
leader.style.transform = "rotate(" + Math.atan2(dy, dx) + "rad)";
nodes.push(leader, label);
}
labelLayer.replaceChildren(...nodes);
}
image.addEventListener("load", updatePhotoLabels, { once: true });
window.addEventListener("resize", updatePhotoLabels);
fallbackRuntime = {
playing: false,
cleanup() {
disposed = true;
window.removeEventListener("resize", updatePhotoLabels);
}
};
window.requestAnimationFrame(updatePhotoLabels);
revealScan();
setStatus("ANNOTATED PHOTO READY");
}
function buildUnavailableScene() {
if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
fallbackRuntime = null;
activeMode = "unavailable";
paused = true;
playButton.textContent = "Play";
playButton.disabled = true;
resetViewButton.disabled = true;
viewportHint.hidden = true;
viewport.replaceChildren();
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "Could not analyze this photo. Try another angle.";
viewport.append(empty);
retryButton.hidden = false;
setStatus("COULD NOT ANALYZE", true);
}
function containedImageRect(image) {
const rect = image.getBoundingClientRect();
const naturalWidth = image.naturalWidth || rect.width || 1;
const naturalHeight = image.naturalHeight || rect.height || 1;
const rectAspect = rect.width / Math.max(1, rect.height);
const imageAspect = naturalWidth / Math.max(1, naturalHeight);
if (rectAspect > imageAspect) {
const width = rect.height * imageAspect;
return {
left: rect.left + (rect.width - width) / 2,
top: rect.top,
width,
height: rect.height
};
}
const height = rect.width / imageAspect;
return {
left: rect.left,
top: rect.top + (rect.height - height) / 2,
width: rect.width,
height
};
}
function clamp01(value) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
function validateFile(file) {
if (!file.type || !file.type.startsWith("image/")) {
return "Upload an image file.";
}
if (file.size > MAX_CLIENT_IMAGE_BYTES) {
return "Image must be 8 MB or smaller.";
}
return "";
}
function showSourcePreview(file) {
if (currentPreviewUrl) URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = URL.createObjectURL(file);
sourceImage.src = currentPreviewUrl;
sourceCard.hidden = false;
}
function fitAssembly(partGroup, grid) {
partGroup.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(partGroup);
if (box.isEmpty()) {
return { radius: 1, target: new THREE.Vector3(0, 0, 0) };
}
const center = box.getCenter(new THREE.Vector3());
partGroup.position.sub(center);
partGroup.updateMatrixWorld(true);
const recenteredBox = new THREE.Box3().setFromObject(partGroup);
const sphere = recenteredBox.getBoundingSphere(new THREE.Sphere());
const radius = Math.max(0.35, sphere.radius);
if (Number.isFinite(recenteredBox.min.y)) {
grid.position.y = recenteredBox.min.y;
}
return { radius, target: new THREE.Vector3(0, 0, 0) };
}
function fitCameraToBounds(camera, controls, bounds, viewDirection, defaultCameraPosition, defaultTarget) {
const target = bounds.target || new THREE.Vector3(0, 0, 0);
const radius = Math.max(0.35, bounds.radius || 1);
const verticalFov = THREE.MathUtils.degToRad(camera.fov);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(0.1, camera.aspect));
const verticalDistance = radius / Math.sin(verticalFov / 2);
const horizontalDistance = radius / Math.sin(horizontalFov / 2);
const distance = Math.max(verticalDistance, horizontalDistance) * 1.35;
defaultTarget.copy(target);
defaultCameraPosition.copy(target).add(viewDirection.clone().multiplyScalar(distance));
camera.position.copy(defaultCameraPosition);
camera.near = Math.max(0.01, distance / 100);
camera.far = Math.max(100, distance * 100);
camera.updateProjectionMatrix();
if (controls) {
controls.target.copy(defaultTarget);
controls.minDistance = Math.max(0.05, radius * 0.6);
controls.maxDistance = Math.max(controls.minDistance * 2, radius * 6);
controls.update();
controls.saveState();
}
}
function buildPartMesh(part, index) {
const geometryData = part.geometry || {};
const size = Array.isArray(geometryData.size) ? geometryData.size : [1, 1, 1];
const x = Math.max(0.05, Number(size[0]) || 1);
const y = Math.max(0.05, Number(size[1]) || 1);
const z = Math.max(0.05, Number(size[2]) || 1);
let geometry;
let inferredMotionAxis = [0, 1, 0];
if (geometryData.shape === "cylinder") {
const profile = axisProfile([x, y, z], "max");
inferredMotionAxis = axisVector(profile.axis);
geometry = new THREE.CylinderGeometry(profile.radius, profile.radius, profile.length, 48);
orientYAxisGeometry(geometry, profile.axis);
} else if (geometryData.shape === "cone") {
const profile = axisProfile([x, y, z], "max");
inferredMotionAxis = axisVector(profile.axis);
geometry = new THREE.ConeGeometry(profile.radius, profile.length, 48);
orientYAxisGeometry(geometry, profile.axis);
} else if (geometryData.shape === "sphere") {
geometry = new THREE.SphereGeometry(Math.max(x, y, z) / 2, 36, 18);
} else if (geometryData.shape === "capsule") {
const profile = axisProfile([x, y, z], "max");
inferredMotionAxis = axisVector(profile.axis);
const radius = Math.min(profile.radius, profile.length / 2);
geometry = new THREE.CapsuleGeometry(radius, Math.max(0.05, profile.length - radius * 2), 8, 24);
orientYAxisGeometry(geometry, profile.axis);
} else if (geometryData.shape === "rod") {
const profile = axisProfile([x, y, z], "max");
inferredMotionAxis = axisVector(profile.axis);
geometry = new THREE.CylinderGeometry(profile.radius, profile.radius, profile.length, 24);
orientYAxisGeometry(geometry, profile.axis);
} else if (geometryData.shape === "gear") {
const profile = axisProfile([x, y, z], "min");
inferredMotionAxis = axisVector(profile.axis);
geometry = gearGeometry(profile.radius, profile.length, geometryData.teeth || 18);
orientZAxisGeometry(geometry, profile.axis);
} else if (geometryData.shape === "torus") {
const profile = axisProfile([x, y, z], "min");
inferredMotionAxis = axisVector(profile.axis);
const outerRadius = profile.radius;
const tube = Math.min(profile.length / 2, outerRadius * 0.36);
geometry = new THREE.TorusGeometry(Math.max(0.05, outerRadius - tube), Math.max(0.025, tube), 16, 64);
orientZAxisGeometry(geometry, profile.axis);
} else if (geometryData.shape === "spring") {
const profile = axisProfile([x, y, z], "max");
inferredMotionAxis = axisVector(profile.axis);
geometry = springGeometry(profile.radius, profile.length, geometryData.coils || 5, geometryData.wire);
orientYAxisGeometry(geometry, profile.axis);
} else {
geometry = new THREE.BoxGeometry(x, y, z);
}
const color = colorFor(geometryData.color, index);
const material = new THREE.MeshStandardMaterial({
color,
metalness: 0.48,
roughness: 0.34,
emissive: color,
emissiveIntensity: 0.04
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData.inferredMotionAxis = inferredMotionAxis;
return mesh;
}
function axisProfile(extents, fallback) {
const values = extents.map((value) => Math.max(0.05, Number(value) || 1));
const maxValue = Math.max(...values);
const minValue = Math.min(...values);
let axis = 1;
if (!dimensionsAreClose(maxValue, minValue)) {
const pairs = [
{ axis: 2, diff: relativeDifference(values[0], values[1]) },
{ axis: 1, diff: relativeDifference(values[0], values[2]) },
{ axis: 0, diff: relativeDifference(values[1], values[2]) }
].sort((left, right) => left.diff - right.diff);
if (pairs[0].diff <= 0.2) {
axis = pairs[0].axis;
} else if (fallback === "min") {
axis = values.indexOf(minValue);
} else if (fallback === "max") {
axis = values.indexOf(maxValue);
}
}
const crossSection = values.filter((_, index) => index !== axis);
return {
axis,
length: values[axis],
radius: Math.max(0.025, (crossSection[0] + crossSection[1]) / 4)
};
}
function axisVector(axis) {
if (axis === 0) return [1, 0, 0];
if (axis === 2) return [0, 0, 1];
return [0, 1, 0];
}
function dimensionsAreClose(left, right) {
return relativeDifference(left, right) <= 0.2;
}
function relativeDifference(left, right) {
return Math.abs(left - right) / Math.max(0.05, Math.max(Math.abs(left), Math.abs(right)));
}
function orientYAxisGeometry(geometry, axis) {
if (axis === 0) {
geometry.rotateZ(-Math.PI / 2);
} else if (axis === 2) {
geometry.rotateX(Math.PI / 2);
}
}
function orientZAxisGeometry(geometry, axis) {
if (axis === 0) {
geometry.rotateY(Math.PI / 2);
} else if (axis === 1) {
geometry.rotateX(-Math.PI / 2);
}
}
function gearGeometry(radius, depth, teeth) {
const shape = new THREE.Shape();
const steps = teeth * 2;
for (let i = 0; i <= steps; i += 1) {
const angle = (i / steps) * Math.PI * 2;
const r = radius * (i % 2 === 0 ? 1 : 0.84);
const x = Math.cos(angle) * r;
const y = Math.sin(angle) * r;
if (i === 0) shape.moveTo(x, y);
else shape.lineTo(x, y);
}
const hole = new THREE.Path();
hole.absarc(0, 0, radius * 0.26, 0, Math.PI * 2, true);
shape.holes.push(hole);
const geometry = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
geometry.center();
return geometry;
}
function springGeometry(outerRadius, height, coils, wire) {
const safeCoils = Math.max(2, Math.min(12, Number(coils) || 5));
const radius = Math.max(0.08, outerRadius * 0.72);
const wireRadius = Math.max(0.018, Math.min(Number(wire) || outerRadius * 0.08, outerRadius * 0.18));
const points = [];
const steps = Math.ceil(safeCoils * 28);
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const angle = t * safeCoils * Math.PI * 2;
points.push(new THREE.Vector3(
Math.cos(angle) * radius,
(t - 0.5) * height,
Math.sin(angle) * radius
));
}
const curve = new THREE.CatmullRomCurve3(points);
return new THREE.TubeGeometry(curve, steps, wireRadius, 8, false);
}
function revealMesh(mesh, elapsed, index) {
const local = Math.max(0, Math.min(1, (elapsed - index * 0.1) / 0.55));
const eased = 1 - Math.pow(1 - local, 3);
mesh.scale.setScalar(eased);
}
function applyMotion(mesh, elapsed) {
const motion = mesh.userData.part.motion || { type: "static" };
const speed = Number(motion.speed || 1);
const phase = Number(motion.phase || 0);
const axisValues = motion.axis || mesh.userData.inferredMotionAxis || [0, 1, 0];
const axis = new THREE.Vector3(...axisValues).normalize();
mesh.position.copy(mesh.userData.basePosition);
mesh.rotation.copy(mesh.userData.baseRotation);
if (motion.type === "rotate") {
mesh.rotateOnAxis(axis, elapsed * speed + phase);
} else if (motion.type === "oscillate") {
mesh.rotateOnAxis(axis, Math.sin(elapsed * speed + phase) * Number(motion.amplitude || 0.25));
} else if (motion.type === "translate") {
const range = motion.range || [-0.25, 0.25];
const offset = range[0] + (range[1] - range[0]) * ((Math.sin(elapsed * speed + phase) + 1) / 2);
mesh.position.add(axis.multiplyScalar(offset));
} else if (motion.type === "screw") {
const range = motion.range || [-0.18, 0.18];
const pitch = Number(motion.pitch || 0.18);
const turn = elapsed * speed + phase;
mesh.rotateOnAxis(axis, turn);
const offset = range[0] + (range[1] - range[0]) * ((Math.sin(turn) + 1) / 2) + Math.sin(turn) * pitch;
mesh.position.add(axis.multiplyScalar(offset));
} else if (motion.type === "orbit") {
const pivot = mesh.userData.pivot || new THREE.Vector3(0, 0, 0);
const offset = mesh.userData.basePosition.clone().sub(pivot).applyAxisAngle(axis, elapsed * speed + phase);
mesh.position.copy(pivot).add(offset);
mesh.rotateOnAxis(axis, elapsed * speed + phase);
} else if (motion.type === "pulse") {
const amplitude = Number(motion.amplitude || 0.18);
const scalar = Math.max(0.2, 1 + Math.sin(elapsed * speed + phase) * amplitude);
mesh.scale.multiplyScalar(scalar);
}
}
function updateLabels(camera, labelLayer, meshes) {
const rect = viewport.getBoundingClientRect();
labelLayer.replaceChildren();
for (const mesh of meshes) {
const projected = mesh.getWorldPosition(new THREE.Vector3()).project(camera);
if (projected.z < -1 || projected.z > 1) continue;
const label = document.createElement("div");
label.className = "scene-label";
label.style.left = Math.min(rect.width - 72, Math.max(72, (projected.x * 0.5 + 0.5) * rect.width)) + "px";
label.style.top = Math.min(rect.height - 36, Math.max(56, (-projected.y * 0.5 + 0.5) * rect.height)) + "px";
label.textContent = mesh.userData.part.name || mesh.userData.part.id || "part";
labelLayer.appendChild(label);
}
}
function colorFor(name, index) {
const key = String(name || "").toLowerCase();
if (key.includes("amber") || key.includes("orange")) return 0xe8a33d;
if (key.includes("cyan")) return 0x5fd4d0;
if (key.includes("steel")) return 0x9aa4a6;
return [0x5fd4d0, 0xe8a33d, 0xc8c0ac, 0x80b8ff, 0xf07f5a, 0xa7d676][index % 6];
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
let message = response.statusText || "Request failed";
try {
const data = await response.json();
message = data.detail || data.error || message;
} catch (_) {}
throw new Error(message);
}
return response.json();
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("File read failed"));
reader.readAsDataURL(file);
});
}
function setBusy(active) {
requestBusy = active;
progress.classList.toggle("active", active);
uploadButton.disabled = active;
confidenceThresholdInput.disabled = active;
}
function setStatus(message, error) {
statusEl.textContent = String(message || "STANDBY").toUpperCase();
statusEl.classList.toggle("error", Boolean(error));
}
function updateConfidenceThreshold() {
const nextThreshold = Number(confidenceThresholdInput.value);
confidenceThreshold = Number.isFinite(nextThreshold)
? nextThreshold
: DEFAULT_CONFIDENCE_THRESHOLD;
thresholdValue.textContent = Math.round(confidenceThreshold * 100) + "%";
}
function revealScan() {
scanLine.classList.remove("active");
void scanLine.offsetWidth;
scanLine.classList.add("active");
}
</script>
</body>
</html>