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

24
client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

22
client/eslint.config.js Normal file
View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
client/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>my-react-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3647
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
client/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "cad",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@types/earcut": "^3.0.0",
"earcut": "^3.0.2",
"mobx": "^6.15.3",
"mobx-react-lite": "^4.1.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sass-embedded": "^1.99.0",
"three": "^0.184.0",
"uuid": "^14.0.0",
"verb-nurbs": "^3.0.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.184.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

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>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
client/public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

46
client/src/App.scss Normal file
View File

@ -0,0 +1,46 @@
#viewport {
width: 600px;
height: 600px;
background: white;
position: absolute;
top: 0px;
left: 0px;
}
#blob-view {
margin-top: 20px;
padding: 10px;
background: #eee;
font-family: monospace;
white-space: pre-wrap;
position: absolute;
top: 0px;
left: 600px;
width: 400px;
text-align: left;
font-size: 10pt;
& .primitive {
border-left: 1px solid #ccc;
// &::before {
// content: "<";
// }
& .title {
& .details {
opacity: 0.5;
}
}
& .indent {
display: inline-block;
padding-left: 1em;
}
}
}

15
client/src/App.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Viewport } from './components/Viewport'
import { DbView } from './components/DbView'
import './App.scss'
export const App = function () {
return (
<div>
<Viewport />
<DbView />
</div>
)
}
export default App

BIN
client/src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

125
client/src/backend/db.ts Normal file
View File

@ -0,0 +1,125 @@
// import { v4 as uuid } from 'uuid';
import type { Edge, Face, HalfEdge, Loop, Primitive, Solid, Surface, Vertex } from "../types/brep";
import { generateCubeBlob } from "./mockCube";
export type DbBlob = {
vertices: Vertex[],
halfEdges: HalfEdge[],
edges: Edge[],
loops: Loop[],
faces: Face[],
surfaces: Surface[],
solids: Solid[],
}
export class Db {
private lastId = 0;
public readonly vertices: Vertex[] = [];
public readonly halfEdges: HalfEdge[] = [];
public readonly edges: Edge[] = [];
public readonly loops: Loop[] = [];
public readonly faces: Face[] = [];
public readonly surfaces: Surface[] = [];
public readonly solids: Solid[] = [];
public get nextId(): string { return (++this.lastId).toString(); };
constructor() {
this.loadFromBlob(generateCubeBlob({ x: 0, y: 0, z: 0 }, 1, () => this.nextId));
}
public loadFromBlobString(blob: string) {
return this.loadFromBlob(JSON.parse(blob));
}
public loadFromBlob(blob: DbBlob) {
this.vertices.splice(0, -1, ...blob.vertices);
this.halfEdges.splice(0, -1, ...blob.halfEdges);
this.edges.splice(0, -1, ...blob.edges);
this.loops.splice(0, -1, ...blob.loops);
this.faces.splice(0, -1, ...blob.faces);
this.surfaces.splice(0, -1, ...blob.surfaces);
this.solids.splice(0, -1, ...blob.solids);
}
public saveToBlob(): DbBlob {
return {
vertices: this.vertices,
halfEdges: this.halfEdges,
edges: this.edges,
loops: this.loops,
faces: this.faces,
surfaces: this.surfaces,
solids: this.solids,
};
}
public saveToString(): string {
return JSON.stringify(this.saveToBlob(), undefined, 4);
}
public get primitives(): Primitive[] {
return [
...this.vertices,
...this.halfEdges,
...this.edges,
...this.loops,
...this.faces,
...this.surfaces,
...this.solids,
];
}
public primitiveById(id: Primitive['id']): Primitive | undefined {
return this.primitives.find((p) => p.id === id);
}
public vertexById(id: Vertex['id']): Vertex | undefined {
return this.vertices.find(v => v.id === id);
}
public halfEdgeById(id: HalfEdge['id']): HalfEdge | undefined {
return this.halfEdges.find(h => h.id === id);
}
public halfEdgesByLoop(loopId: string): HalfEdge[] {
const loop = this.loopById(loopId)!;
const startHalfEdgeId = loop.start;
const halfEdges: HalfEdge[] = [];
const visited = new Set<string>();
let halfEdgeId: string | undefined = startHalfEdgeId;
while (halfEdgeId && !visited.has(halfEdgeId)) {
const halfEdge = this.halfEdgeById(halfEdgeId)! as HalfEdge;
halfEdges.push(halfEdge);
visited.add(halfEdgeId);
halfEdgeId = halfEdge.next;
}
return halfEdges;
}
public edgeById(id: Edge['id']): Edge | undefined {
return this.edges.find(e => e.id === id);
}
public loopById(id: Loop['id']): Loop | undefined {
return this.loops.find(l => l.id === id);
}
public faceById(id: Face['id']): Face | undefined {
return this.faces.find(f => f.id === id);
}
public surfaceById(id: Surface['id']): Surface | undefined {
return this.surfaces.find(s => s.id === id);
}
public solidById(id: Solid['id']): Solid | undefined {
return this.solids.find(s => s.id === id);
}
}
export const db = new Db();

22
client/src/backend/dto.ts Normal file
View File

@ -0,0 +1,22 @@
import type { Mesh, Solid, Surface } from "../types";
export type MeshDto = {
vertices: Float32Array;
normals: Float32Array
indices: Uint16Array;
faceId: Solid['id'];
surfaceId: Surface['id'];
solidId: Solid['id'];
};
export function meshToDto(mesh: Mesh): MeshDto {
return {
vertices: new Float32Array(mesh.vertices.flat()),
normals: new Float32Array(mesh.normals.flat()),
indices: new Uint16Array(mesh.indices.flat()),
faceId: mesh.faceId,
surfaceId: mesh.surfaceId,
solidId: mesh.solidId,
};
}

View File

@ -0,0 +1,34 @@
import type { Face, Mesh, Solid, Surface } from "../../types";
import { db } from "../db";
import { PlaneTessellator } from "./planeTesselator";
export class Geometry {
public static tessellateSolid(solidId: Solid['id']): Mesh[] {
const solid = db.solidById(solidId)!;
return solid.outerSurfaces
.flatMap((surfaceId) =>
Geometry.tessellateSurface(surfaceId)
.map((mesh) => ({ ...mesh, solidId }))
);
}
public static tessellateSurface(surfaceId: Surface['id']): Omit<Mesh, 'solidId'>[] {
const surface = db.surfaceById(surfaceId)!;
return surface.faces
.flatMap((faceId) =>
Geometry.tessellateFace(faceId)
.map((mesh) => ({ ...mesh, surfaceId }))
);
}
public static tessellateFace(faceId: Face['id']): Omit<Mesh, 'surfaceId' | 'solidId'>[] {
const face = db.faceById(faceId)!;
switch (face.kind) {
case 'plane':
return PlaneTessellator.tessellate(face).map((mesh) => ({ ...mesh, faceId: face.id }));
default:
throw new Error(`Unsupported face type: ${face.type}`);
}
}
}

View File

