762 lines
26 KiB
HTML
762 lines
26 KiB
HTML
<!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 += ` ├ <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 += ` │ ${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>
|