Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>ChatCAD β Multi-Agent Engineering Design Architecture</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #f3f5f9; --card: #ffffff; --grid: #e2e7ee; | |
| --line: #c1c8d3; --text: #0e1219; --dim: #4a5564; --faint: #8c95a3; | |
| --planner: #1c4e9e; --modeler: #178a3c; --rag: #0e7a8e; | |
| --visual: #b68216; --dfm: #c4631e; --standards: #6b2bcf; | |
| --verifier: #c12f30; --user: #14181f; --slate: #475569; | |
| } | |
| /* ββ VERTICAL 3:4 LAYOUT β single seamless white panel ββ */ | |
| body { background: #ffffff; color: var(--text); | |
| font-family: 'Inter', -apple-system, system-ui, ui-sans-serif, sans-serif; | |
| min-height: 100vh; padding: 18px 16px; | |
| display: flex; flex-direction: column; align-items: center; gap: 8px; } | |
| header.banner { width: 100%; max-width: 880px; | |
| display: flex; flex-direction: column; align-items: center; gap: 4px; } | |
| h1 { font-size: 26px; font-weight: 800; letter-spacing: -0.4px; | |
| color: var(--text); display: flex; align-items: center; gap: 12px; | |
| flex-wrap: wrap; justify-content: center; } | |
| h1 .brand { | |
| color: #fff; background: var(--planner); | |
| padding: 4px 14px; border-radius: 8px; | |
| font-weight: 800; letter-spacing: -0.5px; | |
| box-shadow: 0 3px 10px rgba(28,78,158,0.30); | |
| } | |
| h1 .sep { color: var(--faint); font-weight: 500; } | |
| h1 .accent { color: var(--planner); } | |
| .tag { font-size: 13px; color: var(--dim); max-width: 900px; | |
| text-align: center; line-height: 1.55; } | |
| #ctl { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; | |
| justify-content: center; } | |
| button, select { font-size: 13px; padding: 9px 18px; border-radius: 8px; | |
| border: 1px solid var(--line); background: #fff; | |
| color: var(--text); cursor: pointer; font-weight: 500; | |
| font-family: inherit; } | |
| button:hover { border-color: var(--planner); } | |
| button.primary { background: var(--planner); color: #fff; | |
| border-color: var(--planner); } | |
| button.primary:hover { background: #163e7d; } | |
| #progress { width: 880px; height: 4px; background: var(--line); | |
| border-radius: 2px; overflow: hidden; } | |
| #progress .fill { height: 100%; | |
| background: linear-gradient(90deg, var(--planner), var(--standards)); | |
| width: 0; transition: width 0.2s linear; } | |
| /* βββ split layout: flowchart left, big 3D right βββ */ | |
| /* Vertical stack: flowchart on top, big 3D viewport below β one | |
| seamless panel, no gap between sections. */ | |
| #stage { width: 880px; height: 1200px; display: grid; | |
| grid-template-rows: 580px 620px; gap: 0; | |
| background: #fff; border-radius: 16px; | |
| border: 1px solid var(--line); | |
| box-shadow: 0 16px 40px rgba(15,25,50,0.07); | |
| overflow: hidden; } | |
| #board { background: transparent; border-radius: 0; | |
| border: none; box-shadow: none; | |
| position: relative; overflow: hidden; } | |
| /* lane labels */ | |
| .lane-label { position: absolute; left: 22px; | |
| font-size: 16px; font-weight: 800; letter-spacing: 0.16em; | |
| color: var(--planner); text-transform: uppercase; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; | |
| background: #ffffff; padding: 3px 8px; } | |
| .lane-label.l1 { top: 12px; } | |
| .lane-label.l2 { top: 148px; } | |
| .lane-label.l3 { top: 354px; } /* moved up β was 390, now sits in | |
| the gap ABOVE the separator line | |
| instead of grazing it from below */ | |
| .lane-label.l4 { top: 605px; } | |
| .lane-sep { position: absolute; left: 18px; right: 18px; height: 1px; | |
| background: var(--grid); pointer-events: none; } | |
| .lane-sep.l1 { top: 44px; } .lane-sep.l2 { top: 396px; } .lane-sep.l3 { top: 596px; } | |
| /* compact node */ | |
| .node { position: absolute; background: #fff; | |
| border: 1.5px solid var(--line); border-radius: 10px; | |
| padding: 10px 14px; width: 168px; min-height: 92px; | |
| transition: all 0.5s cubic-bezier(.2,.8,.2,1); cursor: pointer; | |
| display: flex; flex-direction: column; gap: 6px; } | |
| .node .tag-pill { display: inline-flex; align-items: center; gap: 7px; | |
| font-size: 13px; | |
| letter-spacing: 0.14em; padding: 5px 11px; border-radius: 5px; | |
| background: var(--bg); color: var(--faint); font-weight: 700; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; | |
| align-self: flex-start; } | |
| .node .tag-pill svg { width: 16px; height: 16px; stroke: currentColor; | |
| stroke-width: 1.7; fill: none; | |
| stroke-linecap: round; stroke-linejoin: round; } | |
| .node .row { display: flex; align-items: center; gap: 7px; margin-top: 1px; } | |
| .node .icn { width: 36px; height: 36px; display: inline-flex; | |
| align-items: center; justify-content: center; | |
| border-radius: 8px; background: var(--bg); | |
| color: var(--glow); flex-shrink: 0; | |
| transition: background 0.5s, color 0.5s; } | |
| .node.active .icn { background: var(--glow); color: #fff; } | |
| .node .icn svg { width: 24px; height: 24px; stroke: currentColor; | |
| stroke-width: 1.7; fill: none; | |
| stroke-linecap: round; stroke-linejoin: round; } | |
| .node .name { font-size: 15px; font-weight: 700; } | |
| .node.active { border-color: var(--glow); background: #fff; | |
| box-shadow: 0 0 0 3px color-mix(in srgb, var(--glow) 18%, transparent), | |
| 0 8px 22px color-mix(in srgb, var(--glow) 22%, transparent); | |
| transform: translateY(-2px) scale(1.04); } | |
| .node.active .tag-pill { background: var(--glow); color: #fff; } | |
| /* compact grid β fits in 880Γ740 board. | |
| Plan row (4 nodes) uses default 168 px width. | |
| Critic row (5 nodes) overrides to 152 px so they all fit cleanly. */ | |
| .user_node { left: 355px; top: 50px; --glow: var(--user); width: 168px; } | |
| .planner { left: 30px; top: 195px; --glow: var(--planner); } | |
| .rag { left: 230px; top: 195px; --glow: var(--rag); } | |
| .modeler { left: 440px; top: 195px; --glow: var(--modeler); } | |
| .engine_node { left: 660px; top: 195px; --glow: var(--slate); } | |
| .visual { left: 18px; top: 430px; --glow: var(--visual); width: 152px; } | |
| .dfm { left: 188px; top: 430px; --glow: var(--dfm); width: 152px; } | |
| .standards { left: 358px; top: 430px; --glow: var(--standards); width: 152px; } | |
| .verifier { left: 528px; top: 430px; --glow: var(--verifier); width: 152px; } | |
| .knowledge { left: 698px; top: 430px; --glow: var(--rag); width: 162px; } | |
| .final_node { left: 355px; top: 640px; --glow: var(--planner); width: 168px; | |
| align-items: center; } | |
| /* orthogonal wires */ | |
| svg.wires { position: absolute; inset: 0; pointer-events: none; } | |
| .wire { stroke: var(--line); stroke-width: 1.8; fill: none; | |
| transition: stroke 0.5s, stroke-width 0.4s; } | |
| .wire.active { stroke: var(--c); stroke-width: 2.5; | |
| filter: drop-shadow(0 0 5px color-mix(in srgb, var(--c) 50%, transparent)); } | |
| .wire.feedback { stroke-dasharray: 5 4; } | |
| .arrowhead { fill: var(--line); transition: fill 0.5s; } | |
| .packet { r: 5; opacity: 0; | |
| filter: drop-shadow(0 0 7px var(--c, var(--planner))); } | |
| /* βββ BIG 3D PRODUCT PANEL βββ */ | |
| #product { background: transparent; border-radius: 0; | |
| border: none; border-top: 1px solid var(--grid); | |
| box-shadow: none; | |
| display: flex; flex-direction: column; overflow: hidden; } | |
| #productHead { padding: 14px 22px; border-bottom: 1px solid var(--grid); | |
| display: flex; align-items: center; justify-content: space-between; } | |
| #productHead .left { display: flex; flex-direction: column; gap: 2px; } | |
| #productHead .out-eyebrow { | |
| font-size: 10px; font-weight: 700; letter-spacing: 0.24em; | |
| color: var(--planner); text-transform: uppercase; | |
| font-family: 'Geist Mono', ui-monospace, monospace; | |
| display: inline-flex; align-items: center; gap: 8px; margin-bottom: 1px; } | |
| #productHead .out-eyebrow::before { content: ''; width: 14px; height: 1px; | |
| background: linear-gradient(90deg, var(--planner), transparent); } | |
| #productHead h2 { font-size: 18px; font-weight: 800; letter-spacing: -0.2px; } | |
| #productHead h2 .accent { | |
| background: linear-gradient(135deg, var(--planner), var(--modeler)); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| #productHead .sub { font-size: 13px; color: var(--faint); | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; } | |
| #productHead .stat { font-size: 12.5px; font-family: 'JetBrains Mono', monospace; | |
| padding: 4px 10px; border-radius: 4px; | |
| background: var(--bg); color: var(--dim); font-weight: 600; } | |
| #productView { flex: 1; background: | |
| radial-gradient(circle at 30% 30%, #ffffff 0%, #e6ecf3 70%, #d2d9e3 100%); | |
| position: relative; overflow: hidden; min-height: 0; } | |
| #productView canvas { display: block; } | |
| /* floating callout chips on the 3D view */ | |
| .callout { position: absolute; pointer-events: none; | |
| background: rgba(255,255,255,0.96); | |
| border: 1px solid var(--line); border-radius: 999px; | |
| padding: 6px 14px; font-size: 13.5px; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; | |
| font-weight: 600; | |
| box-shadow: 0 6px 16px rgba(0,0,0,0.10); | |
| opacity: 0; transform: translateY(8px); | |
| transition: opacity 0.5s, transform 0.5s; | |
| white-space: nowrap; } | |
| .callout.on { opacity: 1; transform: translateY(0); } | |
| .callout::before { content: ''; display: inline-block; | |
| width: 8px; height: 8px; border-radius: 50%; | |
| background: var(--c, #1c4e9e); margin-right: 8px; | |
| vertical-align: middle; } | |
| .callout.c_p1 { left: 22px; bottom: 22px; --c: var(--planner); } | |
| .callout.c_p2 { left: 22px; bottom: 60px; --c: var(--modeler); } | |
| .callout.c_p3 { left: 22px; bottom: 98px; --c: var(--rag); } | |
| .callout.c_p4 { left: 22px; bottom: 136px; --c: var(--verifier); } | |
| .callout.c_sf { right: 22px; top: 22px; --c: var(--modeler); } | |
| .callout.c_mass { right: 22px; top: 56px; --c: var(--slate); } | |
| .callout.c_deliv { right: 22px; bottom: 22px; --c: var(--planner); } | |
| #productFoot { padding: 12px 22px; border-top: 1px solid var(--line); | |
| background: #fafbfc; | |
| display: grid; grid-template-columns: 1fr 1fr; | |
| gap: 10px 20px; font-size: 13.5px; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; } | |
| #productFoot .item { display: flex; justify-content: space-between; | |
| color: var(--dim); } | |
| #productFoot .item b { color: var(--text); } | |
| #productFoot .item.ok b { color: var(--modeler); } | |
| /* status ribbon β hidden per user request, keeps the layout tighter | |
| so the footer fits on one screen. Elements still exist in the DOM | |
| so the timeline-runner JS can update them without errors. */ | |
| #ribbon { display: none; } | |
| #counter { font-size: 13px; padding: 4px 10px; border-radius: 4px; | |
| background: var(--bg); color: var(--faint); font-weight: 600; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; | |
| letter-spacing: 0.08em; } | |
| #status { flex: 1; font-size: 15px; color: var(--dim); font-style: italic; } | |
| /* legend */ | |
| /* Legend bar hidden β the colour-coded badges inside each node | |
| already communicate which agent is which. */ | |
| #legend { display: none; } | |
| #legend .pill { padding: 4px 10px; border-radius: 4px; background: transparent; | |
| border: none; } | |
| #legend .pill .dot { display: inline-block; width: 8px; height: 8px; | |
| border-radius: 2px; margin-right: 6px; vertical-align: middle; } | |
| /* side drawer */ | |
| #side { position: fixed; right: -420px; top: 0; height: 100vh; width: 420px; | |
| background: #fff; box-shadow: -8px 0 24px rgba(0,0,0,0.12); | |
| padding: 30px 32px; transition: right 0.4s ease; | |
| font-size: 13px; line-height: 1.55; overflow-y: auto; z-index: 9; } | |
| #side.on { right: 0; } | |
| #side h2 { font-size: 18px; margin-bottom: 6px; } | |
| #side .meta { font-size: 11px; color: var(--faint); | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; | |
| margin-bottom: 18px; letter-spacing: 0.04em; } | |
| #side .sec { font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; | |
| color: var(--faint); font-weight: 700; | |
| margin-top: 16px; margin-bottom: 5px; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; } | |
| #side .row { color: var(--dim); margin-bottom: 4px; } | |
| #side .row b { color: var(--text); } | |
| #side button.close { position: absolute; top: 22px; right: 24px; | |
| background: none; border: none; font-size: 18px; | |
| cursor: pointer; color: var(--faint); } | |
| /* βββ DOMINANT ATTRIBUTION PANEL β solid colour, slim profile βββ */ | |
| footer { width: 880px; margin-top: 8px; | |
| background: #1c4e9e; | |
| color: #fff; border-radius: 10px; | |
| padding: 12px 24px; | |
| box-shadow: 0 6px 18px rgba(28,78,158,0.22); | |
| display: flex; flex-direction: row; gap: 20px; | |
| align-items: center; justify-content: space-between; } | |
| footer .name { | |
| font-size: 17px; font-weight: 800; letter-spacing: -0.2px; | |
| color: #fff; line-height: 1.2; | |
| } | |
| footer .title { | |
| font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.78); | |
| letter-spacing: 0.08em; text-transform: uppercase; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; | |
| margin-top: 2px; | |
| } | |
| footer a.site { | |
| display: inline-flex; align-items: center; gap: 7px; | |
| padding: 7px 16px; background: rgba(255,255,255,0.18); | |
| border: 1.5px solid rgba(255,255,255,0.40); | |
| border-radius: 999px; color: #fff; | |
| font-size: 13px; font-weight: 700; text-decoration: none; | |
| letter-spacing: 0.02em; | |
| transition: background 0.2s, transform 0.15s; | |
| } | |
| footer a.site:hover { | |
| background: rgba(255,255,255,0.30); | |
| transform: translateY(-1px) scale(1.03); | |
| } | |
| footer a.site svg { | |
| width: 14px; height: 14px; stroke: currentColor; | |
| stroke-width: 2; fill: none; stroke-linecap: round; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="banner"> | |
| <h1><span class="brand">ChatCAD</span> <span class="sep">β</span> <span class="accent">Multi-Agent</span> Engineering Design Architecture</h1> | |
| <div class="tag"> | |
| Six specialised <b>AGENTS</b> + a <b>RAG retrieval layer</b> collaborate | |
| on every brief. Watch them assemble the design on the right β | |
| </div> | |
| </header> | |
| <div id="progress"><div class="fill" id="progressFill"></div></div> | |
| <div id="stage"> | |
| <!-- βββββββββ LEFT: flowchart βββββββββ --> | |
| <div id="board"> | |
| <div class="lane-label l1">β INPUT</div><div class="lane-sep l1"></div> | |
| <div class="lane-label l2">β‘ PLAN + MODEL + ENGINE</div> | |
| <div class="lane-label l3">β’ MULTI-CRITIC REVIEW</div><div class="lane-sep l2"></div> | |
| <svg class="wires" viewBox="0 0 880 580" preserveAspectRatio="none"> | |
| <defs> | |
| <marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" | |
| markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| <path d="M 0 0 L 10 5 L 0 10 Z" class="arrowhead"/> | |
| </marker> | |
| </defs> | |
| <!-- USER β PLANNER --> | |
| <path class="wire" id="w_user_planner" | |
| d="M 440 145 L 440 175 L 125 175 L 125 200" | |
| marker-end="url(#ah)"/> | |
| <!-- PLANNER β RAG --> | |
| <path class="wire" id="w_planner_rag" | |
| d="M 200 235 L 250 235" | |
| marker-end="url(#ah)"/> | |
| <path class="wire feedback" id="w_rag_planner" | |
| d="M 250 255 L 200 255" | |
| marker-end="url(#ah)"/> | |
| <!-- PLANNER β MODELER (detour under RAG) --> | |
| <path class="wire" id="w_planner_modeler" | |
| d="M 200 250 L 220 250 L 220 305 L 430 305 L 430 250 L 450 250" | |
| marker-end="url(#ah)"/> | |
| <!-- MODELER β ENGINE --> | |
| <path class="wire" id="w_modeler_engine" | |
| d="M 600 245 L 660 245" | |
| marker-end="url(#ah)"/> | |
| <!-- ENGINE β fan-out trunk --> | |
| <path class="wire" id="w_engine_bus_down" | |
| d="M 735 290 L 735 395 L 105 395"/> | |
| <path class="wire" id="w_bus_visual" d="M 105 395 L 105 430" marker-end="url(#ah)"/> | |
| <path class="wire" id="w_bus_dfm" d="M 275 395 L 275 430" marker-end="url(#ah)"/> | |
| <path class="wire" id="w_bus_stand" d="M 445 395 L 445 430" marker-end="url(#ah)"/> | |
| <path class="wire" id="w_bus_verif" d="M 615 395 L 615 430" marker-end="url(#ah)"/> | |
| <!-- STANDARDS β KNOWLEDGE --> | |
| <path class="wire" id="w_stand_know" | |
| d="M 520 470 L 710 470" | |
| marker-end="url(#ah)"/> | |
| <!-- CRITIC FEEDBACK LOOP β PLANNER --> | |
| <path class="wire feedback" id="w_critics_back" | |
| d="M 445 510 L 445 565 L 22 565 L 22 180 L 50 180" | |
| marker-end="url(#ah)"/> | |
| <circle id="packet" class="packet" cx="0" cy="0" fill="#1c4e9e"/> | |
| </svg> | |
| <div class="node user_node" data-id="user"> | |
| <span class="tag-pill">INPUT</span> | |
| <div class="row"><span class="icn"><!-- USER : person silhouette --> | |
| <svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/> | |
| <path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg> | |
| </span><span class="name">You</span></div> | |
| </div> | |
| <div class="node planner" data-id="planner"> | |
| <span class="tag-pill" style="background:var(--planner);color:#fff"> | |
| <svg viewBox="0 0 24 24"><!-- robot head mark --> | |
| <rect x="5" y="7" width="14" height="11" rx="2"/> | |
| <path d="M9 12h.01 M15 12h.01 M12 4v3 M10 18v2 M14 18v2"/> | |
| </svg>AGENT 1</span> | |
| <div class="row"><span class="icn"><!-- PLANNER : workflow / steps --> | |
| <svg viewBox="0 0 24 24"> | |
| <rect x="3" y="3" width="6" height="6" rx="1"/> | |
| <rect x="15" y="3" width="6" height="6" rx="1"/> | |
| <rect x="3" y="15" width="6" height="6" rx="1"/> | |
| <rect x="15" y="15" width="6" height="6" rx="1"/> | |
| <path d="M9 6h6 M6 9v6 M18 9v6 M9 18h6"/> | |
| </svg> | |
| </span><span class="name">Planner</span></div> | |
| </div> | |
| <div class="node rag" data-id="rag"> | |
| <span class="tag-pill" style="background:var(--rag);color:#fff"> | |
| <svg viewBox="0 0 24 24"><!-- mini RAG mark = small DB cylinder --> | |
| <ellipse cx="12" cy="6" rx="7" ry="2.5"/> | |
| <path d="M5 6v12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/> | |
| </svg>RAG</span> | |
| <div class="row"><span class="icn"><!-- RAG : database/cylinder + search --> | |
| <svg viewBox="0 0 24 24"> | |
| <ellipse cx="9" cy="5" rx="6" ry="2.2"/> | |
| <path d="M3 5v8c0 1.2 2.7 2.2 6 2.2s6-1 6-2.2V5"/> | |
| <path d="M3 10c0 1.2 2.7 2.2 6 2.2s6-1 6-2.2"/> | |
| <circle cx="17" cy="17" r="3"/> | |
| <path d="M19.2 19.2L22 22"/> | |
| </svg> | |
| </span><span class="name">Retrieval</span></div> | |
| </div> | |
| <div class="node modeler" data-id="modeler"> | |
| <span class="tag-pill" style="background:var(--modeler);color:#fff"> | |
| <svg viewBox="0 0 24 24"><!-- robot head mark --> | |
| <rect x="5" y="7" width="14" height="11" rx="2"/> | |
| <path d="M9 12h.01 M15 12h.01 M12 4v3 M10 18v2 M14 18v2"/> | |
| </svg>AGENT 2 Β· LLM</span> | |
| <div class="row"><span class="icn"> | |
| <!-- LLM : Claude-style 12-pointed star-burst logo --> | |
| <svg viewBox="0 0 24 24" style="stroke-width:1.4"> | |
| <g stroke="currentColor" stroke-linecap="round"> | |
| <line x1="12" y1="3" x2="12" y2="7"/> | |
| <line x1="12" y1="17" x2="12" y2="21"/> | |
| <line x1="3" y1="12" x2="7" y2="12"/> | |
| <line x1="17" y1="12" x2="21" y2="12"/> | |
| <line x1="6" y1="6" x2="8.8" y2="8.8"/> | |
| <line x1="15.2" y1="15.2" x2="18" y2="18"/> | |
| <line x1="18" y1="6" x2="15.2" y2="8.8"/> | |
| <line x1="8.8" y1="15.2" x2="6" y2="18"/> | |
| <line x1="12" y1="9.5" x2="12" y2="14.5"/> | |
| <line x1="9.5" y1="12" x2="14.5" y2="12"/> | |
| </g> | |
| </svg> | |
| </span><span class="name">Modeler (LLM)</span></div> | |
| </div> | |
| <div class="node engine_node" data-id="engine"> | |
| <span class="tag-pill" style="background:var(--slate);color:#fff">ENGINE</span> | |
| <div class="row"><span class="icn"><!-- ENGINE : cube / B-rep solid --> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/> | |
| <path d="M12 3v18 M4 7.5l8 4.5 8-4.5"/> | |
| </svg> | |
| </span><span class="name">CadQuery</span></div> | |
| </div> | |
| <div class="node visual" data-id="visual"> | |
| <span class="tag-pill" style="background:var(--visual);color:#fff"> | |
| <svg viewBox="0 0 24 24"><rect x="5" y="7" width="14" height="11" rx="2"/> | |
| <path d="M9 12h.01 M15 12h.01 M12 4v3 M10 18v2 M14 18v2"/></svg>AGENT 3</span> | |
| <div class="row"><span class="icn"><!-- VISUAL : eye --> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/> | |
| <circle cx="12" cy="12" r="3"/> | |
| </svg> | |
| </span><span class="name">Visual</span></div> | |
| </div> | |
| <div class="node dfm" data-id="dfm"> | |
| <span class="tag-pill" style="background:var(--dfm);color:#fff"> | |
| <svg viewBox="0 0 24 24"><rect x="5" y="7" width="14" height="11" rx="2"/> | |
| <path d="M9 12h.01 M15 12h.01 M12 4v3 M10 18v2 M14 18v2"/></svg>AGENT 4</span> | |
| <div class="row"><span class="icn"><!-- DFM : wrench --> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M14.7 6.3a4 4 0 015 5l-2.3-2.3-2.3.7-.7 2.3 2.3 2.3a4 4 0 01-5-5L4 18.7a2 2 0 002.7 2.7L14.7 13"/> | |
| </svg> | |
| </span><span class="name">DfM</span></div> | |
| </div> | |
| <div class="node standards" data-id="standards"> | |
| <span class="tag-pill" style="background:var(--standards);color:#fff"> | |
| <svg viewBox="0 0 24 24"><rect x="5" y="7" width="14" height="11" rx="2"/> | |
| <path d="M9 12h.01 M15 12h.01 M12 4v3 M10 18v2 M14 18v2"/></svg>AGENT 5</span> | |
| <div class="row"><span class="icn"><!-- STANDARDS : ruler / compass --> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M3 17.25V21h3.75L17.8 9.95l-3.75-3.75L3 17.25z"/> | |
| <path d="M14 7l3 3"/> | |
| <path d="M5 12l4 4"/> | |
| </svg> | |
| </span><span class="name">Standards</span></div> | |
| </div> | |
| <div class="node verifier" data-id="verifier"> | |
| <span class="tag-pill" style="background:var(--verifier);color:#fff"> | |
| <svg viewBox="0 0 24 24"><rect x="5" y="7" width="14" height="11" rx="2"/> | |
| <path d="M9 12h.01 M15 12h.01 M12 4v3 M10 18v2 M14 18v2"/></svg>AGENT 6</span> | |
| <div class="row"><span class="icn"><!-- VERIFIER : shield + check --> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M12 3l8 3v6c0 5-3.5 8.5-8 9-4.5-.5-8-4-8-9V6l8-3z"/> | |
| <path d="M8.5 12l2.5 2.5L15.5 10"/> | |
| </svg> | |
| </span><span class="name">Verifier</span></div> | |
| </div> | |
| <div class="node knowledge" data-id="knowledge"> | |
| <span class="tag-pill" style="background:var(--rag);color:#fff"> | |
| <svg viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="7" ry="2.5"/> | |
| <path d="M5 6v12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/></svg>RAG STORE</span> | |
| <div class="row"><span class="icn"><!-- KNOWLEDGE : book/notes stack --> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M4 4h12a3 3 0 013 3v13a3 3 0 00-3-2H4V4z"/> | |
| <path d="M4 4v15a2 2 0 002 2h13"/> | |
| <path d="M8 8h7 M8 11h7 M8 14h5"/> | |
| </svg> | |
| </span><span class="name">Knowledge</span></div> | |
| </div> | |
| </div> | |
| <!-- βββββββββ RIGHT: BIG 3D product panel βββββββββ --> | |
| <div id="product"> | |
| <div id="productHead"> | |
| <div class="left"> | |
| <div class="out-eyebrow">β£ OUTPUT</div> | |
| <h2 id="prodTitle">Final <span class="accent">Product</span></h2> | |
| <div class="sub" id="prodSub">M8 hex bolt Β· Al-6061-T6</div> | |
| </div> | |
| <div class="stat" id="prodStat">buildingβ¦</div> | |
| </div> | |
| <div id="productView"> | |
| <!-- 4 slot-style callouts whose text is set per scenario --> | |
| <div class="callout c_p1" id="ca_p1"></div> | |
| <div class="callout c_p2" id="ca_p2"></div> | |
| <div class="callout c_p3" id="ca_p3"></div> | |
| <div class="callout c_p4" id="ca_p4"></div> | |
| <div class="callout c_sf" id="ca_sf"></div> | |
| <div class="callout c_mass" id="ca_mass"></div> | |
| <div class="callout c_deliv" id="ca_deliv"></div> | |
| </div> | |
| <div id="productFoot"> | |
| <div class="item"><span>mass</span><b id="ft_mass">β g</b></div> | |
| <div class="item"><span>material</span><b id="ft_mat">Al-6061-T6</b></div> | |
| <div class="item"><span>max stress</span><b id="ft_stress">β MPa</b></div> | |
| <div class="item ok"><span>safety factor</span><b id="ft_sf">β</b></div> | |
| <div class="item"><span>parts</span><b id="ft_parts">β / 4</b></div> | |
| <div class="item ok"><span>verifier</span><b id="ft_ver">β</b></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="ribbon"> | |
| <span id="counter">STAGE 0 / 8</span> | |
| <span id="status">βΆ Press play to run a full multi-agent design loop</span> | |
| </div> | |
| <div id="legend"> | |
| <span class="pill"><span class="dot" style="background:var(--planner)"></span>AGENT 1 β Planner</span> | |
| <span class="pill"><span class="dot" style="background:var(--rag)"></span>RAG layer</span> | |
| <span class="pill"><span class="dot" style="background:var(--modeler)"></span>AGENT 2 β Modeler (LLM)</span> | |
| <span class="pill"><span class="dot" style="background:var(--visual)"></span>AGENT 3 β Visual</span> | |
| <span class="pill"><span class="dot" style="background:var(--dfm)"></span>AGENT 4 β DfM</span> | |
| <span class="pill"><span class="dot" style="background:var(--standards)"></span>AGENT 5 β Standards</span> | |
| <span class="pill"><span class="dot" style="background:var(--verifier)"></span>AGENT 6 β Verifier</span> | |
| </div> | |
| <footer> | |
| <div> | |
| <div class="name">Samarjith Biswas, PhD</div> | |
| <div class="title">Research Scientist Β· AI Β· CAD Β· Phononic Metamaterials</div> | |
| </div> | |
| <a class="site" href="https://www.samarjithbiswas.com" | |
| target="_blank" rel="noopener"> | |
| <svg viewBox="0 0 24 24"> | |
| <circle cx="12" cy="12" r="9"/> | |
| <path d="M3 12h18 M12 3a14 14 0 010 18 M12 3a14 14 0 000 18"/> | |
| </svg> | |
| samarjithbiswas.com | |
| </a> | |
| </footer> | |
| <!-- side drawer --> | |
| <div id="side"> | |
| <button class="close" onclick="document.getElementById('side').classList.remove('on')">Γ</button> | |
| <h2 id="sideName">Agent</h2> | |
| <div class="meta" id="sideFn">function()</div> | |
| <div class="sec">Role</div><div class="row" id="sideRole"></div> | |
| <div class="sec">Inputs</div><div class="row" id="sideIn"></div> | |
| <div class="sec">Outputs</div><div class="row" id="sideOut"></div> | |
| <div class="sec">Model</div><div class="row" id="sideModel"></div> | |
| <div class="sec">Source</div><div class="row" id="sideSrc" style="font-family:ui-monospace,monospace"></div> | |
| </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'; | |
| const DETAILS = { | |
| user: { name: 'You β design brief', fn: 'human input', | |
| role: 'Plain-English description.', in: 'free-form text', | |
| out: 'brief β AGENT 1', model: 'n/a', | |
| src: 'app.py /design_agent endpoint' }, | |
| planner: { name: 'AGENT 1 β Planner', fn: 'agents.plan_design()', | |
| role: 'Decomposes brief into 2-6 milestones with success criteria.', | |
| in: 'brief, knowledge_block', out: 'plan.milestones (JSON)', | |
| model: 'claude-opus-4-7', src: 'agents.py Β· line 137' }, | |
| rag: { name: 'RAG retrieval', fn: 'KnowledgeStore.search()', | |
| role: 'TF-IDF cosine search over notes.json.', | |
| in: 'brief, K=6', out: 'top-K notes', | |
| model: 'scikit-learn β no LLM', src: 'cad_engine.py' }, | |
| modeler: { name: 'AGENT 2 β Modeler', fn: 'llm.run_claude() tool loop', | |
| role: 'Tool-using LLM. Picks CadQuery ops one by one.', | |
| in: 'milestone goal, history', out: 'engine.<op>(args) calls', | |
| model: 'claude-opus-4-7 tool_use', src: 'llm.py Β· run_claude()' }, | |
| engine: { name: 'CadQuery engine', fn: 'CadEngine.dispatch()', | |
| role: 'B-rep kernel. Builds geometry, exports STL + render.png.', | |
| in: 'tool name + args', out: 'engine.parts, STL, PNG', | |
| model: 'OpenCascade β no LLM', src: 'cad_engine.py' }, | |
| visual: { name: 'AGENT 3 β Visual critic', fn: 'agents.critique_visual()', | |
| role: 'Compares rendered PNG to brief.', | |
| in: 'brief, milestone, render.png', | |
| out: '{verdict, feedback, specific_changes}', | |
| model: 'claude-opus-4-7 vision', src: 'agents.py Β· line 365' }, | |
| dfm: { name: 'AGENT 4 β DfM critic', fn: 'agents.critique_dfm()', | |
| role: 'Wall thickness, draft, aspect; LLM interpretation.', | |
| in: 'engine.parts, brief', out: '{verdict, issues}', | |
| model: 'numpy + claude-opus-4-7', src: 'agents.py Β· line 242' }, | |
| standards: { name: 'AGENT 5 β Standards critic', fn: 'agents.critique_standards()', | |
| role: 'RAG-driven ISO/ASME compliance check.', | |
| in: 'engine.parts, RAG notes', out: '{verdict, issues}', | |
| model: 'claude-opus-4-7 + RAG', src: 'agents.py Β· line 284' }, | |
| verifier: { name: 'AGENT 6 β Verifier', fn: 'agents.verify_intent()', | |
| role: 'Final gate. match_score + JSON verdict.', | |
| in: 'brief, image, parts list', | |
| out: '{match_score, suggested_commands}', | |
| model: 'claude-opus-4-7 vision', src: 'agents.py Β· line 333' }, | |
| knowledge: { name: 'RAG store', fn: 'KnowledgeStore.add() / .search()', | |
| role: 'Persistent notes; auto-grows after each success.', | |
| in: 'note + tags', out: 'output/knowledge/notes.json', | |
| model: 'no LLM', src: 'cad_engine.py' }, | |
| final: { name: 'Final design package', fn: 'auto-export pipeline', | |
| role: 'STEP + STL + drawing PDF + BOM + report PDF.', | |
| in: 'verified engine state', out: 'output/<name>.{step,stl,pdf,csv}', | |
| model: 'no LLM', src: 'app.py + design_report.py' }, | |
| }; | |
| document.querySelectorAll('.node').forEach(el => { | |
| el.addEventListener('click', () => { | |
| const d = DETAILS[el.dataset.id]; if (!d) return; | |
| document.getElementById('sideName').textContent = d.name; | |
| document.getElementById('sideFn').textContent = d.fn; | |
| document.getElementById('sideRole').innerHTML = d.role; | |
| document.getElementById('sideIn').innerHTML = d.in; | |
| document.getElementById('sideOut').innerHTML = d.out; | |
| document.getElementById('sideModel').innerHTML = d.model; | |
| document.getElementById('sideSrc').innerHTML = d.src; | |
| document.getElementById('side').classList.add('on'); | |
| }); | |
| }); | |
| // βββ BIG 3D viewport (always visible) βββ | |
| const vp = document.getElementById('productView'); | |
| const 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(); | |
| const pmrem = new THREE.PMREMGenerator(renderer); | |
| scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.05).texture; | |
| const camera = new THREE.PerspectiveCamera(34, W/H, 0.1, 1000); | |
| camera.position.set(70, 55, 95); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; controls.dampingFactor = 0.07; | |
| controls.autoRotate = true; controls.autoRotateSpeed = 1.0; | |
| controls.target.set(0, 10, 0); | |
| const k = new THREE.DirectionalLight(0xffffff, 1.3); | |
| k.position.set(60,90,40); scene.add(k); | |
| scene.add(new THREE.DirectionalLight(0xbed0ee, 0.5).translateOnAxis(new THREE.Vector3(-0.4,0.3,-0.5).normalize(),100)); | |
| scene.add(new THREE.HemisphereLight(0xffffff, 0xc0c8d0, 0.5)); | |
| // βββ METAL PALETTE β each scenario picks a different real-world finish βββ | |
| // (SHARED_MATS is the set of materials reused across all scenarios β they | |
| // must NOT be disposed when switching scenes.) | |
| const SHARED_MATS = new Set(); | |
| const steel = new THREE.MeshPhysicalMaterial({ | |
| color: 0xc6cdd6, metalness: 0.95, roughness: 0.30, | |
| clearcoat: 0.20, envMapIntensity: 1.3 }); | |
| const blackSteel = new THREE.MeshPhysicalMaterial({ // black-oxide nut finish | |
| color: 0x3a3f48, metalness: 0.88, roughness: 0.38, | |
| clearcoat: 0.05, envMapIntensity: 1.0 }); | |
| const brass = new THREE.MeshPhysicalMaterial({ // gear bronze | |
| color: 0xd4a04c, metalness: 0.92, roughness: 0.30, | |
| clearcoat: 0.15, envMapIntensity: 1.5 }); | |
| const copper = new THREE.MeshPhysicalMaterial({ // copper accent | |
| color: 0xb87333, metalness: 0.92, roughness: 0.28, | |
| clearcoat: 0.12, envMapIntensity: 1.5 }); | |
| const aluminum = new THREE.MeshPhysicalMaterial({ // bracket / panel | |
| color: 0xe2e6eb, metalness: 0.90, roughness: 0.22, | |
| clearcoat: 0.40, clearcoatRoughness: 0.30, envMapIntensity: 1.5 }); | |
| const zincPlated = new THREE.MeshPhysicalMaterial({ // hardware-store bolt | |
| color: 0xd0d7e2, metalness: 0.85, roughness: 0.40, | |
| clearcoat: 0.10, envMapIntensity: 1.2 }); | |
| // All metal-palette materials are SHARED across scenarios β never dispose. | |
| [steel, blackSteel, brass, copper, aluminum, zincPlated] | |
| .forEach(m => SHARED_MATS.add(m)); | |
| // βββ 5 product scenarios that cycle on each loop βββ | |
| // Each scenario builds its own group of meshes (added to PARTS.steps[]), | |
| // each step is a list of meshes that appear together when the timeline | |
| // fires its 'show 1' / 'show 2' etc. | |
| let PRODUCT_GROUP = new THREE.Group(); | |
| scene.add(PRODUCT_GROUP); | |
| let PARTS = { steps: [[],[],[],[]] }; | |
| function clearProductGroup() { | |
| while (PRODUCT_GROUP.children.length) { | |
| const c = PRODUCT_GROUP.children.pop(); | |
| if (c.geometry) c.geometry.dispose(); | |
| if (c.material && c.material.dispose && !SHARED_MATS.has(c.material)) { | |
| c.material.dispose(); | |
| } | |
| } | |
| // Reset any rotation a previous scenario applied (fuselage rotates | |
| // the whole group horizontal); other scenarios assume identity. | |
| PRODUCT_GROUP.rotation.set(0, 0, 0); | |
| PRODUCT_GROUP.position.set(0, 0, 0); | |
| PARTS = { steps: [[],[],[],[]] }; | |
| } | |
| function hexShape(R) { | |
| const sh = 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) sh.moveTo(x, y); else sh.lineTo(x, y); | |
| } | |
| sh.closePath(); | |
| return sh; | |
| } | |
| // ββ scenario A: hex bolt ββ | |
| function buildBolt() { | |
| clearProductGroup(); | |
| const steel = zincPlated; // β bolt finish: zinc-plated, slightly muted | |
| const headH = 5.3, shankR = 4, shankL = 30, R = 13 / Math.sqrt(3); | |
| // step 1 β shank | |
| const shank = new THREE.Mesh( | |
| new THREE.CylinderGeometry(shankR, shankR, shankL, 32), steel); | |
| shank.position.y = shankL/2; PRODUCT_GROUP.add(shank); | |
| PARTS.steps[0].push(shank); | |
| // step 2 β hex head, sitting flush on top of shank. | |
| // ExtrudeGeometry depth runs 0..headH along +Z. rotation.x = -Ο/2 | |
| // remaps +Z β +Y, so after rotation the head geometry occupies | |
| // LOCAL y = 0..headH (not centred on origin). So the head's | |
| // BOTTOM in local space is y=0, not y=-headH/2. To make the head | |
| // sit on the shank top (y = shankL), position.y must equal shankL, | |
| // NOT shankL + headH/2. (Previous '+headH/2' offset was the 2.65 mm | |
| // gap the user kept seeing between head and shank.) | |
| const head = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(hexShape(R), { depth: headH, bevelEnabled: true, | |
| bevelThickness: 0.4, bevelSize: 0.3, bevelSegments: 2 }), steel); | |
| head.rotation.x = -Math.PI/2; | |
| head.position.y = shankL; | |
| PRODUCT_GROUP.add(head); PARTS.steps[1].push(head); | |
| // step 3 β helical thread | |
| for (let i = 0; i < 22; i++) { | |
| const ring = new THREE.Mesh( | |
| new THREE.TorusGeometry(shankR + 0.1, 0.45, 6, 36), steel); | |
| ring.rotation.x = Math.PI/2; | |
| ring.rotation.z = i * 0.4; | |
| ring.position.y = 2 + i * 1.25; | |
| PRODUCT_GROUP.add(ring); PARTS.steps[2].push(ring); | |
| } | |
| // step 4 β head-shank fillet ring (sits exactly at the junction) | |
| const cham = new THREE.Mesh( | |
| new THREE.TorusGeometry(shankR + 0.6, 0.5, 10, 36), steel); | |
| cham.rotation.x = Math.PI/2; | |
| cham.position.y = shankL; // exactly at the head/shank seam | |
| PRODUCT_GROUP.add(cham); PARTS.steps[3].push(cham); | |
| } | |
| // ββ scenario B: hex nut β just the nut, nothing floating around it ββ | |
| function buildNut() { | |
| clearProductGroup(); | |
| const steel = blackSteel; // β black-oxide finish, classic nut look | |
| const R = 13 / Math.sqrt(3), height = 6.5, boreR = 4.5; | |
| const nutShape = hexShape(R); | |
| const hole = new THREE.Path(); | |
| hole.absarc(0, 0, boreR, 0, Math.PI*2, true); | |
| nutShape.holes.push(hole); | |
| // step 1 β hex prism + through-bore. Bevel is small (0.2) so the | |
| // bore opening stays close to its nominal radius and doesn't flare | |
| // out enough for threads to peek through. | |
| const prism = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(nutShape, { depth: height, bevelEnabled: true, | |
| bevelThickness: 0.3, bevelSize: 0.2, bevelSegments: 2 }), steel); | |
| prism.rotation.x = -Math.PI/2; | |
| prism.position.y = height/2; | |
| prism.renderOrder = 0; // draw prism first | |
| PRODUCT_GROUP.add(prism); PARTS.steps[0].push(prism); | |
| // steps 2 / 3 / 4 β internal helical thread, revealed bottomβtop. | |
| // Aggressively pulled inside the bore: | |
| // bore radius = 4.50 | |
| // thread major = 3.20 (1.30 mm clearance from bore wall) | |
| // tube radius = 0.14 (very slim profile) | |
| // y range = 1.8 .. 4.7 (well below bevel-flared opening) | |
| // count = 4 (3 fewer rings, less visual clutter) | |
| const innerR = 3.20; | |
| const tubeR = 0.14; | |
| const nRings = 4; | |
| for (let i = 0; i < nRings; i++) { | |
| const ring = new THREE.Mesh( | |
| new THREE.TorusGeometry(innerR, tubeR, 6, 40), steel); | |
| ring.rotation.x = Math.PI/2; | |
| ring.position.y = 1.8 + i * 0.95; | |
| ring.renderOrder = 1; // draw after the prism | |
| PRODUCT_GROUP.add(ring); | |
| // Spread 4 rings across the 3 thread-build steps (1, 2, 3) | |
| PARTS.steps[1 + Math.min(2, Math.floor(i * 3 / nRings))].push(ring); | |
| } | |
| // step 4 β leave empty (nut is structurally complete after threads) | |
| } | |
| // ββ scenario C: spur gear (real involute tooth profile) ββ | |
| function buildGear() { | |
| clearProductGroup(); | |
| const steel = brass; // β bronze/brass β classic gear material | |
| const teeth = 24, mod = 1.5, width = 5; | |
| const pitchR = mod * teeth / 2; // 18 mm | |
| const rootR = pitchR - 1.25 * mod; // 16.125 mm | |
| const tipR = pitchR + mod; // 19.5 mm | |
| const pressureAngle = 20 * Math.PI / 180; | |
| const baseR = pitchR * Math.cos(pressureAngle); // 16.91 mm | |
| const boreR = 4; | |
| const toothAngle = (Math.PI * 2) / teeth; | |
| // Tooth thickness at pitch line is half the circular pitch. | |
| const halfToothPitchAngle = (Math.PI / teeth) / 2; | |
| // Tooth thickness at base circle (involute thickness). | |
| // s_b = s_p Β· (r_b / r_p) + 2 Β· r_b Β· (inv(Ξ±_p) - inv(Ξ±_b)). Ξ±_b = 0. | |
| function invAng(a) { return Math.tan(a) - a; } | |
| const halfBaseToothAngle = halfToothPitchAngle * (baseR / pitchR) | |
| + invAng(pressureAngle); | |
| // involute curve from base circle: x = r_b(cos t + t sin t), y = r_b(sin t - t cos t) | |
| function involutePoint(t) { | |
| return [ | |
| baseR * (Math.cos(t) + t * Math.sin(t)), | |
| baseR * (Math.sin(t) - t * Math.cos(t)), | |
| ]; | |
| } | |
| // Solve for the t that lands the involute at the tip radius. | |
| // r(t) = r_b * sqrt(1 + tΒ²) β t_tip = sqrt((tip/base)Β² - 1). | |
| const tTip = Math.sqrt(Math.max(0, (tipR / baseR) ** 2 - 1)); | |
| // Build ONE gear shape with all 24 teeth as a single polyline, plus | |
| // a bore hole. Then extrude once with bevel β gives the chamfered | |
| // top/bottom edges that real gears have. | |
| const gearShape = new THREE.Shape(); | |
| const N_INV = 14; // points along each involute flank | |
| function rot(p, a) { | |
| return [p[0]*Math.cos(a) - p[1]*Math.sin(a), | |
| p[0]*Math.sin(a) + p[1]*Math.cos(a)]; | |
| } | |
| let first = true; | |
| for (let i = 0; i < teeth; i++) { | |
| const center = i * toothAngle; | |
| // RIGHT flank involute (from base β tip), then mirror for the LEFT | |
| // flank. We rotate so the involute is centred on `center`. | |
| // The tooth is symmetric around `center`; the involute on the right | |
| // starts at angle (center - halfBaseToothAngle) at base radius | |
| // and curves outward. | |
| const a0_right = center - halfBaseToothAngle; | |
| const a0_left = center + halfBaseToothAngle; | |
| // Right flank: t from 0 β tTip, rotated so its base point sits at a0_right | |
| for (let k = 0; k <= N_INV; k++) { | |
| const t = (k / N_INV) * tTip; | |
| const p = involutePoint(t); | |
| // Rotate so involute starts at angle a0_right | |
| const rotated = rot(p, a0_right); | |
| if (first) { gearShape.moveTo(rotated[0], rotated[1]); first = false; } | |
| else gearShape.lineTo(rotated[0], rotated[1]); | |
| } | |
| // Tip arc: walk from end-of-right-involute to end-of-left-involute | |
| // along the tip circle in small steps. | |
| const endRight = rot(involutePoint(tTip), a0_right); | |
| const endLeft = rot([involutePoint(tTip)[0], -involutePoint(tTip)[1]], a0_left); | |
| const aR = Math.atan2(endRight[1], endRight[0]); | |
| const aL = Math.atan2(endLeft[1], endLeft[0]); | |
| const TIP_SEG = 5; | |
| for (let k = 1; k <= TIP_SEG; k++) { | |
| const a = aR + ((aL - aR + Math.PI * 2) % (Math.PI * 2)) * (k / TIP_SEG); | |
| gearShape.lineTo(tipR * Math.cos(a), tipR * Math.sin(a)); | |
| } | |
| // Left flank involute: t from tTip β 0, mirrored y, rotated by a0_left | |
| for (let k = N_INV; k >= 0; k--) { | |
| const t = (k / N_INV) * tTip; | |
| const p = involutePoint(t); | |
| const mirrored = [p[0], -p[1]]; | |
| const rotated = rot(mirrored, a0_left); | |
| gearShape.lineTo(rotated[0], rotated[1]); | |
| } | |
| // Root arc from end-of-this-tooth's-left-flank to next tooth's | |
| // right-flank-start, along the root circle. The start point sits | |
| // at angle a0_left at base radius; we need to traverse to the | |
| // next tooth's a0_right which is at a0_left + (toothAngle - 2*halfBaseToothAngle). | |
| const rootStartAngle = a0_left; | |
| const rootEndAngle = center + toothAngle - halfBaseToothAngle; | |
| const ROOT_SEG = 6; | |
| for (let k = 1; k <= ROOT_SEG; k++) { | |
| const a = rootStartAngle + (rootEndAngle - rootStartAngle) * (k / ROOT_SEG); | |
| gearShape.lineTo(rootR * Math.cos(a), rootR * Math.sin(a)); | |
| } | |
| } | |
| gearShape.closePath(); | |
| // Bore hole through centre | |
| const bore = new THREE.Path(); | |
| bore.absarc(0, 0, boreR, 0, Math.PI * 2, true); | |
| gearShape.holes.push(bore); | |
| // Build the gear in 4 progressive groups so the animation reveals it. | |
| // Strategy: make a small "hub-only" mesh for step 1, then half the | |
| // teeth for step 2, second half for step 3, keyway for step 4. | |
| // Easier: render the full gear in step 1 (it's one shape) but ALSO | |
| // show a hub-only ring first that gets replaced by the full thing. | |
| // Step 1: hub disc with bore only (placeholder) | |
| const hubShape = new THREE.Shape(); | |
| hubShape.absarc(0, 0, rootR, 0, Math.PI * 2, false); | |
| const hubHole = new THREE.Path(); | |
| hubHole.absarc(0, 0, boreR, 0, Math.PI * 2, true); | |
| hubShape.holes.push(hubHole); | |
| const hub = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(hubShape, { | |
| depth: width, bevelEnabled: true, | |
| bevelThickness: 0.4, bevelSize: 0.3, bevelSegments: 2 }), | |
| steel); | |
| hub.rotation.x = -Math.PI/2; hub.position.y = width/2; | |
| PRODUCT_GROUP.add(hub); PARTS.steps[0].push(hub); | |
| // Step 2 + 3: full gear with involute teeth. We split it visually | |
| // by stripping half the teeth out for step 2. Simpler implementation: | |
| // build TWO gear meshes β one with only first half of teeth visible, | |
| // one with all. Toggle which is visible. | |
| // Full gear: | |
| const fullGear = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(gearShape, { | |
| depth: width, bevelEnabled: true, | |
| bevelThickness: 0.4, bevelSize: 0.3, bevelSegments: 2 }), | |
| steel); | |
| fullGear.rotation.x = -Math.PI/2; fullGear.position.y = width/2; | |
| fullGear.visible = false; | |
| PRODUCT_GROUP.add(fullGear); | |
| // Show half-formed gear in step 2 (just call it half teeth). | |
| // For animation we'll show the SAME mesh but reveal it across two steps. | |
| PARTS.steps[1].push(fullGear); // first half-reveal | |
| // step 3 already covered by fullGear being on. | |
| // Keep PARTS.steps[2] empty to avoid double-flash; reveal cosmetic | |
| // edge highlight via step 4 below. | |
| // Step 3: a thin colour-tinted highlight ring around the tip circle | |
| // (engineering callout: 'pitch diameter') | |
| const pitchRing = new THREE.Mesh( | |
| new THREE.TorusGeometry(pitchR, 0.10, 6, 96), | |
| new THREE.MeshBasicMaterial({ color: 0x2461c2 })); | |
| pitchRing.rotation.x = Math.PI/2; | |
| pitchRing.position.y = width + 0.4; | |
| PRODUCT_GROUP.add(pitchRing); PARTS.steps[2].push(pitchRing); | |
| // step 4 β keyway notch on the bore | |
| const key = new THREE.Mesh(new THREE.BoxGeometry(1.5, width + 0.5, 2), steel); | |
| key.position.set(0, width/2, boreR); PRODUCT_GROUP.add(key); | |
| PARTS.steps[3].push(key); | |
| } | |
| // ββ scenario D: L-bracket (real L profile + visible through-holes) ββ | |
| function buildBracket() { | |
| clearProductGroup(); | |
| const steel = aluminum; // β brushed aluminum, bright lightweight | |
| // Shrunk overall so the part fits comfortably in the viewport | |
| // (previous 50 Γ 40 Γ 30 was clipping at the edges). | |
| // Hole radius bumped to 3.5 mm so the through-holes are clearly visible. | |
| const legA = 36, legB = 28, t = 3.5, w = 22, holeR = 3.5; | |
| // Build the L cross-section as ONE polygon in the XY plane. | |
| // The legs share the corner (0,0..t,t), so the L shape is a single | |
| // closed polyline β they MEET at the corner with no overlap or seam. | |
| // | |
| // (0, legB)ββ(t, legB) | |
| // β β | |
| // β β β vertical leg (width t) | |
| // β β | |
| // β (t, t)βββ(legA, t) | |
| // β β β horizontal leg (height t) | |
| // β β | |
| // (0, 0)βββββββββββ(legA, 0) | |
| // | |
| // Step 1 puts the WHOLE L-cross-section (just the body, no holes yet). | |
| // Step 2 will rebuild WITH holes cut through. We achieve the step | |
| // animation by hiding/showing two different L-meshes: body-only, then | |
| // body-with-holes. | |
| function lShape(addHoles) { | |
| const s = new THREE.Shape(); | |
| s.moveTo(0, 0); | |
| s.lineTo(legA, 0); | |
| s.lineTo(legA, t); | |
| s.lineTo(t, t); | |
| s.lineTo(t, legB); | |
| s.lineTo(0, legB); | |
| s.closePath(); | |
| if (addHoles) { | |
| // Holes on the horizontal arm β z runs along the depth (w) | |
| // axis after extrusion, but inside the 2-D Shape we put the | |
| // holes on the arm centrelines. Two holes on each arm. | |
| // (Since holes are cut through the extrusion depth, they | |
| // pierce the bracket like real bolted-mount holes.) | |
| // BUT β note the extrusion depth IS the w (z) axis; the holes | |
| // in the 2-D Shape pierce that depth. So 2-D hole positions | |
| // are placed at (x, y) on the L cross-section. | |
| // The arms are thin (t = 4 mm); a circular hole in the 2-D | |
| // cross-section would be 5 mm Γ in the arm thickness β too | |
| // big to fit. So we DON'T put the through-holes here; we cut | |
| // them as separate cylinder subtractions after extrusion. | |
| // (Implemented below via CSG-like cylinder cuts.) | |
| } | |
| return s; | |
| } | |
| // ββ Step 1: solid L cross-section, extruded along z (depth = w) ββ | |
| const solidL = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(lShape(false), { | |
| depth: w, bevelEnabled: true, | |
| bevelThickness: 0.4, bevelSize: 0.3, bevelSegments: 2 }), | |
| steel); | |
| solidL.position.z = -w/2; // centre depth on origin | |
| PRODUCT_GROUP.add(solidL); PARTS.steps[0].push(solidL); | |
| // ββ Step 2: cap the arm corner with a small fillet (visual cue) ββ | |
| // (Just a thin torus arc nestled in the inner corner so step 2 has | |
| // something visible.) | |
| const fillet = new THREE.Mesh( | |
| new THREE.TorusGeometry(2.5, 0.4, 6, 24, Math.PI/2), steel); | |
| fillet.rotation.z = Math.PI/2; fillet.rotation.y = Math.PI/2; | |
| fillet.position.set(t + 0.5, t + 0.5, 0); | |
| PRODUCT_GROUP.add(fillet); PARTS.steps[1].push(fillet); | |
| // ββ Step 3 + 4: REAL circular through-holes on each arm ββ | |
| // We can't easily Boolean-cut at runtime in three.js, so we | |
| // FAKE the through-hole by: | |
| // (a) drawing a black disc on each face of the arm at the hole | |
| // position (so it reads as a dark hole) | |
| // (b) inserting a thin dark cylinder INSIDE the arm thickness | |
| // perpendicular to the hole axis, so any auto-rotation that | |
| // looks at the bracket edge-on still sees darkness through | |
| // the hole region. | |
| // Very dark, slightly emissive so the discs always read as deep | |
| // recesses regardless of the lighting angle. | |
| const darkMat = new THREE.MeshStandardMaterial({ | |
| color: 0x05080d, metalness: 0.0, roughness: 0.95, | |
| emissive: 0x020306 }); | |
| function drillHole(cx, cy, axis, stepArr) { | |
| // Two flat black discs on the +/β faces of the arm + a dark | |
| // cylinder through it. | |
| if (axis === 'y') { // hole punched through the HORIZONTAL arm | |
| // arm spans y = 0..t, depth z = -w/2 .. +w/2, hole at (cx, ?, cy) | |
| // top disc | |
| const top = new THREE.Mesh( | |
| new THREE.CircleGeometry(holeR, 32), darkMat); | |
| top.rotation.x = -Math.PI/2; | |
| top.position.set(cx, t + 0.02, cy); | |
| // bottom disc | |
| const bot = new THREE.Mesh( | |
| new THREE.CircleGeometry(holeR, 32), darkMat); | |
| bot.rotation.x = Math.PI/2; | |
| bot.position.set(cx, -0.02, cy); | |
| // cylinder filling the bore (dark) | |
| const cyl = new THREE.Mesh( | |
| new THREE.CylinderGeometry(holeR * 0.95, holeR * 0.95, t + 0.1, 32), | |
| darkMat); | |
| cyl.position.set(cx, t/2, cy); | |
| PRODUCT_GROUP.add(top, bot, cyl); | |
| stepArr.push(top, bot, cyl); | |
| } else { // hole punched through the VERTICAL arm | |
| // arm spans x = 0..t, hole at (?, cy, cz) | |
| const front = new THREE.Mesh( | |
| new THREE.CircleGeometry(holeR, 32), darkMat); | |
| front.rotation.y = Math.PI/2; | |
| front.position.set(t + 0.02, cx, cy); // (cx here is Y-pos, cy is z-pos) | |
| const back = new THREE.Mesh( | |
| new THREE.CircleGeometry(holeR, 32), darkMat); | |
| back.rotation.y = -Math.PI/2; | |
| back.position.set(-0.02, cx, cy); | |
| const cyl = new THREE.Mesh( | |
| new THREE.CylinderGeometry(holeR * 0.95, holeR * 0.95, t + 0.1, 32), | |
| darkMat); | |
| cyl.rotation.z = Math.PI/2; | |
| cyl.position.set(t/2, cx, cy); | |
| PRODUCT_GROUP.add(front, back, cyl); | |
| stepArr.push(front, back, cyl); | |
| } | |
| } | |
| // Two holes on horizontal arm β positioned near the free end and | |
| // off-centred in depth so both are clearly separated. Z = Β±6 places | |
| // them well inside the new 22 mm depth. | |
| drillHole(legA - 9, -6, 'y', PARTS.steps[2]); | |
| drillHole(legA - 9, 6, 'y', PARTS.steps[2]); | |
| // Two holes on vertical arm | |
| drillHole(legB - 8, -6, 'x', PARTS.steps[3]); | |
| drillHole(legB - 8, 6, 'x', PARTS.steps[3]); | |
| } | |
| // ββ scenario F: CENTER fuselage section only (the modular "FuselageMid" | |
| // piece in the reference β no nose, no tail) ββ | |
| function buildFuselage() { | |
| clearProductGroup(); | |
| // Layout β just one cylindrical section, ~40 mm long, radius 6: | |
| // y = -20 .. +20 center barrel (the only thing in this scenario) | |
| const aluMat = new THREE.MeshPhysicalMaterial({ | |
| color: 0xe8eef5, metalness: 0.92, roughness: 0.25, | |
| clearcoat: 0.45, clearcoatRoughness: 0.35, envMapIntensity: 1.4 }); | |
| const skinSeam = new THREE.MeshStandardMaterial({ | |
| color: 0xb0b8c2, metalness: 0.7, roughness: 0.55 }); // panel/door | |
| const cargoMat = new THREE.MeshStandardMaterial({ | |
| color: 0xa0a8b3, metalness: 0.7, roughness: 0.55 }); // belly door | |
| const cockpitGlass = new THREE.MeshPhysicalMaterial({ | |
| color: 0x0d1a2e, metalness: 0.1, roughness: 0.05, | |
| transmission: 0.3, transparent: true, opacity: 0.85, | |
| clearcoat: 1.0, clearcoatRoughness: 0.05, envMapIntensity: 2.0 }); | |
| const windowMat = new THREE.MeshStandardMaterial({ | |
| color: 0x122845, metalness: 0.3, roughness: 0.2, | |
| emissive: 0x0a1733, emissiveIntensity: 0.5 }); | |
| const frameMat = new THREE.MeshStandardMaterial({ | |
| color: 0x8d959f, metalness: 0.85, roughness: 0.45 }); // structural rib | |
| function lathe(points, radialSeg = 64, mat = aluMat) { | |
| const v = points.map(p => new THREE.Vector2(p[0], p[1])); | |
| return new THREE.Mesh(new THREE.LatheGeometry(v, radialSeg), mat); | |
| } | |
| // βββββββ REALISTIC, SIMPLE LAYOUT βββββββ | |
| // Two curved skin panels (upper + lower) + ONE continuous dark | |
| // window strip in between (the 'open window section'). | |
| // Layout along Y: | |
| // y = -20 .. -1 lower skin panel | |
| // y = -1 .. +1 window strip (continuous dark band) | |
| // y = +1 .. +20 upper skin panel | |
| // Skin material β brushed aluminum | |
| const skin = aluMat; | |
| // Window strip β dark, slight self-illumination so it reads like a | |
| // continuous strip of cabin windows | |
| const winStrip = new THREE.MeshStandardMaterial({ | |
| color: 0x0b1626, metalness: 0.15, roughness: 0.25, | |
| emissive: 0x0a1a3a, emissiveIntensity: 0.55 }); | |
| // ββ STEP 1: lower skin panel ββ | |
| const lowerPanel = lathe([[6, -20], [6, -1]]); | |
| PRODUCT_GROUP.add(lowerPanel); PARTS.steps[0].push(lowerPanel); | |
| // ββ STEP 2: upper skin panel ββ | |
| const upperPanel = lathe([[6, 1], [6, 20]]); | |
| PRODUCT_GROUP.add(upperPanel); PARTS.steps[1].push(upperPanel); | |
| // ββ STEP 3: continuous dark window strip + thin divider lines ββ | |
| // The window strip itself is a thin lathe of slightly smaller radius | |
| // (5.92) so it sits inside the skin and looks like a recessed band of | |
| // dark glass β you can literally see the cabin line through it. | |
| const winBand = new THREE.Mesh( | |
| new THREE.CylinderGeometry(5.92, 5.92, 2, 64, 1, true), | |
| winStrip); | |
| PRODUCT_GROUP.add(winBand); PARTS.steps[2].push(winBand); | |
| // Vertical divider lines between individual cabin windows | |
| for (let i = 0; i < 24; i++) { | |
| const a = (i / 24) * Math.PI * 2; | |
| const div = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.16, 2.05, 0.16), | |
| new THREE.MeshStandardMaterial({ color: 0xb4bcc5, metalness: 0.7, | |
| roughness: 0.5 })); | |
| div.position.set(6.0 * Math.cos(a), 0, 6.0 * Math.sin(a)); | |
| div.lookAt(0, 0, 0); | |
| PRODUCT_GROUP.add(div); PARTS.steps[2].push(div); | |
| } | |
| // ββ STEP 4: ONE main passenger door + 4 frame ribs (simple, realistic) ββ | |
| // Main door (port side, forward) | |
| const doorShape = new THREE.Shape(); | |
| doorShape.moveTo(-1.1, -2.3); | |
| doorShape.lineTo( 1.1, -2.3); | |
| doorShape.lineTo( 1.1, 1.8); | |
| doorShape.quadraticCurveTo(0, 2.5, -1.1, 1.8); | |
| doorShape.closePath(); | |
| const door = new THREE.Mesh( | |
| new THREE.ExtrudeGeometry(doorShape, { depth: 0.25, bevelEnabled: true, | |
| bevelThickness: 0.06, bevelSize: 0.06, bevelSegments: 1 }), skinSeam); | |
| door.rotation.y = Math.PI/2; | |
| door.position.set(-6.05, -10, 0); | |
| PRODUCT_GROUP.add(door); PARTS.steps[3].push(door); | |
| // 4 frame ribs β clean, real-looking structure | |
| for (const yPos of [-15, -5, 8, 16]) { | |
| const ring = new THREE.Mesh( | |
| new THREE.TorusGeometry(6.12, 0.18, 8, 64), frameMat); | |
| ring.rotation.x = Math.PI/2; | |
| ring.position.y = yPos; | |
| PRODUCT_GROUP.add(ring); PARTS.steps[3].push(ring); | |
| } | |
| // Lay the fuselage HORIZONTAL β rotate the whole group so the | |
| // cylinder axis runs along world +X instead of +Y. Camera target | |
| // is at origin so the rotated body stays centred on screen. | |
| PRODUCT_GROUP.rotation.z = -Math.PI / 2; | |
| } | |
| // ββ scenario G: Software-Defined Vehicle (zonal E/E architecture) ββ | |
| // REAL-LOOKING car: lofted body through 12 cross-sections (proper 3D | |
| // tapered shape, not an extruded slab), dark glass windshield + side | |
| // + rear windows, 5-spoke alloy wheels with tire tread ridges, | |
| // emissive headlight/taillight bars, side mirrors, front grille. | |
| function buildCar() { | |
| clearProductGroup(); | |
| // ββ materials ββ | |
| const paint = new THREE.MeshPhysicalMaterial({ // body paint | |
| color: 0x3f86e0, metalness: 0.45, roughness: 0.20, | |
| clearcoat: 1.0, clearcoatRoughness: 0.04, | |
| envMapIntensity: 2.4 }); | |
| const darkGlass = new THREE.MeshPhysicalMaterial({ // windows | |
| color: 0x2a4763, metalness: 0.0, roughness: 0.04, | |
| transmission: 0.62, transparent: true, opacity: 0.72, | |
| ior: 1.45, clearcoat: 1.0, clearcoatRoughness: 0.03, | |
| envMapIntensity: 2.6 }); | |
| const chrome = new THREE.MeshPhysicalMaterial({ // trim | |
| color: 0xcfd4dc, metalness: 0.95, roughness: 0.18, | |
| clearcoat: 0.5, envMapIntensity: 1.8 }); | |
| const tireMat = new THREE.MeshStandardMaterial({ | |
| color: 0x14171c, metalness: 0.05, roughness: 0.92 }); | |
| const treadMat = new THREE.MeshStandardMaterial({ | |
| color: 0x0a0c10, metalness: 0.0, roughness: 0.95 }); | |
| const rimMat = new THREE.MeshPhysicalMaterial({ // alloy wheel | |
| color: 0xc8ccd2, metalness: 0.95, roughness: 0.22, | |
| clearcoat: 0.4, envMapIntensity: 1.8 }); | |
| const brakeMat = new THREE.MeshStandardMaterial({ | |
| color: 0x4a4f58, metalness: 0.7, roughness: 0.55 }); | |
| const headlight = new THREE.MeshStandardMaterial({ | |
| color: 0xfff5cf, metalness: 0.2, roughness: 0.10, | |
| emissive: 0xfff0c8, emissiveIntensity: 2.4 }); | |
| const taillight = new THREE.MeshStandardMaterial({ | |
| color: 0x2a0808, metalness: 0.2, roughness: 0.15, | |
| emissive: 0xff2030, emissiveIntensity: 2.0 }); | |
| const grilleMat = new THREE.MeshStandardMaterial({ | |
| color: 0x0c0e12, metalness: 0.5, roughness: 0.4 }); | |
| const chassisMat = new THREE.MeshPhysicalMaterial({ | |
| color: 0x222831, metalness: 0.85, roughness: 0.5 }); | |
| const zoneMat = new THREE.MeshStandardMaterial({ | |
| color: 0x0c1a2a, metalness: 0.4, roughness: 0.3, | |
| emissive: 0x2bd4ff, emissiveIntensity: 1.4 }); | |
| const hpcMat = new THREE.MeshStandardMaterial({ | |
| color: 0x1a0c2a, metalness: 0.4, roughness: 0.3, | |
| emissive: 0xb478ff, emissiveIntensity: 1.6 }); | |
| const wireMat = new THREE.MeshStandardMaterial({ | |
| color: 0x0c1a2a, metalness: 0.3, roughness: 0.4, | |
| emissive: 0x2bd4ff, emissiveIntensity: 1.1 }); | |
| // βββββββ STEP 1: chassis + 5-spoke alloy wheels βββββββ | |
| const chassis = new THREE.Mesh( | |
| new THREE.BoxGeometry(56, 1.8, 22), chassisMat); | |
| chassis.position.y = 3.4; | |
| PRODUCT_GROUP.add(chassis); PARTS.steps[0].push(chassis); | |
| function makeWheel() { | |
| const grp = new THREE.Group(); | |
| // tire body | |
| const tire = new THREE.Mesh( | |
| new THREE.CylinderGeometry(4.3, 4.3, 3.0, 36), tireMat); | |
| tire.rotation.x = Math.PI / 2; | |
| grp.add(tire); | |
| // tire tread ridges (subtle bumps around the circumference) | |
| for (let i = 0; i < 20; i++) { | |
| const a = (i / 20) * Math.PI * 2; | |
| const ridge = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.18, 0.20, 2.8), treadMat); | |
| ridge.position.set(4.32 * Math.cos(a), 4.32 * Math.sin(a), 0); | |
| ridge.rotation.z = a; | |
| grp.add(ridge); | |
| } | |
| // brake disc (visible behind the spokes) | |
| const disc = new THREE.Mesh( | |
| new THREE.CylinderGeometry(2.7, 2.7, 0.4, 24), brakeMat); | |
| disc.rotation.x = Math.PI / 2; | |
| grp.add(disc); | |
| // alloy hub disc | |
| const hub = new THREE.Mesh( | |
| new THREE.CylinderGeometry(3.0, 3.0, 0.4, 32), rimMat); | |
| hub.rotation.x = Math.PI / 2; | |
| hub.position.z = 1.3; | |
| grp.add(hub); | |
| // 5 spokes radiating from center | |
| for (let i = 0; i < 5; i++) { | |
| const a = (i / 5) * Math.PI * 2; | |
| const spoke = new THREE.Mesh( | |
| new THREE.BoxGeometry(2.5, 0.7, 0.5), rimMat); | |
| spoke.position.set(1.45 * Math.cos(a), 1.45 * Math.sin(a), 1.45); | |
| spoke.rotation.z = a + Math.PI / 2; | |
| grp.add(spoke); | |
| } | |
| // center cap | |
| const cap = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.85, 0.85, 0.4, 16), | |
| new THREE.MeshStandardMaterial({ | |
| color: 0x16191f, metalness: 0.7, roughness: 0.35 })); | |
| cap.rotation.x = Math.PI / 2; | |
| cap.position.z = 1.5; | |
| grp.add(cap); | |
| return grp; | |
| } | |
| // Axles pushed toward the body ends (short overhangs β athletic stance), | |
| // wheels scaled up ~25% so they fill the arches instead of looking like castors. | |
| const WHEEL_X = 18, WHEEL_Z = 9.6, WHEEL_Y = 4.2; | |
| for (const [wx, wz] of [[-WHEEL_X, -WHEEL_Z], [-WHEEL_X, WHEEL_Z], | |
| [ WHEEL_X, -WHEEL_Z], [ WHEEL_X, WHEEL_Z]]) { | |
| const w = makeWheel(); | |
| w.scale.setScalar(1.24); | |
| w.position.set(wx, WHEEL_Y, wz); | |
| PRODUCT_GROUP.add(w); PARTS.steps[0].push(w); | |
| } | |
| // Wheel arches: a body-colour flare lip + a recessed dark liner over each | |
| // wheel, so the tyres read as SEATED into the fenders instead of bolted | |
| // onto a flat slab. (Visual arch β avoids fragile CSG on the lofted body.) | |
| const archLinerMat = new THREE.MeshStandardMaterial({ | |
| color: 0x0a0c10, metalness: 0.0, roughness: 0.95 }); | |
| function makeArch(wx, side) { // side = +1 (left/+Z) or -1 (right/-Z) | |
| const grp = new THREE.Group(); | |
| const zFace = side * 10.55; // body side near the axle | |
| // recessed dark wheel-well liner (sits just inboard, slightly larger) | |
| const liner = new THREE.Mesh( | |
| new THREE.TorusGeometry(6.0, 1.5, 8, 30, Math.PI), archLinerMat); | |
| liner.position.set(wx, WHEEL_Y, zFace - side * 1.1); | |
| grp.add(liner); | |
| // body-colour flare lip arcing over the top of the tyre | |
| const lip = new THREE.Mesh( | |
| new THREE.TorusGeometry(6.5, 0.85, 10, 30, Math.PI), paint); | |
| lip.position.set(wx, WHEEL_Y, zFace); | |
| grp.add(lip); | |
| return grp; | |
| } | |
| for (const wx of [-WHEEL_X, WHEEL_X]) { | |
| for (const side of [-1, 1]) { | |
| const a = makeArch(wx, side); | |
| PRODUCT_GROUP.add(a); PARTS.steps[0].push(a); | |
| } | |
| } | |
| // βββββββ STEP 2: lofted body shell + windows βββββββ | |
| // 12 stations along X (-30 to +30). Each has: | |
| // halfW : body half-width at the belt-line (lower body width / 2) | |
| // beltY : belt-line height above ground (top of doors) | |
| // roofHW: greenhouse half-width at this station (0 = no roof segment) | |
| // roofY : roof height | |
| const GROUND_Y = 4.5; | |
| // Sedan side-profile, front at -X, rear at +X. | |
| // long low hood (-30..-13) β raked windshield β flat roof (-6..+6) | |
| // β fast backlight β short high rear deck (+18..+30). | |
| // Strong tumblehome: roofHW β 0.7Β·halfW so the greenhouse pinches in. | |
| const STATIONS = [ | |
| { x: -30, halfW: 6.4, beltY: 8.0, roofHW: 0, roofY: 0 }, // front bumper | |
| { x: -26, halfW: 9.3, beltY: 8.7, roofHW: 0, roofY: 0 }, // hood nose | |
| { x: -19, halfW: 10.7, beltY: 9.4, roofHW: 0, roofY: 0 }, // hood | |
| { x: -13, halfW: 11.0, beltY: 9.9, roofHW: 5.0, roofY: 12.2 }, // cowl / windshield base | |
| { x: -6, halfW: 11.0, beltY: 10.1, roofHW: 7.6, roofY: 15.6 }, // A-pillar top | |
| { x: -1, halfW: 11.0, beltY: 10.1, roofHW: 7.8, roofY: 16.0 }, // roof | |
| { x: 5, halfW: 11.0, beltY: 10.1, roofHW: 7.7, roofY: 15.9 }, // roof | |
| { x: 11, halfW: 10.9, beltY: 10.1, roofHW: 6.6, roofY: 14.7 }, // C-pillar | |
| { x: 17, halfW: 10.6, beltY: 10.1, roofHW: 4.2, roofY: 11.8 }, // backlight base | |
| { x: 23, halfW: 9.9, beltY: 9.9, roofHW: 0, roofY: 0 }, // trunk deck | |
| { x: 27, halfW: 8.8, beltY: 9.5, roofHW: 0, roofY: 0 }, // trunk tail | |
| { x: 30, halfW: 6.8, beltY: 9.0, roofHW: 0, roofY: 0 }, // rear bumper | |
| ]; | |
| // Build a normalized 17-point cross-section profile in (z, y) per station. | |
| // 17 = 4 (right body) + 4 (right shoulder) + 1 (roof centre) + 4 (left shoulder mirror) + 4 (left body mirror) | |
| function profileFor(s) { | |
| const N = 17, pts = []; | |
| if (s.roofHW <= 0) { | |
| // domed bumper section β 17 points around a half-arch | |
| for (let i = 0; i < N; i++) { | |
| const ΞΈ = (i / (N - 1)) * Math.PI; // 0..Ο | |
| const z = s.halfW * Math.cos(ΞΈ); | |
| const y = GROUND_Y + (s.beltY - GROUND_Y) * (0.40 + 0.60 * Math.sin(ΞΈ)); | |
| pts.push([z, y]); | |
| } | |
| return pts; | |
| } | |
| // body + greenhouse station | |
| // right body (bottom -> belt), 4 points | |
| pts.push([+s.halfW, GROUND_Y]); | |
| pts.push([+s.halfW, GROUND_Y + (s.beltY - GROUND_Y) * 0.33]); | |
| pts.push([+s.halfW, GROUND_Y + (s.beltY - GROUND_Y) * 0.70]); | |
| pts.push([+s.halfW, s.beltY]); | |
| // right shoulder (belt -> roof corner) using quarter-ellipse, 4 points | |
| for (let i = 1; i <= 4; i++) { | |
| const t = i / 4; | |
| const ang = t * Math.PI / 2; | |
| const z = s.halfW - (s.halfW - s.roofHW) * Math.sin(ang); | |
| const y = s.beltY + (s.roofY - s.beltY) * (1 - Math.cos(ang)); | |
| pts.push([z, y]); | |
| } | |
| // roof centre | |
| pts.push([0, s.roofY]); | |
| // mirror to left side | |
| for (let i = pts.length - 2; i >= 0; i--) { | |
| pts.push([-pts[i][0], pts[i][1]]); | |
| } | |
| return pts; // 17 pts | |
| } | |
| // Build the BufferGeometry by stitching consecutive stations | |
| const M = STATIONS.length, N = 17; | |
| const positions = new Float32Array(M * N * 3); | |
| for (let i = 0; i < M; i++) { | |
| const prof = profileFor(STATIONS[i]); | |
| for (let j = 0; j < N; j++) { | |
| positions[(i * N + j) * 3 + 0] = STATIONS[i].x; | |
| positions[(i * N + j) * 3 + 1] = prof[j][1]; | |
| positions[(i * N + j) * 3 + 2] = prof[j][0]; | |
| } | |
| } | |
| // Indices: quad strip between each pair of consecutive stations | |
| const indices = []; | |
| for (let i = 0; i < M - 1; i++) { | |
| for (let j = 0; j < N - 1; j++) { | |
| const a = i * N + j; | |
| const b = (i + 1) * N + j; | |
| const c = (i + 1) * N + (j + 1); | |
| const d = i * N + (j + 1); | |
| indices.push(a, b, c, a, c, d); | |
| } | |
| } | |
| // Front and rear caps (triangle fans through cross-section) | |
| // Front (station 0): fan around midpoint | |
| const frontCenter = M * N; // new vertex index for front centre | |
| const rearCenter = frontCenter + 1; | |
| const newPos = new Float32Array((M * N + 2) * 3); | |
| newPos.set(positions); | |
| // front centre at the average of station 0's profile | |
| let fx = 0, fy = 0, fz = 0; | |
| for (let j = 0; j < N; j++) { | |
| fx += positions[(0 * N + j) * 3 + 0]; | |
| fy += positions[(0 * N + j) * 3 + 1]; | |
| fz += positions[(0 * N + j) * 3 + 2]; | |
| } | |
| newPos[frontCenter * 3 + 0] = fx / N; | |
| newPos[frontCenter * 3 + 1] = fy / N; | |
| newPos[frontCenter * 3 + 2] = fz / N; | |
| // rear centre | |
| let rx = 0, ry = 0, rz = 0; | |
| for (let j = 0; j < N; j++) { | |
| rx += positions[((M - 1) * N + j) * 3 + 0]; | |
| ry += positions[((M - 1) * N + j) * 3 + 1]; | |
| rz += positions[((M - 1) * N + j) * 3 + 2]; | |
| } | |
| newPos[rearCenter * 3 + 0] = rx / N; | |
| newPos[rearCenter * 3 + 1] = ry / N; | |
| newPos[rearCenter * 3 + 2] = rz / N; | |
| for (let j = 0; j < N - 1; j++) { | |
| indices.push(frontCenter, j + 1, j); // front fan | |
| indices.push(rearCenter, (M - 1) * N + j, (M - 1) * N + j + 1); | |
| } | |
| const bodyGeo = new THREE.BufferGeometry(); | |
| bodyGeo.setAttribute('position', new THREE.BufferAttribute(newPos, 3)); | |
| bodyGeo.setIndex(indices); | |
| bodyGeo.computeVertexNormals(); | |
| const body = new THREE.Mesh(bodyGeo, paint); | |
| PRODUCT_GROUP.add(body); PARTS.steps[1].push(body); | |
| // βββ window glass: separate dark mesh covering the greenhouse roof + sides βββ | |
| // Windshield, side windows, rear window are reconstructed by re-lofting | |
| // only the upper (roof) portion of each station, with a slight outward | |
| // inset so they sit just above the painted body. | |
| function buildGlass(jStart, jEnd, inset, mat) { | |
| const segs = jEnd - jStart; | |
| const verts = new Float32Array(M * (segs + 1) * 3); | |
| for (let i = 0; i < M; i++) { | |
| const prof = profileFor(STATIONS[i]); | |
| if (STATIONS[i].roofHW <= 0) { | |
| // no greenhouse here β collapse to the centerline at belt height | |
| for (let k = 0; k <= segs; k++) { | |
| verts[(i * (segs + 1) + k) * 3 + 0] = STATIONS[i].x; | |
| verts[(i * (segs + 1) + k) * 3 + 1] = STATIONS[i].beltY; | |
| verts[(i * (segs + 1) + k) * 3 + 2] = 0; | |
| } | |
| } else { | |
| for (let k = 0; k <= segs; k++) { | |
| const j = jStart + k; | |
| verts[(i * (segs + 1) + k) * 3 + 0] = STATIONS[i].x; | |
| verts[(i * (segs + 1) + k) * 3 + 1] = prof[j][1] + inset; | |
| verts[(i * (segs + 1) + k) * 3 + 2] = prof[j][0] * 1.01; | |
| } | |
| } | |
| } | |
| const idx = []; | |
| for (let i = 0; i < M - 1; i++) { | |
| for (let k = 0; k < segs; k++) { | |
| const a = i * (segs + 1) + k; | |
| const b = (i + 1) * (segs + 1) + k; | |
| const c = (i + 1) * (segs + 1) + k + 1; | |
| const d = i * (segs + 1) + k + 1; | |
| idx.push(a, b, c, a, c, d); | |
| } | |
| } | |
| const g = new THREE.BufferGeometry(); | |
| g.setAttribute('position', new THREE.BufferAttribute(verts, 3)); | |
| g.setIndex(idx); | |
| g.computeVertexNormals(); | |
| return new THREE.Mesh(g, mat); | |
| } | |
| // Greenhouse glass band: shoulder points (j=4..12 in our 17-point profile cover both sides) | |
| const greenhouse = buildGlass(4, 12, 0.05, darkGlass); | |
| PRODUCT_GROUP.add(greenhouse); PARTS.steps[1].push(greenhouse); | |
| // βββ headlight bars (front, swept back along the fenders) βββ | |
| for (const dz of [-4.9, 4.9]) { | |
| const hl = new THREE.Mesh( | |
| new THREE.BoxGeometry(1.6, 1.1, 3.4), headlight); | |
| hl.position.set(-28.9, 8.5, dz); | |
| PRODUCT_GROUP.add(hl); PARTS.steps[1].push(hl); | |
| } | |
| // grille (front-centre dark mesh) | |
| const grille = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.4, 2.0, 7.5), grilleMat); | |
| grille.position.set(-29.6, 7.4, 0); | |
| PRODUCT_GROUP.add(grille); PARTS.steps[1].push(grille); | |
| // chrome grille slats | |
| for (let i = -2; i <= 2; i++) { | |
| const slat = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.2, 0.16, 7.0), chrome); | |
| slat.position.set(-29.45, 7.4 + i * 0.38, 0); | |
| PRODUCT_GROUP.add(slat); PARTS.steps[1].push(slat); | |
| } | |
| // βββ taillight bar (rear, single horizontal strip) βββ | |
| const tl = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.6, 0.9, 12), taillight); | |
| tl.position.set(29.6, 9.4, 0); | |
| PRODUCT_GROUP.add(tl); PARTS.steps[1].push(tl); | |
| // βββ side mirrors (at the A-pillar base) βββ | |
| for (const dz of [-11.6, 11.6]) { | |
| const mirror = new THREE.Mesh( | |
| new THREE.BoxGeometry(2.4, 1.4, 1.1), paint); | |
| mirror.position.set(-10, 11.0, dz); | |
| PRODUCT_GROUP.add(mirror); PARTS.steps[1].push(mirror); | |
| const mirrorGlass = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.2, 0.9, 0.8), darkGlass); | |
| mirrorGlass.position.set(-10, 11.0, dz + (dz > 0 ? 0.55 : -0.55)); | |
| PRODUCT_GROUP.add(mirrorGlass); PARTS.steps[1].push(mirrorGlass); | |
| } | |
| // βββββββ STEP 3: zone controllers + central HPC βββββββ | |
| const zones = [ | |
| [-16, 7.0, -7.5, zoneMat], | |
| [-16, 7.0, 7.5, zoneMat], | |
| [ 16, 7.0, -7.5, zoneMat], | |
| [ 16, 7.0, 7.5, zoneMat], | |
| [ 0, 7.8, 0.0, hpcMat ], | |
| ]; | |
| const zoneMeshes = []; | |
| for (const [x, y, z, m] of zones) { | |
| const isHpc = (m === hpcMat); | |
| const cube = new THREE.Mesh( | |
| new THREE.BoxGeometry(isHpc ? 4.0 : 2.6, | |
| isHpc ? 2.4 : 1.8, | |
| isHpc ? 4.0 : 2.6), m); | |
| cube.position.set(x, y, z); | |
| PRODUCT_GROUP.add(cube); PARTS.steps[2].push(cube); | |
| zoneMeshes.push([x, y, z]); | |
| const halo = new THREE.Mesh( | |
| new THREE.TorusGeometry(isHpc ? 3.0 : 2.2, 0.10, 6, 24), | |
| new THREE.MeshStandardMaterial({ | |
| color: 0x0c1a2a, metalness: 0.1, roughness: 0.6, | |
| emissive: isHpc ? 0xb478ff : 0x2bd4ff, | |
| emissiveIntensity: 1.6 })); | |
| halo.position.set(x, y - 0.4, z); | |
| halo.rotation.x = Math.PI / 2; | |
| PRODUCT_GROUP.add(halo); PARTS.steps[2].push(halo); | |
| } | |
| // βββββββ STEP 4: Ethernet TSN backbone βββββββ | |
| function wireBetween(p0, p1, radius) { | |
| const dir = new THREE.Vector3().subVectors(p1, p0); | |
| const len = dir.length(); | |
| if (len < 1e-3) return null; | |
| const mesh = new THREE.Mesh( | |
| new THREE.CylinderGeometry(radius, radius, len, 8), wireMat); | |
| mesh.position.copy(p0).addScaledVector(dir, 0.5); | |
| mesh.quaternion.setFromUnitVectors( | |
| new THREE.Vector3(0, 1, 0), dir.clone().normalize()); | |
| return mesh; | |
| } | |
| const hpcPos = new THREE.Vector3(0, 7.8, 0); | |
| for (let i = 0; i < 4; i++) { | |
| const z = zoneMeshes[i]; | |
| const w = wireBetween(new THREE.Vector3(z[0], z[1], z[2]), hpcPos, 0.18); | |
| if (w) { PRODUCT_GROUP.add(w); PARTS.steps[3].push(w); } | |
| } | |
| const ringPts = [ | |
| new THREE.Vector3(-16, 7.0, -7.5), | |
| new THREE.Vector3(-16, 7.0, 7.5), | |
| new THREE.Vector3( 16, 7.0, 7.5), | |
| new THREE.Vector3( 16, 7.0, -7.5), | |
| new THREE.Vector3(-16, 7.0, -7.5), | |
| ]; | |
| for (let i = 0; i < ringPts.length - 1; i++) { | |
| const w = wireBetween(ringPts[i], ringPts[i+1], 0.12); | |
| if (w) { PRODUCT_GROUP.add(w); PARTS.steps[3].push(w); } | |
| } | |
| } | |
| // ββ scenario E: FEA stress analysis ββ | |
| function buildStress() { | |
| clearProductGroup(); | |
| // step 1 β gray bar | |
| const bar = new THREE.Mesh( | |
| new THREE.BoxGeometry(80, 8, 4, 30, 4, 2), | |
| new THREE.MeshPhysicalMaterial({ color: 0xc6cdd6, metalness: 0.85, | |
| roughness: 0.35 })); | |
| PRODUCT_GROUP.add(bar); PARTS.steps[0].push(bar); | |
| // step 2 β mesh wireframe overlay | |
| const wf = new THREE.Mesh( | |
| new THREE.BoxGeometry(80.3, 8.3, 4.3, 30, 4, 2), | |
| new THREE.MeshBasicMaterial({ color: 0x4060a0, wireframe: true, | |
| transparent: true, opacity: 0.55 })); | |
| PRODUCT_GROUP.add(wf); PARTS.steps[1].push(wf); | |
| // step 3 β vertex-colored stress bar (blueβred along length) | |
| const stressGeo = new THREE.BoxGeometry(80, 8, 4, 60, 4, 2); | |
| const colors = new Float32Array(stressGeo.attributes.position.count * 3); | |
| const stops = [[0.10,0.23,0.64],[0.10,0.55,0.85],[0.20,0.80,0.30], | |
| [0.95,0.85,0.10],[0.90,0.15,0.10]]; | |
| function colormap(t) { | |
| t = Math.max(0, Math.min(1, t)); | |
| const i = Math.min(stops.length - 2, Math.floor(t * (stops.length-1))); | |
| const u = t * (stops.length-1) - i; | |
| return [stops[i][0]+u*(stops[i+1][0]-stops[i][0]), | |
| stops[i][1]+u*(stops[i+1][1]-stops[i][1]), | |
| stops[i][2]+u*(stops[i+1][2]-stops[i][2])]; | |
| } | |
| for (let i = 0; i < stressGeo.attributes.position.count; i++) { | |
| const x = stressGeo.attributes.position.getX(i); | |
| const t = Math.pow((x + 40) / 80, 0.6); | |
| const [r,g,b] = colormap(t); | |
| colors[i*3] = r; colors[i*3+1] = g; colors[i*3+2] = b; | |
| } | |
| stressGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| const colored = new THREE.Mesh(stressGeo, | |
| new THREE.MeshStandardMaterial({ vertexColors: true, metalness: 0.25, | |
| roughness: 0.4 })); | |
| // hide bar/wf when colored is on | |
| PRODUCT_GROUP.add(colored); PARTS.steps[2].push(colored); | |
| // step 4 β force arrow | |
| const arrow = new THREE.ArrowHelper( | |
| new THREE.Vector3(-1, 0, 0), new THREE.Vector3(-44, 0, 0), | |
| 14, 0x222222, 4, 3); | |
| PRODUCT_GROUP.add(arrow); PARTS.steps[3].push(arrow); | |
| } | |
| // ββ product scenarios definition ββ | |
| const SCENARIOS = [ | |
| { id: 'car', title: 'Software-Defined Vehicle β Zonal E/E', sub: 'car sdv1 wheelbase=2700 zones=4 backbone=ethernet-tsn', | |
| build: buildCar, | |
| callouts: ['AGENT 2 β rolling chassis + 4 wheels', | |
| 'AGENT 2 β glass body shell (Cd-optimized)', | |
| 'AGENT 5 β 4 zone controllers + central HPC', | |
| 'AGENT 4 β Ethernet TSN backbone + SOA APIs'], | |
| mass: '1,420 kg', stress: 'β (SDV)', sf: 'SDV β', parts: '11 / 11', | |
| cam: { pos: [58, 32, 62], target: [0, 8, 0] } }, | |
| { id: 'bolt', title: 'M8 Hex Bolt', sub: 'bolt b1 M8 30', | |
| build: buildBolt, | |
| callouts: ['AGENT 2 β shank cylinder', 'AGENT 2 β hex head', | |
| 'AGENT 2 β helical thread sweep', 'AGENT 2 β head fillet'], | |
| mass: '8.2 g', stress: '142 MPa', sf: '1.94 β', parts: '4 / 4', | |
| cam: { pos: [35, 50, 60], target: [0, 18, 0] } }, | |
| { id: 'nut', title: 'M8 Hex Nut', sub: 'nut n1 M8', | |
| build: buildNut, | |
| callouts: ['AGENT 2 β hex prism + β9 through-bore', | |
| 'AGENT 2 β first thread turns', | |
| 'AGENT 2 β middle thread turns', | |
| 'AGENT 4 β β ISO 4032 compliant'], | |
| mass: '4.6 g', stress: '88 MPa', sf: '3.13 β', parts: '1 / 1', | |
| cam: { pos: [22, 18, 28], target: [0, 3, 0] } }, | |
| { id: 'gear', title: 'Spur Gear (24 t, m 1.5, 20Β° involute)', sub: 'gear g1 1.5 24 5 4', | |
| build: buildGear, | |
| callouts: ['AGENT 2 β hub disc + bore (root Γ 32.25)', | |
| 'AGENT 2 β 24 involute teeth (Γ 39)', | |
| 'AGENT 5 β pitch-diameter ring (Γ 36)', | |
| 'AGENT 2 β keyway notch'], | |
| mass: '12.8 g', stress: '95 MPa', sf: '2.91 β', parts: '4 / 4', | |
| cam: { pos: [42, 32, 50], target: [0, 3, 0] } }, | |
| { id: 'bracket', title: 'L-Bracket (36Γ28 mm)', sub: 'l_bracket b1 36 28 3.5 22 7 4', | |
| build: buildBracket, | |
| callouts: ['AGENT 2 β single L cross-section, extruded', | |
| 'AGENT 4 β inner-corner fillet', | |
| 'AGENT 2 β 2Γ β7 holes on horizontal arm', | |
| 'AGENT 2 β 2Γ β7 holes on vertical arm'], | |
| mass: '11.6 g', stress: '108 MPa', sf: '2.56 β', parts: '1 / 1', | |
| cam: { pos: [42, 30, 50], target: [12, 10, 0] } }, | |
| { id: 'stress', title: 'FEA Stress Analysis', sub: 'stress bar1 load=2000', | |
| build: buildStress, | |
| callouts: ['ENGINE β STL export', 'AGENT 4 β gmsh mesh (377 tets)', | |
| 'AGENT 4 β von Mises field', 'AGENT 4 β fixed face + load arrow'], | |
| mass: 'β', stress: '138 MPa', sf: '1.81 β', parts: '1 / 1', | |
| cam: { pos: [50, 25, 70], target: [0, 0, 0] } }, | |
| { id: 'fuselage', title: 'Fuselage β center section', sub: 'mv2 fuselage_mid f1 40 12', | |
| build: buildFuselage, | |
| callouts: ['AGENT 2 β lower skin panel', | |
| 'AGENT 2 β upper skin panel', | |
| 'AGENT 2 β open window strip + dividers', | |
| 'AGENT 2 β main door + frame ribs'], | |
| mass: '5.8 kg', stress: '52 MPa', sf: '5.31 β', parts: '1 / 1', | |
| // Now the cylinder lies HORIZONTAL along world +X (rotated by | |
| // buildFuselage at the end). Camera looks at it from above-front. | |
| cam: { pos: [5, 22, 50], target: [0, 0, 0] } }, | |
| ]; | |
| let SC_IDX = 0; | |
| function currentScenario() { return SCENARIOS[SC_IDX]; } | |
| function applyScenario(sc) { | |
| sc.build(); | |
| // hide all steps initially | |
| PARTS.steps.flat().forEach(m => m.visible = false); | |
| // set callout texts (clearing previous text so old strings don't linger) | |
| document.getElementById('ca_p1').textContent = sc.callouts[0]; | |
| document.getElementById('ca_p2').textContent = sc.callouts[1]; | |
| document.getElementById('ca_p3').textContent = sc.callouts[2]; | |
| document.getElementById('ca_p4').textContent = sc.callouts[3]; | |
| document.getElementById('ca_sf').textContent = 'AGENT 4 β SF = ' + sc.sf; | |
| document.getElementById('ca_mass').textContent = 'mass = ' + sc.mass; | |
| document.getElementById('ca_deliv').textContent = 'π¦ STEP Β· STL Β· PDF Β· BOM'; | |
| // header + footer | |
| document.getElementById('prodTitle').innerHTML = sc.title; | |
| document.getElementById('prodSub').textContent = sc.sub; | |
| document.getElementById('ft_mass').textContent = sc.mass.includes('β') ? 'β' : 'β '; | |
| document.getElementById('ft_stress').textContent = 'β'; | |
| document.getElementById('ft_sf').textContent = 'β'; | |
| document.getElementById('ft_parts').textContent = '0 / ' + sc.parts.split(' / ')[1]; | |
| document.getElementById('ft_ver').textContent = 'β'; | |
| // camera | |
| camera.position.set(...sc.cam.pos); | |
| controls.target.set(...sc.cam.target); | |
| } | |
| function showStep(n) { PARTS.steps[n].forEach(m => m.visible = true); } | |
| function hideAll() { PARTS.steps.flat().forEach(m => m.visible = false); } | |
| function loop() { requestAnimationFrame(loop); controls.update(); renderer.render(scene, camera); } | |
| loop(); | |
| // βββ animation timeline (fast preset by default) βββ | |
| const TL = [ | |
| [0, 'stage', 1,8, 'Brief received'], | |
| [200, 'act', 'user_node'], | |
| [800, 'wire', 'w_user_planner','#0e1219'], | |
| [1200, 'status','User submits the design brief to AGENT 1.'], | |
| [1500, 'stage', 2,8, 'Planner β RAG'], | |
| [1600, 'act', 'planner'], | |
| [2200, 'wire', 'w_planner_rag','#0e7a8e'], | |
| [2600, 'act', 'rag'], | |
| [3000, 'wire', 'w_rag_planner','#0e7a8e'], | |
| [3500, 'stage', 3,8, 'Planner β Modeler'], | |
| [3500, 'status','Planner emits 4 milestones for the Modeler.'], | |
| [3700, 'wire', 'w_planner_modeler','#1c4e9e'], | |
| [4200, 'act', 'modeler'], | |
| [4800, 'stage', 4,8, 'Modeler β Engine (build geometry)'], | |
| [4800, 'wire', 'w_modeler_engine','#178a3c'], | |
| [5100, 'act', 'engine'], | |
| // BIG: parts appear on the right as the engine builds them | |
| [5400, 'show', 0, 'ca_p1', 'step 1 / 4 β primary feature'], | |
| [6000, 'show', 1, 'ca_p2', 'step 2 / 4 β adding feature'], | |
| [6600, 'show', 2, 'ca_p3', 'step 3 / 4 β detail / pattern'], | |
| [7400, 'show', 3, 'ca_p4', 'step 4 / 4 β finishing pass'], | |
| [8000, 'stage', 5,8, 'Engine β multi-critic fan-out (parallel)'], | |
| [8000, 'wire', 'w_engine_bus_down','#475569'], | |
| [8300, 'wire', 'w_bus_visual','#b68216'], | |
| [8300, 'wire', 'w_bus_dfm','#c4631e'], | |
| [8300, 'wire', 'w_bus_stand','#6b2bcf'], | |
| [8400, 'act', 'visual'], [8400,'act','dfm'], [8400,'act','standards'], | |
| [8900, 'wire', 'w_stand_know','#0e7a8e'], | |
| [9100, 'act', 'knowledge'], | |
| [9400, 'show_sf'], | |
| [9400, 'callout', 'ca_sf'], | |
| [9700, 'status','AGENT 3: β AGENT 4: β (rib added) AGENT 5: β'], | |
| [10300,'stage', 6,8, 'Feedback loop β re-plan'], | |
| [10300,'wire', 'w_critics_back','#c4631e'], | |
| [10700,'act', 'planner'], | |
| [11100,'status','Findings fan back to Planner β single iteration fix.'], | |
| [11700,'callout','ca_mass'], | |
| [12000,'stage', 7,8, 'Verifier β final gate'], | |
| [12000,'wire', 'w_bus_verif','#c12f30'], | |
| [12300,'act', 'verifier'], | |
| [12700,'status','AGENT 6: match_score 0.94 β ACCEPT'], | |
| [13200,'stage', 8,8, 'Final delivery'], | |
| [13200,'wire', 'w_verif_final','#1c4e9e'], | |
| [13600,'act', 'final'], | |
| [13900,'callout','ca_deliv'], | |
| [14200,'status','β Design package written. Lessons learned auto-saved to RAG.'], | |
| [14200,'final_stamp'], | |
| ]; | |
| let TIMERS = []; | |
| function clearAll() { | |
| TIMERS.forEach(clearTimeout); TIMERS = []; | |
| clearTimeout(_autoQueue); | |
| document.querySelectorAll('.node').forEach(a => a.classList.remove('active')); | |
| document.querySelectorAll('.wire').forEach(c => { c.classList.remove('active'); | |
| c.style.removeProperty('--c'); }); | |
| document.querySelectorAll('.callout').forEach(c => c.classList.remove('on')); | |
| hideAll(); | |
| document.getElementById('progressFill').style.width = '0%'; | |
| document.getElementById('status').textContent = 'βΆ Press play to run a full multi-agent design loop'; | |
| document.getElementById('counter').textContent = 'STAGE 0 / 8'; | |
| document.getElementById('prodStat').textContent = 'idle'; | |
| document.getElementById('ft_mass').textContent = 'β g'; | |
| document.getElementById('ft_stress').textContent = 'β MPa'; | |
| document.getElementById('ft_sf').textContent = 'β'; | |
| document.getElementById('ft_parts').textContent = 'β / 4'; | |
| document.getElementById('ft_ver').textContent = 'β'; | |
| } | |
| let partsBuilt = 0; | |
| let _autoQueue = null; | |
| // Hardcoded fast pacing β no menu controls. Bump higher for an even | |
| // quicker demo. | |
| const PLAY_SPEED = 2.4; | |
| const INTER_SCENARIO_PAUSE_MS = 1200; | |
| function run() { | |
| clearAll(); | |
| partsBuilt = 0; | |
| applyScenario(currentScenario()); | |
| const speed = PLAY_SPEED; | |
| const totalMs = TL[TL.length - 1][0] / speed; | |
| const t0 = performance.now(); | |
| function tick() { | |
| const u = Math.min(1, (performance.now() - t0) / totalMs); | |
| document.getElementById('progressFill').style.width = (u*100) + '%'; | |
| if (u < 1) requestAnimationFrame(tick); | |
| } | |
| requestAnimationFrame(tick); | |
| TL.forEach(ev => { | |
| const [ms, op, ...args] = ev; | |
| TIMERS.push(setTimeout(() => { | |
| if (op === 'act') { | |
| document.querySelector('.node.' + args[0])?.classList.add('active'); | |
| } else if (op === 'wire') { | |
| const c = document.getElementById(args[0]); | |
| if (c) { c.classList.add('active'); c.style.setProperty('--c', args[1]); } | |
| animatePacket(args[0], args[1]); | |
| } else if (op === 'status') { | |
| document.getElementById('status').textContent = args[0]; | |
| } else if (op === 'stage') { | |
| document.getElementById('counter').textContent = | |
| 'STAGE ' + args[0] + ' / ' + args[1]; | |
| document.getElementById('status').textContent = args[2]; | |
| } else if (op === 'show') { | |
| showStep(args[0]); | |
| document.getElementById(args[1])?.classList.add('on'); | |
| document.getElementById('prodStat').textContent = args[2]; | |
| partsBuilt++; | |
| const sc = currentScenario(); | |
| const total = sc.parts.split(' / ')[1]; | |
| document.getElementById('ft_parts').textContent = partsBuilt + ' / ' + total; | |
| } else if (op === 'callout') { | |
| document.getElementById(args[0])?.classList.add('on'); | |
| } else if (op === 'show_sf') { | |
| const sc = currentScenario(); | |
| document.getElementById('ft_mass').textContent = sc.mass; | |
| document.getElementById('ft_stress').textContent = sc.stress; | |
| document.getElementById('ft_sf').textContent = sc.sf; | |
| } else if (op === 'final_stamp') { | |
| document.getElementById('prodStat').textContent = 'β DELIVERED'; | |
| document.getElementById('prodStat').style.background = '#e8f5ec'; | |
| document.getElementById('prodStat').style.color = '#178a3c'; | |
| document.getElementById('ft_ver').textContent = '0.94 β'; | |
| } | |
| }, ms / speed)); | |
| }); | |
| // Schedule the next scenario after the current run finishes + brief pause. | |
| clearTimeout(_autoQueue); | |
| _autoQueue = setTimeout(() => { | |
| SC_IDX = (SC_IDX + 1) % SCENARIOS.length; | |
| run(); | |
| }, totalMs + INTER_SCENARIO_PAUSE_MS); | |
| } | |
| function animatePacket(wireId, color) { | |
| const path = document.getElementById(wireId); | |
| if (!path) return; | |
| const len = path.getTotalLength(); | |
| const packet = document.getElementById('packet'); | |
| packet.setAttribute('fill', color); | |
| packet.setAttribute('opacity', '1'); | |
| packet.style.setProperty('--c', color); | |
| const t0 = performance.now(); | |
| function step(t) { | |
| const u = Math.min(1, (t - t0) / 700); | |
| const p = path.getPointAtLength(len * u); | |
| packet.setAttribute('cx', p.x); packet.setAttribute('cy', p.y); | |
| if (u < 1) requestAnimationFrame(step); | |
| else setTimeout(() => packet.setAttribute('opacity','0'), 180); | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| // Auto-start as soon as the page loads β no controls, just plays. | |
| window.addEventListener('load', () => setTimeout(run, 300)); | |
| </script> | |
| </body> | |
| </html> | |