@ -0,0 +1,114 @@
import earcut from 'earcut';
import type { Face, Mesh, V2, V3, Vertex } from "../../types";
import { db } from "../db";
export type Basis3D = { origin: V3; u: V3; v: V3; normal: V3; };
function substract(a: V3, b: V3): V3 {
return [
a[0] - b[0],
a[1] - b[1],
a[2] - b[2],
];
}
function cross(a: V3, b: V3): V3 {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
function normalize(v: V3): V3 {
const len = Math.hypot(v[0], v[1], v[2]);
return [v[0] / len, v[1] / len, v[2] / len];
}
function getNormal(vertices: V3[]): V3 {
const a = vertices[0];
let i = 1;
while (i < vertices.length - 1) {
const ab = substract(vertices[i], a);
const ac = substract(vertices[i + 1], a);
const n = cross(ab, ac);
const len = Math.hypot(n[0], n[1], n[2]);
if (len > 1e-8)
return normalize(n);
i++;
}
throw new Error("Cannot compute normal for a degenerate loop");
}
export class PlaneTessellator {
public static tessellate(face: Face): Omit<Mesh, 'faceId' | 'surfaceId' | 'solidId'>[] {
const vertices3d = PlaneTessellator.getVerticesByLoop(face.outerLoop);
const basis = PlaneTessellator.getBasis(vertices3d);
const vertices2d = PlaneTessellator.projectVertices(vertices3d, basis);
const indices = earcut(vertices2d.flat(), undefined, 2);
return [{
vertices: vertices3d.map((v) => [v.x, v.y, v.z]),
normals: vertices3d.map(() => basis.normal),
indices,
}];
}
private static getVerticesByLoop(loopId: string): Vertex[] {
const halfEdges = db.halfEdgesByLoop(loopId)
.map((he) => ({
halfedge: he,
edge: db.edgeById(he.ownerEdge)!,
}));
if (halfEdges.some((he) => he.edge.kind !== 'line'))
throw new Error(`Loop ${loopId}: only linear half-edges are supported for plane tesselation`);
return halfEdges
.map((he) => db.vertexById(he.halfedge.origin)!);
}
public static projectVertices(vertices: Vertex[], basis: Basis3D): V2[] {
return vertices
.map((vertex) => PlaneTessellator.projectVertex(vertex, basis));
}
public static projectVertex(vertex: Vertex, basis: Basis3D): V2 {
const d = [
vertex.x - basis.origin[0],
vertex.y - basis.origin[1],
vertex.z - basis.origin[2],
];
return [
d[0] * basis.u[0] + d[1] * basis.u[1] + d[2] * basis.u[2],
d[0] * basis.v[0] + d[1] * basis.v[1] + d[2] * basis.v[2],
];
}
public static getBasis(vertices: Vertex[]): Basis3D {
if (vertices.length === 0)
throw new Error("Cannot compute basis for empty vertex list");
const v3ds = vertices.map((v) => [v.x, v.y, v.z] as V3);
const origin = v3ds[0];
const normal = getNormal(v3ds);
const u = normalize(substract(v3ds[1], origin));
const v = normalize(cross(normal, u));
return {
origin,
normal,
u,
v,
}
}
}

View File

@ -0,0 +1,71 @@
// import type { V3 } from "../types";
// import type { Solid } from "../types/brep";
// import { db } from "./db";
// export type Mesh {
// solidId: Solid['id'],
// positions: V3[],
// normals: new Float32Array(normals),
// indices: new Uint16Array(indices),
// faceIds, // parallel to triangle index pairs
// }
// export class MeshService {
// public tesselateSolid(solidId: Solid['id']): Mesh {
// const positions: V3[] = [];
// const normals: V3[] = [];
// const indices: number[] = [];
// const faceIds: string[] = []; // per-triangle face ID for picking
// const solid = db.solids.find(s => s.id === solidId)!;
// const surface = db.surfaces.find(s => s.id === solid.outerSurface[0])!;
// const faces = db.faces.filter(f => surface.faces.includes(f.id));
// for (const face of faces) {
// const loop = db.loops.find(l => l.id === face.outerLoop)!;
// let startHalfEdgeId = loop.start;
// let halfEdgeId = startHalfEdgeId;
// for (let index = 0; ; index++) {
// const halfEdge = db.halfEdges.find(h => h.id === halfEdgeId)!;
// const
// if (!halfEdge.next || halfEdge.next === startHalfEdgeId)
// break;
// halfEdgeId = halfEdge.next;
// }
// // Face vertex winding for each of 6 faces (two triangles per quad face)
// const faceQuads: Record<string, string[]> = {
// '0': ['v0', 'v3', 'v2', 'v1'], // -Z (flip winding for correct normal)
// '1': ['v4', 'v5', 'v6', 'v7'], // +Z
// '2': ['v0', 'v4', 'v7', 'v3'], // -X
// '3': ['v1', 'v2', 'v6', 'v5'], // +X
// '4': ['v0', 'v1', 'v5', 'v4'], // -Y
// '5': ['v3', 'v7', 'v6', 'v2'], // +Y
// };
// let base = 0;
// for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) {
// const face = faces[faceIdx];
// const [a, b, c, d] = faceQuads[faceIdx.toString()].map(vid => 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,
// normals,
// indices,
// faceIds, // parallel to triangle index pairs
// };
// }
// }

155
client/src/backend/mesh.ts Normal file
View File

@ -0,0 +1,155 @@
// import verb from "verb-nurbs";
// import type { V3 } from "../types";
// import type { Loop, Solid, Surface } from "../types/brep";
// import { db } from "./db";
// const verbAny = verb as any;
// export class MeshService {
// public tessellateSolid(solidId: Solid['id']): Mesh {
// const solid = db.solidById(solidId)!;
// return {
// ...this.tessellateSurface(solid.outerSurface[0]),
// solidId,
// }
// }
// public tessellateSurface(surfaceId: Surface['id']): Omit<Mesh, 'solidId'> {
// const surface = db.surfaceById(surfaceId)!;
// const vertices: V3[] = [];
// const normals: V3[] = [];
// const indices: number[] = [];
// for (const faceId of surface.faces) {
// const face = db.faceById(faceId)!;
// const loop = db.loopById(face.outerLoop)!;
// console.dir(loop);
// const boundary = this.getLoopVertices(loop);
// if (boundary.length < 3)
// throw new Error(`Face ${faceId} has boundary with unsupported vertex count: ${boundary.length}`);
// console.dir(boundary);
// const mesh = this.tessellateBoundary(boundary);
// const baseIndex = vertices.length;
// for (const point of mesh.points)
// vertices.push([point[0], point[1], point[2]]);
// for (const normal of mesh.normals)
// normals.push([normal[0], normal[1], normal[2]]);
// for (const face of mesh.faces)
// indices.push(baseIndex + face[0], baseIndex + face[1], baseIndex + face[2]);
// }
// return {
// vertices,
// normals,
// indices,
// surfaceId,
// };
// }
// private getLoopVertices(loop: Loop): V3[] {
// const vertices: V3[] = [];
// let halfEdgeId = loop.start;
// while (halfEdgeId) {
// const halfEdge = db.halfEdgeById(halfEdgeId)!;
// const vertex = db.vertexById(halfEdge.origin)!;
// vertices.push([vertex.x, vertex.y, vertex.z]);
// halfEdgeId = halfEdge.next;
// if (halfEdgeId === loop.start)
// break;
// }
// return vertices;
// }
// private tessellateBoundary(boundary: V3[]): { points: number[][]; normals: number[][]; faces: number[][] } {
// if (boundary.length === 4) {
// return this.tessellateQuadBoundary(boundary);
// }
// return this.tessellatePlanarPolygon(boundary);
// }
// private tessellateQuadBoundary(boundary: V3[]): { points: number[][]; normals: number[][]; faces: number[][] } {
// const surfaceData = this.buildQuadSurfaceData(boundary);
// const node = new verbAny.core.AdaptiveRefinementNode(surfaceData);
// node.divide({ minDepth: 1 });
// return node.triangulate();
// }
// private buildQuadSurfaceData(boundary: V3[]): any {
// const [p0, p1, p2, p3] = boundary;
// const controlPoints = [
// [p0, p3],
// [p1, p2],
// ];
// const weights = [
// [1, 1],
// [1, 1],
// ];
// return {
// knotsU: [0, 0, 1, 1],
// knotsV: [0, 0, 1, 1],
// controlPoints: verbAny.eval.Eval.homogenize2d(controlPoints, weights),
// degreeU: 1,
// degreeV: 1,
// };
// }
// private tessellatePlanarPolygon(boundary: V3[]): { points: number[][]; normals: number[][]; faces: number[][] } {
// const points = boundary.map((pt) => [pt[0], pt[1], pt[2]]);
// const normal = this.computePolygonNormal(points);
// const faces: number[][] = [];
// for (let i = 1; i < points.length - 1; i += 1) {
// faces.push([0, i, i + 1]);
// }
// return {
// points,
// normals: points.map(() => normal),
// faces,
// };
// }
// private computePolygonNormal(points: number[][]): number[] {
// if (points.length < 3) {
// return [0, 0, 0];
// }
// let nx = 0;
// let ny = 0;
// let nz = 0;
// for (let i = 0; i < points.length; i += 1) {
// const [x1, y1, z1] = points[i];
// const [x2, y2, z2] = points[(i + 1) % points.length];
// nx += (y1 - y2) * (z1 + z2);
// ny += (z1 - z2) * (x1 + x2);
// nz += (x1 - x2) * (y1 + y2);
// }
// const length = Math.hypot(nx, ny, nz);
// if (length === 0) {
// return [0, 0, 1];
// }
// return [nx / length, ny / length, nz / length];
// }
// }
// export const meshService = new MeshService();

View File

@ -0,0 +1,164 @@
// import type { Vertex, HalfEdge, Edge, Loop, Face } from "../types/brep";
// import type { DbBlob } from "./db";
// export function generateEmptyBlob(): DbBlob {
// return {
// vertices: [],
// halfEdges: [],
// edges: [],
// loops: [],
// faces: [],
// surfaces: [],
// solids: [],
// };
// }
// export function generateCubeBlob(pos: { x: number; y: number; z: number; }, size: number, nextId: () => string): DbBlob {
// function makeVertex(x: number, y: number, z: number): Vertex {
// return {
// id: nextId(),
// x: pos.x + x,
// y: pos.y + y,
// z: pos.z + z,
// }
// };
// const vertices = [
// makeVertex(0, 0, 0),
// makeVertex(size, 0, 0),
// makeVertex(size, size, 0),
// makeVertex(0, size, 0),
// makeVertex(0, 0, size),
// makeVertex(size, 0, size),
// makeVertex(size, size, size),
// makeVertex(0, size, size),
// ];
// const halfEdges: HalfEdge[] = [];
// const edges: Edge[] = [];
// const loops: Loop[] = [];
// const faces: Face[] = [];
// const faceVertexIndices = [
// [0, 1, 2, 3], // bottom
// [4, 7, 6, 5], // top
// [0, 4, 5, 1], // front
// [1, 5, 6, 2], // right
// [2, 6, 7, 3], // back
// [3, 7, 4, 0], // left
// ];
// const edgeMap = new Map<string, { edgeId: string; halfEdgeIds: string[]; }>();
// const vertexOwnerHalfEdge = new Map<string, string>();
// const halfEdgeById = new Map<string, HalfEdge>();
// for (let faceIndex = 0; faceIndex < 1 /* faceVertexIndices.length */; faceIndex++) {
// const vertexIndices = faceVertexIndices[faceIndex];
// const loopId = nextId();
// const faceId = nextId();
// const faceHalfEdgeIds: string[] = [];
// for (const vertexIndex of vertexIndices) {
// const halfEdgeId = nextId();
// const origin = vertices[vertexIndex].id;
// const halfEdge: HalfEdge = {
// id: halfEdgeId,
// origin,
// twin: '',
// ownerEdge: '',
// };
// halfEdges.push(halfEdge);
// halfEdgeById.set(halfEdgeId, halfEdge);
// faceHalfEdgeIds.push(halfEdgeId);
// if (!vertexOwnerHalfEdge.has(origin)) {
// vertexOwnerHalfEdge.set(origin, halfEdgeId);
// }
// }
// for (let index = 0; index < vertexIndices.length; index++) {
// const currentVertexIndex = vertexIndices[index];
// const nextVertexIndex = vertexIndices[(index + 1) % vertexIndices.length];
// const currentHalfEdgeId = faceHalfEdgeIds[index];
// const currentHalfEdge = halfEdgeById.get(currentHalfEdgeId)!;
// const edgeKey = `${Math.min(currentVertexIndex, nextVertexIndex)}-${Math.max(currentVertexIndex, nextVertexIndex)}`;
// const existing = edgeMap.get(edgeKey);
// if (existing) {
// existing.halfEdgeIds.push(currentHalfEdgeId);
// const edge = edges.find((item) => item.id === existing.edgeId)!;
// edge.b = currentHalfEdgeId;
// currentHalfEdge.ownerEdge = existing.edgeId;
// } else {
// const edgeId = nextId();
// edges.push({
// id: edgeId,
// a: currentHalfEdgeId,
// b: '',
// ownerLoop: loopId,
// });
// edgeMap.set(edgeKey, { edgeId, halfEdgeIds: [currentHalfEdgeId] });
// currentHalfEdge.ownerEdge = edgeId;
// }
// currentHalfEdge.next = faceHalfEdgeIds[(index + 1) % faceHalfEdgeIds.length];
// currentHalfEdge.prev = faceHalfEdgeIds[(index + faceHalfEdgeIds.length - 1) % faceHalfEdgeIds.length];
// }
// loops.push({
// id: loopId,
// start: faceHalfEdgeIds[0],
// ownerFace: faceId,
// });
// faces.push({
// id: faceId,
// outerLoop: loopId,
// holes: [],
// ownerSurface: '',
// });
// }
// for (const { halfEdgeIds } of edgeMap.values()) {
// if (halfEdgeIds.length !== 2) {
// throw new Error('Cube generation failed: expected exactly two halfedges per edge.');
// }
// const [firstHalfEdgeId, secondHalfEdgeId] = halfEdgeIds;
// const firstHalfEdge = halfEdgeById.get(firstHalfEdgeId)!;
// const secondHalfEdge = halfEdgeById.get(secondHalfEdgeId)!;
// firstHalfEdge.twin = secondHalfEdgeId;
// secondHalfEdge.twin = firstHalfEdgeId;
// }
// const verticesWithOwner = vertices.map((vertex) => ({
// ...vertex,
// ownerHalfEdge: vertexOwnerHalfEdge.get(vertex.id),
// }));
// const surfaceId = nextId();
// const solidId = nextId();
// faces.forEach(face => face.ownerSurface = surfaceId);
// const surface = {
// id: surfaceId,
// faces: faces.map(f => f.id),
// };
// return {
// vertices: verticesWithOwner,
// halfEdges,
// edges,
// loops,
// faces,
// surfaces: [surface],
// solids: [{
// id: solidId,
// outerSurface: [surfaceId],
// }],
// };
// }

View File

@ -0,0 +1,152 @@
import type { Vertex, HalfEdge, Edge } from "../types/brep";
import type { DbBlob } from "./db";
export function generateEmptyBlob(): DbBlob {
return {
vertices: [],
halfEdges: [],
edges: [],
loops: [],
faces: [],
surfaces: [],
solids: [],
};
}
export function generateCubeBlob(pos: { x: number; y: number; z: number; }, size: number, nextId: () => string): DbBlob {
function makeVertex(x: number, y: number, z: number): Vertex {
return {
id: nextId(),
type: 'vertex' as const,
x: pos.x + x,
y: pos.y + y,
z: pos.z + z,
ownerHalfEdges: [],
}
};
const halfEdgeIds = Array.from({ length: 24 }, () => nextId());
const edgeIds = Array.from({ length: 12 }, () => nextId());
const loopIds = Array.from({ length: 6 }, () => nextId());
const faceIds = Array.from({ length: 6 }, () => nextId());
const vertices: Vertex[] = [
makeVertex(0, 0, 0),
makeVertex(0, size, 0),
makeVertex(size, size, 0),
makeVertex(size, 0, 0),
makeVertex(0, 0, size),
makeVertex(0, size, size),
makeVertex(size, size, size),
makeVertex(size, 0, size),
];
const halfEdges: HalfEdge[] = [
// { origin: 0, twin: undefined, next: 1, prev: 3, edge: 0, },
// { origin: 1, twin: undefined, next: 2, prev: 0, edge: 1, },
// { origin: 2, twin: undefined, next: 3, prev: 1, edge: 2, },
// { origin: 3, twin: undefined, next: 0, prev: 2, edge: 3, },
// bottom, 0..3
{ origin: 0, twin: 7, next: 1, prev: 3, edge: 0, },
{ origin: 1, twin: 11, next: 2, prev: 0, edge: 1, },
{ origin: 2, twin: 12, next: 3, prev: 1, edge: 2, },
{ origin: 3, twin: 16, next: 0, prev: 2, edge: 3, },
// left, 4..7
{ origin: 0, twin: 19, next: 5, prev: 7, edge: 4, },
{ origin: 4, twin: 23, next: 6, prev: 4, edge: 5, },
{ origin: 5, twin: 8, next: 7, prev: 5, edge: 6, },
{ origin: 1, twin: 0, next: 4, prev: 6, edge: 0, },
// back, 8..11
{ origin: 1, twin: 6, next: 9, prev: 11, edge: 6, },
{ origin: 5, twin: 22, next: 10, prev: 8, edge: 7, },
{ origin: 6, twin: 13, next: 11, prev: 9, edge: 8, },
{ origin: 2, twin: 1, next: 8, prev: 10, edge: 1, },
// right, 12..15
{ origin: 3, twin: 2, next: 13, prev: 15, edge: 2, },
{ origin: 2, twin: 10, next: 14, prev: 12, edge: 8, },
{ origin: 6, twin: 21, next: 15, prev: 13, edge: 9, },
{ origin: 7, twin: 17, next: 12, prev: 14, edge: 10, },
// front, 16..19
{ origin: 0, twin: 3, next: 17, prev: 19, edge: 3, },
{ origin: 3, twin: 15, next: 18, prev: 16, edge: 10, },
{ origin: 7, twin: 20, next: 19, prev: 17, edge: 11, },
{ origin: 4, twin: 4, next: 16, prev: 18, edge: 4, },
// top, 20..23
{ origin: 4, twin: 18, next: 21, prev: 23, edge: 11, },
{ origin: 7, twin: 14, next: 22, prev: 20, edge: 9, },
{ origin: 6, twin: 9, next: 23, prev: 21, edge: 7, },
{ origin: 5, twin: 5, next: 20, prev: 22, edge: 5, },
]
.map((he, idx) => {
const id = halfEdgeIds[idx];
const origin = vertices[he.origin].id;
vertices.find((v) => v.id === origin)!.ownerHalfEdges.push(id);
return {
id,
type: 'halfedge' as const,
origin,
twin: halfEdgeIds[he.twin],
next: halfEdgeIds[he.next],
prev: halfEdgeIds[he.prev],
ownerLoop: loopIds[Math.floor(idx / 4)],
ownerEdge: edgeIds[he.edge],
};
});
const edges: Edge[] = edgeIds.map((id, idx) => ({
id: edgeIds[idx],
type: 'edge' as const,
kind: 'line',
a: halfEdges.find((he) => he.ownerEdge === id)!.id,
b: halfEdges.find((he) => he.ownerEdge === id)!.twin,
}));
const loops = loopIds.map((id, idx) => ({
id: id,
type: 'loop' as const,
start: halfEdgeIds[idx * 4],
ownerFace: faceIds[idx],
}));
const surfaceId = nextId();
const solidId = nextId();
const faces = loops.map((loop, idx) => ({
id: faceIds[idx],
type: 'face' as const,
kind: 'plane' as const,
outerLoop: loop.id,
holes: [],
ownerSurface: surfaceId,
}));
const surface = {
id: surfaceId,
type: 'surface' as const,
kind: 'plane' as const,
faces: faces.map(f => f.id),
};
return {
vertices,
halfEdges,
edges,
loops,
faces,
surfaces: [surface],
solids: [{
id: solidId,
type: 'solid' as const,
outerSurfaces: [surfaceId],
}],
};
}

View File

@ -0,0 +1,28 @@
export type SceneSolidObject = {
type: 'solid',
}
export type SceneObject = SceneSolidObject;
export type Scene = {
objects: SceneObject[],
}
export class SceneBackend {
public async fetch(): Promise<Scene> {
return new Promise((res) => {
setTimeout(async () => {
res(await this.makeMockupScene());
}, 1000);
});
}
public async makeMockupScene(): Promise<Scene> {
return {
objects: [
],
}
}
}

View File

@ -0,0 +1,30 @@
import { useState } from "react";
import { db } from "../backend/db";
import { PrimitiveView } from "./PrimitiveView";
import type { Primitive } from "../types";
export const DbView = function () {
const [selectedPrimitiveId, setSelectedPrimitiveId] = useState<string>('');
let selectedPrimitives: Primitive[] = [];
if (selectedPrimitiveId) {
const primitive = db.primitiveById(selectedPrimitiveId);
if (primitive)
selectedPrimitives = [primitive];
}
else
selectedPrimitives = db.solids;
return (
<div id="blob-view">
<div>
<input type="text" placeholder="Search..." value={selectedPrimitiveId} onChange={(e) => setSelectedPrimitiveId(e.target.value)} />
</div>
<div>
{
selectedPrimitives.map(p => <PrimitiveView key={p.id} primitive={p} />)
}
</div>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { db } from "../backend/db";
import type { Face, HalfEdge, Primitive, Solid, Vertex } from "../types";
export const PrimitiveViewDetails = function ({ primitive }: { primitive: Primitive }) {
function render(): string | undefined {
switch (primitive.type) {
case 'face':
const s = primitive as Face;
return `${s.type}`;
case 'halfedge':
const he = primitive as HalfEdge;
return `origin vertex: ${he.origin}, ${he.twin ? `twin: ${he.twin}, ` : ''}next: ${he.next}, prev: ${he.prev}`;
case 'vertex':
const v = primitive as Vertex;
return `x: ${v.x}, y: ${v.y}, z: ${v.z}`;
default:
return undefined;
}
}
const text = render();
if (!text)
return undefined;
else
return <span className="details">{text}</span>;
}
export const PrimitiveView = function ({ primitive }: { primitive: Primitive }) {
// function getChildType(): string | undefined {
// switch (primitive.type) {
// case 'solid': return 'surface';
// case 'surface': return 'face';
// case 'face': return 'loop';
// case 'loop': return 'halfedge';
// case 'halfedge': return 'vertex';
// default: return undefined;
// }
// }
// const childType = getChildType();
function getChildren(): Primitive[] {
switch (primitive.type) {
case 'solid':
return (primitive as Solid).outerSurfaces.map((sid) => db.surfaceById(sid)!);
case 'surface':
return db.faces.filter(f => f.ownerSurface === primitive.id);
case 'face':
return db.loops.filter(l => l.ownerFace === primitive.id);
case 'loop':
return db.halfEdges.filter(e => e.ownerLoop === primitive.id);
case 'halfedge':
return [
...db.edges.filter(e => e.a === primitive.id || e.b === primitive.id),
...db.vertices.filter(v => v.ownerHalfEdges.includes(primitive.id)),
];
default:
return [];
}
}
const children = getChildren();
return (
<div className={'primitive ' + primitive.type}>
<div className="title">{primitive.type} {primitive.id} <PrimitiveViewDetails primitive={primitive} /></div>
{
children.length
? <div className="indent">
{
children.map(child => <PrimitiveView key={child.id} primitive={child} />)
}
</div>
: <></>
}
</div>
)
}

View File

@ -0,0 +1,193 @@
import { useEffect, useRef } from "react";
import * as THREE from 'three';
import { useInteraction, type InteractionMouseMoveEventArgs } from "../helpers/useInteration";
import { SceneSync } from "../layers/sceneSync";
import { GeometryCache } from "../layers/geometryCache";
import { meshToDto } from "../backend/dto";
import { db } from "../backend/db";
import { NURBSBuilder } from "../verb/NURBSBuilder";
import { MeshService } from "../verb/meshService";
import { Geometry } from "../backend/geometry/geometry";
export type ViewportProps = {
onHover?: (faceIds: string[]) => void;
}
function setupScene(size: { w: number, h: number }): { scene: THREE.Scene, camera: THREE.PerspectiveCamera } {
// --- Scene & Camera ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a12);
const camera = new THREE.PerspectiveCamera(55, size.w / size.h, 0.1, 100);
camera.position.set(4, 3, 6);
// --- Lights ---
scene.add(new THREE.AmbientLight(0xffffff, 0.3));
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
dir.position.set(5, 8, 5);
dir.castShadow = true;
scene.add(dir);
const pt = new THREE.PointLight(0x5588ff, 1.5, 20);
pt.position.set(-3, 2, -3);
scene.add(pt);
// --- Floor & Grid ---
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(14, 14),
new THREE.MeshStandardMaterial({ color: 0x080810 })
);
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;
// scene.add(plane);
// scene.add(new THREE.GridHelper(14, 14, 0x222233, 0x111122));
return { scene, camera };
}
function initializeSceneObjects(scene: THREE.Scene): { positions: THREE.Vector3[], meshes: THREE.Mesh[], geometries: THREE.BufferGeometry[], materials: THREE.Material[] } {
const materials = [
new THREE.MeshStandardMaterial({ color: 0x5f77dd, roughness: 0.3, metalness: 0.4 }),
new THREE.MeshStandardMaterial({ color: 0xdd775f, roughness: 0.5, metalness: 0.1 }),
new THREE.MeshStandardMaterial({ color: 0x5fdd99, roughness: 0.2, metalness: 0.6 }),
new THREE.MeshStandardMaterial({ color: 0xddcc5f, roughness: 0.4, metalness: 0.2 }),
];
const geometries = [
new THREE.BoxGeometry(1, 1, 1),
new THREE.SphereGeometry(0.6, 32, 32),
new THREE.TorusGeometry(0.5, 0.2, 16, 48),
new THREE.ConeGeometry(0.5, 1.2, 32),
new THREE.CylinderGeometry(0.3, 0.5, 1.2, 32),
new THREE.OctahedronGeometry(0.65),
];
const positions: [number, number, number][] = [
[0, 0.5, 0], [2.5, 0.6, 0], [-2.5, 0.6, 0],
[0, 0.6, 2.5], [2.5, 0.6, -2.5], [-2.5, 0.6, 2.5],
];
const meshes = geometries.map((geo, i) => {
const mesh = new THREE.Mesh(geo, materials[i % materials.length]);
mesh.position.set(...positions[i]);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
return mesh;
});
return { positions: positions.map(([x, y, z]) => new THREE.Vector3().set(x, y, z)), meshes, geometries, materials };
}
export const Viewport = function (props: ViewportProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
// let hoveredMesh: THREE.Object3D | null = null;
let handleHover: (e: InteractionMouseMoveEventArgs) => void;
useEffect(() => {
const container = viewportRef.current!;
const W = container.clientWidth;
const H = container.clientHeight;
// --- Renderer ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(W, H);
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
canvasRef.current = renderer.domElement;
const { scene, camera } = setupScene({ w: W, h: H });
cameraRef.current = camera;
const cache = new GeometryCache();
const sync = new SceneSync(scene, cache);
const solid = db.solids[0];
const meshes = Geometry
.tessellateSolid(solid.id);
meshes
.forEach((mesh) => {
console.log(meshes);
const dto = meshToDto(mesh);
sync.addSolid(dto);
});
// sync.setSelected(['48']);
// const { positions, meshes, geometries, materials } = initializeSceneObjects(scene);
// --- Resize handler ---
const onResize = () => {
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener("resize", onResize);
// --- Hover highlight ---
const raycaster = new THREE.Raycaster();
handleHover = (e: InteractionMouseMoveEventArgs) => {
raycaster.setFromCamera(new THREE.Vector2(e.x, e.y), camera);
const hits = raycaster.intersectObjects(sync.meshes);
const hoveredFaceIds = hits.map(hit => hit.object.userData.faceId);
if (hoveredFaceIds.length)
console.log(hoveredFaceIds);
props.onHover?.(hoveredFaceIds);
sync.setSelected(hoveredFaceIds);
};
// --- Animation loop ---
let lastTime = performance.now();
let animId: number;
function animate(time: DOMHighResTimeStamp) {
animId = requestAnimationFrame(animate);
// const deltaTime = lastTime ? time - lastTime : 0;
lastTime = time;
// meshes.forEach((m, i) => {
// m.rotation.y += 0.006 + i * 0.001;
// m.rotation.x += 0.003;
// if (m !== hoveredMesh) {
// m.position.y = positions[i].y + Math.sin(t + i * 1.1) * 0.15;
// }
// });
renderer.render(scene, camera);
}
animId = requestAnimationFrame(animate);
// --- Cleanup ---
return () => {
if (animId)
cancelAnimationFrame(animId);
container.removeChild(renderer.domElement);
window.removeEventListener("resize", onResize);
renderer.dispose();
sync.dispose();
};
}, []);
useInteraction(canvasRef, cameraRef, {
onMouseMove: (e) => handleHover?.(e),
});
return (
<div id="viewport" ref={viewportRef}>
</div>
)
}

View File

@ -0,0 +1,94 @@
import { useEffect, type RefObject } from "react";
import * as THREE from "three";
export type InteractionMouseMoveEventArgs = { x: number, y: number };
export type UseInteractionOptions = {
onMouseMove?: (position: InteractionMouseMoveEventArgs) => void,
}
export function useInteraction(
targetRef: RefObject<HTMLCanvasElement | null>,
cameraRef: RefObject<THREE.PerspectiveCamera | null>,
options: UseInteractionOptions,
): void {
useEffect(
() => {
const target = targetRef.current!;
const camera = cameraRef.current!;
const onWheel = (e: WheelEvent) => {
radius = Math.max(2, Math.min(20, radius + e.deltaY * 0.02));
updateCamera();
e.preventDefault();
};
// --- Orbit Controls (manual implementation, no OrbitControls import needed) ---
let isDragging = false;
let isRightDrag = false;
let lastX = 0, lastY = 0;
let theta = 0.8, phi = 0.9, radius = 8;
let targetX = 0, targetY = 0;
function updateCamera() {
camera.position.x = targetX + radius * Math.sin(phi) * Math.sin(theta);
camera.position.y = radius * Math.cos(phi);
camera.position.z = targetY + radius * Math.sin(phi) * Math.cos(theta);
camera.lookAt(targetX, 0, targetY);
}
updateCamera();
const onMouseDown = (e: MouseEvent) => {
isDragging = true;
isRightDrag = e.button === 2;
lastX = e.clientX;
lastY = e.clientY;
e.preventDefault();
};
const onMouseUp = () => { isDragging = false; };
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
if (isRightDrag) {
targetX -= dx * 0.01;
targetY += dy * 0.01;
} else {
theta -= dx * 0.005;
phi = Math.max(0.15, Math.min(Math.PI - 0.15, phi - dy * 0.005));
}
updateCamera();
};
const onHover = (e: MouseEvent) => {
const rect = target.getBoundingClientRect();
const pos = {
x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
y: -((e.clientY - rect.top) / rect.height) * 2 + 1,
};
options.onMouseMove?.(pos);
};
const onContextMenu = (e: Event) => e.preventDefault();
target.addEventListener("mousedown", onMouseDown);
target.addEventListener("contextmenu", onContextMenu);
target.addEventListener("wheel", onWheel, { passive: false });
target.addEventListener("mousemove", onHover);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
target.removeEventListener("mousedown", onMouseDown);
target.removeEventListener("contextmenu", onContextMenu);
target.removeEventListener("wheel", onWheel);
target.removeEventListener("mousemove", onHover);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
}
});
}

111
client/src/index.css Normal file
View File

@ -0,0 +1,111 @@
/* :root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
} */

View File

@ -0,0 +1,50 @@
import * as THREE from 'three';
import type { MeshDto } from '../backend/dto';
export type Id = string;
export class GeometryCache {
private readonly _cache = new Map<string, THREE.BufferGeometry>();
private key(solidId: Id, lod: number) {
return `${solidId}:${lod}`;
}
public has(solidId: Id, lod: number): boolean {
return this._cache.has(this.key(solidId, lod));
}
public get(solidId: Id, lod: number): THREE.BufferGeometry | undefined {
return this._cache.get(this.key(solidId, lod));
}
public set(solidId: Id, lod: number, payload: THREE.BufferGeometry) {
this._cache.set(this.key(solidId, lod), payload);
}
public getOrCreate(solidId: Id, lod: number, dto: MeshDto): THREE.BufferGeometry {
let geometry = this.get(solidId, lod);
if (geometry)
return geometry;
geometry = new THREE.BufferGeometry();
geometry.userData.solidId = solidId;
// geometry.userData.faceIds = dto.faceIds;
geometry.setAttribute('position', new THREE.BufferAttribute(dto.vertices, 3));
geometry.setAttribute('normal', new THREE.BufferAttribute(dto.normals, 3));
geometry.setIndex(new THREE.BufferAttribute(dto.indices, 1));
this.set(solidId, lod, geometry);
return geometry;
}
unset(solidId: Id, lod: number) {
const geometry = this.get(solidId, lod);
if (geometry) {
geometry.dispose();
this._cache.delete(this.key(solidId, lod));
}
}
}

View File

@ -0,0 +1,104 @@
import * as THREE from 'three';
import type { GeometryCache, Id } from './geometryCache';
import type { MeshDto } from '../backend/dto';
export class SceneSync {
private scene: THREE.Scene;
private meshByFace: Map<Id, THREE.Mesh>;
private cache: GeometryCache;
private _selectedFaceIds: Id[] = [];
private readonly baseMaterial = new THREE.MeshPhongMaterial({
color: 0x4a7fc8, shininess: 40, specular: 0x223344, wireframe: false,
});
constructor(scene: THREE.Scene, cache: GeometryCache) {
this.scene = scene;
this.cache = cache;
this.meshByFace = new Map(); // faceId → THREE.Mesh
}
public get selectedFaceIds() {
return this._selectedFaceIds;
}
public get meshes(): THREE.Mesh[] {
return Array.from(this.meshByFace.values());
}
// Called when FE scene graph syncs from BE
addSolid(dto: MeshDto) {
const faceId = dto.faceId;
const transform = new Float32Array([
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1
]);
if (this.meshByFace.has(faceId))
return;
const geo = this.cache.getOrCreate(faceId, 0, dto);
const mesh = new THREE.Mesh(geo, this.baseMaterial.clone());
mesh.userData.faceId = faceId;
// 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.meshByFace.set(faceId, mesh);
}
updateTransform(faceId: Id, colMajorMat4: number[]) {
const mesh = this.meshByFace.get(faceId);
if (!mesh) return;
const m = new THREE.Matrix4().fromArray(colMajorMat4);
mesh.matrix.copy(m);
mesh.matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
}
setSelected(faceIds: Id[]) {
this._selectedFaceIds = faceIds;
for (const [sid, mesh] of this.meshByFace) {
const mat = mesh.material as THREE.MeshPhongMaterial;
if (faceIds.includes(sid)) {
mat.color.setHex(0xf0a040);
mat.emissive.setHex(0x221100);
}
else {
mat.color.setHex(0x4a7fc8);
mat.emissive.setHex(0x000000);
}
}
}
public disposeMesh(solidId: Id) {
const mesh = this.meshByFace.get(solidId);
if (!mesh)
return;
this.scene.remove(mesh);
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) {
for (const mat of mesh.material)
mat.dispose();
}
else
mesh.material.dispose();
this.cache.unset(solidId, 0);
this.meshByFace.delete(solidId);
}
public dispose() {
for (const solidId of this.meshByFace.keys())
this.disposeMesh(solidId);
this.baseMaterial.dispose();
}
}

