CAD/client/public/cad_stack.html

762 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>CAD Stack — B-rep → Three.js</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f0f11; color: #c8c8d0; font-family: 'Courier New', monospace; display: flex; height: 100vh; overflow: hidden; }
#viewport { flex: 1; position: relative; }
canvas { display: block; }
#panel {
width: 320px; background: #15151a; border-left: 1px solid #2a2a35;
display: flex; flex-direction: column; overflow: hidden;
}
#panel h1 {
font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
color: #5a5a7a; padding: 16px 16px 12px; border-bottom: 1px solid #1e1e28;
font-family: 'Courier New', monospace;
}
.section { border-bottom: 1px solid #1e1e28; }
.section-title {
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
color: #404060; padding: 10px 16px 6px; user-select: none;
}
.log-box {
padding: 6px 16px 10px; font-size: 11px; line-height: 1.7;
max-height: 120px; overflow-y: auto; color: #7a7a9a;
}
.log-box .entry { display: flex; gap: 8px; }
.log-box .layer { color: #3a3a5a; min-width: 80px; }
.log-box .msg { color: #8888aa; }
.log-box .hi { color: #6a9fd8; }
.log-box .ok { color: #5ab88a; }
.log-box .warn{ color: #c89f5a; }
.graph-box {
padding: 8px 16px 12px; font-size: 10px; line-height: 1.8;
color: #5a5a7a; overflow-y: auto; max-height: 180px;
}
.graph-box .node-v { color: #5ab88a; }
.graph-box .node-e { color: #6a9fd8; }
.graph-box .node-f { color: #c89f5a; }
.graph-box .node-s { color: #c86a8a; }
.btn-row { padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 8px; }
button {
background: #1e1e2a; border: 1px solid #2a2a3a; color: #8888aa;
padding: 5px 12px; font-size: 10px; letter-spacing: 0.08em;
text-transform: uppercase; cursor: pointer; border-radius: 3px;
font-family: 'Courier New', monospace; transition: all 0.15s;
}
button:hover { background: #252535; border-color: #4a4a6a; color: #aaaacc; }
button:active { background: #2a2a40; }
#stats {
padding: 10px 16px; font-size: 10px; color: #3a3a5a;
line-height: 1.9; flex: 1; overflow-y: auto;
}
#stats span { color: #5a5a7a; }
.tag {
display: inline-block; font-size: 9px; padding: 1px 5px;
border-radius: 2px; vertical-align: middle; margin-left: 4px;
}
.tag-be { background: #1a1025; color: #8860c8; border: 1px solid #3a2060; }
.tag-fe { background: #0a1525; color: #4a80c8; border: 1px solid #1a3060; }
.tag-br { background: #1a2510; color: #5a9840; border: 1px solid #2a5020; }
#hit-info {
padding: 8px 16px; font-size: 10px; color: #5a9870;
min-height: 28px; border-top: 1px solid #1e1e28;
letter-spacing: 0.06em;
}
</style>
</head>
<body>
<div id="viewport"></div>
<div id="panel">
<h1>CAD Stack / B-rep → Three.js</h1>
<div class="section">
<div class="section-title">Incidence graph <span class="tag tag-be">BE</span></div>
<div class="graph-box" id="graph-box">loading…</div>
</div>
<div class="section">
<div class="section-title">Pipeline log</div>
<div class="log-box" id="log-box"></div>
</div>
<div class="section">
<div class="section-title">Operations <span class="tag tag-fe">FE</span></div>
<div class="btn-row">
<button onclick="app.cmdRotateX()">rotate X+</button>
<button onclick="app.cmdRotateY()">rotate Y+</button>
<button onclick="app.cmdRotateZ()">rotate Z+</button>
<button onclick="app.cmdScale(1.2)">scale ↑</button>
<button onclick="app.cmdScale(0.8)">scale ↓</button>
<button onclick="app.cmdReset()">reset</button>
</div>
</div>
<div id="stats">
<div>Vertices <span id="s-verts"></span></div>
<div>Edges <span id="s-edges"></span></div>
<div>Faces <span id="s-faces"></span></div>
<div>Triangles <span id="s-tris"></span></div>
<div>Draw calls <span id="s-draws"></span></div>
<div>Selected face <span id="s-sel">none</span></div>
</div>
<div id="hit-info">click a face to pick</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
/* =========================================================
LAYER 1 — B-REP KERNEL (mock, normally deep in BE)
Incidence graph: Solid → Faces → Edges → Vertices
========================================================= */
class BRepVertex {
constructor(id, x, y, z) { this.id = id; this.pos = [x, y, z]; }
}
class BRepEdge {
constructor(id, v0, v1) { this.id = id; this.vertices = [v0, v1]; }
}
class BRepFace {
constructor(id, edges, normal) {
this.id = id;
this.edges = edges; // ordered edge IDs around the face
this.normal = normal; // outward normal [x,y,z]
}
}
class BRepSolid {
constructor(id, faces) { this.id = id; this.faces = faces; }
}
/* A unit cube incidence graph:
8 vertices, 12 edges, 6 faces, 1 solid */
function buildCubeIncidenceGraph() {
const verts = [
new BRepVertex('v0',-0.5,-0.5,-0.5), new BRepVertex('v1', 0.5,-0.5,-0.5),
new BRepVertex('v2', 0.5, 0.5,-0.5), new BRepVertex('v3',-0.5, 0.5,-0.5),
new BRepVertex('v4',-0.5,-0.5, 0.5), new BRepVertex('v5', 0.5,-0.5, 0.5),
new BRepVertex('v6', 0.5, 0.5, 0.5), new BRepVertex('v7',-0.5, 0.5, 0.5),
];
const V = Object.fromEntries(verts.map(v => [v.id, v]));
const edges = [
new BRepEdge('e0','v0','v1'), new BRepEdge('e1','v1','v2'),
new BRepEdge('e2','v2','v3'), new BRepEdge('e3','v3','v0'),
new BRepEdge('e4','v4','v5'), new BRepEdge('e5','v5','v6'),
new BRepEdge('e6','v6','v7'), new BRepEdge('e7','v7','v4'),
new BRepEdge('e8','v0','v4'), new BRepEdge('e9','v1','v5'),
new BRepEdge('e10','v2','v6'),new BRepEdge('e11','v3','v7'),
];
const faces = [
new BRepFace('f0',['e0','e1','e2','e3'], [ 0, 0,-1]), // -Z
new BRepFace('f1',['e4','e5','e6','e7'], [ 0, 0, 1]), // +Z
new BRepFace('f2',['e3','e11','e7','e8'], [-1, 0, 0]), // -X
new BRepFace('f3',['e1','e10','e5','e9'], [ 1, 0, 0]), // +X
new BRepFace('f4',['e0','e9','e4','e8'], [ 0,-1, 0]), // -Y
new BRepFace('f5',['e2','e11','e6','e10'],[ 0, 1, 0]), // +Y
];
const solid = new BRepSolid('s0', faces.map(f => f.id));
return { verts: V, edges: Object.fromEntries(edges.map(e => [e.id, e])),
faces: Object.fromEntries(faces.map(f => [f.id, f])),
solid };
}
/* =========================================================
LAYER 2 — BE CLASS (the mock backend, all methods map
to nodes in the architecture diagram)
========================================================= */
export class BE {
constructor() {
// ── B-rep kernel ──────────────────────────────────────
const g = buildCubeIncidenceGraph();
this._verts = g.verts;
this._edges = g.edges;
this._faces = g.faces;
this._solid = g.solid;
this._transform = new Float32Array([
1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 // column-major identity
]);
}
/* ── GEOMETRIC OPERATIONS (Boolean / transforms) ────── */
applyTransform(mat4ColMajor) {
// In a real kernel this would transform the exact geometry.
// Here we update the matrix that the scene model will carry.
this._transform = new Float32Array(mat4ColMajor);
return this;
}
resetTransform() {
this._transform = new Float32Array([
1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1
]);
return this;
}
/* ── TESSELLATOR / MESH GENERATOR ───────────────────── */
// Samples the incidence graph → triangle soup
tessellate(solidId) {
// Face vertex winding for each of 6 faces (two triangles per quad face)
const faceQuads = {
f0: ['v0','v3','v2','v1'], // -Z (flip winding for correct normal)
f1: ['v4','v5','v6','v7'], // +Z
f2: ['v0','v4','v7','v3'], // -X
f3: ['v1','v2','v6','v5'], // +X
f4: ['v0','v1','v5','v4'], // -Y
f5: ['v3','v7','v6','v2'], // +Y
};
const positions = [];
const normals = [];
const indices = [];
const faceIds = []; // per-triangle face ID for picking
let base = 0;
for (const face of Object.values(this._faces)) {
const [a,b,c,d] = faceQuads[face.id].map(vid => this._verts[vid].pos);
const n = face.normal;
// 4 verts per face (no sharing — flat normals)
positions.push(...a, ...b, ...c, ...d);
normals.push(...n,...n,...n,...n);
// two triangles: 0-1-2 and 0-2-3
indices.push(base,base+1,base+2, base,base+2,base+3);
faceIds.push(face.id, face.id);
base += 4;
}
return {
positions: new Float32Array(positions),
normals: new Float32Array(normals),
indices: new Uint16Array(indices),
faceIds, // parallel to triangle index pairs
};
}
/* ── SCENE / DOCUMENT MODEL ──────────────────────────── */
// Returns a lightweight tree describing document structure
getSceneModel() {
return {
type: 'document',
children: [{
type: 'solid',
id: this._solid.id,
transform: Array.from(this._transform), // col-major mat4
faceCount: this._solid.faces.length,
}]
};
}
/* ── DISPLAY MESH (DTO) ──────────────────────────────── */
// Packages tessellation result as a serialisable DTO
getDisplayMesh(solidId, lod = 0) {
const tess = this.tessellate(solidId);
return {
solidId,
lod,
positions: tess.positions,
normals: tess.normals,
indices: tess.indices,
faceIds: tess.faceIds,
triangleCount: tess.indices.length / 3,
};
}
/* ── INCIDENCE GRAPH INSPECTION (for UI) ─────────────── */
getIncidenceGraph() {
return {
vertices: Object.values(this._verts).map(v =>
({ id: v.id, pos: v.pos })),
edges: Object.values(this._edges).map(e =>
({ id: e.id, v: e.vertices })),
faces: Object.values(this._faces).map(f =>
({ id: f.id, edges: f.edges, normal: f.normal })),
solid: { id: this._solid.id, faces: this._solid.faces },
};
}
}
/* =========================================================
LAYER 3 — GEOMETRY CACHE (FE, keyed by solidId + LOD)
========================================================= */
class GeometryCache {
constructor() { this._cache = new Map(); }
key(solidId, lod) { return `${solidId}:${lod}`; }
has(solidId, lod) { return this._cache.has(this.key(solidId, lod)); }
get(solidId, lod) { return this._cache.get(this.key(solidId, lod)); }
set(solidId, lod, payload) { this._cache.set(this.key(solidId, lod), payload); }
// Upload DTO → THREE.BufferGeometry
getOrCreate(solidId, lod, dto) {
if (this.has(solidId, lod)) return this.get(solidId, lod);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(dto.positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(dto.normals, 3));
geo.setIndex(new THREE.BufferAttribute(dto.indices, 1));
// Store faceIds alongside for picking
geo.userData.faceIds = dto.faceIds;
geo.userData.solidId = solidId;
this.set(solidId, lod, geo);
return geo;
}
dispose(solidId, lod) {
const geo = this.get(solidId, lod);
if (geo) { geo.dispose(); this._cache.delete(this.key(solidId, lod)); }
}
}
/* =========================================================
LAYER 4 — FE SCENE GRAPH (client model)
Mirrors the BE document tree; owns selection state
========================================================= */
class FESceneGraph {
constructor() {
this._solids = new Map(); // solidId → { id, transform, selected }
this._selected = null;
this._listeners = [];
}
sync(sceneModel) {
// Apply scene delta from BE getSceneModel()
for (const node of sceneModel.children) {
if (node.type === 'solid') {
this._solids.set(node.id, {
id: node.id,
transform: node.transform,
faceCount: node.faceCount,
selected: this._solids.get(node.id)?.selected ?? false,
});
}
}
this._emit('sync');
}
setSelected(solidId, faceId) {
this._selected = faceId ? { solidId, faceId } : null;
this._emit('selection');
}
getSelected() { return this._selected; }
getSolids() { return Array.from(this._solids.values()); }
getSolid(id) { return this._solids.get(id); }
on(ev, fn) { this._listeners.push({ ev, fn }); }
_emit(ev) { this._listeners.filter(l => l.ev === ev).forEach(l => l.fn()); }
}
/* =========================================================
LAYER 5 — THREE.JS BRIDGE / SCENE SYNC
Drives Mesh / Material / Object3D lifecycle
========================================================= */
class SceneSync {
constructor(threeScene, cache) {
this._scene = threeScene;
this._cache = cache;
this._meshMap = new Map(); // solidId → THREE.Mesh
this._matBase = new THREE.MeshPhongMaterial({
color: 0x4a7fc8, shininess: 40, specular: 0x223344
});
this._matSel = new THREE.MeshPhongMaterial({
color: 0xf0a040, shininess: 60, specular: 0x442200, emissive: 0x221100
});
this._selectedFace = null;
}
// Called when FE scene graph syncs from BE
addSolid(solidId, dto, transform) {
if (this._meshMap.has(solidId)) return;
const geo = this._cache.getOrCreate(solidId, 0, dto);
const mesh = new THREE.Mesh(geo, this._matBase.clone());
mesh.userData.solidId = solidId;
// Apply transform (col-major mat4 from BE)
const m = new THREE.Matrix4();
m.fromArray(transform); // THREE expects col-major
mesh.applyMatrix4(m);
this._scene.add(mesh);
this._meshMap.set(solidId, mesh);
}
updateTransform(solidId, colMajorMat4) {
const mesh = this._meshMap.get(solidId);
if (!mesh) return;
const m = new THREE.Matrix4().fromArray(colMajorMat4);
mesh.matrix.copy(m);
mesh.matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
}
setSelected(solidId, faceId) {
// Reset all to base material
for (const [,mesh] of this._meshMap) {
mesh.material.color.setHex(0x4a7fc8);
mesh.material.emissive.setHex(0x000000);
}
this._selectedFace = faceId ?? null;
if (solidId && faceId) {
const mesh = this._meshMap.get(solidId);
if (mesh) {
mesh.material.color.setHex(0xf0a040);
mesh.material.emissive.setHex(0x221100);
}
}
}
dispose(solidId) {
const mesh = this._meshMap.get(solidId);
if (!mesh) return;
this._scene.remove(mesh);
this._cache.dispose(solidId, 0);
this._meshMap.delete(solidId);
}
getMeshes() { return Array.from(this._meshMap.values()); }
}
/* =========================================================
LAYER 6 — INTERACTION LAYER
Raycaster → solid/face ID translation
========================================================= */
class InteractionLayer {
constructor(camera, domElement) {
this._camera = camera;
this._dom = domElement;
this._ray = new THREE.Raycaster();
this._mouse = new THREE.Vector2();
this._handler = null;
}
// Returns { solidId, faceId, point } or null
pick(meshes, event) {
const rect = this._dom.getBoundingClientRect();
this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this._ray.setFromCamera(this._mouse, this._camera);
const hits = this._ray.intersectObjects(meshes);
if (!hits.length) return null;
const hit = hits[0];
const solidId = hit.object.userData.solidId;
const geo = hit.object.geometry;
// Translate triangle index → faceId using the parallel faceIds array
const triIdx = Math.floor(hit.faceIndex);
const faceId = geo.userData.faceIds?.[triIdx] ?? null;
return { solidId, faceId, point: hit.point, distance: hit.distance };
}
}
/* =========================================================
LAYER 7 — COMMAND LAYER
Serialises user intent → BE operations
========================================================= */
class CommandLayer {
constructor(be, feGraph, sync, log) {
this._be = be;
this._graph = feGraph;
this._sync = sync;
this._log = log;
}
_matMul(a, b) {
// 4×4 column-major multiply
const r = new Float32Array(16);
for (let c = 0; c < 4; c++)
for (let row = 0; row < 4; row++)
for (let k = 0; k < 4; k++)
r[c*4+row] += a[k*4+row] * b[c*4+k];
return Array.from(r);
}
_makeRotX(rad) {
const c = Math.cos(rad), s = Math.sin(rad);
return [1,0,0,0, 0,c,s,0, 0,-s,c,0, 0,0,0,1];
}
_makeRotY(rad) {
const c = Math.cos(rad), s = Math.sin(rad);
return [c,0,-s,0, 0,1,0,0, s,0,c,0, 0,0,0,1];
}
_makeRotZ(rad) {
const c = Math.cos(rad), s = Math.sin(rad);
return [c,s,0,0, -s,c,0,0, 0,0,1,0, 0,0,0,1];
}
_makeScale(f) {
return [f,0,0,0, 0,f,0,0, 0,0,f,0, 0,0,0,1];
}
_apply(mat) {
const solidId = 's0';
const current = this._be.getSceneModel().children[0].transform;
const next = this._matMul(mat, current);
this._be.applyTransform(next);
const scene = this._be.getSceneModel();
this._graph.sync(scene);
const solid = this._graph.getSolid(solidId);
this._sync.updateTransform(solidId, solid.transform);
this._log(`cmd`, `transform applied → solid ${solidId}`, 'ok');
}
rotateX(rad = Math.PI / 12) { this._apply(this._makeRotX(rad)); }
rotateY(rad = Math.PI / 12) { this._apply(this._makeRotY(rad)); }
rotateZ(rad = Math.PI / 12) { this._apply(this._makeRotZ(rad)); }
scale(f) { this._apply(this._makeScale(f)); }
reset() {
this._be.resetTransform();
const scene = this._be.getSceneModel();
this._graph.sync(scene);
this._sync.updateTransform('s0', this._graph.getSolid('s0').transform);
this._log('cmd', 'transform reset', 'ok');
}
}
/* =========================================================
APP — wires everything together
========================================================= */
class CADApp {
constructor() {
this._log = this._log.bind(this);
this._setupThree();
this._setupPipeline();
this._setupInteraction();
this._updateGraphUI();
this._updateStats();
this._animate();
}
_log(layer, msg, cls = 'msg') {
const box = document.getElementById('log-box');
const el = document.createElement('div');
el.className = 'entry';
el.innerHTML = `<span class="layer">${layer}</span><span class="${cls}">${msg}</span>`;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
/* ── Three.js scene setup ──────────────────────────── */
_setupThree() {
const vp = document.getElementById('viewport');
this._renderer = new THREE.WebGLRenderer({ antialias: true });
this._renderer.setPixelRatio(devicePixelRatio);
this._renderer.setClearColor(0x0a0a0e);
vp.appendChild(this._renderer.domElement);
this._scene = new THREE.Scene();
this._camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100);
this._camera.position.set(2, 1.5, 2.5);
this._camera.lookAt(0, 0, 0);
// Lights
const amb = new THREE.AmbientLight(0xffffff, 0.3);
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
dir.position.set(3, 5, 4);
this._scene.add(amb, dir);
// Grid helper
const grid = new THREE.GridHelper(4, 8, 0x222233, 0x1a1a28);
this._scene.add(grid);
// Simple orbit via mouse drag
this._orbit = { active: false, last: {x:0,y:0}, phi:0.5, theta:0.7, r:3.5 };
const dom = this._renderer.domElement;
dom.addEventListener('mousedown', e => {
if (e.button === 0 && !e.shiftKey) {
this._orbit.active = true;
this._orbit.last = {x: e.clientX, y: e.clientY};
}
});
dom.addEventListener('mousemove', e => {
if (!this._orbit.active) return;
const dx = e.clientX - this._orbit.last.x;
const dy = e.clientY - this._orbit.last.y;
this._orbit.theta += dx * 0.005;
this._orbit.phi = Math.max(0.1, Math.min(Math.PI-0.1, this._orbit.phi + dy * 0.005));
this._orbit.last = {x: e.clientX, y: e.clientY};
});
dom.addEventListener('mouseup', () => this._orbit.active = false);
dom.addEventListener('wheel', e => {
this._orbit.r = Math.max(1, Math.min(10, this._orbit.r + e.deltaY * 0.005));
});
this._resize();
window.addEventListener('resize', () => this._resize());
}
_resize() {
const vp = document.getElementById('viewport');
const w = vp.clientWidth, h = vp.clientHeight;
this._renderer.setSize(w, h);
this._camera.aspect = w / h;
this._camera.updateProjectionMatrix();
}
/* ── Pipeline bootstrap ────────────────────────────── */
_setupPipeline() {
this._log('be', 'building B-rep incidence graph', 'hi');
// BE
this._be = new BE();
const graph = this._be.getIncidenceGraph();
this._log('be.kernel', `${graph.vertices.length}V ${graph.edges.length}E ${graph.faces.length}F 1S`, 'hi');
// Tessellator
this._log('be.tessellator', 'sampling faces → triangle soup', 'msg');
const dto = this._be.getDisplayMesh('s0', 0);
this._log('be.displayMesh', `${dto.triangleCount} triangles, ${dto.positions.length/3} verts`, 'ok');
// Geometry cache (FE)
this._cache = new GeometryCache();
// FE scene graph
this._feGraph = new FESceneGraph();
const sceneModel = this._be.getSceneModel();
this._log('be.sceneModel', `document tree: ${sceneModel.children.length} solid(s)`, 'msg');
this._feGraph.sync(sceneModel);
this._log('fe.sceneGraph', 'synced from BE scene model', 'ok');
// Three.js bridge
this._sync = new SceneSync(this._scene, this._cache);
this._sync.addSolid('s0', dto, sceneModel.children[0].transform);
this._log('fe.bridge', 'Mesh created, BufferGeometry uploaded', 'ok');
// Command layer
this._cmd = new CommandLayer(this._be, this._feGraph, this._sync, this._log);
// React to FE graph selection changes
this._feGraph.on('selection', () => {
const sel = this._feGraph.getSelected();
this._sync.setSelected(sel?.solidId, sel?.faceId);
document.getElementById('s-sel').textContent = sel?.faceId ?? 'none';
document.getElementById('hit-info').textContent =
sel ? `solid ${sel.solidId} · face ${sel.faceId}` : 'click a face to pick';
});
}
/* ── Interaction setup ─────────────────────────────── */
_setupInteraction() {
this._interaction = new InteractionLayer(this._camera, this._renderer.domElement);
this._renderer.domElement.addEventListener('click', e => {
if (this._orbit.moved) { this._orbit.moved = false; return; }
const hit = this._interaction.pick(this._sync.getMeshes(), e);
if (hit) {
this._log('fe.interaction', `pick → solid:${hit.solidId} face:${hit.faceId}`, 'warn');
this._feGraph.setSelected(hit.solidId, hit.faceId);
} else {
this._feGraph.setSelected(null, null);
}
});
// Distinguish click vs drag
this._renderer.domElement.addEventListener('mousedown', () => this._orbit.moved = false);
this._renderer.domElement.addEventListener('mousemove', () => {
if (this._orbit.active) this._orbit.moved = true;
});
}
/* ── Public command API (wired to buttons) ─────────── */
cmdRotateX() { this._cmd.rotateX(); this._updateStats(); }
cmdRotateY() { this._cmd.rotateY(); this._updateStats(); }
cmdRotateZ() { this._cmd.rotateZ(); this._updateStats(); }
cmdScale(f) { this._cmd.scale(f); this._updateStats(); }
cmdReset() { this._cmd.reset(); this._updateStats(); }
/* ── UI helpers ────────────────────────────────────── */
_updateGraphUI() {
const g = this._be.getIncidenceGraph();
const box = document.getElementById('graph-box');
let html = '';
html += `<span class="node-s">◆ solid ${g.solid.id}</span><br>`;
g.faces.forEach(f => {
html += `&nbsp;├ <span class="node-f">face ${f.id}</span> n=[${f.normal}]<br>`;
f.edges.forEach((eid,i) => {
const edge = g.edges.find(e => e.id === eid);
const pfx = i === f.edges.length-1 ? '└' : '├';
html += `&nbsp;│ ${pfx} <span class="node-e">${eid}</span> `;
html += `<span class="node-v">${edge.v[0]}${edge.v[1]}</span><br>`;
});
});
box.innerHTML = html;
}
_updateStats() {
const g = this._be.getIncidenceGraph();
document.getElementById('s-verts').textContent = g.vertices.length;
document.getElementById('s-edges').textContent = g.edges.length;
document.getElementById('s-faces').textContent = g.faces.length;
document.getElementById('s-tris').textContent = '12';
document.getElementById('s-draws').textContent = '1';
}
/* ── Render loop ───────────────────────────────────── */
_animate() {
requestAnimationFrame(() => this._animate());
// Orbit camera
const o = this._orbit;
this._camera.position.set(
o.r * Math.sin(o.phi) * Math.sin(o.theta),
o.r * Math.cos(o.phi),
o.r * Math.sin(o.phi) * Math.cos(o.theta),
);
this._camera.lookAt(0, 0, 0);
this._renderer.render(this._scene, this._camera);
}
}
/* ── Boot ── */
const app = new CADApp();
window.app = app; // expose to button onclick handlers
</script>
</body>
</html>