initial commit
This commit is contained in:
commit
aa17ecd4c9
|
|
@ -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?
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 += ` ├ <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>
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -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 |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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 |
|
|
@ -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();
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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],
|
||||||
|
// }],
|
||||||
|
// };
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
@ -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],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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: [
|
||||||
|
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
} */
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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'][],
|
||||||
|
}
|
||||||
|
|
@ -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'][],
|
||||||
|
}
|
||||||
|
|
@ -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'];
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './brep';
|
||||||
|
export * from './types';
|
||||||
|
export * from './geometry';
|
||||||
|
|
@ -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]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue