Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>chat_cad — How it works</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #f7f9fc; --card: #ffffff; --border: #e5eaf2; | |
| --text: #14181f; --dim: #5a6373; --faint: #9aa3b2; | |
| --blue: #2461c2; --green: #2f9e44; --purple: #7c3aed; | |
| --orange: #f59e0b; --red: #e23636; --gold: #c9982e; | |
| --shadow: 0 10px 32px rgba(20,30,50,0.08); | |
| } | |
| body { background: var(--bg); color: var(--text); | |
| font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; | |
| min-height: 100vh; overflow: hidden; } | |
| /* ─── COVER SPLASH ─── */ | |
| #cover { position: fixed; inset: 0; z-index: 10; | |
| display: flex; flex-direction: column; align-items: center; | |
| justify-content: center; gap: 14px; | |
| background: radial-gradient(ellipse at top left, #eaf1fb 0%, #f7f9fc 60%); | |
| transition: opacity 1.2s ease, visibility 0s 1.2s; } | |
| #cover.gone { opacity: 0; visibility: hidden; } | |
| #cover .brand { font-size: 110px; font-weight: 800; letter-spacing: -3px; | |
| background: linear-gradient(135deg, #2461c2 0%, #7c3aed 60%, #e23636 100%); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| text-shadow: 0 30px 60px rgba(36,97,194,0.12); } | |
| #cover .tag { font-size: 22px; color: var(--dim); font-weight: 400; | |
| letter-spacing: 0.3px; max-width: 600px; text-align: center; | |
| line-height: 1.4; margin-top: -10px; } | |
| #cover .accent { color: var(--blue); font-weight: 600; } | |
| #cover .play { margin-top: 30px; | |
| padding: 16px 36px; font-size: 17px; font-weight: 700; | |
| background: linear-gradient(135deg, var(--blue), var(--purple)); | |
| color: #fff; border: none; border-radius: 999px; | |
| cursor: pointer; box-shadow: 0 12px 30px rgba(36,97,194,0.35); | |
| transition: transform 0.15s, box-shadow 0.15s; } | |
| #cover .play:hover { transform: translateY(-2px) scale(1.02); | |
| box-shadow: 0 16px 38px rgba(36,97,194,0.45); } | |
| #cover .floaters { position: absolute; inset: 0; pointer-events: none; | |
| overflow: hidden; opacity: 0.55; } | |
| .floater { position: absolute; font-size: 32px; opacity: 0.3; | |
| animation: float linear infinite; } | |
| @keyframes float { | |
| 0% { transform: translate(0,0) rotate(0); opacity: 0; } | |
| 20% { opacity: 0.35; } | |
| 80% { opacity: 0.35; } | |
| 100% { transform: translate(40px,-80vh) rotate(360deg); opacity: 0; } | |
| } | |
| /* ─── MAIN VIEW ─── */ | |
| #app { min-height: 100vh; padding: 22px 26px; | |
| display: flex; flex-direction: column; align-items: center; gap: 14px; } | |
| header { display: flex; justify-content: space-between; align-items: center; | |
| width: 1280px; } | |
| header .title { font-size: 24px; font-weight: 700; letter-spacing: -0.4px; } | |
| header .title .accent { color: var(--blue); } | |
| header .ctl { display: flex; gap: 10px; align-items: center; } | |
| button, select { font-size: 13px; padding: 8px 14px; border-radius: 8px; | |
| border: 1px solid var(--border); background: #fff; | |
| color: var(--text); cursor: pointer; font-weight: 500; } | |
| button:hover { border-color: var(--blue); } | |
| button.primary { background: var(--blue); color: #fff; border-color: var(--blue); } | |
| button.primary:hover { background: #1c4e9e; } | |
| #stage { width: 1280px; height: 680px; gap: 16px; | |
| display: grid; grid-template-columns: 1fr 520px; } | |
| #flow, #workbench { background: var(--card); border-radius: 16px; | |
| border: 1px solid var(--border); box-shadow: var(--shadow); | |
| position: relative; overflow: hidden; } | |
| #flow { padding: 22px 18px; } | |
| /* node styling */ | |
| .node { position: absolute; padding: 10px 14px; border-radius: 11px; | |
| background: #fbfcfe; border: 1.5px solid var(--border); | |
| min-width: 140px; text-align: center; | |
| transition: all 0.6s cubic-bezier(.2,.8,.2,1); } | |
| .node .icn { font-size: 24px; line-height: 1; margin-bottom: 3px; | |
| filter: grayscale(0.4); transition: filter 0.4s; } | |
| .node .ttl { font-size: 12.5px; font-weight: 700; } | |
| .node .sub2 { font-size: 10px; color: var(--faint); | |
| font-family: ui-monospace, monospace; margin-top: 2px; } | |
| .node.active { background: #fff; border-color: var(--glow); | |
| box-shadow: 0 0 0 4px color-mix(in srgb, var(--glow) 14%, transparent), | |
| 0 10px 28px color-mix(in srgb, var(--glow) 20%, transparent); | |
| transform: scale(1.06); } | |
| .node.active .icn { filter: grayscale(0); } | |
| .node.done { opacity: 0.75; } | |
| .node.user { left: 22px; top: 280px; --glow: var(--blue); } | |
| .node.chat { left: 190px; top: 280px; --glow: var(--blue); } | |
| .node.router { left: 360px; top: 280px; --glow: var(--orange); } | |
| .node.parser { left: 530px; top: 100px; --glow: var(--green); } | |
| .node.llm { left: 530px; top: 220px; --glow: var(--purple); } | |
| .node.agent { left: 530px; top: 340px; --glow: var(--gold); } | |
| .node.intent { left: 530px; top: 460px; --glow: var(--orange); } | |
| .node.engine { left: 530px; top: 580px; --glow: var(--blue); } | |
| svg.wires { position: absolute; inset: 0; pointer-events: none; } | |
| .wire { stroke: #d8dee7; stroke-width: 2; fill: none; | |
| transition: stroke 0.5s, stroke-width 0.5s; } | |
| .wire.active { stroke: var(--c); stroke-width: 3; | |
| filter: drop-shadow(0 0 7px color-mix(in srgb, var(--c) 60%, transparent)); } | |
| .packet { r: 7; filter: drop-shadow(0 0 10px var(--c, #f59e0b)); } | |
| /* workbench */ | |
| #workbench { display: flex; flex-direction: column; } | |
| #wbhead { padding: 16px 20px; border-bottom: 1px solid var(--border); } | |
| #wbtitle { font-size: 18px; font-weight: 700; } | |
| #wbcmd { font-size: 12px; color: var(--faint); | |
| font-family: ui-monospace, monospace; margin-top: 4px; } | |
| #viewport { flex: 1; background: | |
| radial-gradient(circle at 30% 30%, #ffffff 0%, #e9eff5 70%, #d8dfe8 100%); | |
| position: relative; } | |
| #viewport canvas { display: block; } | |
| #wblegend { position: absolute; bottom: 12px; left: 12px; | |
| background: rgba(255,255,255,0.92); border: 1px solid var(--border); | |
| border-radius: 8px; padding: 8px 12px; font-size: 11px; | |
| font-family: ui-monospace, monospace; | |
| opacity: 0; transition: opacity 0.5s; } | |
| #wblegend.on { opacity: 1; } | |
| #wblegend .bar { height: 8px; width: 180px; margin: 4px 0; border-radius: 2px; | |
| background: linear-gradient(to right, | |
| #1a3aa3, #1a8cd9, #33cc4d, #f2d91a, #e6261a); } | |
| #wblegend .lbls { display: flex; justify-content: space-between; | |
| font-size: 9px; color: var(--dim); } | |
| #wbsteps { border-top: 1px solid var(--border); padding: 12px 20px; | |
| background: #fafbfd; min-height: 96px; max-height: 96px; | |
| overflow-y: auto; font-family: ui-monospace, monospace; | |
| font-size: 11.5px; line-height: 1.85; } | |
| #wbsteps .line { color: var(--faint); transition: color 0.4s, opacity 0.4s; } | |
| #wbsteps .line.done { color: var(--green); } | |
| #wbsteps .line.active { color: var(--blue); font-weight: 600; } | |
| #wbsteps .line .tick { display: inline-block; width: 14px; } | |
| /* ribbon */ | |
| #ribbon { width: 1280px; background: #fff; border: 1px solid var(--border); | |
| border-radius: 12px; padding: 12px 22px; box-shadow: var(--shadow); | |
| display: flex; align-items: center; gap: 14px; | |
| font-family: ui-monospace, monospace; font-size: 13.5px; } | |
| #typing { color: var(--blue); } | |
| #typing .cur { display: inline-block; width: 8px; height: 16px; | |
| background: var(--blue); vertical-align: middle; margin-left: 2px; | |
| animation: blink 1s steps(2) infinite; } | |
| @keyframes blink { 0%,50% { opacity: 1 } 50.01%,100% { opacity: 0 } } | |
| #status { color: var(--dim); flex: 1; text-align: right; | |
| font-family: ui-sans-serif; font-size: 12.5px; font-style: italic; } | |
| /* progress bar */ | |
| #progress { width: 1280px; height: 4px; background: var(--border); | |
| border-radius: 2px; overflow: hidden; } | |
| #progress .fill { height: 100%; background: | |
| linear-gradient(90deg, var(--blue), var(--purple)); | |
| width: 0; transition: width 0.2s linear; } | |
| /* legend pills */ | |
| #legend { display: flex; gap: 10px; flex-wrap: wrap; | |
| font-size: 11px; font-family: ui-monospace, monospace; | |
| max-width: 1280px; } | |
| #legend .pill { padding: 5px 12px; border-radius: 999px; | |
| background: #fff; border: 1px solid var(--border); } | |
| #legend .pill .dot { display: inline-block; width: 8px; height: 8px; | |
| border-radius: 50%; margin-right: 6px; | |
| vertical-align: middle; } | |
| footer { font-size: 11px; color: var(--faint); margin-top: 4px; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ─── COVER SPLASH ─── --> | |
| <div id="cover"> | |
| <div class="floaters" id="floaters"></div> | |
| <div class="brand">chat_cad</div> | |
| <div class="tag"> | |
| Plain English in. <span class="accent">Real parametric CAD</span> out. | |
| Watch how every chat line becomes <span class="accent">precision geometry</span>, | |
| stress analysis, and a manufacturer-ready design package. | |
| </div> | |
| <button class="play" id="startBtn">▶ Begin demo</button> | |
| <div style="margin-top: 22px; font-size: 11px; color: var(--faint); | |
| font-family: ui-monospace, monospace; letter-spacing: 1px;"> | |
| chat → parser → CadQuery → Three.js | |
| </div> | |
| </div> | |
| <!-- ─── MAIN VIEW ─── --> | |
| <div id="app"> | |
| <header> | |
| <div class="title">How <span class="accent">chat_cad</span> works</div> | |
| <div class="ctl"> | |
| <button id="play" class="primary">▶ Play</button> | |
| <button id="restart">↻ Restart</button> | |
| <select id="scenario"> | |
| <optgroup label="Fundamentals"> | |
| <option value="bolt">Build a hex bolt</option> | |
| <option value="nut">Build a hex nut</option> | |
| <option value="bracket">Recognise an L-bracket from English</option> | |
| </optgroup> | |
| <optgroup label="Transmission"> | |
| <option value="gear">Spur gear — module 1.5, 24 teeth</option> | |
| <option value="planetary">Planetary gearset — sun + 3 planets + ring</option> | |
| <option value="worm">Worm drive — worm + worm wheel</option> | |
| <option value="crank">Crankshaft + connecting rod</option> | |
| </optgroup> | |
| <optgroup label="Aerospace + Advanced"> | |
| <option value="turbojet">Turbojet engine — multi-stage</option> | |
| <option value="phononic">Phononic crystal lattice — 6×6</option> | |
| <option value="acoustic">Inverse acoustic design — band-gap target</option> | |
| </optgroup> | |
| <optgroup label="Engineering"> | |
| <option value="stress">Stress analysis with color contour</option> | |
| </optgroup> | |
| </select> | |
| </div> | |
| </header> | |
| <div id="progress"><div class="fill" id="progressFill"></div></div> | |
| <div id="stage"> | |
| <!-- LEFT: pipeline --> | |
| <div id="flow"> | |
| <svg class="wires" viewBox="0 0 540 680"> | |
| <path class="wire" id="w_user_chat" d="M 158 310 L 190 310"/> | |
| <path class="wire" id="w_chat_router" d="M 326 310 L 360 310"/> | |
| <path class="wire" id="w_router_parser" d="M 500 310 Q 515 310 515 130 L 530 130"/> | |
| <path class="wire" id="w_router_llm" d="M 500 310 Q 515 310 515 250 L 530 250"/> | |
| <path class="wire" id="w_router_agent" d="M 500 310 Q 515 310 515 370 L 530 370"/> | |
| <path class="wire" id="w_router_intent" d="M 500 310 Q 515 310 515 490 L 530 490"/> | |
| <path class="wire" id="w_parser_engine" d="M 595 160 Q 595 400 595 580"/> | |
| <path class="wire" id="w_llm_engine" d="M 595 280 L 595 580"/> | |
| <path class="wire" id="w_agent_engine" d="M 595 400 L 595 580"/> | |
| <path class="wire" id="w_intent_engine" d="M 595 520 L 595 580"/> | |
| <circle id="packet" class="packet" cx="0" cy="0" opacity="0" fill="#f59e0b"/> | |
| </svg> | |
| <div class="node user"><div class="icn">🧑💼</div> | |
| <div class="ttl">You</div><div class="sub2">prompt</div></div> | |
| <div class="node chat"><div class="icn">💬</div> | |
| <div class="ttl">Chat box</div><div class="sub2">browser</div></div> | |
| <div class="node router"><div class="icn">🔀</div> | |
| <div class="ttl">Smart router</div><div class="sub2">classifier</div></div> | |
| <div class="node parser"><div class="icn">⚙️</div> | |
| <div class="ttl">Parser</div><div class="sub2">grammar</div></div> | |
| <div class="node llm"><div class="icn">🧠</div> | |
| <div class="ttl">LLM</div><div class="sub2">Claude · Gemini · Ollama</div></div> | |
| <div class="node agent"><div class="icn">🤖</div> | |
| <div class="ttl">Design agent</div><div class="sub2">multi-step loop</div></div> | |
| <div class="node intent"><div class="icn">💡</div> | |
| <div class="ttl">Intent fallback</div><div class="sub2">47 keywords</div></div> | |
| <div class="node engine"><div class="icn">🏗️</div> | |
| <div class="ttl">CadQuery engine</div><div class="sub2">B-rep solid model</div></div> | |
| </div> | |
| <!-- RIGHT: Three.js workbench --> | |
| <div id="workbench"> | |
| <div id="wbhead"> | |
| <div id="wbtitle">M8 Hex Bolt</div> | |
| <div id="wbcmd">bolt b1 M8 30</div> | |
| </div> | |
| <div id="viewport"> | |
| <div id="wblegend"> | |
| <div style="font-weight:700;font-size:10.5px;letter-spacing:0.06em">VON MISES STRESS</div> | |
| <div class="bar"></div> | |
| <div class="lbls"><span>0 MPa</span><span id="lgmax">138 MPa</span></div> | |
| </div> | |
| </div> | |
| <div id="wbsteps"></div> | |
| </div> | |
| </div> | |
| <div id="ribbon"> | |
| <span id="typing"></span> | |
| <span id="status">ready</span> | |
| </div> | |
| <div id="legend"> | |
| <span class="pill"><span class="dot" style="background:var(--blue)"></span>3D engine</span> | |
| <span class="pill"><span class="dot" style="background:var(--green)"></span>parser</span> | |
| <span class="pill"><span class="dot" style="background:var(--purple)"></span>LLM</span> | |
| <span class="pill"><span class="dot" style="background:var(--gold)"></span>design agent</span> | |
| <span class="pill"><span class="dot" style="background:var(--orange)"></span>intent fallback</span> | |
| <span class="pill"><span class="dot" style="background:var(--red)"></span>FEA / stress</span> | |
| </div> | |
| <footer>chat_cad — built on CadQuery, OpenCascade, Three.js, scikit-fem, gmsh</footer> | |
| </div> | |
| <script type="importmap"> | |
| { "imports": { | |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" | |
| }} | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; | |
| // ─── floating cover-page background ─── | |
| (function spawnFloaters() { | |
| const icons = ['🔩','⚙️','🔧','📐','📏','⚒️','🧰']; | |
| const box = document.getElementById('floaters'); | |
| for (let i = 0; i < 18; i++) { | |
| const el = document.createElement('div'); | |
| el.className = 'floater'; | |
| el.textContent = icons[i % icons.length]; | |
| el.style.left = (Math.random() * 100) + 'vw'; | |
| el.style.top = (Math.random() * 100 + 100) + 'vh'; | |
| el.style.fontSize = (24 + Math.random() * 28) + 'px'; | |
| el.style.animationDuration = (12 + Math.random() * 12) + 's'; | |
| el.style.animationDelay = (-Math.random() * 18) + 's'; | |
| box.appendChild(el); | |
| } | |
| })(); | |
| // ─── Three.js workbench ─── | |
| const vp = document.getElementById('viewport'); | |
| let W = vp.clientWidth, H = vp.clientHeight; | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(W, H); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.1; | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| vp.appendChild(renderer.domElement); | |
| const scene = new THREE.Scene(); | |
| scene.background = null; | |
| const pmrem = new THREE.PMREMGenerator(renderer); | |
| scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.1).texture; | |
| const camera = new THREE.PerspectiveCamera(36, W/H, 0.1, 2000); | |
| camera.position.set(85, 60, 130); | |
| camera.lookAt(0, 10, 0); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; controls.dampingFactor = 0.06; | |
| controls.autoRotate = true; controls.autoRotateSpeed = 1.2; | |
| controls.target.set(0, 5, 0); | |
| // lights | |
| const key = new THREE.DirectionalLight(0xffffff, 1.4); | |
| key.position.set(60, 80, 50); scene.add(key); | |
| const fill = new THREE.DirectionalLight(0xbcd0ee, 0.55); | |
| fill.position.set(-40, 30, -60); scene.add(fill); | |
| scene.add(new THREE.HemisphereLight(0xffffff, 0xc0c8d0, 0.45)); | |
| // floor for shadow | |
| const floor = new THREE.Mesh( | |
| new THREE.CircleGeometry(120, 64), | |
| new THREE.ShadowMaterial({ opacity: 0.12 })); | |
| floor.rotation.x = -Math.PI/2; floor.position.y = -25; scene.add(floor); | |
| // container for the active part | |
| const partGroup = new THREE.Group(); | |
| scene.add(partGroup); | |
| // materials | |
| const steelMat = new THREE.MeshPhysicalMaterial({ | |
| color: 0xc6cdd6, metalness: 0.95, roughness: 0.30, | |
| clearcoat: 0.25, clearcoatRoughness: 0.6, envMapIntensity: 1.3, | |
| }); | |
| const meshWireMat = new THREE.LineBasicMaterial({ color: 0x4060a0, opacity: 0.6, transparent: true }); | |
| // ─── helpers to build sophisticated parts ─── | |
| function clearPart() { | |
| while (partGroup.children.length) { | |
| const c = partGroup.children.pop(); | |
| if (c.geometry) c.geometry.dispose(); | |
| if (c.material && c.material.dispose) c.material.dispose(); | |
| } | |
| } | |
| // Build M8 hex bolt progressively. Returns control object for animated reveals. | |
| function makeBolt() { | |
| clearPart(); | |
| const AF = 13; // across flats | |
| const R = AF / Math.sqrt(3); // circumradius | |
| const headH = 5.3, shankR = 4, shankL = 30; | |
| // hex head | |
| const hexShape = new THREE.Shape(); | |
| for (let i = 0; i < 6; i++) { | |
| const a = i * Math.PI / 3 + Math.PI / 6; | |
| const x = R * Math.cos(a), y = R * Math.sin(a); | |
| if (i === 0) hexShape.moveTo(x, y); else hexShape.lineTo(x, y); | |
| } | |
| hexShape.closePath(); | |
| const head = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(hexShape, { depth: headH, bevelEnabled: true, | |
| bevelThickness: 0.6, bevelSize: 0.4, bevelSegments: 2, steps: 1 }), | |
| steelMat); | |
| head.rotation.x = -Math.PI/2; head.position.y = shankL/2 + headH/2; | |
| head.visible = false; partGroup.add(head); | |
| // shank | |
| const shank = new THREE.Mesh( | |
| new THREE.CylinderGeometry(shankR, shankR, shankL, 48), | |
| steelMat); | |
| shank.visible = false; partGroup.add(shank); | |
| // helical thread — small torus rings stacked (visual proxy for real sweep) | |
| const threads = new THREE.Group(); threads.visible = false; | |
| const pitch = 1.25; | |
| const threadLen = shankL * 0.7; | |
| const z0 = -shankL/2 + (shankL - threadLen); | |
| const nRings = Math.floor(threadLen / pitch); | |
| for (let i = 0; i < nRings; i++) { | |
| const ring = new THREE.Mesh( | |
| new THREE.TorusGeometry(shankR + 0.05, pitch * 0.35, 6, 48), | |
| steelMat); | |
| ring.position.y = z0 + i * pitch + pitch/2; | |
| ring.rotation.x = Math.PI / 2; | |
| // tilt slightly for helix appearance | |
| ring.rotation.z = (i * 6) * Math.PI / 180; | |
| threads.add(ring); | |
| } | |
| partGroup.add(threads); | |
| return { head, shank, threads }; | |
| } | |
| function makeNut() { | |
| clearPart(); | |
| const AF = 13, R = AF / Math.sqrt(3), height = 6.5; | |
| const hexShape = new THREE.Shape(); | |
| for (let i = 0; i < 6; i++) { | |
| const a = i * Math.PI / 3 + Math.PI / 6; | |
| const x = R * Math.cos(a), y = R * Math.sin(a); | |
| if (i === 0) hexShape.moveTo(x, y); else hexShape.lineTo(x, y); | |
| } | |
| hexShape.closePath(); | |
| // bore hole | |
| const hole = new THREE.Path(); | |
| hole.absarc(0, 0, 4.5, 0, Math.PI * 2, false); | |
| hexShape.holes.push(hole); | |
| const prism = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(hexShape, { depth: height, bevelEnabled: true, | |
| bevelThickness: 0.5, bevelSize: 0.4, bevelSegments: 2, steps: 1 }), | |
| steelMat); | |
| prism.rotation.x = -Math.PI/2; prism.position.y = height/2; | |
| prism.visible = false; partGroup.add(prism); | |
| // bore appearance: hidden initially, becomes visible together | |
| // internal thread rings | |
| const threads = new THREE.Group(); threads.visible = false; | |
| const pitch = 1.25; const nRings = Math.floor(height / pitch); | |
| for (let i = 0; i < nRings; i++) { | |
| const ring = new THREE.Mesh( | |
| new THREE.TorusGeometry(4.0, 0.25, 6, 48), steelMat); | |
| ring.position.y = i * pitch + pitch/2; | |
| ring.rotation.x = Math.PI / 2; | |
| ring.rotation.z = (i * 12) * Math.PI / 180; | |
| threads.add(ring); | |
| } | |
| partGroup.add(threads); | |
| return { prism, threads }; | |
| } | |
| function makeBracket() { | |
| clearPart(); | |
| const legA = 50, legB = 40, t = 4, w = 30; | |
| // horizontal arm | |
| const h = new THREE.Mesh( | |
| new THREE.BoxGeometry(legA, t, w), steelMat); | |
| h.position.set(legA/2 - t/2, t/2, 0); | |
| h.visible = false; partGroup.add(h); | |
| // vertical arm | |
| const v = new THREE.Mesh( | |
| new THREE.BoxGeometry(t, legB, w), steelMat); | |
| v.position.set(t/2, legB/2, 0); | |
| v.visible = false; partGroup.add(v); | |
| // mounting holes — visualised as dark cylinders | |
| const holes = new THREE.Group(); holes.visible = false; | |
| const darkMat = new THREE.MeshStandardMaterial({ color: 0x1a1d22, roughness: 0.4 }); | |
| // two holes per arm | |
| [-8, 8].forEach(z => { | |
| const hh = new THREE.Mesh(new THREE.CylinderGeometry(2.5, 2.5, t + 0.2, 24), darkMat); | |
| hh.position.set(legA - 10, t/2, z); hh.rotation.x = Math.PI/2; | |
| holes.add(hh); | |
| const hv = new THREE.Mesh(new THREE.CylinderGeometry(2.5, 2.5, t + 0.2, 24), darkMat); | |
| hv.position.set(t/2, legB - 10, z); hv.rotation.z = Math.PI/2; | |
| holes.add(hv); | |
| }); | |
| partGroup.add(holes); | |
| return { h, v, holes }; | |
| } | |
| // ─── advanced parts ─── | |
| function makeGear(teeth = 24, module = 1.5, width = 5, bore = 5) { | |
| clearPart(); | |
| const pitchR = module * teeth / 2; | |
| const addendum = module; | |
| const dedendum = 1.25 * module; | |
| const rootR = pitchR - dedendum; | |
| const tipR = pitchR + addendum; | |
| // hub (disk minus bore) | |
| const hubShape = new THREE.Shape(); | |
| hubShape.absarc(0, 0, rootR, 0, Math.PI * 2, false); | |
| if (bore > 0) { | |
| const hole = new THREE.Path(); | |
| hole.absarc(0, 0, bore / 2, 0, Math.PI * 2, true); | |
| hubShape.holes.push(hole); | |
| } | |
| const hub = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(hubShape, { depth: width, bevelEnabled: true, | |
| bevelThickness: 0.3, bevelSize: 0.2, bevelSegments: 1, steps: 1 }), | |
| steelMat); | |
| hub.rotation.x = -Math.PI/2; hub.position.y = width/2; | |
| hub.visible = false; partGroup.add(hub); | |
| // teeth as separate group | |
| const teethG = new THREE.Group(); teethG.visible = false; | |
| const halfAngle = Math.PI / teeth / 2; // half tooth span | |
| for (let i = 0; i < teeth; i++) { | |
| const a = (i / teeth) * Math.PI * 2; | |
| const toothShape = new THREE.Shape(); | |
| toothShape.moveTo(rootR * Math.cos(-halfAngle), rootR * Math.sin(-halfAngle)); | |
| toothShape.lineTo(tipR * Math.cos(-halfAngle/2), tipR * Math.sin(-halfAngle/2)); | |
| toothShape.lineTo(tipR * Math.cos( halfAngle/2), tipR * Math.sin( halfAngle/2)); | |
| toothShape.lineTo(rootR * Math.cos( halfAngle), rootR * Math.sin( halfAngle)); | |
| toothShape.closePath(); | |
| const tooth = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(toothShape, { depth: width, bevelEnabled: false }), | |
| steelMat); | |
| tooth.rotation.x = -Math.PI/2; | |
| tooth.rotation.y = a; | |
| tooth.position.y = width/2; | |
| teethG.add(tooth); | |
| } | |
| partGroup.add(teethG); | |
| return { hub, teethG }; | |
| } | |
| function makePlanetary() { | |
| clearPart(); | |
| const m = 1.5, w = 5; | |
| const sunT = 18, planT = 15; | |
| const ringT = sunT + 2 * planT; | |
| const ringR_inner = m * ringT / 2; | |
| const sunR = m * sunT / 2; | |
| const planR = m * planT / 2; | |
| const carrier_r = sunR + planR; | |
| // ring (outer thick annulus) | |
| const ringShape = new THREE.Shape(); | |
| ringShape.absarc(0, 0, ringR_inner + 3 * m, 0, Math.PI * 2, false); | |
| const ringHole = new THREE.Path(); | |
| ringHole.absarc(0, 0, ringR_inner, 0, Math.PI * 2, true); | |
| ringShape.holes.push(ringHole); | |
| const ring = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(ringShape, { depth: w, bevelEnabled: false }), | |
| steelMat); | |
| ring.rotation.x = -Math.PI/2; ring.position.y = w/2; | |
| ring.visible = false; partGroup.add(ring); | |
| // sun gear | |
| const sun = new THREE.Mesh( | |
| new THREE.CylinderGeometry(sunR, sunR, w, 48), steelMat); | |
| sun.visible = false; partGroup.add(sun); | |
| // 3 planets | |
| const planets = new THREE.Group(); planets.visible = false; | |
| for (let i = 0; i < 3; i++) { | |
| const a = i * (Math.PI * 2 / 3); | |
| const p = new THREE.Mesh( | |
| new THREE.CylinderGeometry(planR, planR, w, 36), steelMat); | |
| p.position.set(carrier_r * Math.cos(a), 0, carrier_r * Math.sin(a)); | |
| planets.add(p); | |
| } | |
| partGroup.add(planets); | |
| return { ring, sun, planets }; | |
| } | |
| function makeWormDrive() { | |
| clearPart(); | |
| const m = 1.5; | |
| // worm: cylindrical helix-like body | |
| const wormBody = new THREE.Mesh( | |
| new THREE.CylinderGeometry(5, 5, 40, 48), steelMat); | |
| wormBody.rotation.z = Math.PI/2; | |
| wormBody.position.set(0, 17, 0); | |
| wormBody.visible = false; partGroup.add(wormBody); | |
| // helical thread rings on worm | |
| const wormTh = new THREE.Group(); wormTh.visible = false; | |
| for (let i = -19; i <= 19; i += 1) { | |
| const r = new THREE.Mesh( | |
| new THREE.TorusGeometry(5.4, 0.7, 5, 36), steelMat); | |
| r.position.set(i, 17, 0); | |
| r.rotation.y = Math.PI/2; | |
| r.rotation.z = i * 0.35; | |
| wormTh.add(r); | |
| } | |
| partGroup.add(wormTh); | |
| // worm wheel: disc with circumferential gear teeth (visual approximation) | |
| const wheelHub = new THREE.Mesh( | |
| new THREE.CylinderGeometry(18, 18, 6, 48), steelMat); | |
| wheelHub.position.y = -8; | |
| wheelHub.visible = false; partGroup.add(wheelHub); | |
| // teeth on the wheel rim | |
| const wheelTeeth = new THREE.Group(); wheelTeeth.visible = false; | |
| const tCount = 30; | |
| for (let i = 0; i < tCount; i++) { | |
| const a = (i / tCount) * Math.PI * 2; | |
| const t = new THREE.Mesh( | |
| new THREE.BoxGeometry(1.5, 6, 4), steelMat); | |
| t.position.set(19 * Math.cos(a), -8, 19 * Math.sin(a)); | |
| t.lookAt(0, -8, 0); | |
| wheelTeeth.add(t); | |
| } | |
| partGroup.add(wheelTeeth); | |
| return { wormBody, wormTh, wheelHub, wheelTeeth }; | |
| } | |
| function makeCrankRod() { | |
| clearPart(); | |
| // crankshaft: main journals + offset rod journal + webs | |
| const journalR = 6, crankR = 5, throw_ = 12, webT = 4; | |
| const matD = new THREE.MeshPhysicalMaterial({ | |
| color: 0xa0a8b2, metalness: 0.85, roughness: 0.35, envMapIntensity: 1.2 }); | |
| // first main journal | |
| const j1 = new THREE.Mesh(new THREE.CylinderGeometry(journalR, journalR, 10, 36), matD); | |
| j1.position.x = -16; j1.rotation.z = Math.PI/2; | |
| j1.visible = false; partGroup.add(j1); | |
| // web 1 | |
| const w1 = new THREE.Mesh(new THREE.BoxGeometry(webT, 14, 12), matD); | |
| w1.position.x = -8; w1.position.y = throw_/2; | |
| w1.visible = false; partGroup.add(w1); | |
| // rod journal (offset) | |
| const rj = new THREE.Mesh(new THREE.CylinderGeometry(crankR, crankR, 12, 36), matD); | |
| rj.position.set(0, throw_, 0); rj.rotation.z = Math.PI/2; | |
| rj.visible = false; partGroup.add(rj); | |
| // web 2 | |
| const w2 = new THREE.Mesh(new THREE.BoxGeometry(webT, 14, 12), matD); | |
| w2.position.x = 8; w2.position.y = throw_/2; | |
| w2.visible = false; partGroup.add(w2); | |
| // second main journal | |
| const j2 = new THREE.Mesh(new THREE.CylinderGeometry(journalR, journalR, 10, 36), matD); | |
| j2.position.x = 16; j2.rotation.z = Math.PI/2; | |
| j2.visible = false; partGroup.add(j2); | |
| // connecting rod (big-eye + small-eye + I-beam shank) | |
| const rodGroup = new THREE.Group(); rodGroup.visible = false; | |
| const bigEyeOD = 10, smallEyeOD = 5, rodL = 40; | |
| const bigEye = new THREE.Mesh( | |
| new THREE.CylinderGeometry(bigEyeOD, bigEyeOD, 12, 36), steelMat); | |
| bigEye.position.set(0, throw_, 0); bigEye.rotation.z = Math.PI/2; | |
| rodGroup.add(bigEye); | |
| const smallEye = new THREE.Mesh( | |
| new THREE.CylinderGeometry(smallEyeOD, smallEyeOD, 8, 36), steelMat); | |
| smallEye.position.set(0, throw_ + rodL, 0); smallEye.rotation.z = Math.PI/2; | |
| rodGroup.add(smallEye); | |
| // I-beam shank as a thin box | |
| const shank = new THREE.Mesh(new THREE.BoxGeometry(4, rodL - 5, 6), steelMat); | |
| shank.position.set(0, throw_ + rodL/2, 0); | |
| rodGroup.add(shank); | |
| partGroup.add(rodGroup); | |
| return { j1, w1, rj, w2, j2, rodGroup }; | |
| } | |
| function makeTurbojet() { | |
| clearPart(); | |
| const stages = new THREE.Group(); stages.visible = false; | |
| // spinner cone | |
| const spinner = new THREE.Mesh( | |
| new THREE.ConeGeometry(8, 12, 36), steelMat); | |
| spinner.position.x = -45; | |
| spinner.rotation.z = Math.PI / 2; | |
| partGroup.add(spinner); | |
| spinner.visible = false; | |
| // fan (large disc with hint of blades) | |
| const fan = new THREE.Mesh( | |
| new THREE.CylinderGeometry(22, 22, 4, 48), steelMat); | |
| fan.position.x = -38; fan.rotation.z = Math.PI/2; | |
| fan.visible = false; partGroup.add(fan); | |
| // compressor stages | |
| const compressor = new THREE.Group(); compressor.visible = false; | |
| for (let i = 0; i < 4; i++) { | |
| const c = new THREE.Mesh( | |
| new THREE.CylinderGeometry(17 - i * 0.8, 17 - i * 0.8, 3, 36), | |
| i % 2 ? new THREE.MeshPhysicalMaterial({color:0xb8c2d0,metalness:0.9,roughness:0.35}) : steelMat); | |
| c.position.x = -30 + i * 5; c.rotation.z = Math.PI/2; | |
| compressor.add(c); | |
| } | |
| partGroup.add(compressor); | |
| // combustor (slightly wider barrel) | |
| const combustor = new THREE.Mesh( | |
| new THREE.CylinderGeometry(14, 14, 12, 36), | |
| new THREE.MeshPhysicalMaterial({ color: 0xb86b3a, metalness: 0.7, roughness: 0.4 })); | |
| combustor.position.x = -2; combustor.rotation.z = Math.PI/2; | |
| combustor.visible = false; partGroup.add(combustor); | |
| // turbine stages | |
| const turbine = new THREE.Group(); turbine.visible = false; | |
| for (let i = 0; i < 2; i++) { | |
| const t = new THREE.Mesh( | |
| new THREE.CylinderGeometry(15 - i * 1, 15 - i * 1, 4, 36), steelMat); | |
| t.position.x = 10 + i * 5; t.rotation.z = Math.PI/2; | |
| turbine.add(t); | |
| } | |
| partGroup.add(turbine); | |
| // exhaust nozzle (converging) | |
| const nozzle = new THREE.Mesh( | |
| new THREE.CylinderGeometry(8, 14, 16, 36), steelMat); | |
| nozzle.position.x = 28; nozzle.rotation.z = -Math.PI/2; | |
| nozzle.visible = false; partGroup.add(nozzle); | |
| return { spinner, fan, compressor, combustor, turbine, nozzle }; | |
| } | |
| function makePhononic() { | |
| clearPart(); | |
| // 6×6 lattice of unit cells (plate with a hole) | |
| const lattice = new THREE.Group(); lattice.visible = false; | |
| const a = 10, t = 1.5, holeR = 3; | |
| for (let i = 0; i < 6; i++) { | |
| for (let j = 0; j < 6; j++) { | |
| // each cell: thin plate with a circular hole (Shape with hole) | |
| const sh = new THREE.Shape(); | |
| sh.moveTo(-a/2, -a/2); sh.lineTo(a/2, -a/2); | |
| sh.lineTo(a/2, a/2); sh.lineTo(-a/2, a/2); | |
| sh.closePath(); | |
| const hole = new THREE.Path(); | |
| hole.absarc(0, 0, holeR, 0, Math.PI * 2, true); | |
| sh.holes.push(hole); | |
| const cell = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(sh, { depth: t, bevelEnabled: false }), | |
| steelMat); | |
| cell.rotation.x = -Math.PI/2; | |
| cell.position.set((i - 2.5) * a, t/2, (j - 2.5) * a); | |
| lattice.add(cell); | |
| } | |
| } | |
| partGroup.add(lattice); | |
| return { lattice }; | |
| } | |
| function makeAcoustic() { | |
| clearPart(); | |
| // single "winning" unit cell visualised with a colour-encoded bandgap mark | |
| const a = 14, t = 2, holeR = 5; | |
| const sh = new THREE.Shape(); | |
| sh.moveTo(-a/2, -a/2); sh.lineTo(a/2, -a/2); | |
| sh.lineTo(a/2, a/2); sh.lineTo(-a/2, a/2); | |
| sh.closePath(); | |
| const hole = new THREE.Path(); | |
| hole.absarc(0, 0, holeR, 0, Math.PI * 2, true); | |
| sh.holes.push(hole); | |
| const unit = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(sh, { depth: t, bevelEnabled: true, | |
| bevelThickness: 0.2, bevelSize: 0.2, bevelSegments: 1 }), | |
| steelMat); | |
| unit.rotation.x = -Math.PI/2; unit.position.y = t/2; | |
| unit.visible = false; partGroup.add(unit); | |
| // small grid showing the lattice expansion (4×4) | |
| const lattice = new THREE.Group(); lattice.visible = false; | |
| for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) { | |
| const c = unit.clone(); | |
| c.position.set((i - 1.5) * a, t/2, (j - 1.5) * a); | |
| lattice.add(c); | |
| } | |
| partGroup.add(lattice); | |
| return { unit, lattice }; | |
| } | |
| function makeStressBar() { | |
| clearPart(); | |
| const bar = new THREE.Mesh( | |
| new THREE.BoxGeometry(100, 10, 5, 20, 4, 2), steelMat); | |
| bar.visible = false; partGroup.add(bar); | |
| // wireframe mesh | |
| const wf = new THREE.Mesh( | |
| new THREE.BoxGeometry(100.4, 10.4, 5.4, 20, 4, 2), | |
| new THREE.MeshBasicMaterial({ color: 0x4060a0, wireframe: true, | |
| transparent: true, opacity: 0.55 })); | |
| wf.visible = false; partGroup.add(wf); | |
| // stress-colored bar (vertex colors gradient blue->red across length) | |
| const stressGeo = new THREE.BoxGeometry(100, 10, 5, 60, 4, 2); | |
| const colors = new Float32Array(stressGeo.attributes.position.count * 3); | |
| const pos = stressGeo.attributes.position; | |
| function colormap(t) { | |
| const stops = [[0.0,[0.10,0.23,0.64]],[0.25,[0.10,0.55,0.85]], | |
| [0.50,[0.20,0.80,0.30]],[0.75,[0.95,0.85,0.10]], | |
| [1.00,[0.90,0.15,0.10]]]; | |
| t = Math.max(0, Math.min(1, t)); | |
| for (let i = 0; i < stops.length - 1; i++) { | |
| const [t0,c0] = stops[i], [t1,c1] = stops[i+1]; | |
| if (t <= t1) { const u = (t-t0)/(t1-t0||1); | |
| return [c0[0]+u*(c1[0]-c0[0]), c0[1]+u*(c1[1]-c0[1]), c0[2]+u*(c1[2]-c0[2])]; } | |
| } | |
| return stops[stops.length-1][1]; | |
| } | |
| for (let i = 0; i < pos.count; i++) { | |
| const x = pos.getX(i); | |
| // stress concentrates at the fixed face (+X) | |
| const t = (x + 50) / 100; // 0 at -50, 1 at +50 | |
| const stress_t = Math.pow(t, 0.6); // bias toward higher values | |
| const [r,g,b] = colormap(stress_t); | |
| colors[i*3] = r; colors[i*3+1] = g; colors[i*3+2] = b; | |
| } | |
| stressGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| const stressMat = new THREE.MeshStandardMaterial({ | |
| vertexColors: true, metalness: 0.2, roughness: 0.5 }); | |
| const colored = new THREE.Mesh(stressGeo, stressMat); | |
| colored.visible = false; partGroup.add(colored); | |
| // force arrow indicator | |
| const arrowGroup = new THREE.Group(); arrowGroup.visible = false; | |
| const arrow = new THREE.ArrowHelper( | |
| new THREE.Vector3(-1, 0, 0), new THREE.Vector3(-55, 0, 0), | |
| 14, 0x222222, 4, 3); | |
| arrowGroup.add(arrow); partGroup.add(arrowGroup); | |
| return { bar, wf, colored, arrow: arrowGroup }; | |
| } | |
| function frameCamera(scenario) { | |
| // reset camera based on scenario | |
| if (scenario === 'bolt') { camera.position.set(45, 24, 60); controls.target.set(0, 5, 0); } | |
| else if (scenario === 'nut') { camera.position.set(28, 18, 35); controls.target.set(0, 3, 0); } | |
| else if (scenario === 'bracket') { camera.position.set(65, 50, 75); controls.target.set(20, 18, 0); } | |
| else if (scenario === 'stress') { camera.position.set(50, 30, 110); controls.target.set(0, 0, 0); } | |
| else if (scenario === 'gear') { camera.position.set(38, 28, 52); controls.target.set(0, 2.5, 0); } | |
| else if (scenario === 'planetary'){ camera.position.set(55, 50, 70); controls.target.set(0, 2.5, 0); } | |
| else if (scenario === 'worm') { camera.position.set(50, 30, 60); controls.target.set(0, 5, 0); } | |
| else if (scenario === 'crank') { camera.position.set(50, 40, 70); controls.target.set(0, 8, 0); } | |
| else if (scenario === 'turbojet'){ camera.position.set(0, 38, 90); controls.target.set(0, 0, 0); } | |
| else if (scenario === 'phononic'){ camera.position.set(50, 45, 60); controls.target.set(0, 0, 0); } | |
| else if (scenario === 'acoustic'){ camera.position.set(55, 40, 55); controls.target.set(0, 0, 0); } | |
| controls.update(); | |
| } | |
| window.addEventListener('resize', () => { | |
| W = vp.clientWidth; H = vp.clientHeight; | |
| renderer.setSize(W, H); | |
| camera.aspect = W/H; camera.updateProjectionMatrix(); | |
| }); | |
| function render() { | |
| requestAnimationFrame(render); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| render(); | |
| // ─── scenario timelines ─── | |
| // All slowed down so each scenario takes ~16 seconds. | |
| const SCENARIOS = { | |
| bolt: { | |
| title: 'M8 Hex Bolt', cmd: 'bolt b1 M8 30', | |
| steps: [ | |
| "1. you type 'bolt b1 M8 30'", | |
| "2. smart router classifies the verb", | |
| "3. parser routes to engine — known keyword", | |
| "4. extrude cylindrical shank — Ø 8 × 30 mm", | |
| "5. union the hex head, 13 mm across flats", | |
| "6. sweep helical thread valleys along shank", | |
| "7. dimension + safety check ⇢ ready", | |
| ], | |
| run: (cb) => { | |
| const obj = makeBolt(); frameCamera('bolt'); | |
| cb.timeline = [ | |
| [0, 'type'], [1800, 'glow','chat','w_user_chat','#2461c2'], | |
| [2200, 'log', 0], | |
| [3000, 'glow','router','w_chat_router','#2461c2'], | |
| [3300, 'status','router classifies the verb "bolt"'], | |
| [4000, 'log', 1], | |
| [4400, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4800, 'log', 2], | |
| [5800, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [6200, () => obj.shank.visible = true], | |
| [6200, 'status','extruding shank cylinder…'], | |
| [6500, 'log', 3], | |
| [8200, () => obj.head.visible = true], | |
| [8200, 'status','attaching hex head'], | |
| [8500, 'log', 4], | |
| [10400, () => obj.threads.visible = true], | |
| [10400, 'status','sweeping helical thread (real CadQuery geometry)…'], | |
| [10700, 'log', 5], | |
| [13800, 'log', 6], | |
| [13800, 'status','✓ M8×30 bolt ready. Drop into chat_cad with one line.'], | |
| ]; | |
| }, | |
| }, | |
| nut: { | |
| title: 'M8 Hex Nut', cmd: 'nut n1 M8', | |
| steps: [ | |
| "1. you type 'nut n1 M8'", | |
| "2. smart router → parser keyword", | |
| "3. extrude hex prism (13 mm across flats, h=6.5)", | |
| "4. drill through-bore (⌀ 8 mm)", | |
| "5. sweep internal helical thread inside the bore", | |
| "6. ready — mates with M8 bolt 1:1", | |
| ], | |
| run: (cb) => { | |
| const obj = makeNut(); frameCamera('nut'); | |
| cb.timeline = [ | |
| [0, 'type'], [1600, 'glow','chat','w_user_chat','#2461c2'], | |
| [2000, 'log', 0], | |
| [2700, 'glow','router','w_chat_router','#2461c2'], | |
| [3000, 'log', 1], | |
| [3700, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4500, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5000, () => obj.prism.visible = true], | |
| [5000, 'status','extruding hex prism with the through-bore in one go'], | |
| [5300, 'log', 2], | |
| [7500, 'status','through-bore live — Ø 8 mm'], | |
| [7800, 'log', 3], | |
| [9500, () => obj.threads.visible = true], | |
| [9500, 'status','sweeping internal thread crests…'], | |
| [9800, 'log', 4], | |
| [12800, 'log', 5], | |
| [12800, 'status','✓ M8 nut ready'], | |
| ]; | |
| }, | |
| }, | |
| bracket: { | |
| title: 'L-Bracket (50 × 40 × 4 mm)', cmd: 'Draw me a L bracket', | |
| steps: [ | |
| "1. you type plain English: 'Draw me a L bracket'", | |
| "2. smart router — not a parser keyword", | |
| "3. intent fallback scans 47 keywords → matches 'bracket'", | |
| "4. emits l_bracket b1 50 40 4 30 5 2", | |
| "5. extrude horizontal arm", | |
| "6. extrude vertical arm at 90°", | |
| "7. drill 4× Ø 5 mm mounting holes", | |
| "8. ✓ — and the LLM was never called", | |
| ], | |
| run: (cb) => { | |
| const obj = makeBracket(); frameCamera('bracket'); | |
| cb.timeline = [ | |
| [0, 'type'], [2200, 'glow','chat','w_user_chat','#2461c2'], | |
| [2600, 'log', 0], | |
| [3400, 'glow','router','w_chat_router','#2461c2'], | |
| [3800, 'log', 1], | |
| [4200, 'status','no parser match — try intent fallback'], | |
| [4900, 'glow','intent','w_router_intent','#f59e0b'], | |
| [5300, 'log', 2], | |
| [5800, 'status','matched keyword "bracket" — emits l_bracket b1 50 40 4 30 5 2'], | |
| [6300, 'log', 3], | |
| [7400, 'glow','engine','w_intent_engine','#f59e0b'], | |
| [7800, () => obj.h.visible = true], | |
| [7800, 'log', 4], | |
| [9600, () => obj.v.visible = true], | |
| [9600, 'log', 5], | |
| [11800, () => obj.holes.visible = true], | |
| [11800, 'log', 6], | |
| [12100, 'status','4 × Ø 5 mounting holes drilled'], | |
| [14000, 'log', 7], | |
| [14000, 'status','✓ L-bracket ready (LLM never called)'], | |
| ]; | |
| }, | |
| }, | |
| gear: { | |
| title: 'Spur Gear (m=1.5, 24 teeth)', cmd: 'gear g1 1.5 24 5 5', | |
| steps: [ | |
| "1. you type 'gear g1 1.5 24 5 5'", | |
| "2. router → parser keyword 'gear'", | |
| "3. compute pitch diameter (36 mm) + root + tip radii", | |
| "4. extrude hub disc with central bore", | |
| "5. polar-pattern 24 involute-style teeth around the rim", | |
| "6. ✓ gear ready — pair it with another for a train", | |
| ], | |
| run: (cb) => { | |
| const obj = makeGear(24, 1.5, 5, 5); frameCamera('gear'); | |
| cb.timeline = [ | |
| [0, 'type'], [1700, 'glow','chat','w_user_chat','#2461c2'], | |
| [2200, 'log', 0], | |
| [2900, 'glow','router','w_chat_router','#2461c2'], | |
| [3300, 'log', 1], | |
| [4000, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4500, 'log', 2], | |
| [5500, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5800, () => obj.hub.visible = true], | |
| [5800, 'status','hub disc extruded with central bore'], | |
| [6200, 'log', 3], | |
| [8200, () => obj.teethG.visible = true], | |
| [8200, 'status','arraying 24 tooth wedges around the pitch circle'], | |
| [8500, 'log', 4], | |
| [11500, 'log', 5], | |
| [11500, 'status','✓ spur gear ready — mate with another for a train'], | |
| ]; | |
| }, | |
| }, | |
| planetary: { | |
| title: 'Planetary Gearset (sun + 3 planets + ring)', cmd: 'planetary p1 1.5 18 15 5', | |
| steps: [ | |
| "1. you type 'planetary p1 1.5 18 15 5'", | |
| "2. parser → 3-component assembly", | |
| "3. build the internally-toothed ring (annulus)", | |
| "4. place the central sun gear", | |
| "5. arrange 3 planets at 120° intervals", | |
| "6. ✓ epicyclic set ready — ratio = 1 + Z_R / Z_S", | |
| ], | |
| run: (cb) => { | |
| const obj = makePlanetary(); frameCamera('planetary'); | |
| cb.timeline = [ | |
| [0, 'type'], [2000, 'glow','chat','w_user_chat','#2461c2'], | |
| [2400, 'log', 0], | |
| [3100, 'glow','router','w_chat_router','#2461c2'], | |
| [3800, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4200, 'log', 1], | |
| [5000, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5400, () => obj.ring.visible = true], | |
| [5400, 'status','outer ring with internal teeth (Z = 48)'], | |
| [5800, 'log', 2], | |
| [7800, () => obj.sun.visible = true], | |
| [7800, 'status','sun gear at centre (Z = 18)'], | |
| [8200, 'log', 3], | |
| [10000, () => obj.planets.visible = true], | |
| [10000, 'status','3 planet gears at 120° (Z = 15 each)'], | |
| [10300, 'log', 4], | |
| [13500, 'log', 5], | |
| [13500, 'status','✓ planetary set complete — speed ratio = 1 + 48/18 = 3.67'], | |
| ]; | |
| }, | |
| }, | |
| worm: { | |
| title: 'Worm Drive (worm + wheel pair)', cmd: 'worm w1 1.5 40 1\nwormwheel ww1 1.5 30 6', | |
| steps: [ | |
| "1. you type 'worm w1 1.5 40 1' then 'wormwheel ww1 …'", | |
| "2. parser builds the worm body (root cylinder)", | |
| "3. helical thread swept around the worm", | |
| "4. concave-tooth worm wheel beneath the worm", | |
| "5. ratio = 30:1 (single-start worm, 30-tooth wheel)", | |
| "6. ✓ high-reduction non-back-drivable drive", | |
| ], | |
| run: (cb) => { | |
| const obj = makeWormDrive(); frameCamera('worm'); | |
| cb.timeline = [ | |
| [0, 'type'], [2200, 'glow','chat','w_user_chat','#2461c2'], | |
| [2600, 'log', 0], | |
| [3400, 'glow','router','w_chat_router','#2461c2'], | |
| [4000, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4800, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5200, () => obj.wormBody.visible = true], | |
| [5200, 'status','worm root cylinder extruded'], | |
| [5500, 'log', 1], | |
| [7600, () => obj.wormTh.visible = true], | |
| [7600, 'status','single-start helical thread swept'], | |
| [7900, 'log', 2], | |
| [9800, () => { obj.wheelHub.visible = true; obj.wheelTeeth.visible = true; }], | |
| [9800, 'status','worm wheel with 30 teeth placed below'], | |
| [10100, 'log', 3], | |
| [12200, 'log', 4], | |
| [13500, 'log', 5], | |
| [13500, 'status','✓ worm drive ready — 30:1 reduction, self-locking'], | |
| ]; | |
| }, | |
| }, | |
| crank: { | |
| title: 'Crankshaft + Connecting Rod', cmd: 'crank ck1 1 6 5 12 4\nconrod cr1 10 5 50', | |
| steps: [ | |
| "1. you type 'crank ck1 …' then 'conrod cr1 …'", | |
| "2. parser builds main + rod journals", | |
| "3. main journal (cylindrical bearing surface)", | |
| "4. webs + offset rod journal at throw distance", | |
| "5. connecting rod (big-eye, shank, small-eye)", | |
| "6. ✓ engine slider-crank ready", | |
| ], | |
| run: (cb) => { | |
| const obj = makeCrankRod(); frameCamera('crank'); | |
| cb.timeline = [ | |
| [0, 'type'], [2300, 'glow','chat','w_user_chat','#2461c2'], | |
| [2700, 'log', 0], | |
| [3400, 'glow','router','w_chat_router','#2461c2'], | |
| [4100, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4900, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5300, () => obj.j1.visible = true], | |
| [5300, 'status','first main journal'], | |
| [5600, 'log', 1], | |
| [6600, () => obj.w1.visible = true], | |
| [6600, 'status','crank web (offset arm)'], | |
| [7700, () => obj.rj.visible = true], | |
| [7700, 'status','offset rod journal — 12 mm throw'], | |
| [8000, 'log', 2], | |
| [8800, () => obj.w2.visible = true], | |
| [8800, 'status','second web'], | |
| [9700, () => obj.j2.visible = true], | |
| [9700, 'status','second main journal — crankshaft complete'], | |
| [10000, 'log', 3], | |
| [11800, () => obj.rodGroup.visible = true], | |
| [11800, 'status','connecting rod attached (big-eye + I-beam + small-eye)'], | |
| [12100, 'log', 4], | |
| [14500, 'log', 5], | |
| [14500, 'status','✓ slider-crank assembly ready for piston coupling'], | |
| ]; | |
| }, | |
| }, | |
| turbojet: { | |
| title: 'Turbojet Engine (multi-stage)', cmd: 'turbojet jet1 100 400', | |
| steps: [ | |
| "1. you type 'turbojet jet1 100 400'", | |
| "2. recipe assembles 7 components in sequence", | |
| "3. spinner cone (front)", | |
| "4. fan + multi-stage compressor", | |
| "5. combustor (hot core)", | |
| "6. multi-stage turbine", | |
| "7. converging exhaust nozzle", | |
| "8. ✓ complete jet engine — 14 sub-parts in one chat line", | |
| ], | |
| run: (cb) => { | |
| const obj = makeTurbojet(); frameCamera('turbojet'); | |
| cb.timeline = [ | |
| [0, 'type'], [2200, 'glow','chat','w_user_chat','#2461c2'], | |
| [2700, 'log', 0], | |
| [3400, 'glow','router','w_chat_router','#2461c2'], | |
| [4000, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4800, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5200, () => obj.spinner.visible = true], | |
| [5200, 'status','spinner — paraboloid bullet at front'], | |
| [5500, 'log', 1], | |
| [5500, 'log', 2], | |
| [6800, () => obj.fan.visible = true], | |
| [6800, 'status','fan stage (large bypass disc)'], | |
| [8000, () => obj.compressor.visible = true], | |
| [8000, 'status','4-stage axial compressor (rotor / stator pairs)'], | |
| [8300, 'log', 3], | |
| [10100, () => obj.combustor.visible = true], | |
| [10100, 'status','annular combustor — fuel burns in this section'], | |
| [10400, 'log', 4], | |
| [12000, () => obj.turbine.visible = true], | |
| [12000, 'status','2-stage turbine — drives the compressor + fan'], | |
| [12300, 'log', 5], | |
| [13800, () => obj.nozzle.visible = true], | |
| [13800, 'status','converging exhaust nozzle — accelerates the gas'], | |
| [14100, 'log', 6], | |
| [15500, 'log', 7], | |
| [15500, 'status','✓ full turbojet built in one chat line — 7 visible components'], | |
| ]; | |
| }, | |
| }, | |
| phononic: { | |
| title: 'Phononic Crystal Lattice (6×6)', cmd: 'pc square sq1 10 1.5 5\npc lattice lat1 sq1 10 6 6', | |
| steps: [ | |
| "1. you type 'pc square sq1 10 1.5 5'", | |
| "2. build one unit cell — 10 mm square plate, ⌀5 hole", | |
| "3. then 'pc lattice lat1 sq1 10 6 6'", | |
| "4. tile the unit cell 6×6 = 36 cells", | |
| "5. ✓ phononic crystal ready — has acoustic band-gap by design", | |
| "6. surrogate predicts which frequencies it blocks", | |
| ], | |
| run: (cb) => { | |
| const obj = makePhononic(); frameCamera('phononic'); | |
| cb.timeline = [ | |
| [0, 'type'], [2300, 'glow','chat','w_user_chat','#2461c2'], | |
| [2900, 'log', 0], | |
| [3500, 'glow','router','w_chat_router','#2461c2'], | |
| [4200, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4900, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5400, () => obj.lattice.visible = true], | |
| [5400, 'log', 1], | |
| [5400, 'status','unit cell: square plate (10 mm × 1.5 mm) with ⌀5 hole'], | |
| [7500, 'log', 2], | |
| [9000, 'log', 3], | |
| [9000, 'status','tiling 6×6 = 36 cells — periodic structure complete'], | |
| [12000, 'log', 4], | |
| [12000, 'status','✓ metamaterial built. Surrogate predicts band-gap in <100 ms'], | |
| [14500, 'log', 5], | |
| ]; | |
| }, | |
| }, | |
| acoustic: { | |
| title: 'Inverse Acoustic Design (target 12 kHz block)', cmd: 'acoustic block 12000 name silencer1', | |
| steps: [ | |
| "1. you type 'acoustic block 12000 name silencer1'", | |
| "2. parser → inverse_design coordinate descent loop", | |
| "3. evaluate 5 unit-cell families × N geometry trials", | |
| "4. score each candidate's predicted band-gap vs target", | |
| "5. best fit: square_hole, 95% match — build the unit cell", | |
| "6. tile 4×4 lattice — silencer1 ready", | |
| ], | |
| run: (cb) => { | |
| const obj = makeAcoustic(); frameCamera('acoustic'); | |
| cb.timeline = [ | |
| [0, 'type'], [2400, 'glow','chat','w_user_chat','#2461c2'], | |
| [3000, 'log', 0], | |
| [3700, 'glow','router','w_chat_router','#2461c2'], | |
| [4400, 'glow','parser','w_router_parser','#2f9e44'], | |
| [5100, 'log', 1], | |
| [5800, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [6300, 'status','coordinate descent across square / hex / cross / pillar / core'], | |
| [6600, 'log', 2], | |
| [8500, 'status','100+ geometry trials evaluated by the surrogate'], | |
| [8800, 'log', 3], | |
| [10300, () => obj.unit.visible = true], | |
| [10300, 'status','best candidate: square_hole — predicted 10.2 – 13.8 kHz'], | |
| [10600, 'log', 4], | |
| [12500, () => { obj.unit.visible = false; obj.lattice.visible = true; }], | |
| [12500, 'status','tiling 4×4 unit cells — silencer1 ready'], | |
| [12800, 'log', 5], | |
| [14800, 'status','✓ inverse acoustic design complete — 95% match in 1 ms'], | |
| ]; | |
| }, | |
| }, | |
| stress: { | |
| title: 'Stress Analysis (steel bar, 2000 N axial)', cmd: 'stress bar1 load=2000 axis=X material=steel', | |
| steps: [ | |
| "1. you type 'stress bar1 load=2000 axis=X material=steel'", | |
| "2. parser routes to the FEA worker", | |
| "3. STL is extracted from the B-rep solid", | |
| "4. gmsh meshes the bar into 377 tetrahedra", | |
| "5. scikit-fem assembles K, solves K·u = f", | |
| "6. von Mises stress per node", | |
| "7. blue (low) → red (high) — COMSOL-style contour", | |
| "8. ✓ max σ 138 MPa, SF 1.81 against yield", | |
| ], | |
| run: (cb) => { | |
| const obj = makeStressBar(); frameCamera('stress'); | |
| cb.timeline = [ | |
| [0, 'type'], [2200, 'glow','chat','w_user_chat','#2461c2'], | |
| [2600, 'log', 0], | |
| [3200, 'glow','router','w_chat_router','#2461c2'], | |
| [3800, 'glow','parser','w_router_parser','#2f9e44'], | |
| [4200, 'log', 1], | |
| [5000, 'glow','engine','w_parser_engine','#2f9e44'], | |
| [5400, () => obj.bar.visible = true], | |
| [5400, 'log', 2], | |
| [5400, 'status','engine exported STL → handing to FEA worker'], | |
| [7500, () => obj.wf.visible = true], | |
| [7500, 'status','gmsh meshing — 170 nodes, 377 tetrahedra'], | |
| [7800, 'log', 3], | |
| [9800, 'status','scikit-fem assembling stiffness K, solving K·u = f'], | |
| [10100, 'log', 4], | |
| [11600, () => { obj.bar.visible = false; obj.wf.visible = false; | |
| obj.colored.visible = true; obj.arrow.visible = true; }], | |
| [11600, 'log', 5], | |
| [11900, 'status','von Mises stress per node — applying colormap'], | |
| [12300, () => document.getElementById('wblegend').classList.add('on')], | |
| [13500, 'log', 6], | |
| [13800, 'status','colored mesh ready — red = stress concentration at fix'], | |
| [15500, 'log', 7], | |
| [15500, 'status','✓ max σ 138 MPa, SF 1.81 (OK against 250 MPa yield)'], | |
| ]; | |
| }, | |
| }, | |
| }; | |
| // ─── timeline runner ─── | |
| let TIMERS = []; | |
| function clearTimers() { TIMERS.forEach(clearTimeout); TIMERS = []; } | |
| function resetVisuals() { | |
| document.querySelectorAll('.node').forEach(n => n.classList.remove('active','done')); | |
| document.querySelectorAll('.wire').forEach(w => { w.classList.remove('active'); w.style.removeProperty('--c'); }); | |
| document.querySelectorAll('#wbsteps .line').forEach(l => l.classList.remove('done','active')); | |
| document.getElementById('packet').setAttribute('opacity', '0'); | |
| document.getElementById('typing').textContent = ''; | |
| document.getElementById('status').textContent = 'ready'; | |
| document.getElementById('wblegend').classList.remove('on'); | |
| document.getElementById('progressFill').style.width = '0%'; | |
| } | |
| function typeInto(el, text, ms = 1400) { | |
| const t0 = performance.now(); | |
| function step(t) { | |
| const u = Math.min(1, (t - t0) / ms); | |
| const n = Math.floor(text.length * u); | |
| el.innerHTML = text.slice(0, n) + '<span class="cur"></span>'; | |
| if (u < 1) requestAnimationFrame(step); | |
| else el.innerHTML = text + '<span class="cur"></span>'; | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| function animatePacket(wireId, color) { | |
| const path = document.getElementById(wireId); | |
| if (!path) return; | |
| const len = path.getTotalLength(); | |
| const p = document.getElementById('packet'); | |
| p.setAttribute('fill', color); p.setAttribute('opacity', '1'); | |
| p.style.setProperty('--c', color); | |
| const t0 = performance.now(); | |
| function step(t) { | |
| const u = Math.min(1, (t - t0) / 900); | |
| const pt = path.getPointAtLength(len * u); | |
| p.setAttribute('cx', pt.x); p.setAttribute('cy', pt.y); | |
| if (u < 1) requestAnimationFrame(step); | |
| else setTimeout(() => p.setAttribute('opacity', '0'), 200); | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| function renderSteps(steps) { | |
| const el = document.getElementById('wbsteps'); | |
| el.innerHTML = steps.map((s,i) => | |
| `<div class="line" data-i="${i}"><span class="tick">○</span> ${s}</div>`).join(''); | |
| } | |
| function runScenario(key) { | |
| clearTimers(); | |
| resetVisuals(); | |
| const sc = SCENARIOS[key]; | |
| document.getElementById('wbtitle').textContent = sc.title; | |
| document.getElementById('wbcmd').textContent = sc.cmd; | |
| renderSteps(sc.steps); | |
| const carrier = {}; | |
| sc.run(carrier); | |
| const totalMs = Math.max(...carrier.timeline.map(t => t[0])) + 1200; | |
| // schedule a progress-bar updater | |
| const pT0 = performance.now(); | |
| function progressTick() { | |
| const u = Math.min(1, (performance.now() - pT0) / totalMs); | |
| document.getElementById('progressFill').style.width = (u * 100) + '%'; | |
| if (u < 1) requestAnimationFrame(progressTick); | |
| } | |
| requestAnimationFrame(progressTick); | |
| // schedule each event | |
| carrier.timeline.forEach(ev => { | |
| const [ms, op, ...args] = ev; | |
| TIMERS.push(setTimeout(() => { | |
| if (op === 'type') typeInto(document.getElementById('typing'), sc.cmd); | |
| else if (op === 'glow') { | |
| const [node, wire, color] = args; | |
| document.querySelectorAll('.node.active').forEach(n => n.classList.add('done')); | |
| document.querySelector('.node.' + node)?.classList.add('active'); | |
| const w = document.getElementById(wire); | |
| if (w) { w.classList.add('active'); w.style.setProperty('--c', color); } | |
| animatePacket(wire, color); | |
| } | |
| else if (op === 'log') { | |
| const lines = document.querySelectorAll('#wbsteps .line'); | |
| lines.forEach((l, i) => { | |
| l.querySelector('.tick').textContent = | |
| (i < args[0]) ? '✓' : (i === args[0] ? '●' : '○'); | |
| if (i < args[0]) l.classList.add('done'); | |
| else if (i === args[0]) l.classList.add('active'); | |
| }); | |
| } | |
| else if (op === 'status') document.getElementById('status').textContent = args[0]; | |
| else if (typeof op === 'function') op(); | |
| }, ms)); | |
| }); | |
| return totalMs; | |
| } | |
| let _loop = null; | |
| function startLoop() { | |
| const key = document.getElementById('scenario').value; | |
| const ms = runScenario(key); | |
| _loop = setTimeout(startLoop, ms + 2500); | |
| } | |
| document.getElementById('startBtn').addEventListener('click', () => { | |
| document.getElementById('cover').classList.add('gone'); | |
| setTimeout(startLoop, 600); | |
| }); | |
| document.getElementById('play').addEventListener('click', () => { | |
| clearTimeout(_loop); startLoop(); | |
| }); | |
| document.getElementById('restart').addEventListener('click', () => { | |
| clearTimeout(_loop); resetVisuals(); setTimeout(startLoop, 400); | |
| }); | |
| document.getElementById('scenario').addEventListener('change', () => { | |
| clearTimeout(_loop); resetVisuals(); setTimeout(startLoop, 400); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |