parent
e751d2c5dd
commit
5de79305b7
|
|
@ -10,6 +10,8 @@
|
|||
"dependencies": {
|
||||
"@types/earcut": "^3.0.0",
|
||||
"earcut": "^3.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json5": "^2.2.3",
|
||||
"mobx": "^6.15.3",
|
||||
"mobx-react-lite": "^4.1.1",
|
||||
"react": "^19.2.6",
|
||||
|
|
@ -18,7 +20,8 @@
|
|||
"three": "^0.184.0",
|
||||
"three-mesh-bvh": "^0.9.10",
|
||||
"uuid": "^14.0.0",
|
||||
"verb-nurbs": "^3.0.3"
|
||||
"verb-nurbs": "^3.0.3",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
|
@ -1551,6 +1554,12 @@
|
|||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -2185,6 +2194,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
|
@ -2223,7 +2244,6 @@
|
|||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
|
|
@ -3617,6 +3637,22 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"dependencies": {
|
||||
"@types/earcut": "^3.0.0",
|
||||
"earcut": "^3.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json5": "^2.2.3",
|
||||
"mobx": "^6.15.3",
|
||||
"mobx-react-lite": "^4.1.1",
|
||||
"react": "^19.2.6",
|
||||
|
|
@ -20,7 +22,8 @@
|
|||
"three": "^0.184.0",
|
||||
"three-mesh-bvh": "^0.9.10",
|
||||
"uuid": "^14.0.0",
|
||||
"verb-nurbs": "^3.0.3"
|
||||
"verb-nurbs": "^3.0.3",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
left: 000px;
|
||||
width: 600px;
|
||||
font-size: 75%;
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#blob-view {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ export const App = function () {
|
|||
return (
|
||||
<div>
|
||||
<Viewport />
|
||||
<HitTestView />
|
||||
<DbView />
|
||||
<div className="side-panel">
|
||||
<HitTestView />
|
||||
<DbView />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import type { Mesh, Solid, Surface } from "../../types";
|
||||
import type { Face, Mesh, Solid, Surface, Vertex } from "../../types";
|
||||
|
||||
export type MeshDto = {
|
||||
vertices: Float32Array;
|
||||
normals: Float32Array
|
||||
indices: Uint16Array;
|
||||
|
||||
faceId: Solid['id'];
|
||||
vertexIds: Vertex['id'][];
|
||||
faceId: Face['id'];
|
||||
surfaceId: Surface['id'];
|
||||
solidId: Solid['id'];
|
||||
};
|
||||
|
|
@ -15,6 +16,7 @@ export function meshToDto(mesh: Mesh): MeshDto {
|
|||
vertices: new Float32Array(mesh.vertices.flat()),
|
||||
normals: new Float32Array(mesh.normals.flat()),
|
||||
indices: new Uint16Array(mesh.indices.flat()),
|
||||
vertexIds: mesh.vertexIds,
|
||||
faceId: mesh.faceId,
|
||||
surfaceId: mesh.surfaceId,
|
||||
solidId: mesh.solidId,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export class PlaneTessellator {
|
|||
vertices: vertices3d.map((v) => [v.x, v.y, v.z]),
|
||||
normals: vertices3d.map(() => basis.normal),
|
||||
indices,
|
||||
vertexIds: vertices3d.map((v) => v.id),
|
||||
}];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
// 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
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
// 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();
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
// 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],
|
||||
// }],
|
||||
// };
|
||||
|
||||
// }
|
||||
|
|
@ -1,16 +1,29 @@
|
|||
import * as yaml from 'yaml';
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { state } from "../state/root";
|
||||
|
||||
export const HitTestView = observer(function () {
|
||||
|
||||
const left = state.mousePosition.x;
|
||||
const top = state.mousePosition.y;
|
||||
|
||||
return (
|
||||
<div id="hit-test">
|
||||
<pre>
|
||||
<div id="hit-test" style={{ top, left }}>
|
||||
<pre style={{ textWrap: 'wrap' }}>
|
||||
{
|
||||
state.hitTest.hits.map((hit) =>
|
||||
`${top},${left}`
|
||||
}
|
||||
{
|
||||
state.hitResults.hits.map((hit) =>
|
||||
<div key={hit.object.uuid}>
|
||||
{JSON.stringify(hit.point.toArray())}
|
||||
{JSON.stringify(hit.object.userData)}
|
||||
<div>{yaml.stringify(
|
||||
{
|
||||
hit: { ...hit, object: undefined, triangle: undefined },
|
||||
userData: hit.object.userData,
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type ThreeViewTickEventArgs = ThreeViewEventArgs & {
|
|||
}
|
||||
|
||||
export type ThreeViewMouseEventArgs = ThreeViewEventArgs & {
|
||||
screenPosition: THREE.Vector2Like,
|
||||
position: THREE.Vector2Like,
|
||||
hitResults: HitResults,
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +109,8 @@ export const ThreeView = function (props: ThreeViewProps) {
|
|||
e.screenSize,
|
||||
);
|
||||
props.onMouseMove?.({
|
||||
position: e.position,
|
||||
screenPosition: e.screenPosition,
|
||||
camera,
|
||||
scene,
|
||||
hitResults,
|
||||
|
|
@ -119,6 +123,8 @@ export const ThreeView = function (props: ThreeViewProps) {
|
|||
e.screenSize,
|
||||
);
|
||||
props.onClick?.({
|
||||
position: e.position,
|
||||
screenPosition: e.screenPosition,
|
||||
camera,
|
||||
scene,
|
||||
hitResults,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const Viewport = function () {
|
|||
const sceneHelper = useSceneHelper();
|
||||
|
||||
function handleMouseMove(e: ThreeViewMouseEventArgs) {
|
||||
state.setHitTest(e.hitResults);
|
||||
state.setHitTest(e.screenPosition, e.hitResults);
|
||||
|
||||
sceneHelper.clear();
|
||||
if (e.hitResults.hits.length) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { Plane, Ray, Vector3, type Triangle, type Vector2Like, type Vector3Like } from "three";
|
||||
|
||||
export function dot2(a: Vector2Like, b: Vector2Like) { return a.x * b.x + a.y * b.y; }
|
||||
export function sub2(a: Vector2Like, b: Vector2Like): Vector2Like { return { x: a.x - b.x, y: a.y - b.y }; }
|
||||
export function len2(a: Vector2Like) { return Math.sqrt(dot2(a, a)); }
|
||||
|
||||
/** Closest point on 2D segment [a,b] to point p */
|
||||
function closestPointOnSegment2D(p: Vector2Like, a: Vector2Like, b: Vector2Like): Vector2Like {
|
||||
const ab = sub2(b, a);
|
||||
const t = Math.max(0, Math.min(1, dot2(sub2(p, a), ab) / dot2(ab, ab)));
|
||||
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
||||
}
|
||||
|
||||
/** Signed area — positive if a,b,c are CCW */
|
||||
export function signedArea2(a: Vector2Like, b: Vector2Like, c: Vector2Like) {
|
||||
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
}
|
||||
|
||||
export function pointInTriangle2D(p: Vector2Like, a: Vector2Like, b: Vector2Like, c: Vector2Like): boolean {
|
||||
const d1 = signedArea2(p, a, b);
|
||||
const d2 = signedArea2(p, b, c);
|
||||
const d3 = signedArea2(p, c, a);
|
||||
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
|
||||
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
|
||||
/** Minimum distance from point p to triangle (a,b,c) in 2D.
|
||||
* Returns 0 if p is inside. */
|
||||
export function distPointToTriangle2D(p: Vector2Like, a: Vector2Like, b: Vector2Like, c: Vector2Like): number {
|
||||
if (pointInTriangle2D(p, a, b, c))
|
||||
return 0;
|
||||
const d1 = len2(sub2(p, closestPointOnSegment2D(p, a, b)));
|
||||
const d2 = len2(sub2(p, closestPointOnSegment2D(p, b, c)));
|
||||
const d3 = len2(sub2(p, closestPointOnSegment2D(p, c, a)));
|
||||
return Math.min(d1, d2, d3);
|
||||
}
|
||||
|
||||
// ─── Plane utilities ─────────────────────────────────────────────────────────
|
||||
|
||||
export type TrianglePlane = {
|
||||
plane: Plane;
|
||||
/** Two orthonormal vectors spanning the plane */
|
||||
u: Vector3Like;
|
||||
v: Vector3Like;
|
||||
};
|
||||
|
||||
/** Build a local 2D coordinate frame for the triangle's plane */
|
||||
export function buildTrianglePlane(tri: Triangle): TrianglePlane {
|
||||
const normal = new Vector3();
|
||||
tri.getNormal(normal);
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, tri.a);
|
||||
|
||||
const u = tri.b.clone().sub(tri.a).normalize();
|
||||
const v = normal.clone().cross(u); // already normalized since normal⊥u, both unit
|
||||
|
||||
return { plane, u, v };
|
||||
}
|
||||
|
||||
/** Project a 3D point onto the plane's 2D coordinate frame */
|
||||
export function projectOntoPlane(point: Vector3, origin: Vector3Like, frame: TrianglePlane): Vector2Like {
|
||||
const d = point.clone().sub(origin);
|
||||
return { x: d.dot(frame.u), y: d.dot(frame.v) };
|
||||
}
|
||||
|
||||
// ─── Main intersection ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Intersect the local-space frustum axis ray with the triangle plane.
|
||||
* Returns the hit point and depth, or null if ray is parallel to the plane.
|
||||
*/
|
||||
export function rayPlaneHit(
|
||||
ray: Ray,
|
||||
plane: Plane,
|
||||
): { point: Vector3Like; depth: number } | null {
|
||||
const denom = plane.normal.dot(ray.direction);
|
||||
if (Math.abs(denom) < 1e-10) return null;
|
||||
const t = -(plane.distanceToPoint(ray.origin)) / denom;
|
||||
if (t < 0) return null; // behind apex
|
||||
return {
|
||||
point: ray.origin.clone().addScaledVector(ray.direction, t),
|
||||
depth: t,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,35 @@
|
|||
import * as THREE from 'three';
|
||||
import { CONTAINED, ExtendedTriangle, INTERSECTED, NOT_INTERSECTED } from 'three-mesh-bvh';
|
||||
import { CircularFrustum } from './circularFrustum';
|
||||
import type { Id } from '../types';
|
||||
|
||||
export type HitResults = {
|
||||
hits: HitResult[];
|
||||
}
|
||||
|
||||
export type TriangleVertexHitDetail = {
|
||||
kind: 'vertex',
|
||||
index: 0 | 1 | 2,
|
||||
id: Id,
|
||||
}
|
||||
export type TriangleEdgeHitDetail = {
|
||||
kind: 'edge',
|
||||
index: 0 | 1 | 2, // edge 0=AB, 1=BC, 2=CA
|
||||
id: Id,
|
||||
}
|
||||
export type TriangleFaceHitDetail = {
|
||||
kind: 'face',
|
||||
}
|
||||
|
||||
export type TriangleHitDetail = TriangleVertexHitDetail | TriangleEdgeHitDetail | TriangleFaceHitDetail;
|
||||
|
||||
export type HitResult = {
|
||||
object: THREE.Object3D;
|
||||
point: THREE.Vector3; // world-space closest hit point
|
||||
depth: number; // depth along frustum axis
|
||||
triangle?: ExtendedTriangle; // only present when BVH was used
|
||||
triangle: { a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3 };
|
||||
triHit?: TriangleHitDetail;
|
||||
vertexIds: [Id, Id, Id] | undefined; // undefined is when geometry does not have .index
|
||||
}
|
||||
|
||||
export type CircularFrustumIntersectionOptions = {
|
||||
|
|
@ -31,6 +50,49 @@ export function intersectionResultToBvh(value: IntersectionResult): typeof NOT_I
|
|||
}
|
||||
}
|
||||
|
||||
const BARYCENTRIC_EPSILON = 1e-1;
|
||||
|
||||
function classifyTriangleHit(
|
||||
point: THREE.Vector3,
|
||||
tri: ExtendedTriangle,
|
||||
vertexIds: Id[],
|
||||
): TriangleHitDetail {
|
||||
// Compute barycentric coords via areas
|
||||
const ab = tri.b.clone().sub(tri.a);
|
||||
const ac = tri.c.clone().sub(tri.a);
|
||||
const ap = point.clone().sub(tri.a);
|
||||
|
||||
const d00 = ab.dot(ab);
|
||||
const d01 = ab.dot(ac);
|
||||
const d11 = ac.dot(ac);
|
||||
const d20 = ap.dot(ab);
|
||||
const d21 = ap.dot(ac);
|
||||
const denom = d00 * d11 - d01 * d01;
|
||||
|
||||
const v = (d11 * d20 - d01 * d21) / denom; // weight of b
|
||||
const w = (d00 * d21 - d01 * d20) / denom; // weight of c
|
||||
const u = 1 - v - w; // weight of a
|
||||
|
||||
const eps = 1 - BARYCENTRIC_EPSILON;
|
||||
const onA = u > eps;
|
||||
const onB = v > eps;
|
||||
const onC = w > eps;
|
||||
|
||||
if (onA) return { kind: 'vertex', index: 0, id: vertexIds[0] };
|
||||
if (onB) return { kind: 'vertex', index: 1, id: vertexIds[1] };
|
||||
if (onC) return { kind: 'vertex', index: 2, id: vertexIds[2] };
|
||||
|
||||
const onAB = w < BARYCENTRIC_EPSILON; // u+v≈1, w≈0
|
||||
const onBC = u < BARYCENTRIC_EPSILON;
|
||||
const onCA = v < BARYCENTRIC_EPSILON;
|
||||
|
||||
if (onAB) return { kind: 'edge', index: 0, id: 'none' };
|
||||
if (onBC) return { kind: 'edge', index: 1, id: 'none' };
|
||||
if (onCA) return { kind: 'edge', index: 2, id: 'none' };
|
||||
|
||||
return { kind: 'face' };
|
||||
}
|
||||
|
||||
export class CircularFrustumIntersection {
|
||||
public readonly frustum: CircularFrustum;
|
||||
|
||||
|
|
@ -135,8 +197,30 @@ export class CircularFrustumIntersection {
|
|||
if (this.insersectsSphere(boundingSphere) === 'NOT_INTERSECTED')
|
||||
return [];
|
||||
|
||||
function getGeometryVertextIdByIndex(vertexIndex: number): Id {
|
||||
return mesh.userData.vertexIds[vertexIndex];
|
||||
}
|
||||
|
||||
function getGeometryVertextIds(triIndex: number): HitResult['vertexIds'] {
|
||||
return geometry.index
|
||||
? [
|
||||
getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3]),
|
||||
getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 1]),
|
||||
getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 2]),
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const axisRay = new THREE.Ray(localFrustum.apex, localFrustum.axisNormalized);
|
||||
|
||||
function tryBestPoint(v: THREE.Vector3, best: { depth: number, local?: THREE.Vector3 }) {
|
||||
const d = CircularFrustumIntersection.pointAxialDepth(v, localFrustum);
|
||||
if (d !== 'NOT_INTERSECTED' && d < best.depth) {
|
||||
best.depth = d as number;
|
||||
best.local = v.clone();
|
||||
}
|
||||
};
|
||||
|
||||
const results: HitResult[] = [];
|
||||
|
||||
if (!geometry.boundsTree)
|
||||
|
|
@ -147,29 +231,29 @@ export class CircularFrustumIntersection {
|
|||
bvh.shapecast({
|
||||
intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)),
|
||||
|
||||
intersectsTriangle: (tri: ExtendedTriangle, _index: number, contained: boolean) => {
|
||||
intersectsTriangle: (tri: ExtendedTriangle, triIndex: number, contained: boolean) => {
|
||||
const tiangleVertexIds = getGeometryVertextIds(triIndex);
|
||||
|
||||
if (contained) {
|
||||
const worldPoint = tri.a.clone().applyMatrix4(mesh.matrixWorld);
|
||||
const depth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex));
|
||||
results.push({ object: mesh, point: worldPoint, depth, triangle: tri });
|
||||
results.push({
|
||||
object: mesh,
|
||||
point: worldPoint,
|
||||
depth,
|
||||
triangle: tri,
|
||||
triHit: classifyTriangleHit(tri.a, tri, tiangleVertexIds ?? []),
|
||||
vertexIds: tiangleVertexIds,
|
||||
});
|
||||
return !findAll;
|
||||
}
|
||||
|
||||
let bestDepth = Infinity;
|
||||
let bestLocal: THREE.Vector3 | undefined = undefined;
|
||||
|
||||
const tryPoint = (v: THREE.Vector3) => {
|
||||
const d = CircularFrustumIntersection.pointAxialDepth(v, localFrustum);
|
||||
if (d !== 'NOT_INTERSECTED' && (d as number) < bestDepth) {
|
||||
bestDepth = d as number;
|
||||
bestLocal = v.clone();
|
||||
}
|
||||
};
|
||||
let bestPoint: { depth: number, local?: THREE.Vector3 } = { depth: Infinity, local: undefined };
|
||||
|
||||
// step 1: test vertices
|
||||
tryPoint(tri.a);
|
||||
tryPoint(tri.b);
|
||||
tryPoint(tri.c);
|
||||
tryBestPoint(tri.a, bestPoint);
|
||||
tryBestPoint(tri.b, bestPoint);
|
||||
tryBestPoint(tri.c, bestPoint);
|
||||
|
||||
// step 2: edges for a triangle that straddle the cone surface
|
||||
// for each edge, find the point closest to the frustum axis ray, and also the point closest to the apex.
|
||||
|
|
@ -196,30 +280,38 @@ export class CircularFrustumIntersection {
|
|||
const edgeLen = edge.length();
|
||||
const tClamped = Math.max(0, Math.min(edgeLen, t));
|
||||
const pointOnEdge = a.clone().addScaledVector(edgeDir, tClamped);
|
||||
tryPoint(pointOnEdge);
|
||||
tryBestPoint(pointOnEdge, bestPoint);
|
||||
}
|
||||
|
||||
// Closest point on edge to the apex itself
|
||||
const tApex = Math.max(0, Math.min(1, -toA.dot(edge) / edge.lengthSq()));
|
||||
tryPoint(a.clone().addScaledVector(edge, tApex));
|
||||
tryBestPoint(a.clone().addScaledVector(edge, tApex), bestPoint);
|
||||
}
|
||||
|
||||
// step 3: closest point on triangle face to the apex
|
||||
const closestOnFace = new THREE.Vector3();
|
||||
tri.closestPointToPoint(localFrustum.apex, closestOnFace);
|
||||
if (!isNaN(closestOnFace.x))
|
||||
tryPoint(closestOnFace);
|
||||
tryBestPoint(closestOnFace, bestPoint);
|
||||
|
||||
// 4. Axis ray through the face — catches large faces the cone passes through
|
||||
// step 3: large faces that frustum (its axis) passes through
|
||||
const faceHit = new THREE.Vector3();
|
||||
if (axisRay.intersectTriangle(tri.a, tri.b, tri.c, false, faceHit)) {
|
||||
tryPoint(faceHit);
|
||||
tryBestPoint(faceHit, bestPoint);
|
||||
}
|
||||
|
||||
if (bestLocal !== undefined) {
|
||||
const worldPoint = (bestLocal as THREE.Vector3).clone().applyMatrix4(mesh.matrixWorld);
|
||||
if (bestPoint.local) {
|
||||
const worldPoint = bestPoint.local.clone().applyMatrix4(mesh.matrixWorld);
|
||||
const worldDepth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex));
|
||||
results.push({ object: mesh, point: worldPoint, depth: worldDepth, triangle: tri });
|
||||
const { a, b, c } = tri;
|
||||
results.push({
|
||||
object: mesh,
|
||||
point: worldPoint,
|
||||
depth: worldDepth,
|
||||
triangle: { a, b, c },
|
||||
triHit: classifyTriangleHit(bestPoint.local, tri, tiangleVertexIds),
|
||||
vertexIds: tiangleVertexIds,
|
||||
});
|
||||
return !findAll;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { normalizeScreenPosition } from "../normalizeScreenPosition";
|
|||
const CLICK_THRESHOLD = 2; // px
|
||||
|
||||
export type InteractionMouseEventArgs = {
|
||||
screenPosition: THREE.Vector2Like,
|
||||
position: THREE.Vector2Like,
|
||||
screenSize: THREE.Vector2Like,
|
||||
pixelSize: THREE.Vector2Like,
|
||||
|
|
@ -59,8 +60,14 @@ export function useInteraction(
|
|||
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
isDragging = false;
|
||||
if ((e.clientX - startX < CLICK_THRESHOLD) && (e.clientY - startY < CLICK_THRESHOLD))
|
||||
options.onMouseClick?.(normalizeScreenPosition({ x: e.clientX, y: e.clientY }, target));
|
||||
if ((e.clientX - startX < CLICK_THRESHOLD) && (e.clientY - startY < CLICK_THRESHOLD)) {
|
||||
|
||||
const screenPosition = { x: e.clientX, y: e.clientY };
|
||||
options.onMouseClick?.({
|
||||
...normalizeScreenPosition(screenPosition, target),
|
||||
screenPosition,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
|
|
@ -80,7 +87,11 @@ export function useInteraction(
|
|||
};
|
||||
|
||||
const onHover = (e: MouseEvent) => {
|
||||
options.onMouseMove?.(normalizeScreenPosition({ x: e.clientX, y: e.clientY }, target));
|
||||
const screenPosition = { x: e.clientX, y: e.clientY };
|
||||
options.onMouseMove?.({
|
||||
...normalizeScreenPosition(screenPosition, target),
|
||||
screenPosition,
|
||||
});
|
||||
};
|
||||
|
||||
const onContextMenu = (e: Event) => e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ColorRepresentation, Object3D, Object3DEventMap, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, Vector3, Vector3Like } from "three";
|
||||
import type { ColorRepresentation, Object3D, Object3DEventMap, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, Vector3Like } from "three";
|
||||
import { SceneSync } from "../layers/sceneSync";
|
||||
import { GeometryCache } from "../layers/geometryCache";
|
||||
import type { Id } from "../types";
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export class SceneSync {
|
|||
const geo = this.cache.getOrCreate(faceId, 0, dto);
|
||||
const mesh = new THREE.Mesh(geo, this.baseMaterial.clone());
|
||||
mesh.userData.faceId = faceId;
|
||||
mesh.userData.vertexIds = dto.vertexIds;
|
||||
mesh.userData.surfaceId = dto.surfaceId;
|
||||
mesh.userData.solidId = dto.solidId;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { makeAutoObservable } from "mobx";
|
||||
import type { Id } from "../types";
|
||||
import type { HitResults } from "../helpers/circularFrustumIntersect";
|
||||
import type { Vector2Like } from "three";
|
||||
|
||||
export class Root {
|
||||
public selectedPrimitiveIds: Id[] = [];
|
||||
public hitTest: HitResults = { hits: [] };
|
||||
public hitResults: HitResults = { hits: [] };
|
||||
public mousePosition: Vector2Like = { x: 0, y: 0 };
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
|
|
@ -14,8 +16,9 @@ export class Root {
|
|||
this.selectedPrimitiveIds = value;
|
||||
}
|
||||
|
||||
public setHitTest(value: HitResults) {
|
||||
this.hitTest = value;
|
||||
public setHitTest(mousePosition: Vector2Like, hitResults: HitResults) {
|
||||
this.mousePosition = mousePosition;
|
||||
this.hitResults = hitResults;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Face, Solid, Surface } from "./brep";
|
||||
import type { Face, Solid, Surface, Vertex } from "./brep";
|
||||
|
||||
export type V2 = [x: number, y: number];
|
||||
export type V3 = [x: number, y: number, z: number];
|
||||
|
|
@ -9,6 +9,7 @@ export type Mesh = {
|
|||
normals: V3[];
|
||||
indices: number[];
|
||||
|
||||
vertexIds: Vertex['id'][];
|
||||
faceId: Face['id'];
|
||||
surfaceId: Surface['id'];
|
||||
solidId: Solid['id'];
|
||||
|
|
|
|||
Loading…
Reference in New Issue