| |
| |
| |
| |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| import { DragControls } from 'three/addons/controls/DragControls.js'; |
| import { MeshRenderer } from './mesh-renderer.js'; |
| import { fetchTopology, predictDebounced } from './api-client.js'; |
| import { colormapGradientCSS } from './colormap.js'; |
|
|
| |
| |
| |
| const container = document.getElementById('canvas-container'); |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0xffffff); |
|
|
| const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 200); |
| camera.position.set(18, -18, 14); |
| camera.up.set(0, 0, 1); |
|
|
| const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setPixelRatio(window.devicePixelRatio); |
| container.appendChild(renderer.domElement); |
|
|
| |
| scene.add(new THREE.AmbientLight(0xffffff, 0.6)); |
| const dl1 = new THREE.DirectionalLight(0xffffff, 0.7); |
| dl1.position.set(10, -10, 15); |
| scene.add(dl1); |
| const dl2 = new THREE.DirectionalLight(0xffffff, 0.25); |
| dl2.position.set(-10, 10, 5); |
| scene.add(dl2); |
|
|
| |
| const axes = new THREE.AxesHelper(2); |
| axes.position.set(-0.5, -0.5, -0.5); |
| scene.add(axes); |
|
|
| |
| const orbitControls = new OrbitControls(camera, renderer.domElement); |
| orbitControls.enableDamping = true; |
| orbitControls.dampingFactor = 0.1; |
| orbitControls.target.set(0, 0, 1); |
|
|
| const meshRenderer = new MeshRenderer(scene); |
|
|
| |
| |
| |
| let topology = null; |
| let currentParams = {}; |
| let colorMode = 'q'; |
| let latestData = null; |
| let symmetryLocked = true; |
|
|
| |
| const cpSpheres = []; |
| let dragControls = null; |
|
|
| |
| |
| |
| function onResize() { |
| const w = container.clientWidth, h = container.clientHeight; |
| camera.aspect = w / h; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(w, h); |
| } |
| window.addEventListener('resize', onResize); |
|
|
| |
| |
| |
| |
| |
| |
| function mirrorQuarterTile(tile4) { |
| |
| const step1 = []; |
| for (const [x, y, z] of tile4) { |
| step1.push([x, y, z]); |
| step1.push([-x, y, z]); |
| } |
| |
| const all = []; |
| for (const [x, y, z] of step1) { |
| all.push([x, y, z]); |
| all.push([x, -y, z]); |
| } |
| return all; |
| } |
|
|
| |
| |
| |
| function buildUI(topo) { |
| const { bounds, presets, tile } = topo; |
|
|
| |
| const slidersDiv = document.getElementById('sliders'); |
| slidersDiv.innerHTML = ''; |
| for (const [key, b] of Object.entries(bounds)) { |
| currentParams[key] = b.default; |
| const row = document.createElement('div'); |
| row.className = 'slider-row'; |
| row.innerHTML = ` |
| <label>${b.label}</label> |
| <input type="range" min="${b.min}" max="${b.max}" step="0.01" |
| value="${b.default}" data-key="${key}"> |
| <span class="val">${b.default.toFixed(2)}</span> |
| `; |
| slidersDiv.appendChild(row); |
|
|
| const input = row.querySelector('input'); |
| const valSpan = row.querySelector('.val'); |
| input.addEventListener('input', (e) => { |
| const v = parseFloat(e.target.value); |
| currentParams[key] = v; |
| valSpan.textContent = v.toFixed(2); |
| requestPrediction(); |
| }); |
| } |
|
|
| |
| const presetsDiv = document.getElementById('presets'); |
| presetsDiv.innerHTML = ''; |
| for (const [name, params] of Object.entries(presets)) { |
| const btn = document.createElement('button'); |
| btn.textContent = name; |
| btn.addEventListener('click', () => { |
| for (const [k, v] of Object.entries(params)) { |
| currentParams[k] = v; |
| const input = slidersDiv.querySelector(`input[data-key="${k}"]`); |
| if (input) { |
| input.value = v; |
| input.parentElement.querySelector('.val').textContent = v.toFixed(2); |
| } |
| } |
| requestPrediction(); |
| }); |
| presetsDiv.appendChild(btn); |
| } |
|
|
| |
| document.getElementById('break-symmetry').addEventListener('change', (e) => { |
| symmetryLocked = !e.target.checked; |
| }); |
|
|
| |
| document.getElementById('color-mode').addEventListener('change', (e) => { |
| colorMode = e.target.value; |
| const label = e.target.options[e.target.selectedIndex].text; |
| document.getElementById('colorbar-title').textContent = label; |
| if (latestData) updateView(latestData); |
| }); |
|
|
| |
| document.getElementById('show-target').addEventListener('change', (e) => { |
| meshRenderer.targetGroup.visible = e.target.checked; |
| }); |
| document.getElementById('show-surface').addEventListener('change', (e) => { |
| meshRenderer.surfaceGroup.visible = e.target.checked; |
| }); |
| document.getElementById('show-supports').addEventListener('change', (e) => { |
| meshRenderer.supportsGroup.visible = e.target.checked; |
| }); |
| document.getElementById('show-cp').addEventListener('change', (e) => { |
| meshRenderer.cpGroup.visible = e.target.checked; |
| meshRenderer.cpDragGroup.visible = e.target.checked; |
| cpSpheres.forEach(s => s.visible = e.target.checked); |
| }); |
|
|
| |
| document.getElementById('colorbar-gradient').style.background = colormapGradientCSS(); |
|
|
| |
| buildControlPointSpheres(tile); |
| } |
|
|
| |
| |
| |
| function buildControlPointSpheres(tile) { |
| const cpGeo = new THREE.SphereGeometry(0.2, 16, 16); |
|
|
| for (let i = 0; i < 4; i++) { |
| const mat = new THREE.MeshPhongMaterial({ color: 0xff0000 }); |
| const sphere = new THREE.Mesh(cpGeo, mat); |
| const base = tile[i]; |
| sphere.userData = { cpIndex: i, base }; |
| scene.add(sphere); |
| cpSpheres.push(sphere); |
| } |
| updateControlPointPositions(); |
|
|
| dragControls = new DragControls(cpSpheres, camera, renderer.domElement); |
| dragControls.addEventListener('dragstart', () => { orbitControls.enabled = false; }); |
| dragControls.addEventListener('dragend', () => { orbitControls.enabled = true; }); |
| dragControls.addEventListener('drag', (event) => { |
| const obj = event.object; |
| const { cpIndex, base } = obj.userData; |
|
|
| if (symmetryLocked) { |
| |
| if (cpIndex === 0) { |
| |
| currentParams.c1_z = clamp(obj.position.z - base[2], 1, 10); |
| obj.position.x = base[0]; |
| obj.position.y = base[1]; |
| obj.position.z = base[2] + currentParams.c1_z; |
| } else if (cpIndex === 1) { |
| |
| currentParams.c2_x = clamp(obj.position.x - base[0], -5, 5); |
| currentParams.c2_z = clamp(obj.position.z - base[2], 0, 10); |
| obj.position.y = base[1]; |
| obj.position.x = base[0] + currentParams.c2_x; |
| obj.position.z = base[2] + currentParams.c2_z; |
| } else if (cpIndex === 2) { |
| |
| currentParams.c3_y = clamp(obj.position.y - base[1], -5, 5); |
| obj.position.x = base[0]; |
| obj.position.z = base[2]; |
| obj.position.y = base[1] + currentParams.c3_y; |
| } else { |
| |
| obj.position.set(base[0], base[1], base[2]); |
| } |
| } else { |
| |
| if (cpIndex === 0) { |
| currentParams.c1_z = clamp(obj.position.z - base[2], 1, 10); |
| } else if (cpIndex === 1) { |
| currentParams.c2_x = clamp(obj.position.x - base[0], -5, 5); |
| currentParams.c2_z = clamp(obj.position.z - base[2], 0, 10); |
| } else if (cpIndex === 2) { |
| currentParams.c3_y = clamp(obj.position.y - base[1], -5, 5); |
| } |
| } |
| syncSlidersFromParams(); |
| requestPrediction(); |
| }); |
| } |
|
|
| function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } |
|
|
| function syncSlidersFromParams() { |
| const slidersDiv = document.getElementById('sliders'); |
| for (const [k, v] of Object.entries(currentParams)) { |
| const input = slidersDiv.querySelector(`input[data-key="${k}"]`); |
| if (input) { |
| input.value = v; |
| input.parentElement.querySelector('.val').textContent = v.toFixed(2); |
| } |
| } |
| } |
|
|
| function updateControlPointPositions() { |
| if (!topology) return; |
| const tile = topology.tile; |
|
|
| |
| |
| const cp4 = [ |
| [tile[0][0], tile[0][1], tile[0][2] + currentParams.c1_z], |
| [tile[1][0] + currentParams.c2_x, tile[1][1], tile[1][2] + currentParams.c2_z], |
| [tile[2][0], tile[2][1] + currentParams.c3_y, tile[2][2]], |
| [tile[3][0], tile[3][1], tile[3][2]], |
| ]; |
|
|
| |
| cpSpheres.forEach((s, i) => { |
| s.position.set(cp4[i][0], cp4[i][1], cp4[i][2]); |
| }); |
|
|
| |
| const allCp = mirrorQuarterTile(cp4); |
| meshRenderer.updateControlPoints(allCp); |
| } |
|
|
| |
| |
| |
| function requestPrediction() { |
| stopDiversityAnimation(); |
| if (meshRenderer.resetSurfaceTint) meshRenderer.resetSurfaceTint(); |
| predictDebounced(currentParams, (data) => { |
| latestData = data; |
| updateView(data); |
| updateControlPointPositions(); |
| }); |
| } |
|
|
| function updateView(data) { |
| const range = meshRenderer.update(data, colorMode); |
| if (range) { |
| |
| const { vmin, vmax } = range; |
| const fmt = Math.max(Math.abs(vmin), Math.abs(vmax)) < 0.01 |
| ? (v) => v.toExponential(1) : (v) => v.toFixed(3); |
| document.getElementById('cb-max').textContent = fmt(vmax); |
| document.getElementById('cb-mid1').textContent = fmt((vmin + vmax) * 0.75 + vmin * 0.25); |
| document.getElementById('cb-zero').textContent = fmt((vmin + vmax) / 2); |
| document.getElementById('cb-mid2').textContent = fmt(vmin * 0.75 + vmax * 0.25); |
| document.getElementById('cb-min').textContent = fmt(vmin); |
| } |
| updateMetrics(data); |
|
|
| const sel = document.getElementById('color-mode'); |
| document.getElementById('colorbar-title').textContent = sel.options[sel.selectedIndex].text; |
| } |
|
|
| function updateMetrics(data) { |
| const { q, forces, inference_ms } = data; |
| const qMin = Math.min(...q).toFixed(3); |
| const qMax = Math.max(...q).toFixed(3); |
| const fMin = Math.min(...forces).toFixed(2); |
| const fMax = Math.max(...forces).toFixed(2); |
| const allComp = q.every(v => v <= 0.001); |
|
|
| document.getElementById('metrics').innerHTML = ` |
| Inference: <span class="value">${inference_ms} ms</span><br> |
| q range: <span class="value">[${qMin}, ${qMax}]</span><br> |
| F range: <span class="value">[${fMin}, ${fMax}]</span><br> |
| Edges: <span class="value">${q.length}</span><br> |
| All compression: <span class="${allComp ? 'ok' : 'value'}">${allComp ? 'Yes' : 'No'}</span> |
| `; |
| } |
|
|
| |
| |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| orbitControls.update(); |
| renderer.render(scene, camera); |
| } |
|
|
| |
| |
| |
| let diversityFrames = null; |
| let diversityTimer = null; |
| let diversityTotal = 0; |
| let diversityIdx = 0; |
|
|
| const FRAME_INTERVAL_MS = 300; |
|
|
| async function checkVAE() { |
| try { |
| const res = await fetch('/api/has_vae'); |
| const data = await res.json(); |
| if (data.has_vae) { |
| document.getElementById('vae-group').style.display = 'block'; |
| document.getElementById('diversity-panel').style.display = 'flex'; |
| initDiversityChartPlaceholder(); |
| document.getElementById('btn-diversity').addEventListener('click', requestDiversity); |
| } |
| } catch (e) { } |
| } |
|
|
| function initDiversityChartPlaceholder() { |
| if (typeof Plotly === 'undefined') return; |
| const el = document.getElementById('diversity-chart'); |
| if (!el) return; |
| Plotly.newPlot(el, [], { |
| title: { text: 'Click "Sample diverse equilibria" to begin', font: { size: 13 } }, |
| margin: { l: 52, r: 42, t: 40, b: 46 }, |
| paper_bgcolor: '#fcfcfc', |
| plot_bgcolor: '#fafafa', |
| xaxis: { title: 'Edge (sorted by design freedom)', zeroline: false }, |
| yaxis: { title: 'σ<sub>q</sub>', zeroline: false }, |
| font: { family: 'Segoe UI, system-ui, sans-serif', size: 11, color: '#222' }, |
| }, { displayModeBar: false, responsive: true }); |
| } |
|
|
| async function requestDiversity() { |
| const btn = document.getElementById('btn-diversity'); |
| stopDiversityAnimation(); |
| btn.textContent = 'Sampling...'; |
| btn.disabled = true; |
| document.getElementById('diversity-status').textContent = 'Running VAE encoder on current target shape...'; |
|
|
| try { |
| const res = await fetch('/api/diversity', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ ...currentParams, n_samples: 40 }), |
| }); |
| const data = await res.json(); |
| if (!data.samples) { |
| throw new Error(data.error || 'No samples returned'); |
| } |
|
|
| const frames = data.samples.map(s => ({ ...s, kind: 'vae' })); |
| frames.push({ ...data.deterministic, kind: 'det' }); |
|
|
| diversityFrames = frames; |
| diversityTotal = frames.length; |
| diversityIdx = 0; |
|
|
| renderDiversityChart(data); |
| btn.textContent = 'Resample'; |
| btn.disabled = false; |
|
|
| diversityTimer = setInterval(tickDiversity, FRAME_INTERVAL_MS); |
| tickDiversity(); |
| } catch (e) { |
| btn.textContent = 'Error sampling'; |
| btn.disabled = false; |
| document.getElementById('diversity-status').textContent = 'Sampling failed: ' + e.message; |
| } |
| } |
|
|
| function stopDiversityAnimation() { |
| if (diversityTimer !== null) { |
| clearInterval(diversityTimer); |
| diversityTimer = null; |
| } |
| } |
|
|
| function turboColor(frac) { |
| |
| const stops = [ |
| [0.0, [0.18, 0.07, 0.34]], |
| [0.3, [0.10, 0.58, 0.93]], |
| [0.5, [0.31, 0.92, 0.40]], |
| [0.7, [0.97, 0.76, 0.19]], |
| [1.0, [0.77, 0.11, 0.05]], |
| ]; |
| const x = Math.max(0, Math.min(1, frac)); |
| for (let i = 1; i < stops.length; i++) { |
| if (x <= stops[i][0]) { |
| const [x0, c0] = stops[i - 1]; |
| const [x1, c1] = stops[i]; |
| const t = (x - x0) / (x1 - x0 || 1); |
| return [ |
| c0[0] + (c1[0] - c0[0]) * t, |
| c0[1] + (c1[1] - c0[1]) * t, |
| c0[2] + (c1[2] - c0[2]) * t, |
| ]; |
| } |
| } |
| return stops[stops.length - 1][1]; |
| } |
|
|
| function tickDiversity() { |
| if (!diversityFrames || !latestData) return; |
| const cur = diversityIdx; |
| const frame = diversityFrames[cur]; |
| const isFinal = frame.kind === 'det'; |
|
|
| |
| const fakeData = { |
| ...latestData, |
| predicted: frame.predicted, |
| q: frame.q, |
| forces: frame.q.map((qi, i) => qi * (latestData.lengths ? latestData.lengths[i] : 1)), |
| }; |
| meshRenderer.update(fakeData, colorMode); |
|
|
| |
| if (meshRenderer.setSurfaceTint) { |
| if (isFinal) { |
| meshRenderer.setSurfaceTint(0x1565c0, 0.85); |
| } else { |
| const frac = cur / Math.max(diversityTotal - 2, 1); |
| const [r, g, b] = turboColor(frac); |
| meshRenderer.setSurfaceTint((r * 255 << 16) | (g * 255 << 8) | (b * 255), 0.6); |
| } |
| } |
|
|
| updateDiversityLine(cur, isFinal); |
|
|
| |
| const status = document.getElementById('diversity-status'); |
| if (isFinal) { |
| status.innerHTML = |
| `<b>Converged</b> — deterministic FDM prediction | shape error: ${frame.shape_error.toFixed(3)}`; |
| } else { |
| status.innerHTML = |
| `Exploring <b>${cur + 1}/${diversityTotal - 1}</b> VAE samples — all satisfy mechanical equilibrium`; |
| } |
|
|
| diversityIdx++; |
| if (isFinal) { |
| stopDiversityAnimation(); |
| } |
| } |
|
|
| |
| |
| |
| let chartSortIdx = null; |
| let chartQStd = null; |
| let chartXEdges = null; |
| let chartGhostCount = 0; |
|
|
| function renderDiversityChart(data) { |
| if (typeof Plotly === 'undefined') return; |
| const el = document.getElementById('diversity-chart'); |
| if (!el) return; |
|
|
| chartSortIdx = data.sort_idx; |
| chartQStd = chartSortIdx.map(i => data.q_std_per_edge[i]); |
| chartXEdges = chartQStd.map((_, i) => i); |
| chartGhostCount = 0; |
|
|
| const bars = { |
| type: 'bar', |
| x: chartXEdges, |
| y: chartQStd, |
| marker: { color: '#f5b041', line: { width: 0 } }, |
| opacity: 0.55, |
| name: 'Design freedom (population σ<sub>q</sub>)', |
| yaxis: 'y', |
| hoverinfo: 'skip', |
| }; |
|
|
| |
| const ghost = { |
| type: 'scatter', |
| mode: 'lines', |
| x: [], |
| y: [], |
| line: { color: '#6b7280', width: 1.0 }, |
| opacity: 0.25, |
| yaxis: 'y2', |
| name: 'Past samples', |
| hoverinfo: 'skip', |
| connectgaps: false, |
| }; |
|
|
| |
| const current = { |
| type: 'scatter', |
| mode: 'lines', |
| x: chartXEdges, |
| y: new Array(chartXEdges.length).fill(null), |
| line: { color: '#c62828', width: 2.6 }, |
| yaxis: 'y2', |
| name: 'Current iteration', |
| hoverinfo: 'skip', |
| }; |
|
|
| |
| const det = { |
| type: 'scatter', |
| mode: 'lines', |
| x: chartXEdges, |
| y: new Array(chartXEdges.length).fill(null), |
| line: { color: '#0d47a1', width: 3.0 }, |
| yaxis: 'y2', |
| name: 'Deterministic FDM', |
| hoverinfo: 'skip', |
| visible: true, |
| }; |
|
|
| |
| let qMin = Infinity, qMax = -Infinity; |
| for (const f of diversityFrames) { |
| for (const qi of f.q) { |
| if (qi < qMin) qMin = qi; |
| if (qi > qMax) qMax = qi; |
| } |
| } |
| const qPad = 0.08 * Math.max(Math.abs(qMin), Math.abs(qMax), 1e-6); |
|
|
| const layout = { |
| title: { |
| text: `Ensemble of ${diversityTotal - 1} equilibrium solutions + deterministic FDM`, |
| font: { size: 12.5, color: '#1a1a1a' }, |
| x: 0.02, xanchor: 'left', |
| }, |
| margin: { l: 58, r: 58, t: 46, b: 52 }, |
| paper_bgcolor: '#fcfcfc', |
| plot_bgcolor: '#fafafa', |
| font: { family: 'Segoe UI, system-ui, sans-serif', size: 11, color: '#222' }, |
| xaxis: { |
| title: 'Edge (sorted by design freedom)', |
| zeroline: false, |
| showgrid: false, |
| range: [-0.5, chartXEdges.length - 0.5], |
| }, |
| yaxis: { |
| title: 'σ<sub>q</sub> (population)', |
| titlefont: { color: '#7a4f00' }, |
| tickfont: { color: '#7a4f00' }, |
| zeroline: false, |
| gridcolor: '#e4e8ee', |
| }, |
| yaxis2: { |
| title: 'Force density q<sub>i</sub>', |
| titlefont: { color: '#222' }, |
| tickfont: { color: '#222' }, |
| overlaying: 'y', |
| side: 'right', |
| zeroline: false, |
| showgrid: false, |
| range: [qMin - qPad, qMax + qPad], |
| }, |
| showlegend: true, |
| legend: { |
| x: 0.99, y: 1.08, xanchor: 'right', yanchor: 'top', |
| orientation: 'h', |
| font: { size: 10 }, |
| bgcolor: 'rgba(255,255,255,0.9)', |
| bordercolor: '#888', |
| borderwidth: 0.6, |
| }, |
| }; |
|
|
| Plotly.newPlot(el, [bars, ghost, current, det], layout, |
| { displayModeBar: false, responsive: true }); |
| } |
|
|
| function updateDiversityLine(idx, isFinal) { |
| if (!chartSortIdx || typeof Plotly === 'undefined') return; |
| const el = document.getElementById('diversity-chart'); |
| if (!el || !el.data) return; |
|
|
| const q = diversityFrames[idx].q; |
| const qOrdered = chartSortIdx.map(i => q[i]); |
|
|
| if (!isFinal) { |
| |
| const prevData = el.data[2]; |
| if (prevData && Array.isArray(prevData.y) && prevData.y.some(v => v !== null)) { |
| const ghostX = (el.data[1].x || []).concat(prevData.x).concat([null]); |
| const ghostY = (el.data[1].y || []).concat(prevData.y).concat([null]); |
| Plotly.restyle(el, { x: [ghostX], y: [ghostY] }, [1]); |
| } |
| |
| Plotly.restyle(el, { y: [qOrdered] }, [2]); |
| |
| Plotly.restyle(el, { y: [new Array(qOrdered.length).fill(null)] }, [3]); |
| } else { |
| |
| const prevData = el.data[2]; |
| if (prevData && Array.isArray(prevData.y) && prevData.y.some(v => v !== null)) { |
| const ghostX = (el.data[1].x || []).concat(prevData.x).concat([null]); |
| const ghostY = (el.data[1].y || []).concat(prevData.y).concat([null]); |
| Plotly.restyle(el, { x: [ghostX], y: [ghostY] }, [1]); |
| } |
| Plotly.restyle(el, { y: [new Array(qOrdered.length).fill(null)] }, [2]); |
| Plotly.restyle(el, { y: [qOrdered] }, [3]); |
| } |
| } |
|
|
| async function init() { |
| onResize(); |
| topology = await fetchTopology(); |
| meshRenderer.init(topology); |
| buildUI(topology); |
| requestPrediction(); |
| checkVAE(); |
| animate(); |
| } |
|
|
| init(); |
|
|