10
client/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

17
client/src/state/root.ts Normal file
View File

@ -0,0 +1,17 @@
import { makeAutoObservable } from "mobx";
import type { Id } from "../types";
export class Root {
public selectedPrimitiveIds: Id[] = [];
constructor() {
makeAutoObservable(this);
}
public setSelectedPrimitiveIds(value: Id[]) {
this.selectedPrimitiveIds = value;
}
}
export const state = new Root();

52
client/src/types/brep.ts Normal file
View File

@ -0,0 +1,52 @@
export type Id = string;
export type PrimitiveType = 'vertex' | 'halfedge' | 'edge' | 'loop' | 'face' | 'surface' | 'solid';
export type Primitive<T extends PrimitiveType = PrimitiveType> = {
id: Id,
type: T,
version?: number;
}
export type Vertex = Primitive<'vertex'> & {
x: number,
y: number,
z: number,
ownerHalfEdges: HalfEdge['id'][],
}
export type HalfEdge = Primitive<'halfedge'> & {
origin: Vertex['id'],
twin?: HalfEdge['id'],
next: HalfEdge['id'], // next in loop
prev: HalfEdge['id'], // prev in loop
ownerLoop: Loop['id'],
ownerEdge: Edge['id'],
}
export type Edge = Primitive<'edge'> & {
kind: 'line',
a?: HalfEdge['id'],
b?: HalfEdge['id'],
}
export type Loop = Primitive<'loop'> & {
start: HalfEdge['id'],
ownerFace: Face['id'],
}
export type Face = Primitive<'face'> & {
kind: 'plane',
outerLoop: Loop['id'],
holes: Loop['id'][],
ownerSurface: Surface['id'],
}
export type Surface = Primitive<'surface'> & {
faces: Face['id'][],
}
export type Solid = Primitive<'solid'> & {
outerSurfaces: Surface['id'][],
// holes: Surface['id'][],
}

