| import "./styles.css"; |
| import * as THREE from "three"; |
| import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; |
| import { STLExporter } from "three/examples/jsm/exporters/STLExporter.js"; |
| import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; |
| import { renderScadToGroup } from "./scadRenderer.js"; |
|
|
| const app = document.querySelector("#app"); |
| const route = window.location.pathname; |
| const detailedBlogUrl = |
| "https://huggingface.co/spaces/sanjuhs/cadforge-cadquery-openenv/blob/main/docs/detailed-blog/cadforge-detailed-blog.md"; |
| const bestModelUrl = "https://huggingface.co/sanjuhs/qwen35-9b-cadforge-grpo-adaptive-repair-lora"; |
| const trainingLogsUrl = "https://huggingface.co/datasets/sanjuhs/cadforge-training-evidence"; |
| const trainingGistUrl = "https://gist.github.com/sanjuhs/10596f688e8b4560910a3b1b137bfeeb"; |
| const isLandingPage = route === "/" || route === "/index.html"; |
| const isCadQueryGeneratorPage = route === "/cadquery"; |
| const isCadQueryRendererPage = route === "/cadquery-renderer"; |
| const isCadQueryEnvPage = route === "/cadquery-env"; |
| const isCadQueryPage = isCadQueryGeneratorPage || isCadQueryRendererPage || isCadQueryEnvPage; |
| const isOpenScadPage = route === "/openscad" || (!isLandingPage && !isCadQueryPage); |
|
|
| app.innerHTML = ` |
| <main class="landing ${isLandingPage ? "" : "hidden"}"> |
| <section> |
| <p class="eyebrow">CADForge Experiment 2</p> |
| <h1>CAD workbench</h1> |
| <p class="intro">Choose the code-CAD path to render and inspect. OpenSCAD is the agent loop; CadQuery is the real Python CAD backend for parametric mechanical parts.</p> |
| <div class="route-grid"> |
| <a class="route-card" href="/openscad"> |
| <span>OpenSCAD</span> |
| <strong>Markus chair agent</strong> |
| <small>GPT-5.4 generates SCAD, renders the supported CSG subset, and iterates with topology feedback.</small> |
| </a> |
| <a class="route-card" href="/cadquery"> |
| <span>CadQuery</span> |
| <strong>Python CAD generator</strong> |
| <small>GPT-5.4 writes CadQuery, the backend exports STL, and the viewer inspects the generated mesh.</small> |
| </a> |
| <a class="route-card" href="/cadquery-renderer"> |
| <span>CadQuery</span> |
| <strong>Renderer test bench</strong> |
| <small>Run known-good CadQuery code through the backend to verify Python, CadQuery, STL export, and frontend rendering.</small> |
| </a> |
| <a class="route-card" href="/cadquery-env"> |
| <span>RLVE</span> |
| <strong>Reward environment</strong> |
| <small>Generate, revise, render, and score Markus-chair CadQuery candidates against the ideal code and GLB reference.</small> |
| </a> |
| <a class="route-card" href="${detailedBlogUrl}" target="_blank" rel="noreferrer"> |
| <span>Submission</span> |
| <strong>Detailed blog</strong> |
| <small>Read the full hackathon story: frontier model failures, reward design, SFT/GRPO evidence, and self-improvement loops.</small> |
| </a> |
| <a class="route-card" href="${bestModelUrl}" target="_blank" rel="noreferrer"> |
| <span>Model</span> |
| <strong>Best trained LoRA</strong> |
| <small>Download the final Qwen3.5-9B adaptive-repair adapter for CadQuery generation and repair tests.</small> |
| </a> |
| </div> |
| |
| <div class="evidence-panel"> |
| <div class="section-head"> |
| <p class="eyebrow">RunPod H200 evidence</p> |
| <h2>Seven training runs</h2> |
| <p class="intro">We did not just run one fine-tune. CADForge exposed reward hacking, then strict build-gated GRPO and adaptive repair improved buildable CAD behavior.</p> |
| </div> |
| <div class="evidence-actions"> |
| <a href="${bestModelUrl}" target="_blank" rel="noreferrer">Download best model</a> |
| <a href="${trainingLogsUrl}" target="_blank" rel="noreferrer">Training logs</a> |
| <a href="${trainingGistUrl}" target="_blank" rel="noreferrer">Training scripts Gist</a> |
| </div> |
| <table class="run-table"> |
| <thead><tr><th>Run</th><th>Result</th><th>Takeaway</th></tr></thead> |
| <tbody> |
| <tr><td>1. Qwen3.5-2B SFT</td><td>loss 1.4480 -> 0.1658; eval 0.4477 -> 0.2676</td><td>learned CadQuery grammar</td></tr> |
| <tr><td>2. Qwen3.5-2B dense GRPO</td><td>160 completions; 0.0% builds; mean/best 0.3387 / 0.5303</td><td>reward signal existed, but was hackable</td></tr> |
| <tr><td>3. Qwen3.5-9B SFT</td><td>loss 2.6020 -> 0.1413; eval 0.3650 -> 0.2398</td><td>larger model learned structure faster</td></tr> |
| <tr><td>4. Qwen3.5-9B dense GRPO</td><td>160 completions; 0.0% builds; mean/best 0.4355 / 0.6828</td><td>higher reward still did not mean buildable CAD</td></tr> |
| <tr><td>5. Qwen3.5-9B strict GRPO</td><td>320 completions; 96 buildable; best CADForge score 0.9352</td><td>build gate created the breakthrough</td></tr> |
| <tr><td>6. Adaptive repair v1</td><td>120 repairs; 0 buildable; clipped completions surfaced</td><td>environment found a curriculum bug</td></tr> |
| <tr><td>7. Adaptive repair final 8192</td><td>180 repairs; 53 buildable; 0 clipped; best reward 0.882</td><td>failure mining recovered buildable repairs</td></tr> |
| </tbody> |
| </table> |
| </div> |
| |
| <div class="evidence-grid"> |
| <section class="evidence-card"> |
| <h2>Reward hacking lesson</h2> |
| <p>Dense reward alone was too forgiving: models earned partial credit for code-shaped text and semantic words while failing to export CAD. The fix was strict build gating: failed builds stay negative, and topology/contact/semantic/reference/editability rewards unlock only after CadQuery exports geometry.</p> |
| </section> |
| <section class="evidence-card"> |
| <h2>Step reward API</h2> |
| <p>The environment acts like a standard tool/reward loop. Each CadQuery action returns build status, reward dimensions, verifier notes, artifact paths, and STL renders. Those observations become SFT rows, GRPO rollouts, or human debugging traces.</p> |
| </section> |
| <section class="evidence-card"> |
| <h2>Space endpoints</h2> |
| <p><code>POST /api/space/repair-loop</code> runs weak seed -> repair -> reward. <code>POST /api/space/demo</code> scores a buildable candidate. <code>GET /api/space/loop-stl/{task_id}</code> returns generated STL artifacts for visual comparison.</p> |
| </section> |
| </div> |
| </section> |
| </main> |
| |
| <main class="shell ${isLandingPage ? "hidden" : ""}"> |
| <section class="panel controls"> |
| <nav class="subnav"> |
| <a href="/">Home</a> |
| <a class="${isOpenScadPage ? "active" : ""}" href="/openscad">OpenSCAD</a> |
| <a class="${isCadQueryGeneratorPage ? "active" : ""}" href="/cadquery">CadQuery</a> |
| <a class="${isCadQueryRendererPage ? "active" : ""}" href="/cadquery-renderer">Renderer</a> |
| <a class="${isCadQueryEnvPage ? "active" : ""}" href="/cadquery-env">Env</a> |
| <a href="${detailedBlogUrl}" target="_blank" rel="noreferrer">Detailed Blog</a> |
| <a href="${bestModelUrl}" target="_blank" rel="noreferrer">Best Model</a> |
| </nav> |
| <div> |
| <p class="eyebrow">CADForge Experiment 2</p> |
| <h1>${isCadQueryEnvPage ? "CadQuery reward environment" : isCadQueryRendererPage ? "CadQuery renderer test bench" : isCadQueryGeneratorPage ? "CadQuery generator" : "Markus chair CAD generator"}</h1> |
| <p class="intro">${isCadQueryEnvPage ? "Generate or revise CadQuery in a REPL loop, render the model, and score it against the ideal Markus CadQuery model plus the GLB reference." : isCadQueryRendererPage ? "Verify the real Python CadQuery backend, STL export path, and frontend mesh viewer with known-good code before testing generated CAD." : isCadQueryGeneratorPage ? "Generate or edit real CadQuery scripts in the backend, export STL, and inspect the generated mechanical mesh." : "Generate editable SCAD code, render it as real CSG geometry, and inspect topology before comparing against the Markus chair reference."}</p> |
| </div> |
| |
| <label class="${isCadQueryPage ? "hidden" : ""}" for="prompt">Design prompt</label> |
| <textarea class="${isCadQueryPage ? "hidden" : ""}" id="prompt" spellcheck="false">Create an editable OpenSCAD model of an office chair similar to an IKEA Markus chair. It should have a seat, tall backrest, headrest-like upper section, armrests, a central support column or leg structure, and a five-star rolling base. Every structural part must touch or union into one coherent watertight object with no floating parts.</textarea> |
| |
| <button id="generate-scad" class="primary generate-only ${isCadQueryPage ? "hidden" : ""}">Generate</button> |
| |
| <section class="scad-lab ${isCadQueryPage ? "hidden" : ""}"> |
| <div class="scad-lab-head"> |
| <label for="scad-code">OpenSCAD code</label> |
| <span>real CSG subset renderer</span> |
| </div> |
| <textarea id="scad-code" spellcheck="false">difference() { |
| union() { |
| cube([80, 55, 6]); |
| translate([12, 10, 6]) cube([8, 8, 55]); |
| translate([60, 10, 6]) cube([8, 8, 55]); |
| translate([12, 38, 6]) cube([8, 8, 55]); |
| translate([60, 38, 6]) cube([8, 8, 55]); |
| translate([0, 42, 58]) cube([80, 8, 55]); |
| } |
| translate([20, 27, -1]) cylinder(h=8, r=4, $fn=32); |
| }</textarea> |
| <div id="scad-output" class="scad-output">Supported now: cube, sphere, cylinder, translate, rotate, scale, union, difference, and intersection.</div> |
| </section> |
| |
| <section class="scad-lab ${isCadQueryGeneratorPage ? "" : "hidden"}"> |
| <div class="scad-lab-head"> |
| <label for="cadquery-prompt">CadQuery prompt</label> |
| <span>GPT-5.4 -> Python CadQuery -> STL</span> |
| </div> |
| <textarea id="cadquery-prompt" spellcheck="false">Design a simple 6061 aluminum wall-mounted J hook for a 120 N downward hanging load at the hook tip. It should have a filleted wall plate, four countersunk mounting holes, a curved J hook arm, and triangular gussets. Generate clean parametric CadQuery code.</textarea> |
| <button id="generate-cadquery" class="primary generate-only">Generate</button> |
| </section> |
| |
| <section class="scad-lab ${isCadQueryEnvPage ? "" : "hidden"}"> |
| <div class="scad-lab-head"> |
| <label for="cadquery-env-prompt">RLVE prompt</label> |
| <span>code REPL + reward</span> |
| </div> |
| <textarea id="cadquery-env-prompt" spellcheck="false">Create an editable CadQuery model of an IKEA Markus-like office chair. It should have a seat, tall backrest, headrest-like upper cushion, armrests, central gas cylinder, five-star base, and caster proxies. Prefer clear functions and named dimensions.</textarea> |
| <div class="env-row"> |
| <label for="cadquery-provider">Provider</label> |
| <select id="cadquery-provider"> |
| <option value="openai">OpenAI</option> |
| <option value="ollama">Ollama</option> |
| </select> |
| <label for="cadquery-model">Model</label> |
| <input id="cadquery-model" value="gpt-5.4" /> |
| </div> |
| <div class="button-row"> |
| <button id="env-generate-cadquery" class="primary">Generate</button> |
| <button id="env-revise-cadquery">Revise</button> |
| <button id="env-evaluate-cadquery">Evaluate</button> |
| <button id="env-load-ideal">Load Ideal</button> |
| </div> |
| <div id="cadquery-reward-output" class="scad-output">No reward yet.</div> |
| </section> |
| |
| <section class="scad-lab ${isCadQueryPage ? "" : "hidden"}"> |
| <div class="scad-lab-head"> |
| <label for="cadquery-code">CadQuery code</label> |
| <span>edit then render real STL</span> |
| </div> |
| <textarea id="cadquery-code" spellcheck="false">Loading sample CadQuery code...</textarea> |
| <div class="button-row"> |
| <button id="render-cadquery" class="primary">Render</button> |
| <button id="test-cadquery-backend" class="${isCadQueryRendererPage ? "" : "hidden"}">Test Backend</button> |
| <button id="load-cadquery-sample">Load Sample</button> |
| </div> |
| <div id="cadquery-output" class="scad-output">Backend will run the real CadQuery sample and return an STL mesh.</div> |
| </section> |
| |
| <div id="status" class="status">Ready.</div> |
| <div id="agent-steps" class="agent-steps"></div> |
| |
| <div class="metrics" id="metrics"></div> |
| <pre id="json" class="json"></pre> |
| </section> |
| |
| <section class="viewer-wrap"> |
| <div class="viewer-top"> |
| <div> |
| <p class="eyebrow">3D Viewer</p> |
| <h2 id="design-title">${isCadQueryPage ? "No CadQuery mesh loaded" : "No CAD design loaded"}</h2> |
| </div> |
| <div class="legend"> |
| <span><i class="swatch plate"></i>primitive/body</span> |
| <span><i class="swatch rib"></i>structure</span> |
| <span><i class="swatch boss"></i>shaft/bolt/load boss</span> |
| <span><i class="swatch slot"></i>slots/bores</span> |
| <span><i class="swatch decorative"></i>decorative CAD</span> |
| <span><i class="swatch load"></i>load</span> |
| <span><i class="swatch stress"></i>stress</span> |
| <span><i class="swatch warning"></i>low-SF member</span> |
| </div> |
| </div> |
| <div id="viewer"></div> |
| <div id="view-captures" class="view-captures"></div> |
| </section> |
| </main> |
| `; |
|
|
| const viewer = document.querySelector("#viewer"); |
| const promptInput = document.querySelector("#prompt"); |
| const presetButtons = document.querySelectorAll("[data-preset]"); |
| const systemPromptInput = document.querySelector("#system-prompt"); |
| const generateButton = document.querySelector("#generate"); |
| const benchmarkButton = document.querySelector("#benchmark"); |
| const toolEpisodeButton = document.querySelector("#tool-episode"); |
| const sampleButton = document.querySelector("#sample"); |
| const exportButton = document.querySelector("#export"); |
| const captureViewsButton = document.querySelector("#capture-views"); |
| const scadCodeInput = document.querySelector("#scad-code"); |
| const generateScadButton = document.querySelector("#generate-scad"); |
| const iterateScadButton = document.querySelector("#iterate-scad"); |
| const renderScadButton = document.querySelector("#render-scad"); |
| const loadScadExampleButton = document.querySelector("#load-scad-example"); |
| const scadOutputEl = document.querySelector("#scad-output"); |
| const cadqueryPromptInput = document.querySelector("#cadquery-prompt"); |
| const cadqueryEnvPromptInput = document.querySelector("#cadquery-env-prompt"); |
| const cadqueryProviderSelect = document.querySelector("#cadquery-provider"); |
| const cadqueryModelInput = document.querySelector("#cadquery-model"); |
| const cadqueryCodeInput = document.querySelector("#cadquery-code"); |
| const generateCadqueryButton = document.querySelector("#generate-cadquery"); |
| const envGenerateCadqueryButton = document.querySelector("#env-generate-cadquery"); |
| const envReviseCadqueryButton = document.querySelector("#env-revise-cadquery"); |
| const envEvaluateCadqueryButton = document.querySelector("#env-evaluate-cadquery"); |
| const envLoadIdealButton = document.querySelector("#env-load-ideal"); |
| const renderCadqueryButton = document.querySelector("#render-cadquery"); |
| const testCadqueryBackendButton = document.querySelector("#test-cadquery-backend"); |
| const loadCadquerySampleButton = document.querySelector("#load-cadquery-sample"); |
| const cadqueryOutputEl = document.querySelector("#cadquery-output"); |
| const cadqueryRewardOutputEl = document.querySelector("#cadquery-reward-output"); |
| const agentStepsEl = document.querySelector("#agent-steps"); |
| const toolBudgetInput = document.querySelector("#tool-budget"); |
| const toolBudgetValue = document.querySelector("#tool-budget-value"); |
| const viewCapturesEl = document.querySelector("#view-captures"); |
| const statusEl = document.querySelector("#status"); |
| const renderStateSelect = document.querySelector("#render-state-select"); |
| const renderStateNote = document.querySelector("#render-state-note"); |
| const benchmarkResultsEl = document.querySelector("#benchmark-results"); |
| const metricsEl = document.querySelector("#metrics"); |
| const traceEl = document.querySelector("#toolcalls"); |
| const agentLoopEl = document.querySelector("#agent-loop"); |
| const iterationsEl = document.querySelector("#iterations"); |
| const llmInputEl = document.querySelector("#llm-input"); |
| const jsonEl = document.querySelector("#json"); |
| const titleEl = document.querySelector("#design-title"); |
| const tabButtons = document.querySelectorAll("[data-tab]"); |
| const tabPanels = { |
| loop: document.querySelector("#loop-panel"), |
| tools: document.querySelector("#tools-panel"), |
| iterations: document.querySelector("#iterations-panel"), |
| llm: document.querySelector("#llm-panel"), |
| json: document.querySelector("#json-panel") |
| }; |
|
|
| let currentGroup = null; |
| let latestDesign = null; |
| let latestAnalysis = null; |
| let latestAgentRun = null; |
| let latestRenderStates = []; |
| let latestScadStats = null; |
|
|
| const promptPresets = { |
| chair: |
| "Build a simple four-legged chair as editable code-CAD. It must support a 700 N seated load, include a seat panel, four connected legs, lower crossbars, and a backrest, fit inside a 500 mm x 500 mm x 900 mm envelope, and avoid floating parts.", |
| "advanced-chair": |
| "Build an ergonomic curvy chair as editable code-CAD. It needs a curved seat, four connected splayed legs, crossbars, armrests, a curved backrest, and a headrest. It must withstand 1000 N on the seat and 100 N on the backrest while remaining one connected watertight CAD-like object.", |
| truss: |
| "Build a simple lightweight truss support as code-CAD. Use connected triangular load paths, two fixed mounting holes on the left, a load boss on the right, and enough ribs/cross-members to carry a 250 N downward load with safety factor above 2.0.", |
| cantilever: |
| "Design a lightweight 6061 aluminum cantilever bracket. It is fixed by two M5 bolts on the left side and must carry 120 N downward at the tip 90 mm from the fixed edge. Keep mass below 45 g and safety factor above 2.0.", |
| "torque-clamp": |
| "Design a compact 6061 aluminum clamp fixture that resists 120 Nm torque around a shaft proxy. Use a twin-bolt fixed root on the left, place the shaft/load boss near the free end, keep the load path clear, and maintain safety factor above 2.0 while minimizing mass.", |
| hook: |
| "Design a simple 6061 aluminum wall-mounted J hook for a 120 N downward hanging load at the hook tip. It should visibly look like a hook, with a compact wall mount and a curved hook arm, not a ribbed cantilever bracket.", |
| "motor-stator": |
| "Design a simple 12-slot axial motor stator concept. It should visibly look like a circular stator ring with radial teeth and a center shaft opening. Use steel and keep the structure compact.", |
| table: |
| "Design a small table with six legs. It should have a rectangular tabletop, six visible support legs, lower crossbar stretchers, and the capability to withstand 500 N of downward force.", |
| "bike-fixture": |
| "Design a compact 6061 aluminum bike accessory mounting fixture for a 120 N downward load. Use a clamp-like mount and a short supported arm, with safety factor above 2.0." |
| }; |
|
|
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0xf6f7f9); |
|
|
| const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); |
| camera.position.set(90, -130, 92); |
|
|
| const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| viewer.appendChild(renderer.domElement); |
|
|
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.target.set(55, 0, 8); |
|
|
| scene.add(new THREE.HemisphereLight(0xffffff, 0x8792a2, 2.1)); |
| const keyLight = new THREE.DirectionalLight(0xffffff, 2.2); |
| keyLight.position.set(90, -80, 140); |
| scene.add(keyLight); |
|
|
| const grid = new THREE.GridHelper(180, 18, 0xc9d1dc, 0xe2e7ee); |
| grid.rotation.x = Math.PI / 2; |
| grid.position.z = -2; |
| scene.add(grid); |
|
|
| function resize() { |
| const rect = viewer.getBoundingClientRect(); |
| renderer.setSize(rect.width, rect.height); |
| camera.aspect = rect.width / Math.max(rect.height, 1); |
| camera.updateProjectionMatrix(); |
| } |
|
|
| window.addEventListener("resize", resize); |
| resize(); |
|
|
| function setStatus(message, kind = "") { |
| statusEl.textContent = message; |
| statusEl.className = `status ${kind}`; |
| } |
|
|
| function metric(label, value, suffix = "") { |
| return `<div class="metric"><span>${label}</span><strong>${value}${suffix}</strong></div>`; |
| } |
|
|
| function fitCameraToObject(object, options = {}) { |
| resize(); |
| const box = new THREE.Box3().setFromObject(object); |
| if (box.isEmpty()) return; |
|
|
| const center = new THREE.Vector3(); |
| const sphere = new THREE.Sphere(); |
| box.getCenter(center); |
| box.getBoundingSphere(sphere); |
|
|
| const radius = Math.max(sphere.radius, 1); |
| const verticalFov = THREE.MathUtils.degToRad(camera.fov); |
| const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(camera.aspect, 0.1)); |
| const fitFov = Math.min(verticalFov, horizontalFov); |
| const distance = (radius / Math.sin(fitFov / 2)) * (options.padding ?? 1.35); |
| const direction = (options.direction || new THREE.Vector3(0.9, -1.25, 0.72)).normalize(); |
|
|
| camera.near = Math.max(radius / 10000, 0.01); |
| camera.far = Math.max(distance + radius * 8, 1000); |
| camera.updateProjectionMatrix(); |
| controls.minDistance = Math.max(radius * 0.03, 1); |
| controls.maxDistance = Math.max(distance + radius * 10, 1000); |
| controls.target.copy(center); |
| camera.position.copy(center).addScaledVector(direction, distance); |
| controls.update(); |
| } |
|
|
| function renderMetrics(analysis) { |
| const cadforge = analysis.cadforge || {}; |
| metricsEl.innerHTML = [ |
| metric("Score", analysis.score), |
| metric("AST nodes", cadforge.ast_nodes ?? "n/a"), |
| metric("Components", cadforge.connected_components ?? "n/a"), |
| metric("Watertight", cadforge.watertight_proxy === undefined ? "n/a" : cadforge.watertight_proxy ? "yes" : "no"), |
| metric("Editability", cadforge.editability_score ?? "n/a"), |
| metric("Safety factor", analysis.safety_factor), |
| metric("Stress", analysis.max_stress_mpa, " MPa"), |
| metric("Strain", analysis.max_strain_microstrain, " uε"), |
| metric("Deflection", analysis.tip_deflection_mm, " mm"), |
| metric("Thermal rise", analysis.thermal_delta_c_proxy, " C"), |
| metric("Mass", analysis.mass_g, " g"), |
| metric("Verdict", analysis.verdict) |
| ].join(""); |
| } |
|
|
| function renderScadFromEditor() { |
| const material = new THREE.MeshStandardMaterial({ color: 0x38a078, metalness: 0.12, roughness: 0.44 }); |
| const { group, stats } = renderScadToGroup(scadCodeInput.value, material); |
| if (currentGroup) scene.remove(currentGroup); |
| currentGroup = group; |
| latestDesign = null; |
| latestAnalysis = null; |
| latestScadStats = stats; |
| scene.add(group); |
| const box = new THREE.Box3().setFromObject(group); |
| const size = new THREE.Vector3(); |
| box.getSize(size); |
| fitCameraToObject(group, { padding: 1.45 }); |
| titleEl.textContent = "Rendered OpenSCAD CSG"; |
| scadOutputEl.textContent = `Rendered ${Math.round(stats.triangles)} triangles from ${stats.root_nodes} root node(s).`; |
| metricsEl.innerHTML = [ |
| metric("SCAD roots", stats.root_nodes), |
| metric("Triangles", Math.round(stats.triangles)), |
| metric("Components", stats.connected_components), |
| metric("Floating", stats.floating_parts), |
| metric("Boundary edges", stats.boundary_edges), |
| metric("Watertight", stats.watertight ? "yes" : "no"), |
| metric("Renderer", "OpenSCAD subset") |
| ].join(""); |
| jsonEl.textContent = JSON.stringify({ scad_code: scadCodeInput.value, render_stats: stats }, null, 2); |
| setStatus("Rendered SCAD code into the 3D viewer.", "ok"); |
| window.setTimeout(() => captureViews({ silent: true }), 80); |
| } |
|
|
| function arrayBufferFromBase64(value) { |
| const binary = window.atob(value); |
| const bytes = new Uint8Array(binary.length); |
| for (let index = 0; index < binary.length; index += 1) { |
| bytes[index] = binary.charCodeAt(index); |
| } |
| return bytes.buffer; |
| } |
|
|
| function renderCadqueryStl(result) { |
| if (result.repaired && result.code && cadqueryCodeInput) { |
| cadqueryCodeInput.value = result.code; |
| } |
| const loader = new STLLoader(); |
| const geometry = loader.parse(arrayBufferFromBase64(result.stl_base64)); |
| geometry.computeVertexNormals(); |
| geometry.computeBoundingBox(); |
| geometry.computeBoundingSphere(); |
|
|
| if (currentGroup) scene.remove(currentGroup); |
| const material = new THREE.MeshStandardMaterial({ color: 0x7a8f9f, metalness: 0.32, roughness: 0.34 }); |
| const mesh = new THREE.Mesh(geometry, material); |
| currentGroup = new THREE.Group(); |
| currentGroup.add(mesh); |
| scene.add(currentGroup); |
|
|
| const box = new THREE.Box3().setFromObject(currentGroup); |
| const size = new THREE.Vector3(); |
| box.getSize(size); |
| fitCameraToObject(currentGroup, { padding: 1.5 }); |
|
|
| const triangles = geometry.index ? geometry.index.count / 3 : geometry.attributes.position.count / 3; |
| titleEl.textContent = result.name || "Rendered CadQuery STL"; |
| cadqueryOutputEl.textContent = result.repaired |
| ? `Generated STL with ${Math.round(triangles)} triangles from real CadQuery. Repair applied: ${result.repair_note}` |
| : `Generated STL with ${Math.round(triangles)} triangles from real CadQuery.`; |
| const rewardMetrics = result.reward |
| ? [ |
| metric("Reward", Number(result.reward.total).toFixed(3)), |
| metric("Contact", Number(result.reward.contact || 0).toFixed(3)), |
| metric("Similarity", Number(result.reward.reference_similarity || 0).toFixed(3)), |
| metric("Semantic", Number(result.reward.semantic_parts || 0).toFixed(3)) |
| ] |
| : []; |
| metricsEl.innerHTML = [ |
| metric("Backend", "CadQuery"), |
| metric("Triangles", Math.round(triangles)), |
| metric("X size", size.x.toFixed(1), " mm"), |
| metric("Y size", size.y.toFixed(1), " mm"), |
| metric("Z size", size.z.toFixed(1), " mm"), |
| metric("Features", result.cadquery_features?.length || 0), |
| ...rewardMetrics |
| ].join(""); |
| jsonEl.textContent = JSON.stringify({ ...result, stl_base64: `<${result.stl_base64.length} base64 chars>` }, null, 2); |
| setStatus(`Rendered CadQuery STL in ${(result.elapsed_ms / 1000).toFixed(1)}s.`, "ok"); |
| window.setTimeout(() => captureViews({ silent: true }), 80); |
| } |
|
|
| function renderRewardSummary(result) { |
| if (!cadqueryRewardOutputEl || !result.reward) return; |
| const reward = result.reward; |
| const notes = (result.notes || []).map((note) => `<li>${note}</li>`).join(""); |
| cadqueryRewardOutputEl.innerHTML = ` |
| <strong>Total ${Number(reward.total).toFixed(3)}</strong> |
| <div class="reward-grid"> |
| <span>build ${Number(reward.build).toFixed(3)}</span> |
| <span>topology ${Number(reward.topology).toFixed(3)}</span> |
| <span>contact ${Number(reward.contact || 0).toFixed(3)}</span> |
| <span>semantic ${Number(reward.semantic_parts).toFixed(3)}</span> |
| <span>reference ${Number(reward.reference_similarity).toFixed(3)}</span> |
| <span>silhouette ${Number(reward.silhouette).toFixed(3)}</span> |
| <span>editability ${Number(reward.editability).toFixed(3)}</span> |
| </div> |
| <ul>${notes}</ul> |
| `; |
| } |
|
|
| async function renderCadquerySample() { |
| setStatus("Running fixed CadQuery sample and exporting STL...", "working"); |
| if (renderCadqueryButton) renderCadqueryButton.disabled = true; |
| try { |
| const response = await fetch("/api/cadquery/sample-hook", { method: "POST" }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "CadQuery render failed.", "error"); |
| jsonEl.textContent = JSON.stringify(result, null, 2); |
| return; |
| } |
| renderCadqueryStl(result); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "CadQuery render failed.", "error"); |
| } finally { |
| if (renderCadqueryButton) renderCadqueryButton.disabled = false; |
| } |
| } |
|
|
| async function testCadqueryBackend() { |
| setStatus("Testing CadQuery backend with a known-good cube...", "working"); |
| if (testCadqueryBackendButton) testCadqueryBackendButton.disabled = true; |
| if (renderCadqueryButton) renderCadqueryButton.disabled = true; |
| try { |
| const response = await fetch("/api/cadquery/health", { method: "POST" }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "CadQuery backend test failed.", "error"); |
| cadqueryOutputEl.textContent = result.stderr || result.error || "CadQuery backend test failed."; |
| jsonEl.textContent = JSON.stringify(result, null, 2); |
| return false; |
| } |
| cadqueryCodeInput.value = result.code || cadqueryCodeInput.value; |
| renderCadqueryStl(result); |
| cadqueryOutputEl.textContent = `Backend OK: Python imported CadQuery, generated STL, and returned ${result.stl_bytes} bytes.`; |
| return true; |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "CadQuery backend test failed.", "error"); |
| return false; |
| } finally { |
| if (testCadqueryBackendButton) testCadqueryBackendButton.disabled = false; |
| if (renderCadqueryButton) renderCadqueryButton.disabled = false; |
| } |
| } |
|
|
| async function loadCadquerySampleCode({ render = false } = {}) { |
| setStatus("Loading CadQuery sample code...", "working"); |
| try { |
| const response = await fetch("/api/cadquery/sample-code"); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "Could not load CadQuery sample.", "error"); |
| return; |
| } |
| cadqueryCodeInput.value = result.cadquery_code || ""; |
| cadqueryOutputEl.textContent = "Loaded the real heavy-duty hook CadQuery sample."; |
| setStatus("Loaded CadQuery sample code.", "ok"); |
| if (render) await renderCadqueryCode(); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "Could not load CadQuery sample.", "error"); |
| } |
| } |
|
|
| async function renderCadqueryCode() { |
| setStatus("Running CadQuery code and exporting STL...", "working"); |
| if (renderCadqueryButton) renderCadqueryButton.disabled = true; |
| if (generateCadqueryButton) generateCadqueryButton.disabled = true; |
| try { |
| const response = await fetch("/api/cadquery/render-code", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ cadquery_code: cadqueryCodeInput.value }) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "CadQuery render failed.", "error"); |
| jsonEl.textContent = JSON.stringify(result, null, 2); |
| return false; |
| } |
| renderCadqueryStl(result); |
| return true; |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "CadQuery render failed.", "error"); |
| return false; |
| } finally { |
| if (renderCadqueryButton) renderCadqueryButton.disabled = false; |
| if (generateCadqueryButton) generateCadqueryButton.disabled = false; |
| } |
| } |
|
|
| async function generateCadqueryCode() { |
| setStatus("Asking GPT-5.4 to generate CadQuery code...", "working"); |
| if (generateCadqueryButton) generateCadqueryButton.disabled = true; |
| if (renderCadqueryButton) renderCadqueryButton.disabled = true; |
| try { |
| const response = await fetch("/api/cadquery/generate", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ prompt: cadqueryPromptInput.value }) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "CadQuery generation failed.", "error"); |
| jsonEl.textContent = JSON.stringify(result, null, 2); |
| return; |
| } |
| cadqueryCodeInput.value = result.cadquery_code || ""; |
| cadqueryOutputEl.textContent = result.rationale || "Generated CadQuery code."; |
| jsonEl.textContent = JSON.stringify({ ...result, cadquery_code: "<shown in editor>" }, null, 2); |
| const rendered = await renderCadqueryCode(); |
| if (!rendered) { |
| cadqueryOutputEl.textContent = `${result.rationale || "Generated CadQuery code."} Render failed; edit the code or regenerate.`; |
| } |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "CadQuery generation failed.", "error"); |
| } finally { |
| if (generateCadqueryButton) generateCadqueryButton.disabled = false; |
| if (renderCadqueryButton) renderCadqueryButton.disabled = false; |
| } |
| } |
|
|
| async function evaluateCadqueryEnv({ rewardMode = "full" } = {}) { |
| setStatus(`Evaluating CadQuery reward (${rewardMode})...`, "working"); |
| if (envEvaluateCadqueryButton) envEvaluateCadqueryButton.disabled = true; |
| try { |
| const response = await fetch("/api/cadquery/evaluate-code", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| cadquery_code: cadqueryCodeInput.value, |
| task_prompt: cadqueryEnvPromptInput?.value || cadqueryPromptInput?.value || "", |
| reward_mode: rewardMode, |
| episode_id: "frontend", |
| step_id: `step-${Date.now()}` |
| }) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "CadQuery evaluation failed.", "error"); |
| jsonEl.textContent = JSON.stringify(result, null, 2); |
| return null; |
| } |
| renderRewardSummary(result); |
| if (result.stl_base64) renderCadqueryStl({ ...result, name: "CadQuery RLVE candidate" }); |
| jsonEl.textContent = JSON.stringify({ ...result, stl_base64: result.stl_base64 ? `<${result.stl_base64.length} base64 chars>` : undefined }, null, 2); |
| setStatus(`Reward ${Number(result.reward.total).toFixed(3)} computed.`, "ok"); |
| return result; |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "CadQuery evaluation failed.", "error"); |
| return null; |
| } finally { |
| if (envEvaluateCadqueryButton) envEvaluateCadqueryButton.disabled = false; |
| } |
| } |
|
|
| async function runCadqueryReplStep({ revise = false } = {}) { |
| setStatus(revise ? "Revising CadQuery with verifier context..." : "Generating CadQuery candidate...", "working"); |
| if (envGenerateCadqueryButton) envGenerateCadqueryButton.disabled = true; |
| if (envReviseCadqueryButton) envReviseCadqueryButton.disabled = true; |
| let reward = null; |
| if (revise && cadqueryCodeInput.value.trim()) { |
| reward = await evaluateCadqueryEnv({ rewardMode: "fast" }); |
| } |
| try { |
| const response = await fetch("/api/cadquery/repl-step", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| prompt: cadqueryEnvPromptInput?.value || cadqueryPromptInput?.value || "", |
| current_code: revise ? cadqueryCodeInput.value : "", |
| provider: cadqueryProviderSelect?.value || "openai", |
| model: cadqueryModelInput?.value || "", |
| reward |
| }) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "CadQuery REPL step failed.", "error"); |
| jsonEl.textContent = JSON.stringify(result, null, 2); |
| return; |
| } |
| cadqueryCodeInput.value = result.cadquery_code || ""; |
| cadqueryOutputEl.textContent = `${result.provider} ${result.model} returned a CadQuery candidate.`; |
| jsonEl.textContent = JSON.stringify({ ...result, cadquery_code: "<shown in editor>" }, null, 2); |
| await evaluateCadqueryEnv({ rewardMode: "full" }); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "CadQuery REPL step failed.", "error"); |
| } finally { |
| if (envGenerateCadqueryButton) envGenerateCadqueryButton.disabled = false; |
| if (envReviseCadqueryButton) envReviseCadqueryButton.disabled = false; |
| } |
| } |
|
|
| async function loadIdealCadqueryCode() { |
| setStatus("Loading ideal Markus CadQuery code...", "working"); |
| try { |
| const response = await fetch("/api/cadquery/ideal-code"); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "Could not load ideal CadQuery code.", "error"); |
| return; |
| } |
| cadqueryCodeInput.value = result.cadquery_code || ""; |
| cadqueryOutputEl.textContent = "Loaded ideal Markus CadQuery reference."; |
| await evaluateCadqueryEnv({ rewardMode: "full" }); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "Could not load ideal CadQuery code.", "error"); |
| } |
| } |
|
|
| function makePlateShape(design) { |
| const shape = new THREE.Shape(); |
| const length = design.base_length_mm; |
| const halfWidth = design.base_width_mm / 2; |
| shape.moveTo(0, -halfWidth); |
| shape.lineTo(length, -halfWidth); |
| shape.lineTo(length, halfWidth); |
| shape.lineTo(0, halfWidth); |
| shape.lineTo(0, -halfWidth); |
|
|
| for (const hole of design.fixed_holes || []) { |
| const path = new THREE.Path(); |
| path.absellipse(hole.x, hole.y, hole.radius, hole.radius, 0, Math.PI * 2, false, 0); |
| shape.holes.push(path); |
| } |
|
|
| for (const feature of design.features || []) { |
| if (feature.type === "lightening_hole") { |
| const path = new THREE.Path(); |
| path.absellipse(feature.x, feature.y, feature.radius, feature.radius, 0, Math.PI * 2, false, 0); |
| shape.holes.push(path); |
| } |
| } |
|
|
| return shape; |
| } |
|
|
| function addBoxBetween(group, feature, material) { |
| const x1 = feature.x; |
| const y1 = feature.y; |
| const x2 = feature.x2; |
| const y2 = feature.y2; |
| const length = Math.max(Math.hypot(x2 - x1, y2 - y1), 1); |
| const width = Math.max(feature.width, 1); |
| const height = Math.max(feature.height, 1); |
| const geometry = new THREE.BoxGeometry(length, width, height); |
| const mesh = new THREE.Mesh(geometry, material); |
| mesh.position.set((x1 + x2) / 2, (y1 + y2) / 2, height / 2 + latestDesign.base_thickness_mm / 2); |
| mesh.rotation.z = Math.atan2(y2 - y1, x2 - x1); |
| group.add(mesh); |
| } |
|
|
| function vectorFromLoad(analysis) { |
| const vector = analysis?.force_vector_n || analysis?.load_case?.vector_n || [0, 0, -1]; |
| const result = new THREE.Vector3(vector[0] || 0, vector[1] || 0, vector[2] || -1); |
| return result.length() > 0 ? result.normalize() : new THREE.Vector3(0, 0, -1); |
| } |
|
|
| function loadPointFromAnalysis(design, analysis) { |
| const point = analysis?.load_case?.load_point || [ |
| design.load_point_x_mm, |
| design.load_point_y_mm, |
| design.base_thickness_mm |
| ]; |
| return new THREE.Vector3( |
| Number(point[0] ?? design.load_point_x_mm), |
| Number(point[1] ?? design.load_point_y_mm), |
| Number(point[2] ?? design.base_thickness_mm) |
| ); |
| } |
|
|
| function visualLoadPoint(design, analysis) { |
| const family = familyOf(design); |
| if (family === "wall_hook") { |
| const feature = (design.features || []).find((item) => item.type === "hook_curve"); |
| const wallHeight = Math.max(design.base_width_mm, 48); |
| const reach = Math.max(feature?.x2 || design.base_length_mm, 54); |
| return new THREE.Vector3(reach * 0.56, 0, wallHeight * 0.58 - 22); |
| } |
| if (family === "torque_clamp") return new THREE.Vector3(82, 0, 24); |
| if (family === "motor_stator") return new THREE.Vector3(design.base_length_mm / 2, 0, Math.max(design.base_thickness_mm, 8) + 8); |
| if (family === "chair") return new THREE.Vector3(design.base_length_mm / 2, 0, 54); |
| if (family === "table") return new THREE.Vector3(design.base_length_mm / 2, 0, 58); |
| if (family === "freeform_object") return new THREE.Vector3(design.load_point_x_mm || design.base_length_mm / 2, design.load_point_y_mm || 0, 28); |
| return loadPointFromAnalysis(design, analysis); |
| } |
|
|
| function addCylinderBetween(group, start, end, radius, material) { |
| const direction = new THREE.Vector3().subVectors(end, start); |
| const length = direction.length(); |
| if (length <= 0.01) return; |
| const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 24), material); |
| mesh.position.copy(start).add(end).multiplyScalar(0.5); |
| mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize()); |
| group.add(mesh); |
| } |
|
|
| function addCurveTube(group, points, radius, material, segments = 32) { |
| const curve = new THREE.CatmullRomCurve3(points); |
| const mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, segments, radius, 16, false), material); |
| group.add(mesh); |
| return mesh; |
| } |
|
|
| function addClosedCurveTube(group, points, radius, material, segments = 48) { |
| const curve = new THREE.CatmullRomCurve3(points, true); |
| const mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, segments, radius, 16, true), material); |
| group.add(mesh); |
| return mesh; |
| } |
|
|
| function hasFeature(design, type) { |
| return (design.features || []).some((feature) => feature.type === type); |
| } |
|
|
| function box(group, size, position, material) { |
| const mesh = new THREE.Mesh(new THREE.BoxGeometry(size.x, size.y, size.z), material); |
| mesh.position.copy(position); |
| group.add(mesh); |
| return mesh; |
| } |
|
|
| function familyOf(design) { |
| if (hasFeature(design, "hook_curve")) return "wall_hook"; |
| if (hasFeature(design, "stator_ring")) return "motor_stator"; |
| if (hasFeature(design, "tabletop")) return "table"; |
| if (hasFeature(design, "seat_panel")) return "chair"; |
| if (hasFeature(design, "clamp_jaw")) return "torque_clamp"; |
| if ((design.features || []).some((feature) => ["generic_panel", "support_tube", "curved_tube", "flat_foot", "armrest", "headrest"].includes(feature.type))) return "freeform_object"; |
| return "bracket"; |
| } |
|
|
| function isCurvyChair(design) { |
| const text = [ |
| design.title, |
| design.rationale, |
| ...(design.features || []).map((feature) => `${feature.note || ""} ${feature.radius || 0}`) |
| ] |
| .join(" ") |
| .toLowerCase(); |
| return /curv|round|organic|sweep|arched|bent|flow/.test(text) || (design.features || []).some((feature) => feature.type.startsWith("chair_") && Number(feature.radius || 0) >= 6); |
| } |
|
|
| function ellipsePointsOnBack(cx, y, cz, rx, rz, rotation = 0, count = 28) { |
| const points = []; |
| for (let i = 0; i < count; i += 1) { |
| const t = (Math.PI * 2 * i) / count; |
| const x = Math.cos(t) * rx; |
| const z = Math.sin(t) * rz; |
| points.push( |
| new THREE.Vector3( |
| cx + x * Math.cos(rotation) - z * Math.sin(rotation), |
| y, |
| cz + x * Math.sin(rotation) + z * Math.cos(rotation) |
| ) |
| ); |
| } |
| return points; |
| } |
|
|
| function renderChairDecorations(group, design, materials, dims) { |
| const decorative = (design.features || []).filter((feature) => feature.type === "decorative_curve"); |
| if (!decorative.length) return; |
| const flowerRequested = decorative.some((feature) => /flower|petal|blossom|rose|lotus/i.test(feature.note || "")); |
| const material = new THREE.MeshStandardMaterial({ color: 0x7aa0bd, metalness: 0.1, roughness: 0.38 }); |
| const centerX = dims.length / 2; |
| const backPlaneY = dims.backY + 17.5; |
| const centerZ = dims.seatZ + 38; |
|
|
| if (flowerRequested) { |
| const petalRadius = Math.max(1.6, decorative[0]?.radius || 3); |
| const petalLength = Math.max(9, (decorative[1]?.width || 44) / 5); |
| const petalWidth = Math.max(4, (decorative[1]?.height || 24) / 6); |
| for (let i = 0; i < 6; i += 1) { |
| const angle = (Math.PI * 2 * i) / 6; |
| const petalCenter = new THREE.Vector3(centerX + Math.cos(angle) * 10, backPlaneY, centerZ + Math.sin(angle) * 10); |
| addClosedCurveTube(group, ellipsePointsOnBack(petalCenter.x, backPlaneY, petalCenter.z, petalLength, petalWidth, angle), petalRadius * 0.32, material, 42); |
| } |
| const blossom = new THREE.Mesh(new THREE.SphereGeometry(Math.max(2.5, petalRadius * 0.62), 24, 16), material); |
| blossom.position.set(centerX, backPlaneY, centerZ); |
| group.add(blossom); |
| addCurveTube( |
| group, |
| [ |
| new THREE.Vector3(centerX, backPlaneY, centerZ - 3), |
| new THREE.Vector3(centerX - 4, backPlaneY, centerZ - 18), |
| new THREE.Vector3(centerX - 2, backPlaneY, dims.seatZ + 10) |
| ], |
| Math.max(1.1, petalRadius * 0.22), |
| materials.rib, |
| 24 |
| ); |
| return; |
| } |
|
|
| for (const [index, feature] of decorative.entries()) { |
| const z = centerZ + (index - decorative.length / 2) * 8; |
| addCurveTube( |
| group, |
| [ |
| new THREE.Vector3(centerX - feature.width / 2, backPlaneY, z), |
| new THREE.Vector3(centerX, backPlaneY, z + feature.height / 3), |
| new THREE.Vector3(centerX + feature.width / 2, backPlaneY, z) |
| ], |
| Math.max(1.2, feature.radius * 0.25), |
| material, |
| 32 |
| ); |
| } |
| } |
|
|
| function featureZ(feature, fallback = 24) { |
| return Number.isFinite(Number(feature.z)) ? Number(feature.z) : fallback; |
| } |
|
|
| function renderPrimitiveFeature(group, feature, materials, options = {}) { |
| const structureMaterial = options.structureMaterial || materials.rib; |
| const bodyMaterial = options.bodyMaterial || materials.plate; |
| const decorativeMaterial = |
| options.decorativeMaterial || |
| new THREE.MeshStandardMaterial({ color: 0xd58f22, metalness: 0.08, roughness: 0.42 }); |
| const tubeRadius = Math.max(Number(feature.radius || 0), Number(feature.width || 0) / 2, 2); |
| const height = Math.max(Number(feature.height || 0), 4); |
| const x = Number(feature.x || 0); |
| const y = Number(feature.y || 0); |
| const x2 = Number.isFinite(Number(feature.x2)) ? Number(feature.x2) : x; |
| const y2 = Number.isFinite(Number(feature.y2)) ? Number(feature.y2) : y; |
|
|
| if (feature.type === "tabletop" || feature.type === "generic_panel") { |
| const panelLength = Math.max(Number(feature.width || 0), 24); |
| const panelDepth = Math.max(options.panelDepth || latestDesign?.base_width_mm || 44, 18); |
| const panelHeight = Math.max(Number(feature.height || 0), 4); |
| const z = feature.type === "tabletop" ? 52 : featureZ(feature, panelHeight / 2); |
| box(group, new THREE.Vector3(panelLength, panelDepth, panelHeight), new THREE.Vector3(x || panelLength / 2, y, z), bodyMaterial); |
| return; |
| } |
|
|
| if (feature.type === "table_leg") { |
| const legHeight = Math.max(height, 24); |
| const radius = Math.max(Number(feature.radius || 0), Number(feature.width || 0) / 2, 2.4); |
| addCylinderBetween( |
| group, |
| new THREE.Vector3(x, y, 2), |
| new THREE.Vector3(x, y, legHeight), |
| radius, |
| structureMaterial |
| ); |
| return; |
| } |
|
|
| if (feature.type === "support_tube") { |
| const z = featureZ(feature, height || 20); |
| addCylinderBetween(group, new THREE.Vector3(x, y, z), new THREE.Vector3(x2, y2, z), tubeRadius, structureMaterial); |
| return; |
| } |
|
|
| if (feature.type === "curved_tube") { |
| const z = featureZ(feature, height || 24); |
| const mid = new THREE.Vector3((x + x2) / 2, (y + y2) / 2, z + Math.max(Number(feature.radius || 0) * 2, 12)); |
| addCurveTube(group, [new THREE.Vector3(x, y, z), mid, new THREE.Vector3(x2, y2, z)], tubeRadius, decorativeMaterial, 42); |
| return; |
| } |
|
|
| if (feature.type === "flat_foot") { |
| const footLength = Math.max(Number(feature.width || 0), 16); |
| const footDepth = Math.max(Number(feature.radius || 0) * 2.4, 8); |
| const footHeight = Math.max(Number(feature.height || 0), 2.5); |
| const mesh = box(group, new THREE.Vector3(footLength, footDepth, footHeight), new THREE.Vector3(x, y, footHeight / 2), decorativeMaterial); |
| mesh.rotation.z = Math.atan2(y2 - y, x2 - x || 1); |
| return; |
| } |
| } |
|
|
| function renderChairExtras(group, design, materials, dims) { |
| const decorativeMaterial = new THREE.MeshStandardMaterial({ color: 0xd58f22, metalness: 0.08, roughness: 0.42 }); |
| const leftX = 10; |
| const rightX = dims.length - 10; |
| const frontY = -dims.width / 2 + 7; |
| const backY = dims.backY + 6; |
| const seatTop = dims.seatZ + 5; |
|
|
| for (const feature of design.features || []) { |
| if (feature.type === "armrest") { |
| const sideX = feature.x < dims.length / 2 ? leftX - 8 : rightX + 8; |
| addCurveTube( |
| group, |
| [ |
| new THREE.Vector3(sideX, frontY, seatTop + 2), |
| new THREE.Vector3(sideX, 0, seatTop + 18), |
| new THREE.Vector3(sideX, backY, seatTop + 16) |
| ], |
| Math.max(Number(feature.width || 0) / 2, 3), |
| decorativeMaterial, |
| 44 |
| ); |
| } |
| if (feature.type === "headrest") { |
| addCurveTube( |
| group, |
| [ |
| new THREE.Vector3(dims.length * 0.22, dims.backY + 19, dims.seatZ + 72), |
| new THREE.Vector3(dims.length * 0.5, dims.backY + 25, dims.seatZ + 78), |
| new THREE.Vector3(dims.length * 0.78, dims.backY + 19, dims.seatZ + 72) |
| ], |
| Math.max(Number(feature.radius || 0) * 0.42, 3.2), |
| decorativeMaterial, |
| 42 |
| ); |
| } |
| if (feature.type === "flat_foot") { |
| renderPrimitiveFeature(group, feature, materials, { decorativeMaterial }); |
| } |
| if (feature.type === "support_tube" || feature.type === "curved_tube") { |
| renderPrimitiveFeature(group, feature, materials, { decorativeMaterial }); |
| } |
| } |
| } |
|
|
| function renderTableFamily(group, design, materials) { |
| const decorativeMaterial = new THREE.MeshStandardMaterial({ color: 0xd58f22, metalness: 0.08, roughness: 0.42 }); |
| const features = design.features || []; |
| const tabletop = features.find((feature) => feature.type === "tabletop") || { |
| type: "tabletop", |
| x: design.base_length_mm / 2, |
| y: 0, |
| width: design.base_length_mm, |
| height: design.base_thickness_mm |
| }; |
| renderPrimitiveFeature(group, tabletop, materials, { panelDepth: design.base_width_mm, decorativeMaterial }); |
|
|
| const legs = features.filter((feature) => feature.type === "table_leg"); |
| const fallbackLegs = [ |
| { type: "table_leg", x: 10, y: -28, width: 6, height: 48, radius: 3 }, |
| { type: "table_leg", x: 50, y: -28, width: 6, height: 48, radius: 3 }, |
| { type: "table_leg", x: 90, y: -28, width: 6, height: 48, radius: 3 }, |
| { type: "table_leg", x: 10, y: 28, width: 6, height: 48, radius: 3 }, |
| { type: "table_leg", x: 50, y: 28, width: 6, height: 48, radius: 3 }, |
| { type: "table_leg", x: 90, y: 28, width: 6, height: 48, radius: 3 } |
| ]; |
| for (const leg of legs.length ? legs : fallbackLegs.slice(0, 4)) { |
| renderPrimitiveFeature(group, leg, materials, { decorativeMaterial }); |
| } |
| for (const feature of features.filter((item) => ["support_tube", "curved_tube", "flat_foot", "generic_panel"].includes(item.type))) { |
| renderPrimitiveFeature(group, feature, materials, { decorativeMaterial }); |
| } |
| } |
|
|
| function renderFreeformFamily(group, design, materials) { |
| const decorativeMaterial = new THREE.MeshStandardMaterial({ color: 0xd58f22, metalness: 0.08, roughness: 0.42 }); |
| for (const feature of design.features || []) { |
| renderPrimitiveFeature(group, feature, materials, { panelDepth: design.base_width_mm, decorativeMaterial }); |
| } |
| } |
|
|
| function addArcTube(group, center, radius, startAngle, endAngle, tubeRadius, material) { |
| const points = []; |
| const segments = 36; |
| for (let i = 0; i <= segments; i += 1) { |
| const t = startAngle + ((endAngle - startAngle) * i) / segments; |
| points.push(new THREE.Vector3(center.x, center.y + Math.cos(t) * radius, center.z + Math.sin(t) * radius)); |
| } |
| const curve = new THREE.CatmullRomCurve3(points); |
| const mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, segments, tubeRadius, 16, false), material); |
| group.add(mesh); |
| return mesh; |
| } |
|
|
| function makeAnnulusGeometry(outerRadius, innerRadius, depth) { |
| const shape = new THREE.Shape(); |
| shape.absarc(0, 0, outerRadius, 0, Math.PI * 2, false); |
| const hole = new THREE.Path(); |
| hole.absarc(0, 0, innerRadius, 0, Math.PI * 2, true); |
| shape.holes.push(hole); |
| const geometry = new THREE.ExtrudeGeometry(shape, { |
| depth, |
| bevelEnabled: true, |
| bevelThickness: 0.25, |
| bevelSize: 0.25, |
| bevelSegments: 1 |
| }); |
| geometry.center(); |
| return geometry; |
| } |
|
|
| function renderHookFamily(group, design, materials) { |
| const wallHeight = Math.max(design.base_width_mm, 48); |
| const wallWidth = Math.max(design.base_width_mm * 0.42, 24); |
| const wallThickness = Math.max(design.base_thickness_mm, 6); |
| box( |
| group, |
| new THREE.Vector3(wallThickness, wallWidth, wallHeight), |
| new THREE.Vector3(0, 0, wallHeight / 2), |
| materials.plate |
| ); |
|
|
| for (const hole of design.fixed_holes || []) { |
| const z = wallHeight / 2 + hole.y; |
| const marker = new THREE.Mesh( |
| new THREE.CylinderGeometry(hole.radius, hole.radius, wallThickness + 0.8, 32), |
| new THREE.MeshStandardMaterial({ color: 0xd9fff0, roughness: 0.35 }) |
| ); |
| marker.rotation.z = Math.PI / 2; |
| marker.position.set(-0.2, 0, z); |
| group.add(marker); |
| } |
|
|
| const feature = (design.features || []).find((item) => item.type === "hook_curve"); |
| const tubeRadius = Math.max(feature?.width || 8, 3) / 2; |
| const rootZ = wallHeight * 0.58; |
| const reach = Math.max(feature?.x2 || design.base_length_mm, 54); |
| const curve = new THREE.CatmullRomCurve3([ |
| new THREE.Vector3(wallThickness / 2, 0, rootZ), |
| new THREE.Vector3(reach * 0.45, 0, rootZ), |
| new THREE.Vector3(reach * 0.78, 0, rootZ - 8), |
| new THREE.Vector3(reach * 0.86, 0, rootZ - 25), |
| new THREE.Vector3(reach * 0.66, 0, rootZ - 33), |
| new THREE.Vector3(reach * 0.52, 0, rootZ - 21) |
| ]); |
| const hook = new THREE.Mesh(new THREE.TubeGeometry(curve, 52, tubeRadius, 18, false), materials.rib); |
| group.add(hook); |
|
|
| const root = new THREE.Mesh(new THREE.SphereGeometry(tubeRadius * 1.45, 28, 18), materials.boss); |
| root.position.set(wallThickness / 2 + 1, 0, rootZ); |
| group.add(root); |
| } |
|
|
| function renderClampFamily(group, design, materials) { |
| const rootHeight = 30; |
| box(group, new THREE.Vector3(18, design.base_width_mm, rootHeight), new THREE.Vector3(9, 0, rootHeight / 2), materials.plate); |
| for (const hole of design.fixed_holes || []) { |
| const marker = new THREE.Mesh( |
| new THREE.CylinderGeometry(hole.radius, hole.radius, 19, 28), |
| new THREE.MeshStandardMaterial({ color: 0xd9fff0, roughness: 0.35 }) |
| ); |
| marker.rotation.z = Math.PI / 2; |
| marker.position.set(9, hole.y, rootHeight / 2); |
| group.add(marker); |
| } |
|
|
| const center = new THREE.Vector3(62, 0, 24); |
| const shaft = new THREE.Mesh(new THREE.CylinderGeometry(10, 10, 66, 48), materials.boss); |
| shaft.rotation.z = Math.PI / 2; |
| shaft.position.copy(center); |
| group.add(shaft); |
|
|
| addArcTube(group, center, 20, -Math.PI * 0.76, Math.PI * 0.76, 4.6, materials.rib); |
| addArcTube(group, center, 20, Math.PI * 1.24, Math.PI * 2.76, 4.6, materials.rib); |
| box(group, new THREE.Vector3(68, 8, 12), new THREE.Vector3(46, -22, 24), materials.rib); |
| box(group, new THREE.Vector3(68, 8, 12), new THREE.Vector3(46, 22, 24), materials.rib); |
| box(group, new THREE.Vector3(28, 8, 12), new THREE.Vector3(20, -22, 24), materials.rib); |
| box(group, new THREE.Vector3(28, 8, 12), new THREE.Vector3(20, 22, 24), materials.rib); |
| box(group, new THREE.Vector3(12, 52, 8), new THREE.Vector3(86, 0, 24), materials.plate); |
| box(group, new THREE.Vector3(20, 8, 8), new THREE.Vector3(70, -30, 24), materials.plate); |
| box(group, new THREE.Vector3(20, 8, 8), new THREE.Vector3(70, 30, 24), materials.plate); |
| for (const y of [-24, 24]) { |
| const bolt = new THREE.Mesh(new THREE.CylinderGeometry(2.2, 2.2, 16, 24), materials.boss); |
| bolt.rotation.z = Math.PI / 2; |
| bolt.position.set(82, y, 24); |
| group.add(bolt); |
| } |
| } |
|
|
| function renderStatorFamily(group, design, materials) { |
| const ring = (design.features || []).find((feature) => feature.type === "stator_ring"); |
| const tooth = (design.features || []).find((feature) => feature.type === "stator_tooth"); |
| const centerX = ring?.x || design.base_length_mm / 2; |
| const centerY = ring?.y || 0; |
| const outerRadius = (ring?.radius || 34) + Math.max(ring?.width || 14, 8); |
| const innerRadius = Math.max(tooth?.radius || 17, 12); |
| const height = Math.max(ring?.height || design.base_thickness_mm, 6); |
| const ringMesh = new THREE.Mesh(makeAnnulusGeometry(outerRadius, innerRadius, height), materials.plate); |
| ringMesh.position.set(centerX, centerY, height / 2); |
| group.add(ringMesh); |
|
|
| const toothCount = 12; |
| for (let i = 0; i < toothCount; i += 1) { |
| const angle = (Math.PI * 2 * i) / toothCount; |
| const radial = innerRadius + (tooth?.height || 18) / 2; |
| const toothMesh = new THREE.Mesh( |
| new THREE.BoxGeometry(tooth?.height || 18, tooth?.width || 8, height + 2), |
| materials.rib |
| ); |
| toothMesh.position.set(centerX + Math.cos(angle) * radial, centerY + Math.sin(angle) * radial, height / 2); |
| toothMesh.rotation.z = angle; |
| group.add(toothMesh); |
|
|
| const slot = new THREE.Mesh( |
| new THREE.BoxGeometry(outerRadius - innerRadius - 8, Math.max((tooth?.width || 8) * 0.55, 3), height + 2.4), |
| new THREE.MeshStandardMaterial({ color: 0x24313c, transparent: true, opacity: 0.28, roughness: 0.6 }) |
| ); |
| slot.position.set(centerX + Math.cos(angle + Math.PI / toothCount) * (innerRadius + (outerRadius - innerRadius) / 2), centerY + Math.sin(angle + Math.PI / toothCount) * (innerRadius + (outerRadius - innerRadius) / 2), height / 2); |
| slot.rotation.z = angle + Math.PI / toothCount; |
| group.add(slot); |
| } |
|
|
| const bore = new THREE.Mesh( |
| new THREE.CylinderGeometry(innerRadius * 0.88, innerRadius * 0.88, height + 2, 64), |
| new THREE.MeshStandardMaterial({ color: 0xf2f7fb, roughness: 0.5 }) |
| ); |
| bore.rotation.x = Math.PI / 2; |
| bore.position.set(centerX, centerY, height / 2); |
| group.add(bore); |
| } |
|
|
| function renderChairFamily(group, design, materials) { |
| const length = Math.max(design.base_length_mm || 90, 72); |
| const width = Math.max(design.base_width_mm || 70, 54); |
| const thickness = Math.max(design.base_thickness_mm || 6, 5); |
| const seatZ = 46; |
| const legSize = 5.5; |
| const frontY = -width / 2 + 9; |
| const backY = width / 2 - 9; |
| const leftX = 12; |
| const rightX = length - 12; |
| const curvy = isCurvyChair(design); |
|
|
| box(group, new THREE.Vector3(length, width, thickness), new THREE.Vector3(length / 2, 0, seatZ), materials.plate); |
|
|
| if (curvy) { |
| const legRadius = Math.max(2.8, ((design.features || []).find((feature) => feature.type === "chair_leg")?.width || 7) / 2); |
| const railRadius = 2.6; |
| const seatTop = seatZ + thickness / 2 + 0.5; |
| const seatBottom = seatZ - thickness / 2; |
| const footZ = 2; |
| const legData = [ |
| { top: new THREE.Vector3(leftX, frontY, seatBottom), foot: new THREE.Vector3(2, frontY - 12, footZ), bow: new THREE.Vector3(-5, -7, 22) }, |
| { top: new THREE.Vector3(rightX, frontY, seatBottom), foot: new THREE.Vector3(length - 2, frontY - 12, footZ), bow: new THREE.Vector3(5, -7, 22) }, |
| { top: new THREE.Vector3(leftX, backY, seatBottom), foot: new THREE.Vector3(2, backY + 13, footZ), bow: new THREE.Vector3(-6, 9, 24) }, |
| { top: new THREE.Vector3(rightX, backY, seatBottom), foot: new THREE.Vector3(length - 2, backY + 13, footZ), bow: new THREE.Vector3(6, 9, 24) } |
| ]; |
|
|
| for (const leg of legData) { |
| const mid = new THREE.Vector3().copy(leg.top).add(leg.foot).multiplyScalar(0.5).add(leg.bow); |
| addCurveTube(group, [leg.top, mid, leg.foot], legRadius, materials.rib, 36); |
| } |
|
|
| addCurveTube( |
| group, |
| [ |
| new THREE.Vector3(leftX - 4, frontY - 2, seatTop), |
| new THREE.Vector3(length / 2, frontY - 8, seatTop + 1.2), |
| new THREE.Vector3(rightX + 4, frontY - 2, seatTop) |
| ], |
| railRadius, |
| materials.rib, |
| 36 |
| ); |
| addCurveTube(group, [new THREE.Vector3(leftX, frontY, seatTop), new THREE.Vector3(leftX - 4, 0, seatTop + 1.4), new THREE.Vector3(leftX, backY, seatTop)], railRadius, materials.rib, 34); |
| addCurveTube(group, [new THREE.Vector3(rightX, frontY, seatTop), new THREE.Vector3(rightX + 4, 0, seatTop + 1.4), new THREE.Vector3(rightX, backY, seatTop)], railRadius, materials.rib, 34); |
|
|
| const lowZ = 20; |
| addCurveTube(group, [new THREE.Vector3(4, frontY - 10, lowZ), new THREE.Vector3(length / 2, frontY - 14, lowZ + 2), new THREE.Vector3(length - 4, frontY - 10, lowZ)], railRadius, materials.rib, 34); |
| addCurveTube(group, [new THREE.Vector3(4, backY + 11, lowZ), new THREE.Vector3(length / 2, backY + 15, lowZ + 2), new THREE.Vector3(length - 4, backY + 11, lowZ)], railRadius, materials.rib, 34); |
| addCurveTube(group, [new THREE.Vector3(4, frontY - 10, lowZ), new THREE.Vector3(0, 0, lowZ + 2), new THREE.Vector3(4, backY + 11, lowZ)], railRadius, materials.rib, 34); |
| addCurveTube(group, [new THREE.Vector3(length - 4, frontY - 10, lowZ), new THREE.Vector3(length, 0, lowZ + 2), new THREE.Vector3(length - 4, backY + 11, lowZ)], railRadius, materials.rib, 34); |
|
|
| const postTopZ = seatZ + 58; |
| addCurveTube(group, [new THREE.Vector3(leftX, backY, seatTop), new THREE.Vector3(leftX - 5, backY + 9, seatZ + 72 / 2), new THREE.Vector3(leftX - 2, backY + 16, postTopZ)], legRadius, materials.rib, 42); |
| addCurveTube(group, [new THREE.Vector3(rightX, backY, seatTop), new THREE.Vector3(rightX + 5, backY + 9, seatZ + 72 / 2), new THREE.Vector3(rightX + 2, backY + 16, postTopZ)], legRadius, materials.rib, 42); |
|
|
| for (const z of [seatZ + 24, seatZ + 38, seatZ + 52]) { |
| addCurveTube( |
| group, |
| [ |
| new THREE.Vector3(leftX - 2, backY + 14, z), |
| new THREE.Vector3(length / 2, backY + 21, z + 3), |
| new THREE.Vector3(rightX + 2, backY + 14, z) |
| ], |
| z === seatZ + 52 ? 3.5 : 3, |
| z === seatZ + 38 ? materials.plate : materials.rib, |
| 42 |
| ); |
| } |
| renderChairExtras(group, design, materials, { length, width, seatZ, backY }); |
| renderChairDecorations(group, design, materials, { length, width, seatZ, backY }); |
| return; |
| } |
|
|
| const legHeight = seatZ - thickness / 2; |
|
|
| for (const [x, y] of [ |
| [leftX, frontY], |
| [rightX, frontY], |
| [leftX, backY], |
| [rightX, backY] |
| ]) { |
| box(group, new THREE.Vector3(legSize, legSize, legHeight), new THREE.Vector3(x, y, legHeight / 2), materials.rib); |
| } |
|
|
| const backPostHeight = 54; |
| const backPostCenterZ = seatZ + thickness / 2 + backPostHeight / 2; |
| box(group, new THREE.Vector3(legSize, legSize, backPostHeight), new THREE.Vector3(leftX, backY, backPostCenterZ), materials.rib); |
| box(group, new THREE.Vector3(legSize, legSize, backPostHeight), new THREE.Vector3(rightX, backY, backPostCenterZ), materials.rib); |
| box(group, new THREE.Vector3(length - 18, 5, 30), new THREE.Vector3(length / 2, backY + 1, seatZ + 38), materials.plate); |
|
|
| box(group, new THREE.Vector3(length - 18, 4, 4), new THREE.Vector3(length / 2, frontY, 22), materials.rib); |
| box(group, new THREE.Vector3(length - 18, 4, 4), new THREE.Vector3(length / 2, backY, 22), materials.rib); |
| box(group, new THREE.Vector3(4, width - 18, 4), new THREE.Vector3(leftX, 0, 22), materials.rib); |
| box(group, new THREE.Vector3(4, width - 18, 4), new THREE.Vector3(rightX, 0, 22), materials.rib); |
| renderChairExtras(group, design, materials, { length, width, seatZ, backY }); |
| renderChairDecorations(group, design, materials, { length, width, seatZ, backY }); |
| } |
|
|
| function addLoadArrow(group, design) { |
| const red = new THREE.MeshStandardMaterial({ color: 0xdd3b3b, roughness: 0.45 }); |
| const loadPoint = visualLoadPoint(design, latestAnalysis); |
| const direction = vectorFromLoad(latestAnalysis); |
| const arrowLength = Math.max(22, Math.min(46, design.base_length_mm * 0.32)); |
| const coneHeight = Math.max(8, Math.min(13, arrowLength * 0.28)); |
| const shaftStart = loadPoint.clone().sub(direction.clone().multiplyScalar(arrowLength)); |
| const coneBase = loadPoint.clone().sub(direction.clone().multiplyScalar(coneHeight)); |
| addCylinderBetween(group, shaftStart, coneBase, 1.15, red); |
|
|
| const cone = new THREE.Mesh(new THREE.ConeGeometry(4.5, coneHeight, 28), red); |
| cone.position.copy(loadPoint).sub(direction.clone().multiplyScalar(coneHeight / 2)); |
| cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction); |
| group.add(cone); |
|
|
| const contact = new THREE.Mesh( |
| new THREE.SphereGeometry(2.5, 20, 14), |
| new THREE.MeshStandardMaterial({ color: 0x9f1f1f, emissive: 0x2c0505, roughness: 0.35 }) |
| ); |
| contact.position.copy(loadPoint); |
| group.add(contact); |
| } |
|
|
| function severityColor(severity) { |
| const s = Math.max(0, Math.min(1, severity)); |
| return new THREE.Color().setHSL(0.34 * (1 - s), 0.85, 0.5); |
| } |
|
|
| function addAnalysisOverlays(group, design, analysis) { |
| const stressMaterial = new THREE.MeshStandardMaterial({ color: 0xdf4a35, emissive: 0x3b0704, roughness: 0.35 }); |
| const family = familyOf(design); |
| if (family !== "bracket") { |
| const stressPoints = family === "chair" |
| ? [ |
| new THREE.Vector3(14, 24, 48), |
| new THREE.Vector3(design.base_length_mm - 14, 24, 48), |
| visualLoadPoint(design, analysis) |
| ] |
| : [ |
| new THREE.Vector3(Math.max(12, design.base_length_mm * 0.18), 0, Math.max(design.base_thickness_mm, 8) + 6), |
| visualLoadPoint(design, analysis) |
| ]; |
| stressPoints.forEach((point, index) => { |
| const marker = new THREE.Mesh(new THREE.SphereGeometry(index === 0 ? 4 : 3, 24, 16), stressMaterial.clone()); |
| marker.material.color = severityColor(index === 0 ? 0.72 : 0.42); |
| marker.position.copy(point); |
| group.add(marker); |
| }); |
| return; |
| } |
|
|
| for (const region of analysis.stress_regions || []) { |
| const marker = new THREE.Mesh( |
| new THREE.SphereGeometry(2.5 + 5 * Math.max(region.severity, 0.05), 24, 16), |
| stressMaterial.clone() |
| ); |
| marker.material.color = severityColor(region.severity); |
| marker.position.set(region.x, region.y, region.z ?? design.base_thickness_mm + 6); |
| group.add(marker); |
| } |
|
|
| const deflection = Math.max(analysis.tip_deflection_mm || 0, 0.01); |
| const lineMaterial = new THREE.LineBasicMaterial({ color: 0x7b5fd6, linewidth: 2 }); |
| const points = [ |
| new THREE.Vector3(0, design.load_point_y_mm, design.base_thickness_mm + 1), |
| new THREE.Vector3(design.load_point_x_mm / 2, design.load_point_y_mm, design.base_thickness_mm + 1 - deflection * 6), |
| new THREE.Vector3(design.load_point_x_mm, design.load_point_y_mm, design.base_thickness_mm + 1 - deflection * 12) |
| ]; |
| group.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), lineMaterial)); |
|
|
| const heatSeverity = Math.max(0, Math.min(1, (analysis.thermal_delta_c_proxy || 0) / 45)); |
| const heatMaterial = new THREE.MeshStandardMaterial({ |
| color: severityColor(heatSeverity), |
| transparent: true, |
| opacity: 0.32, |
| roughness: 0.8 |
| }); |
| const heatPad = new THREE.Mesh( |
| new THREE.BoxGeometry(design.base_length_mm * 0.75, design.base_width_mm * 0.65, 0.5), |
| heatMaterial |
| ); |
| heatPad.position.set(design.base_length_mm * 0.55, 0, design.base_thickness_mm + 0.35); |
| group.add(heatPad); |
| } |
|
|
| function renderDesign(design, analysis) { |
| latestDesign = design; |
| latestAnalysis = analysis; |
| if (currentGroup) scene.remove(currentGroup); |
|
|
| const group = new THREE.Group(); |
| const plateMaterial = new THREE.MeshStandardMaterial({ color: 0x8ca3b7, metalness: 0.2, roughness: 0.48 }); |
| const ribMaterial = new THREE.MeshStandardMaterial({ color: analysis.safety_factor >= 2 ? 0x23a36f : 0xd88b25, metalness: 0.1, roughness: 0.5 }); |
| const bossMaterial = new THREE.MeshStandardMaterial({ color: 0x4b6f9f, metalness: 0.1, roughness: 0.45 }); |
| const familyMaterials = { plate: plateMaterial, rib: ribMaterial, boss: bossMaterial }; |
| const family = familyOf(design); |
|
|
| if (family === "wall_hook") { |
| renderHookFamily(group, design, familyMaterials); |
| } else if (family === "motor_stator") { |
| renderStatorFamily(group, design, familyMaterials); |
| } else if (family === "chair") { |
| renderChairFamily(group, design, familyMaterials); |
| } else if (family === "table") { |
| renderTableFamily(group, design, familyMaterials); |
| } else if (family === "freeform_object") { |
| renderFreeformFamily(group, design, familyMaterials); |
| } else if (family === "torque_clamp") { |
| renderClampFamily(group, design, familyMaterials); |
| } else { |
|
|
| const plateGeometry = new THREE.ExtrudeGeometry(makePlateShape(design), { |
| depth: Math.max(design.base_thickness_mm, 1), |
| bevelEnabled: true, |
| bevelThickness: 0.4, |
| bevelSize: 0.4, |
| bevelSegments: 2 |
| }); |
| plateGeometry.center(); |
| const plate = new THREE.Mesh(plateGeometry, plateMaterial); |
| plate.position.set(design.base_length_mm / 2, 0, design.base_thickness_mm / 2); |
| group.add(plate); |
|
|
| const edges = new THREE.LineSegments( |
| new THREE.EdgesGeometry(plateGeometry), |
| new THREE.LineBasicMaterial({ color: 0x2f3a45, transparent: true, opacity: 0.35 }) |
| ); |
| edges.position.copy(plate.position); |
| group.add(edges); |
|
|
| for (const feature of design.features || []) { |
| if (feature.type === "rib") addBoxBetween(group, feature, ribMaterial); |
| if (feature.type === "boss") { |
| const boss = new THREE.Mesh( |
| new THREE.CylinderGeometry(Math.max(feature.radius, 1), Math.max(feature.radius, 1), Math.max(feature.height, 1), 40), |
| bossMaterial |
| ); |
| boss.position.set(feature.x, feature.y, design.base_thickness_mm + feature.height / 2); |
| group.add(boss); |
| } |
| if (feature.type === "fillet_marker") { |
| const marker = new THREE.Mesh(new THREE.SphereGeometry(Math.max(feature.radius, 1), 16, 12), bossMaterial); |
| marker.position.set(feature.x, feature.y, design.base_thickness_mm + Math.max(feature.radius, 1)); |
| group.add(marker); |
| } |
| } |
| } |
|
|
| addLoadArrow(group, design); |
| addAnalysisOverlays(group, design, analysis); |
| scene.add(group); |
| currentGroup = group; |
|
|
| const centerX = design.base_length_mm / 2; |
| if (family === "chair") { |
| controls.target.set(centerX, 0, 48); |
| camera.position.set(centerX, -165, 64); |
| } else if (family === "table") { |
| controls.target.set(centerX, 0, 32); |
| camera.position.set(centerX + 55, -145, 82); |
| } else if (family === "freeform_object") { |
| controls.target.set(centerX, 0, 28); |
| camera.position.set(centerX + 65, -130, 82); |
| } else if (family === "motor_stator") { |
| controls.target.set(centerX, 0, 6); |
| camera.position.set(centerX + 10, -135, 105); |
| } else if (family === "wall_hook") { |
| controls.target.set(34, 0, 28); |
| camera.position.set(76, -112, 70); |
| } else { |
| controls.target.set(centerX, 0, 18); |
| camera.position.set(centerX + 70, -135, 90); |
| } |
| fitCameraToObject(group, { |
| padding: family === "chair" || family === "table" ? 1.55 : 1.35 |
| }); |
| titleEl.textContent = design.title; |
| renderMetrics(analysis); |
| jsonEl.textContent = JSON.stringify({ design, analysis }, null, 2); |
| window.setTimeout(() => captureViews({ silent: true }), 80); |
| } |
|
|
| function cameraPoseForView(view, design = latestDesign) { |
| const family = design ? familyOf(design) : "bracket"; |
| let centerX = design?.base_length_mm ? design.base_length_mm / 2 : 50; |
| let targetZ = family === "chair" ? 56 : family === "motor_stator" ? 6 : family === "table" ? 34 : family === "freeform_object" ? 30 : 22; |
| let target = new THREE.Vector3(centerX, 0, targetZ); |
| let distance = family === "chair" ? 155 : family === "table" ? 145 : 130; |
|
|
| if (!design && currentGroup) { |
| const box = new THREE.Box3().setFromObject(currentGroup); |
| const size = new THREE.Vector3(); |
| const sphere = new THREE.Sphere(); |
| box.getSize(size); |
| box.getCenter(target); |
| box.getBoundingSphere(sphere); |
| centerX = target.x; |
| targetZ = target.z; |
| distance = Math.max(sphere.radius * 3.3, Math.max(size.x, size.y, size.z, 80) * 1.55); |
| } |
|
|
| const poses = { |
| isometric: family === "chair" || family === "table" |
| ? new THREE.Vector3(centerX + distance * 0.26, -distance, targetZ + distance * 0.22) |
| : new THREE.Vector3(centerX + distance * 0.55, -distance, targetZ + distance * 0.62), |
| front: new THREE.Vector3(centerX, -distance, targetZ + 8), |
| back: new THREE.Vector3(centerX, distance, targetZ + 8), |
| left: new THREE.Vector3(centerX - distance, 0, targetZ + 8), |
| right: new THREE.Vector3(centerX + distance, 0, targetZ + 8), |
| top: new THREE.Vector3(centerX, 0, targetZ + distance), |
| bottom: new THREE.Vector3(centerX, 0, targetZ - distance) |
| }; |
| return { position: poses[view] || poses.isometric, target }; |
| } |
|
|
| function setCameraView(view) { |
| if (!currentGroup) return; |
| const pose = cameraPoseForView(view, latestDesign); |
| camera.position.copy(pose.position); |
| controls.target.copy(pose.target); |
| controls.update(); |
| renderer.render(scene, camera); |
| } |
|
|
| function captureViews(options = {}) { |
| if (!currentGroup) return; |
| const originalPosition = camera.position.clone(); |
| const originalTarget = controls.target.clone(); |
| const views = ["isometric", "front", "back", "left", "right", "top", "bottom"]; |
| const captures = views.map((view) => { |
| setCameraView(view); |
| return { view, url: renderer.domElement.toDataURL("image/png") }; |
| }); |
| camera.position.copy(originalPosition); |
| controls.target.copy(originalTarget); |
| controls.update(); |
| viewCapturesEl.innerHTML = captures |
| .map( |
| (capture) => ` |
| <figure> |
| <img src="${capture.url}" alt="${capture.view} view of current CAD render" /> |
| <figcaption>${capture.view}</figcaption> |
| </figure> |
| ` |
| ) |
| .join(""); |
| if (!options.silent) setStatus("Captured isometric, front, back, left, right, top, and bottom debug views.", "ok"); |
| } |
|
|
| function populateRenderStates(trace = [], finalDesign = latestDesign, finalAnalysis = latestAnalysis) { |
| latestRenderStates = (trace || []) |
| .filter((step) => step.content?.design_snapshot) |
| .map((step, index) => ({ |
| label: `${index + 1}. after ${step.name}`, |
| design: step.content.design_snapshot |
| })); |
|
|
| if (finalDesign) { |
| latestRenderStates.push({ label: `Final committed design (${trace?.length || "current"} tool calls)`, design: finalDesign }); |
| } |
|
|
| renderStateSelect.innerHTML = latestRenderStates.length |
| ? latestRenderStates.map((state, index) => `<option value="${index}">${escapeHtml(state.label)}</option>`).join("") |
| : `<option>No run loaded</option>`; |
| renderStateSelect.disabled = latestRenderStates.length === 0; |
| if (latestRenderStates.length) { |
| renderStateSelect.value = String(latestRenderStates.length - 1); |
| renderStateNote.textContent = "Viewer is rendering the latest committed design."; |
| } else { |
| renderStateNote.textContent = "Viewer is rendering the local sample."; |
| } |
| renderStateSelect.onchange = () => { |
| const selected = latestRenderStates[Number(renderStateSelect.value)]; |
| if (!selected) return; |
| renderDesign(selected.design, finalAnalysis || latestAnalysis); |
| renderStateNote.textContent = selected.label.includes("Final") |
| ? "Viewer is rendering the latest committed design." |
| : `Viewer is rendering an intermediate snapshot: ${selected.label}.`; |
| }; |
| } |
|
|
| function renderBenchmarkResults(result) { |
| if (!result?.results?.length) { |
| benchmarkResultsEl.innerHTML = ""; |
| return; |
| } |
|
|
| benchmarkResultsEl.innerHTML = result.results |
| .map((item) => { |
| if (item.error) { |
| return ` |
| <div class="benchmark-card error-card"> |
| <div> |
| <span>${item.model}</span> |
| <strong>error</strong> |
| </div> |
| <p>${item.error}</p> |
| </div> |
| `; |
| } |
| const best = item.best; |
| const trace = item.trace.map((step) => `${step.iteration}: ${step.analysis.score}`).join(" → "); |
| return ` |
| <div class="benchmark-card"> |
| <div> |
| <span>${item.model}</span> |
| <strong>${best.analysis.score}</strong> |
| </div> |
| <p>${best.design.title}</p> |
| <small>scores ${trace}</small> |
| </div> |
| `; |
| }) |
| .join(""); |
| } |
|
|
| function markusFeedback(stats = {}, scadCode = "") { |
| const code = scadCode.toLowerCase(); |
| const dimensions = stats.dimensions || {}; |
| const width = Number(dimensions.x || 0); |
| const depth = Number(dimensions.y || 0); |
| const height = Number(dimensions.z || 0); |
| const components = Number(stats.connected_components || 0); |
| const floating = Number(stats.floating_parts || Math.max(0, components - 1)); |
| const boundary = Number(stats.boundary_edges || 0); |
| const nonManifold = Number(stats.non_manifold_edges || 0); |
| const checks = [ |
| { name: "compile/render", pass: !stats.compile_error }, |
| { name: "single connected body", pass: components === 1 && floating === 0 }, |
| { name: "watertight topology", pass: stats.watertight === true && boundary === 0 && nonManifold === 0 }, |
| { name: "chair proportions", pass: height > Math.max(width, Math.abs(depth)) * 0.9 && height > 80 }, |
| { name: "seat/back structure", pass: code.includes("back") || code.includes("backrest") || height > 120 }, |
| { name: "armrests", pass: code.includes("armrest") || code.includes("arm rest") }, |
| { name: "central column", pass: code.includes("column") || code.includes("cylinder") }, |
| { name: "five-star base", pass: code.includes("spoke") || code.includes("star") || (code.match(/rotate\(/g) || []).length >= 4 }, |
| { name: "casters or feet", pass: code.includes("caster") || code.includes("wheel") || code.includes("foot") } |
| ]; |
| const passed = checks.filter((check) => check.pass).length; |
| const markus_score = Math.round((passed / checks.length) * 100); |
| const missing = checks.filter((check) => !check.pass).map((check) => check.name); |
| const should_stop = markus_score >= 78 && components === 1 && floating === 0 && !stats.compile_error; |
| return { |
| target: "IKEA Markus-like office chair heuristic, not full GLB distance", |
| markus_score, |
| should_stop, |
| missing, |
| topology: { |
| connected_components: components, |
| floating_parts: floating, |
| boundary_edges: boundary, |
| non_manifold_edges: nonManifold, |
| watertight: stats.watertight === true |
| }, |
| dimensions_mm: { width_x: width, depth_y: depth, height_z: height } |
| }; |
| } |
|
|
| function renderAgentSteps(steps = []) { |
| if (!agentStepsEl) return; |
| if (!steps.length) { |
| agentStepsEl.innerHTML = ""; |
| return; |
| } |
| agentStepsEl.innerHTML = ` |
| <h3>Agent tool loop</h3> |
| ${steps |
| .map((step) => { |
| const feedback = step.feedback || {}; |
| const stats = step.render_stats || {}; |
| return ` |
| <details class="agent-step" ${step.step === steps.length ? "open" : ""}> |
| <summary> |
| <span>Step ${step.step}</span> |
| <strong>${feedback.markus_score ?? "?"}/100</strong> |
| </summary> |
| <p>${escapeHtml(step.rationale || step.error || "")}</p> |
| <dl> |
| <div><dt>tool</dt><dd>${escapeHtml(step.tool || "")}</dd></div> |
| <div><dt>model</dt><dd>${escapeHtml(step.model || "")}</dd></div> |
| <div><dt>components</dt><dd>${escapeHtml(String(stats.connected_components ?? "n/a"))}</dd></div> |
| <div><dt>floating</dt><dd>${escapeHtml(String(stats.floating_parts ?? "n/a"))}</dd></div> |
| <div><dt>watertight</dt><dd>${escapeHtml(String(stats.watertight ?? "n/a"))}</dd></div> |
| </dl> |
| ${feedback.missing?.length ? `<small>Missing: ${escapeHtml(feedback.missing.join(", "))}</small>` : "<small>Verifier says the current candidate is close enough for this scoped loop.</small>"} |
| </details> |
| `; |
| }) |
| .join("")} |
| `; |
| } |
|
|
| function renderTrace(trace) { |
| if (!trace?.length) { |
| traceEl.innerHTML = ""; |
| return; |
| } |
|
|
| traceEl.innerHTML = trace |
| .map((step, index) => { |
| const content = |
| typeof step.content === "string" |
| ? step.content |
| : JSON.stringify(step.content, (key, value) => { |
| if (value?.method && value?.element_results) { |
| return { |
| method: value.method, |
| node_count: value.nodes?.length, |
| element_count: value.elements?.length || value.tets?.length, |
| max_stress_mpa: value.max_stress_mpa, |
| tip_deflection_mm: value.tip_deflection_mm || value.load_point_deflection_mm, |
| safety_factor: value.safety_factor, |
| mass_g: value.mass_g, |
| score: value.score, |
| force_vector_n: value.force_vector_n, |
| load_case: value.load_case, |
| top_elements: value.element_results.slice(0, 5) |
| }; |
| } |
| return value; |
| }, 2); |
| return ` |
| <details class="trace-step" ${index >= trace.length - 2 ? "open" : ""}> |
| <summary>${index + 1}. ${step.role}${step.name ? ` / ${step.name}` : ""}</summary> |
| <pre>${escapeHtml(content)}</pre> |
| </details> |
| `; |
| }) |
| .join(""); |
| } |
|
|
| function setActiveTab(name) { |
| tabButtons.forEach((button) => button.classList.toggle("active", button.dataset.tab === name)); |
| Object.entries(tabPanels).forEach(([key, panel]) => panel.classList.toggle("active", key === name)); |
| } |
|
|
| function feedbackSummary(analysis) { |
| if (!analysis) return {}; |
| return { |
| score: analysis.score, |
| mass_g: analysis.mass_g, |
| max_stress_mpa: analysis.max_stress_mpa, |
| max_strain_microstrain: analysis.max_strain_microstrain, |
| safety_factor: analysis.safety_factor, |
| tip_deflection_mm: analysis.tip_deflection_mm, |
| thermal_delta_c_proxy: analysis.thermal_delta_c_proxy, |
| load_case: analysis.load_case, |
| stress_regions: analysis.stress_regions |
| }; |
| } |
|
|
| function llmMessagesFromTrace(trace = []) { |
| return { |
| system: trace.find((step) => step.role === "system")?.content || "", |
| user: trace.find((step) => step.role === "user")?.content || "" |
| }; |
| } |
|
|
| function renderAgentLoop(run) { |
| if (!run) { |
| const empty = `<p class="empty-note">Run Generate Agent Loop or Run Tool Episode to inspect the CADForge loop.</p>`; |
| agentLoopEl.innerHTML = empty; |
| iterationsEl.innerHTML = empty; |
| llmInputEl.innerHTML = empty; |
| return; |
| } |
|
|
| if (run.type === "tool_episode") { |
| const steps = run.trace || []; |
| agentLoopEl.innerHTML = ` |
| <div class="loop-strip"> |
| <span>Prompt</span><span>CSG family</span><span>AST/CAD actions</span><span>Geometry validation</span><span>Structural check</span><span>Commit</span> |
| </div> |
| <h3>CADForge Tool Episode</h3> |
| <div class="iteration-grid tool-grid"> |
| ${steps |
| .map( |
| (step, index) => ` |
| <div class="iteration-card"> |
| <span>Tool ${index + 1}</span> |
| <strong>${escapeHtml(step.name || "tool")}</strong> |
| <small>${escapeHtml(step.content?.result?.message || step.content?.result?.added_feature || step.content?.result?.engine || step.content?.result?.verdict || "state updated")}</small> |
| </div> |
| ` |
| ) |
| .join("")} |
| </div> |
| `; |
| iterationsEl.innerHTML = ` |
| <h3>Design State After Each Tool</h3> |
| ${steps |
| .map( |
| (step, index) => ` |
| <details class="trace-step" ${index >= run.trace.length - 2 ? "open" : ""}> |
| <summary>${index + 1}. ${escapeHtml(step.name)} ${step.content?.design_snapshot?.features ? `(${step.content.design_snapshot.features.length} features)` : ""}</summary> |
| <pre>${escapeHtml(JSON.stringify(step.content?.design_snapshot || step.content, null, 2))}</pre> |
| </details> |
| ` |
| ) |
| .join("")} |
| `; |
| const cadStep = steps.find((step) => step.name === "export_cadquery"); |
| const planner = run.planner || {}; |
| const plannerRounds = planner.rounds || []; |
| const toolContexts = steps.map((step, index) => ({ |
| step: index + 1, |
| tool: step.name, |
| round: step.content?.round || null, |
| note: plannerRounds.length |
| ? "Sequential executor context inside a receding-horizon planner round." |
| : "Sequential executor context. This is not a separate GPT call in the current implementation.", |
| tool_input: step.content?.params || {}, |
| compact_observation_for_next_step: step.content?.result || null |
| })); |
| llmInputEl.innerHTML = ` |
| <p class="empty-note">${plannerRounds.length ? `Current architecture: ${plannerRounds.length} planner/replanner calls. Each call observes the current CAD/simulation state, proposes the next actions, then the environment executes the expanded tools.` : "Current architecture: one actual GPT planning call creates the high-level CAD plan, then the environment expands and executes the tool calls sequentially."}</p> |
| ${plannerRounds.length |
| ? plannerRounds |
| .map( |
| (round) => ` |
| <details class="trace-step" ${round.round === 1 ? "open" : ""}> |
| <summary>Actual LLM call ${round.round}: ${escapeHtml(round.kind || "planner")} via ${escapeHtml(round.source || "unknown")}</summary> |
| <pre>${escapeHtml( |
| JSON.stringify( |
| { |
| round: round.round, |
| source: round.source, |
| model: round.model, |
| system: round.system, |
| user: round.user, |
| planner_error: round.error || null, |
| observation: round.observation || null, |
| model_plan: round.plan || null |
| }, |
| null, |
| 2 |
| ) |
| )}</pre> |
| </details> |
| ` |
| ) |
| .join("") |
| : `<details class="trace-step" open> |
| <summary>Actual LLM call: ${planner.source === "openai_tool_planner" ? `GPT tool-planner input to ${escapeHtml(planner.model || "model")}` : "local fallback planner"}</summary> |
| <pre>${escapeHtml( |
| JSON.stringify( |
| { |
| user_prompt: run.prompt || promptInput.value, |
| source: planner.source || "unknown", |
| system: planner.system || "Local deterministic planner was used.", |
| user: planner.user || run.prompt || promptInput.value, |
| planner_error: planner.error || null, |
| model_plan: planner.plan || null, |
| selected_family: steps[0]?.content?.params?.family || "unknown", |
| available_tools: ["create_design_family", "set_material", "add_feature", "set_load", "export_cadquery", "run_fea", "commit_design"], |
| cadquery_export: cadStep?.content?.result || null |
| }, |
| null, |
| 2 |
| ) |
| )}</pre> |
| </details>`} |
| <details class="trace-step"> |
| <summary>Sequential tool contexts (${steps.length} executed tools)</summary> |
| <pre>${escapeHtml(JSON.stringify(toolContexts, null, 2))}</pre> |
| </details> |
| `; |
| return; |
| } |
|
|
| const iterations = run.iterations || []; |
| agentLoopEl.innerHTML = ` |
| <div class="loop-strip"> |
| <span>Prompt</span><span>LLM code-CAD JSON</span><span>CADForge verifier</span><span>Feedback summary</span><span>Next iteration</span> |
| </div> |
| <h3>CADForge Iterations</h3> |
| `; |
| iterationsEl.innerHTML = ` |
| <h3>Iteration Scores</h3> |
| <div class="iteration-grid"> |
| ${iterations |
| .map( |
| (iteration) => ` |
| <div class="iteration-card"> |
| <span>Iteration ${iteration.iteration}</span> |
| <strong>${iteration.analysis?.score ?? "n/a"}</strong> |
| <small>${escapeHtml(iteration.design?.title || "Untitled design")}</small> |
| </div> |
| ` |
| ) |
| .join("")} |
| </div> |
| `; |
| llmInputEl.innerHTML = ` |
| <h3>LLM Inputs</h3> |
| ${iterations |
| .map((iteration) => { |
| const messages = llmMessagesFromTrace(iteration.trace); |
| return ` |
| <details class="trace-step"> |
| <summary>Iteration ${iteration.iteration} input to ${escapeHtml(iteration.model || run.model || "model")}</summary> |
| <pre>${escapeHtml( |
| JSON.stringify( |
| { |
| system: messages.system, |
| user: messages.user, |
| feedback_summary_sent_next_time: feedbackSummary(iteration.analysis) |
| }, |
| null, |
| 2 |
| ) |
| )}</pre> |
| </details> |
| `; |
| }) |
| .join("")} |
| `; |
| } |
|
|
| function escapeHtml(value) { |
| return String(value) |
| .replaceAll("&", "&") |
| .replaceAll("<", "<") |
| .replaceAll(">", ">") |
| .replaceAll('"', """); |
| } |
|
|
| async function loadSample() { |
| setStatus("Loading local sample..."); |
| const response = await fetch("/api/sample"); |
| const result = await response.json(); |
| renderDesign(result.design, result.analysis); |
| latestAgentRun = null; |
| renderAgentLoop(latestAgentRun); |
| renderTrace([]); |
| populateRenderStates([], result.design, result.analysis); |
| setStatus("Loaded local sample.", "ok"); |
| } |
|
|
| async function generateDesign() { |
| setStatus(`Running receding-horizon CAD agent with ${toolBudgetInput.value} tool calls...`, "working"); |
| benchmarkResultsEl.innerHTML = ""; |
| generateButton.disabled = true; |
| try { |
| const response = await fetch("/api/receding-agent", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ prompt: promptInput.value, target_tool_calls: Number(toolBudgetInput.value) }) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| if (result.design && result.analysis) renderDesign(result.design, result.analysis); |
| setStatus(result.error || "Agent generation failed.", "error"); |
| return; |
| } |
| renderDesign(result.design, result.analysis); |
| renderTrace(result.trace); |
| latestAgentRun = { type: "tool_episode", prompt: promptInput.value, trace: result.trace, planner: result.planner }; |
| renderAgentLoop(latestAgentRun); |
| populateRenderStates(result.trace, result.design, result.analysis); |
| setActiveTab("loop"); |
| const plannerLabel = result.planner?.source === "receding_horizon" ? `${result.planner.rounds?.length || 1} planner calls` : "single planner"; |
| setStatus(`Agent loop complete: ${result.trace.length} tool calls, ${plannerLabel}, score ${result.analysis.score}.`, "ok"); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "Agent generation failed.", "error"); |
| } finally { |
| generateButton.disabled = false; |
| } |
| } |
|
|
| async function benchmarkDesigns() { |
| setStatus("Running GPT-5.4 iterative benchmark...", "working"); |
| benchmarkButton.disabled = true; |
| generateButton.disabled = true; |
| benchmarkResultsEl.innerHTML = ""; |
| try { |
| const response = await fetch("/api/benchmark", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| prompt: promptInput.value, |
| system_prompt: systemPromptInput.value, |
| models: ["gpt-5.4"], |
| iterations: 3 |
| }) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "Benchmark failed.", "error"); |
| return; |
| } |
|
|
| renderBenchmarkResults(result); |
| const validResults = result.results.filter((item) => item.best); |
| if (!validResults.length) { |
| setStatus("Benchmark finished, but no model produced a valid design.", "error"); |
| return; |
| } |
|
|
| const winner = validResults |
| .map((item) => item.best) |
| .sort((a, b) => b.analysis.score - a.analysis.score)[0]; |
| renderDesign(winner.design, winner.analysis); |
| renderTrace(winner.trace); |
| const winningResult = validResults.find((item) => item.best === winner) || validResults[0]; |
| latestAgentRun = { |
| type: "llm_benchmark", |
| model: winningResult.model, |
| iterations: winningResult.trace |
| }; |
| renderAgentLoop(latestAgentRun); |
| populateRenderStates(winner.trace || [], winner.design, winner.analysis); |
| setActiveTab("iterations"); |
| setStatus(`Benchmark complete in ${(result.elapsed_ms / 1000).toFixed(1)}s. Rendering best design from ${winner.model}.`, "ok"); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "Benchmark failed.", "error"); |
| } finally { |
| benchmarkButton.disabled = false; |
| generateButton.disabled = false; |
| } |
| } |
|
|
| async function runToolEpisode() { |
| setStatus("Running Python tool episode...", "working"); |
| toolEpisodeButton.disabled = true; |
| benchmarkResultsEl.innerHTML = ""; |
| try { |
| const response = await fetch("/api/tool-episode", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ prompt: promptInput.value, target_tool_calls: Number(toolBudgetInput.value) }) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| setStatus(result.error || "Tool episode failed.", "error"); |
| return; |
| } |
| renderDesign(result.design, result.analysis); |
| renderTrace(result.trace); |
| latestAgentRun = { type: "tool_episode", prompt: promptInput.value, trace: result.trace, planner: result.planner }; |
| renderAgentLoop(latestAgentRun); |
| populateRenderStates(result.trace, result.design, result.analysis); |
| setActiveTab("loop"); |
| const plannerLabel = result.planner?.source === "openai_tool_planner" ? `planned by ${result.planner.model}` : "local fallback planner"; |
| setStatus(`Tool episode complete: ${result.trace.length} actions, ${plannerLabel}, score ${result.analysis.score}.`, "ok"); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "Tool episode failed.", "error"); |
| } finally { |
| toolEpisodeButton.disabled = false; |
| } |
| } |
|
|
| async function callScadAgentTool(endpoint, payload) { |
| const response = await fetch(endpoint, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(payload) |
| }); |
| const result = await response.json(); |
| if (!response.ok) { |
| throw new Error(result.error || "SCAD generation failed."); |
| } |
| return result; |
| } |
|
|
| async function generateScad(iterate = false) { |
| const button = iterate ? iterateScadButton : generateScadButton; |
| const maxSteps = iterate ? 1 : 5; |
| const agentSteps = []; |
| setStatus(iterate ? "Asking model to revise the SCAD code..." : "Running multi-step SCAD agent against the Markus chair target...", "working"); |
| if (button) button.disabled = true; |
| renderAgentSteps(agentSteps); |
| try { |
| for (let step = 1; step <= maxSteps; step += 1) { |
| const isInitial = step === 1 && !iterate; |
| const endpoint = isInitial ? "/api/scad-generate" : "/api/scad-iterate"; |
| const tool = isInitial ? "generate_scad_candidate" : "iterate_scad_with_render_feedback"; |
| setStatus(`Tool ${step}/${maxSteps}: ${tool}`, "working"); |
| const result = await callScadAgentTool(endpoint, { |
| prompt: promptInput.value, |
| scad_code: scadCodeInput.value, |
| render_stats: latestScadStats |
| }); |
| scadCodeInput.value = result.scad_code || ""; |
| scadOutputEl.textContent = result.rationale || "Generated SCAD code."; |
|
|
| let feedback; |
| try { |
| renderScadFromEditor(); |
| feedback = markusFeedback(latestScadStats, scadCodeInput.value); |
| } catch (renderError) { |
| latestScadStats = { |
| compile_error: renderError instanceof Error ? renderError.message : "SCAD render failed.", |
| connected_components: 0, |
| floating_parts: 999, |
| boundary_edges: 999, |
| non_manifold_edges: 999, |
| watertight: false |
| }; |
| feedback = markusFeedback(latestScadStats, scadCodeInput.value); |
| setStatus(latestScadStats.compile_error, "error"); |
| } |
|
|
| agentSteps.push({ |
| step, |
| tool, |
| source: result.source, |
| model: result.model, |
| rationale: result.rationale, |
| render_stats: latestScadStats, |
| feedback |
| }); |
| renderAgentSteps(agentSteps); |
| jsonEl.textContent = JSON.stringify({ latest_result: result, agent_steps: agentSteps }, null, 2); |
|
|
| if (feedback.should_stop) { |
| setStatus(`Stopped after ${step} tool calls: Markus heuristic ${feedback.markus_score}/100 with connected topology.`, "ok"); |
| return; |
| } |
| } |
| const finalFeedback = agentSteps[agentSteps.length - 1]?.feedback; |
| setStatus(`Completed ${agentSteps.length} tool calls. Markus heuristic ${finalFeedback?.markus_score ?? "?"}/100.`, "ok"); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "SCAD generation failed.", "error"); |
| agentSteps.push({ |
| step: agentSteps.length + 1, |
| tool: "agent_error", |
| error: error instanceof Error ? error.message : "SCAD generation failed.", |
| render_stats: latestScadStats || {}, |
| feedback: markusFeedback(latestScadStats || {}, scadCodeInput.value) |
| }); |
| renderAgentSteps(agentSteps); |
| } finally { |
| if (button) button.disabled = false; |
| } |
| } |
|
|
| function exportStl() { |
| if (!currentGroup) { |
| setStatus("Load or generate a design first.", "error"); |
| return; |
| } |
| const exporter = new STLExporter(); |
| const stl = exporter.parse(currentGroup); |
| const blob = new Blob([stl], { type: "model/stl" }); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement("a"); |
| link.href = url; |
| link.download = "cadforge-design.stl"; |
| link.click(); |
| URL.revokeObjectURL(url); |
| setStatus("Exported STL from the rendered mesh.", "ok"); |
| } |
|
|
| if (generateButton) generateButton.addEventListener("click", generateDesign); |
| if (benchmarkButton) benchmarkButton.addEventListener("click", benchmarkDesigns); |
| if (toolEpisodeButton) toolEpisodeButton.addEventListener("click", runToolEpisode); |
| if (sampleButton) sampleButton.addEventListener("click", loadSample); |
| if (exportButton) exportButton.addEventListener("click", exportStl); |
| if (captureViewsButton) captureViewsButton.addEventListener("click", () => captureViews()); |
| if (generateScadButton) generateScadButton.addEventListener("click", () => generateScad(false)); |
| if (generateCadqueryButton) generateCadqueryButton.addEventListener("click", generateCadqueryCode); |
| if (envGenerateCadqueryButton) envGenerateCadqueryButton.addEventListener("click", () => runCadqueryReplStep({ revise: false })); |
| if (envReviseCadqueryButton) envReviseCadqueryButton.addEventListener("click", () => runCadqueryReplStep({ revise: true })); |
| if (envEvaluateCadqueryButton) envEvaluateCadqueryButton.addEventListener("click", () => evaluateCadqueryEnv({ rewardMode: "full" })); |
| if (envLoadIdealButton) envLoadIdealButton.addEventListener("click", loadIdealCadqueryCode); |
| if (renderCadqueryButton) renderCadqueryButton.addEventListener("click", renderCadqueryCode); |
| if (testCadqueryBackendButton) testCadqueryBackendButton.addEventListener("click", testCadqueryBackend); |
| if (loadCadquerySampleButton) loadCadquerySampleButton.addEventListener("click", () => loadCadquerySampleCode({ render: false })); |
| if (iterateScadButton) iterateScadButton.addEventListener("click", () => generateScad(true)); |
| if (renderScadButton) renderScadButton.addEventListener("click", () => { |
| try { |
| renderScadFromEditor(); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "SCAD render failed.", "error"); |
| } |
| }); |
| if (loadScadExampleButton) loadScadExampleButton.addEventListener("click", () => { |
| scadCodeInput.value = `difference() { |
| union() { |
| cube([90, 60, 7]); |
| translate([10, 10, 7]) cylinder(h=58, r=5, $fn=32); |
| translate([75, 10, 7]) cylinder(h=58, r=5, $fn=32); |
| translate([10, 45, 7]) cylinder(h=58, r=5, $fn=32); |
| translate([75, 45, 7]) cylinder(h=58, r=5, $fn=32); |
| translate([0, 47, 60]) cube([90, 9, 58]); |
| translate([5, 8, 32]) cube([80, 6, 6]); |
| translate([5, 46, 32]) cube([80, 6, 6]); |
| } |
| translate([25, 30, -1]) cylinder(h=10, r=5, $fn=32); |
| }`; |
| renderScadFromEditor(); |
| }); |
| if (toolBudgetInput && toolBudgetValue) toolBudgetInput.addEventListener("input", () => { |
| toolBudgetValue.textContent = toolBudgetInput.value; |
| }); |
| presetButtons.forEach((button) => { |
| button.addEventListener("click", () => { |
| promptInput.value = promptPresets[button.dataset.preset] || promptInput.value; |
| setStatus(`Loaded ${button.textContent} prompt.`, "ok"); |
| }); |
| }); |
| tabButtons.forEach((button) => button.addEventListener("click", () => setActiveTab(button.dataset.tab))); |
|
|
| function animate() { |
| controls.update(); |
| renderer.render(scene, camera); |
| requestAnimationFrame(animate); |
| } |
|
|
| animate(); |
| if (isOpenScadPage) { |
| try { |
| renderScadFromEditor(); |
| } catch (error) { |
| setStatus(error instanceof Error ? error.message : "SCAD render failed.", "error"); |
| } |
| } |
|
|
| if (isCadQueryRendererPage) { |
| testCadqueryBackend(); |
| } else if (isCadQueryEnvPage) { |
| loadIdealCadqueryCode(); |
| } else if (isCadQueryGeneratorPage) { |
| loadCadquerySampleCode({ render: true }); |
| } |
|
|
| if (systemPromptInput) { |
| fetch("/api/system-prompt") |
| .then((response) => response.json()) |
| .then((result) => { |
| systemPromptInput.value = result.system_prompt || ""; |
| }) |
| .catch(() => { |
| systemPromptInput.value = ""; |
| }); |
| } |
|
|