initial commit

This commit is contained in:
azykov@mail.ru
2026-05-15 19:23:38 +03:00
commit aa17ecd4c9
42 changed files with 6816 additions and 0 deletions
+761
View File
@@ -0,0 +1,761 @@
<!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>