View File

@ -0,0 +1,50 @@
export type Id = string;
export type Primitive = {
id: Id,
version?: number;
}
export type Vertex = Primitive & {
x: number,
y: number,
z: number,
ownerHalfEdgeA?: HalfEdge['id'],
ownerHalfEdgeB?: HalfEdge['id'],
}
export type HalfEdge = Primitive & {
origin: Vertex['id'],
twin?: HalfEdge['id'],
next: HalfEdge['id'], // next in loop
prev: HalfEdge['id'], // prev in loop
ownerLoop: Loop['id'],
// ownerEdge: Edge['id'],
}
// export type Edge = Primitive & {
// a: HalfEdge['id'],
// b: HalfEdge['id'],
// // crease: boolean, // sharp vs smooth
// ownerLoop: Loop['id'],
// }
export type Loop = Primitive & {
start: HalfEdge['id'],
ownerFace: Face['id'],
}
export type Face = Primitive & {
outerLoop: Loop['id'],
holes: Loop['id'][],
ownerSurface: Surface['id'],
}
export type Surface = Primitive & {
faces: Face['id'][],
}
export type Solid = Primitive & {
outerSurface: Surface['id'][],
// holes: Surface['id'][],
}

View File

@ -0,0 +1,15 @@
import type { Face, Solid, Surface } from "./brep";
export type V2 = [x: number, y: number];
export type V3 = [x: number, y: number, z: number];
// export type V3 = [number, number, number];
export type Mesh = {
vertices: V3[];
normals: V3[];
indices: number[];
faceId: Face['id'];
surfaceId: Surface['id'];
solidId: Solid['id'];
};

View File

@ -0,0 +1,3 @@
export * from './brep';
export * from './types';
export * from './geometry';

View File

View File

@ -0,0 +1,433 @@
import verb from "verb-nurbs";
import { db } from "../backend/db";
import type { Loop } from "../types";
const verbAny = verb as any;
type BoundaryLoop = {
vertices: Point3D[],
orientation: 'cw' | 'ccw',
};
export type Point3D = [number, number, number];
// export interface NURBSSurface {
// degreeU: number;
// degreeV: number;
// knotsU: number[];
// knotsV: number[];
// controlPoints: Point3D[][];
// weights: number[][];
// }
export class NURBSBuilder {
public static buildSurfaceFromHalfEdgeLoop(
loop: Loop,
degreeU: number = 1,
degreeV: number = 1,
resolutionU: number = 1,
resolutionV: number = 1
) {
// Extract boundary loop from half-edges
const boundaryLoop = this.extractBoundaryLoop(loop);
// Generate control points using Coons patch or lofting
const controlPoints = this.generateControlPointsFromLoop(
boundaryLoop,
resolutionU,
resolutionV
);
console.dir(boundaryLoop);
console.dir(controlPoints);
// Generate knot vectors
const knotsU = this.generateKnots(controlPoints.length, degreeU);
const knotsV = this.generateKnots(controlPoints[0].length, degreeV);
console.dir(knotsU);
console.dir(knotsV);
return verbAny.geom.NurbsSurface.byKnotsControlPointsWeights(
degreeU,
degreeV,
knotsU,
knotsV,
controlPoints,
);
}
private static extractBoundaryLoop(loop: Loop): BoundaryLoop {
const vertices: Point3D[] = [];
let halfEdgeId = loop.start;
while (true) {
const halfEdge = db.halfEdgeById(halfEdgeId)!;
const vertex = db.vertexById(halfEdge.origin)!;
vertices.push([vertex.x, vertex.y, vertex.z]);
halfEdgeId = halfEdge.next;
if (halfEdgeId === loop.start)
break;
}
return {
vertices,
orientation: 'cw',
};
}
// private static computeLoopOrientation(vertices: Point3D[]): 'clockwise' | 'counter-clockwise' {
// let sum = 0;
// for (let i = 0; i < vertices.length; i++) {
// const current = vertices[i];
// const next = vertices[(i + 1) % vertices.length];
// sum += (next.x - current.x) * (next.y + current.y);
// }
// return sum > 0 ? 'counter-clockwise' : 'clockwise';
// }
private static generateControlPointsFromLoop(
boundaryLoop: BoundaryLoop,
resolutionU: number,
resolutionV: number
): Point3D[][] {
// Extract four boundary curves from the loop
const boundaries = this.extractFourBoundaries(boundaryLoop);
// Generate grid of control points
const grid: Point3D[][] = [];
for (let i = 0; i <= resolutionU; i++) {
grid[i] = [];
const u = i / resolutionU;
// Sample top and bottom boundaries
const topPoint = this.sampleBoundaryCurve(boundaries[0], u);
const bottomPoint = this.sampleBoundaryCurve(boundaries[2], u);
for (let j = 0; j <= resolutionV; j++) {
const v = j / resolutionV;
// Sample left and right boundaries
const leftPoint = this.sampleBoundaryCurve(boundaries[3], v);
const rightPoint = this.sampleBoundaryCurve(boundaries[1], v);
// Coons patch interpolation
grid[i][j] = this.coonsPatchInterpolation(
topPoint,
rightPoint,
bottomPoint,
leftPoint,
u,
v
);
}
}
return grid;
}
/**
* Extract four boundary curves from a loop for Coons patch
*/
private static extractFourBoundaries(loop: BoundaryLoop): Point3D[][] {
const vertices = loop.vertices;
const boundaries: Point3D[][] = [[], [], [], []];
// Divide the loop into 4 segments
const segmentSize = Math.floor(vertices.length / 4);
const remainder = vertices.length % 4;
let startIdx = 0;
for (let i = 0; i < 4; i++) {
const endIdx = startIdx + segmentSize + (i < remainder ? 1 : 0);
// Extract segment
for (let j = startIdx; j <= endIdx && j < vertices.length; j++) {
boundaries[i].push(vertices[j]);
}
startIdx = endIdx;
}
// Ensure all boundaries have at least 2 points
for (const boundary of boundaries) {
if (boundary.length < 2) {
// Duplicate points if needed
while (boundary.length < 2) {
boundary.push(boundary[0]);
}
}
}
return boundaries;
}
/**
* Sample a boundary curve at parameter t using centripetal Catmull-Rom interpolation
*/
private static sampleBoundaryCurve(boundary: Point3D[], t: number): Point3D {
if (boundary.length === 1) return boundary[0];
if (boundary.length === 2) {
// Linear interpolation for 2 points
return [
boundary[0][0] * (1 - t) + boundary[1][0] * t,
boundary[0][1] * (1 - t) + boundary[1][1] * t,
boundary[0][2] * (1 - t) + boundary[1][2] * t
];
}
// Find segment index
const index = t * (boundary.length - 1);
const i0 = Math.floor(index);
const i1 = Math.min(i0 + 1, boundary.length - 1);
const frac = index - i0;
// Catmull-Rom interpolation for smoother curves
const iPrev = Math.max(0, i0 - 1);
const iNext = Math.min(boundary.length - 1, i1 + 1);
const p0 = boundary[iPrev];
const p1 = boundary[i0];
const p2 = boundary[i1];
const p3 = boundary[iNext];
const t2 = frac * frac;
const t3 = t2 * frac;
return [
0.5 * ((2 * p1[0]) +
(-p0[0] + p2[0]) * frac +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
0.5 * ((2 * p1[1]) +
(-p0[1] + p2[1]) * frac +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
0.5 * ((2 * p1[2]) +
(-p0[2] + p2[2]) * frac +
(2 * p0[2] - 5 * p1[2] + 4 * p2[2] - p3[2]) * t2 +
(-p0[2] + 3 * p1[2] - 3 * p2[2] + p3[2]) * t3)
];
}
/**
* Coons patch interpolation for surface points
*/
private static coonsPatchInterpolation(
top: Point3D,
right: Point3D,
bottom: Point3D,
left: Point3D,
u: number,
v: number
): Point3D {
// Bilinear interpolation
const linearTopBottom = [
top[0] * (1 - v) + bottom[0] * v,
top[1] * (1 - v) + bottom[1] * v,
top[2] * (1 - v) + bottom[2] * v
];
const linearLeftRight = [
left[0] * (1 - u) + right[0] * u,
left[1] * (1 - u) + right[1] * u,
left[2] * (1 - u) + right[2] * u
];
// Corner blending
const corners = {
topLeft: [0, 0, 0],
topRight: [0, 0, 0],
bottomLeft: [0, 0, 0],
bottomRight: [0, 0, 0]
};
// Extract corner points from boundaries
// For simplicity, assume boundaries provide corner points at t=0 and t=1
const bilinearCorners = {
x: corners.topLeft[0] * (1 - u) * (1 - v) +
corners.topRight[0] * u * (1 - v) +
corners.bottomRight[0] * u * v +
corners.bottomLeft[0] * (1 - u) * v,
y: corners.topLeft[1] * (1 - u) * (1 - v) +
corners.topRight[1] * u * (1 - v) +
corners.bottomRight[1] * u * v +
corners.bottomLeft[1] * (1 - u) * v,
z: corners.topLeft[2] * (1 - u) * (1 - v) +
corners.topRight[2] * u * (1 - v) +
corners.bottomRight[2] * u * v +
corners.bottomLeft[2] * (1 - u) * v
};
return [
linearTopBottom[0] + linearLeftRight[0] - bilinearCorners.x,
linearTopBottom[1] + linearLeftRight[1] - bilinearCorners.y,
linearTopBottom[2] + linearLeftRight[2] - bilinearCorners.z
];
}
/**
* Generate weight matrix
*/
private static generateWeights(controlPoints: Point3D[][]): number[][] {
const weights: number[][] = [];
for (let i = 0; i < controlPoints.length; i++) {
weights[i] = [];
for (let j = 0; j < controlPoints[i].length; j++) {
weights[i][j] = 1.0;
}
}
return weights;
}
/**
* Generate knot vector for NURBS surface
*/
private static generateKnots(numControlPoints: number, degree: number): number[] {
const numKnots = numControlPoints + degree;
const knots: number[] = [];
for (let i = 0; i <= numKnots; i++) {
if (i <= degree) {
knots.push(0);
} else if (i >= numControlPoints) {
knots.push(1);
} else {
knots.push((i - degree) / (numControlPoints - degree));
}
}
return knots;
}
/**
* Build NURBS surface from multiple boundary loops (for holes)
*/
static buildSurfaceFromMultipleLoops(
outerLoop: Loop,
innerLoops: Loop[],
degreeU: number = 1,
degreeV: number = 1,
resolutionU: number = 2,
resolutionV: number = 2,
) {
// Extract outer boundary
const outerBoundary = this.extractBoundaryLoop(outerLoop);
// Extract inner boundaries (holes)
const innerBoundaries = innerLoops.map(loop =>
this.extractBoundaryLoop(loop)
);
// Generate control points with hole consideration
const controlPoints = this.generateControlPointsWithHoles(
outerBoundary,
innerBoundaries,
resolutionU,
resolutionV
);
const knotsU = this.generateKnots(controlPoints.length, degreeU);
const knotsV = this.generateKnots(controlPoints[0].length, degreeV);
return verbAny.geom.NurbsSurface.byKnotsControlPointsWeights({
degreeU,
degreeV,
knotsU,
knotsV,
controlPoints,
});
}
/**
* Generate control points for surface with holes
*/
private static generateControlPointsWithHoles(
outerBoundary: BoundaryLoop,
innerBoundaries: BoundaryLoop[],
resolutionU: number,
resolutionV: number
): Point3D[][] {
// Simplified implementation - fill grid and adjust for holes
const grid: Point3D[][] = [];
for (let i = 0; i <= resolutionU; i++) {
grid[i] = [];
const u = i / resolutionU;
for (let j = 0; j <= resolutionV; j++) {
const v = j / resolutionV;
// Sample outer boundaries
const pointOnBoundary = this.sampleBoundaryLoop(outerBoundary, u, v);
// Check if point is inside any hole
let isInsideHole = false;
for (const hole of innerBoundaries) {
if (this.isPointInsidePolygon(pointOnBoundary, hole.vertices)) {
isInsideHole = true;
break;
}
}
if (isInsideHole) {
// Adjust point or leave undefined (will be handled by NURBS)
grid[i][j] = this.adjustPointForHole(pointOnBoundary, innerBoundaries, u, v);
} else {
grid[i][j] = pointOnBoundary;
}
}
}
return grid;
}
/**
* Sample a point on the boundary loop using parameterization
*/
private static sampleBoundaryLoop(loop: BoundaryLoop, u: number, v: number): Point3D {
// Simplified - map (u,v) to position on boundary
const vertices = loop.vertices;
const index = Math.floor((u + v) / 2 * (vertices.length - 1));
const clampedIndex = Math.min(index, vertices.length - 1);
return vertices[clampedIndex];
}
/**
* Check if a point is inside a polygon (ray casting algorithm)
*/
private static isPointInsidePolygon(point: Point3D, polygon: Point3D[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i][0], yi = polygon[i][1];
const xj = polygon[j][0], yj = polygon[j][1];
const intersect = ((yi > point[1]) != (yj > point[1])) &&
(point[0] < (xj - xi) * (point[1] - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
/**
* Adjust point position for hole regions
*/
private static adjustPointForHole(
point: Point3D,
holes: BoundaryLoop[],
u: number,
v: number
): Point3D {
// Simple adjustment - move point inward
// More sophisticated algorithms would use harmonic mapping
return [
point[0] * (0.8 + 0.2 * u),
point[1] * (0.8 + 0.2 * v),
point[2]
];
}
}

View File

@ -0,0 +1,53 @@
import verb from "verb-nurbs";
import type { Solid, Surface } from "../types/brep";
import { db } from "../backend/db";
import { NURBSBuilder } from "./NURBSBuilder";
const verbAny = verb as any;
export type Point = [number, number, number];
export type Tri = [number, number, number];
export type UV = [number, number];
export type Mesh = {
points: Point[];
faces: Tri[];
normals: Point[];
uvs: UV[];
surfaceId: Surface['id'];
solidId: Solid['id'];
};
export class MeshService {
public static tesselateSolid(solidId: Solid['id']): Mesh {
for (const surfaceId of db.solidById(solidId)!.outerSurfaces) {
const surface = db.surfaceById(surfaceId)!;
for (const faceId of surface.faces) {
const face = db.faceById(faceId)!;
const loop = db.loopById(face.outerLoop)!;
const nurbsSurface = NURBSBuilder.buildSurfaceFromHalfEdgeLoop(loop);
const meshData = (nurbsSurface as any).tessellate();
console.log(meshData.points);
return {
...meshData,
solidId,
surfaceId,
}
}
}
return {
points: [],
faces: [],
normals: [],
uvs: [],
surfaceId: '',
solidId,
}
}
}

25
client/tsconfig.app.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
client/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
client/tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
client/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})