/**
* Main entry point for VAE-FDM web explorer.
* Matches the desktop PyVista app as closely as possible.
*/
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';
// ---------------------------------------------------------------------------
// Scene (white background like PyVista: white -> aliceblue)
// ---------------------------------------------------------------------------
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);
// Lighting (match PyVista default)
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);
// Axes helper (like pl.add_axes())
const axes = new THREE.AxesHelper(2);
axes.position.set(-0.5, -0.5, -0.5);
scene.add(axes);
// Orbit controls
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);
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let topology = null;
let currentParams = {};
let colorMode = 'q';
let latestData = null;
let symmetryLocked = true;
// Draggable control point spheres
const cpSpheres = [];
let dragControls = null;
// ---------------------------------------------------------------------------
// Resize
// ---------------------------------------------------------------------------
function onResize() {
const w = container.clientWidth, h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
window.addEventListener('resize', onResize);
// ---------------------------------------------------------------------------
// Mirror a quarter-tile to full 16 control points (double symmetry).
// Matches calculate_grid_from_tile_quarter in neural_fdm/generators/grids.py:
// 1. Mirror across YZ plane (negate X)
// 2. Mirror the 8 points across XZ plane (negate Y)
// ---------------------------------------------------------------------------
function mirrorQuarterTile(tile4) {
// Step 1: original + mirror across YZ (negate x)
const step1 = [];
for (const [x, y, z] of tile4) {
step1.push([x, y, z]);
step1.push([-x, y, z]);
}
// Step 2: step1 + mirror across XZ (negate y)
const all = [];
for (const [x, y, z] of step1) {
all.push([x, y, z]);
all.push([x, -y, z]);
}
return all;
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
function buildUI(topo) {
const { bounds, presets, tile } = topo;
// Sliders
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 = `
${b.default.toFixed(2)}
`;
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();
});
}
// Presets
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);
}
// Break symmetry checkbox
document.getElementById('break-symmetry').addEventListener('change', (e) => {
symmetryLocked = !e.target.checked;
});
// Color mode
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);
});
// Visibility toggles
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);
});
// Colorbar gradient
document.getElementById('colorbar-gradient').style.background = colormapGradientCSS();
// Build draggable control points
buildControlPointSpheres(tile);
}
// ---------------------------------------------------------------------------
// 3D control point spheres
// ---------------------------------------------------------------------------
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) {
// Constrain to symmetric DOFs only (match desktop sliders)
if (cpIndex === 0) {
// Only z movement
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) {
// x and z movement
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) {
// Only y movement
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 {
// c4 is fixed
obj.position.set(base[0], base[1], base[2]);
}
} else {
// Free drag (break symmetry) - still map back to nearest params
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;
// Compute transformed tile (tile + transform), matching desktop:
// transform = [[0,0,c1_z], [c2_x,0,c2_z], [0,c3_y,0], [0,0,0]]
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]],
];
// Update draggable red spheres
cpSpheres.forEach((s, i) => {
s.position.set(cp4[i][0], cp4[i][1], cp4[i][2]);
});
// Update mirrored orange dots (all 16 points)
const allCp = mirrorQuarterTile(cp4);
meshRenderer.updateControlPoints(allCp);
}
// ---------------------------------------------------------------------------
// Prediction & view update
// ---------------------------------------------------------------------------
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) {
// Show actual data range (like PyVista desktop), not symmetric
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: ${inference_ms} ms
q range: [${qMin}, ${qMax}]
F range: [${fMin}, ${fMax}]
Edges: ${q.length}
All compression: ${allComp ? 'Yes' : 'No'}
`;
}
// ---------------------------------------------------------------------------
// Render loop
// ---------------------------------------------------------------------------
function animate() {
requestAnimationFrame(animate);
orbitControls.update();
renderer.render(scene, camera);
}
// ---------------------------------------------------------------------------
// VAE Diversity — auto animation matching the desktop designer
// ---------------------------------------------------------------------------
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) { /* no VAE */ }
}
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: 'σq', 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) {
// Compact 4-stop approximation of matplotlib's "turbo" colormap
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';
// Update mesh with the current frame's predicted vertices + q values
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);
// Tint the surface: turbo color ramp for VAE samples, steelblue for final
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);
// Status label
const status = document.getElementById('diversity-status');
if (isFinal) {
status.innerHTML =
`Converged — deterministic FDM prediction | shape error: ${frame.shape_error.toFixed(3)}`;
} else {
status.innerHTML =
`Exploring ${cur + 1}/${diversityTotal - 1} VAE samples — all satisfy mechanical equilibrium`;
}
diversityIdx++;
if (isFinal) {
stopDiversityAnimation();
}
}
// ---------------------------------------------------------------------------
// Plotly chart: design-freedom bars + parallel-coordinates overlay
// ---------------------------------------------------------------------------
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 σq)',
yaxis: 'y',
hoverinfo: 'skip',
};
// Ghost trace: grows as the animation progresses (we append per tick)
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,
};
// Current iteration line (bright red, updates every tick)
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',
};
// Final deterministic line (navy, drawn on last frame)
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,
};
// y2 range derived from all q frames
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: 'σq (population)',
titlefont: { color: '#7a4f00' },
tickfont: { color: '#7a4f00' },
zeroline: false,
gridcolor: '#e4e8ee',
},
yaxis2: {
title: 'Force density qi',
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) {
// Move previous current line into a cumulative ghost trace
const prevData = el.data[2]; // index 2 = current line
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]);
}
// Update current line
Plotly.restyle(el, { y: [qOrdered] }, [2]);
// Hide deterministic until final
Plotly.restyle(el, { y: [new Array(qOrdered.length).fill(null)] }, [3]);
} else {
// Stash the last current line as ghost, clear current, draw deterministic
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();