Spaces:
Running
Running
| <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> | |