vertex id identification in hit test

code cleanup
This commit is contained in:
azykov@mail.ru 2026-05-23 20:40:49 +03:00
parent e751d2c5dd
commit 5de79305b7
No known key found for this signature in database
19 changed files with 303 additions and 436 deletions

View File

@ -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",

View File

@ -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",

View File

@ -13,6 +13,8 @@
left: 000px;
width: 600px;
font-size: 75%;
pointer-events: none;
color: white;
}
#blob-view {

View File

@ -8,8 +8,10 @@ export const App = function () {
return (
<div>
<Viewport />
<HitTestView />
<DbView />
<div className="side-panel">
<HitTestView />
<DbView />
</div>
</div>
)
}

View File

@ -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,

View File

@ -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),
}];
}

View File

@ -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
// };
// }
// }

View File

@ -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();

View File

@ -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],
// }],
// };
// }

View File

@ -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>
)
}

View File

@ -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,

View File

@ -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) {

84
client/src/helpers/2d.ts Normal file
View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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();

View File

@ -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";

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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'];