From 5de79305b78b103c68c5e42601d3b09c1df870fb Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Sat, 23 May 2026 20:40:49 +0300 Subject: [PATCH] vertex id identification in hit test code cleanup --- client/package-lock.json | 40 ++++- client/package.json | 5 +- client/src/App.scss | 4 +- client/src/App.tsx | 6 +- client/src/backend/dto/mesh.ts | 6 +- .../src/backend/geometry/planeTesselator.ts | 1 + client/src/backend/mesh copy.ts | 71 -------- client/src/backend/mesh.ts | 155 ----------------- client/src/backend/mockCube copy.ts | 164 ------------------ client/src/components/HitTestView.tsx | 23 ++- client/src/components/ThreeVIew.tsx | 6 + client/src/components/Viewport.tsx | 2 +- client/src/helpers/2d.ts | 84 +++++++++ .../src/helpers/circularFrustumIntersect.ts | 140 ++++++++++++--- client/src/helpers/hooks/useInteration.ts | 17 +- client/src/helpers/sceneHelper.ts | 2 +- client/src/layers/sceneSync.ts | 1 + client/src/state/root.ts | 9 +- client/src/types/geometry.ts | 3 +- 19 files changed, 303 insertions(+), 436 deletions(-) delete mode 100644 client/src/backend/mesh copy.ts delete mode 100644 client/src/backend/mesh.ts delete mode 100644 client/src/backend/mockCube copy.ts create mode 100644 client/src/helpers/2d.ts diff --git a/client/package-lock.json b/client/package-lock.json index 8590727..bef6367 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index adef8d9..b34dd86 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/App.scss b/client/src/App.scss index 1e3303b..5159b79 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -13,6 +13,8 @@ left: 000px; width: 600px; font-size: 75%; + pointer-events: none; + color: white; } #blob-view { @@ -36,7 +38,7 @@ // &::before { // content: "<"; - + // } & .title { diff --git a/client/src/App.tsx b/client/src/App.tsx index 3413d57..6018f83 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,8 +8,10 @@ export const App = function () { return (
- - +
+ + +
) } diff --git a/client/src/backend/dto/mesh.ts b/client/src/backend/dto/mesh.ts index 0a1e1bb..3407704 100644 --- a/client/src/backend/dto/mesh.ts +++ b/client/src/backend/dto/mesh.ts @@ -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, diff --git a/client/src/backend/geometry/planeTesselator.ts b/client/src/backend/geometry/planeTesselator.ts index aeb698b..e92f531 100644 --- a/client/src/backend/geometry/planeTesselator.ts +++ b/client/src/backend/geometry/planeTesselator.ts @@ -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), }]; } diff --git a/client/src/backend/mesh copy.ts b/client/src/backend/mesh copy.ts deleted file mode 100644 index 7ac0160..0000000 --- a/client/src/backend/mesh copy.ts +++ /dev/null @@ -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 = { -// '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 -// }; -// } -// } diff --git a/client/src/backend/mesh.ts b/client/src/backend/mesh.ts deleted file mode 100644 index 09d487d..0000000 --- a/client/src/backend/mesh.ts +++ /dev/null @@ -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 { -// 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(); diff --git a/client/src/backend/mockCube copy.ts b/client/src/backend/mockCube copy.ts deleted file mode 100644 index a335a31..0000000 --- a/client/src/backend/mockCube copy.ts +++ /dev/null @@ -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(); -// const vertexOwnerHalfEdge = new Map(); -// const halfEdgeById = new Map(); - -// 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], -// }], -// }; - -// } \ No newline at end of file diff --git a/client/src/components/HitTestView.tsx b/client/src/components/HitTestView.tsx index 6182f10..adb9a2a 100644 --- a/client/src/components/HitTestView.tsx +++ b/client/src/components/HitTestView.tsx @@ -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 ( -
-
+        
+
                 {
-                    state.hitTest.hits.map((hit) =>
+                    `${top},${left}`
+                }
+                {
+                    state.hitResults.hits.map((hit) =>
                         
- {JSON.stringify(hit.point.toArray())} - {JSON.stringify(hit.object.userData)} +
{yaml.stringify( + { + hit: { ...hit, object: undefined, triangle: undefined }, + userData: hit.object.userData, + }, + undefined, + 2, + )}
) } diff --git a/client/src/components/ThreeVIew.tsx b/client/src/components/ThreeVIew.tsx index ad9b217..fefe069 100644 --- a/client/src/components/ThreeVIew.tsx +++ b/client/src/components/ThreeVIew.tsx @@ -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, diff --git a/client/src/components/Viewport.tsx b/client/src/components/Viewport.tsx index 87cc596..6e1d09e 100644 --- a/client/src/components/Viewport.tsx +++ b/client/src/components/Viewport.tsx @@ -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) { diff --git a/client/src/helpers/2d.ts b/client/src/helpers/2d.ts new file mode 100644 index 0000000..1eaccf4 --- /dev/null +++ b/client/src/helpers/2d.ts @@ -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, + }; +} \ No newline at end of file diff --git a/client/src/helpers/circularFrustumIntersect.ts b/client/src/helpers/circularFrustumIntersect.ts index df928d4..f9121f9 100644 --- a/client/src/helpers/circularFrustumIntersect.ts +++ b/client/src/helpers/circularFrustumIntersect.ts @@ -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; } diff --git a/client/src/helpers/hooks/useInteration.ts b/client/src/helpers/hooks/useInteration.ts index 7a7d404..f1af11a 100644 --- a/client/src/helpers/hooks/useInteration.ts +++ b/client/src/helpers/hooks/useInteration.ts @@ -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(); diff --git a/client/src/helpers/sceneHelper.ts b/client/src/helpers/sceneHelper.ts index ca1fcb7..0b76667 100644 --- a/client/src/helpers/sceneHelper.ts +++ b/client/src/helpers/sceneHelper.ts @@ -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"; diff --git a/client/src/layers/sceneSync.ts b/client/src/layers/sceneSync.ts index bebbe14..48d25aa 100644 --- a/client/src/layers/sceneSync.ts +++ b/client/src/layers/sceneSync.ts @@ -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; diff --git a/client/src/state/root.ts b/client/src/state/root.ts index 1abe70a..225f508 100644 --- a/client/src/state/root.ts +++ b/client/src/state/root.ts @@ -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; } } diff --git a/client/src/types/geometry.ts b/client/src/types/geometry.ts index 29bf331..c1bdebe 100644 --- a/client/src/types/geometry.ts +++ b/client/src/types/geometry.ts @@ -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'];