diff --git a/client/src/components/HitTestView.tsx b/client/src/components/HitTestView.tsx index e8a65c0..5da0728 100644 --- a/client/src/components/HitTestView.tsx +++ b/client/src/components/HitTestView.tsx @@ -11,21 +11,17 @@ export const HitTestView = observer(function ({ float }: { float: boolean }) { const result = JSON.parse(JSON.stringify(hit)) as HitResult; const resultAny = result as any; - delete (resultAny.intersection.object); - delete (resultAny.intersection.triangle); - delete (resultAny.intersection.details.kind); - delete (resultAny.intersection.details.index); - delete (resultAny.intersection.triangleVertexIds); - delete (resultAny.intersection.details); + delete (resultAny.object); + delete (resultAny.uuid); + delete (resultAny.id); + delete (resultAny.faceId); + delete (resultAny.kind); + delete (resultAny.visibility); - resultAny.intersection.point = formatPoint(resultAny.intersection.point) - resultAny.intersection.depth = Number(Number(resultAny.intersection.depth).toFixed(3)); - resultAny.intersection.radialDistanceAbsolute = Number(Number(resultAny.intersection.radialDistanceAbsolute).toFixed(3)); - resultAny.intersection.radialDistance = Number(Number(resultAny.intersection.radialDistance).toFixed(3)); - - if (result.kind === 'edge') { - delete (resultAny.intersection.triangle); - } + resultAny.point = formatPoint(resultAny.point) + resultAny.depth = Number(Number(resultAny.depth).toFixed(10)); + resultAny.radialDistanceAbsolute = Number(Number(result.radialDistanceAbsolute).toFixed(3)); + resultAny.radialDistance = Number(Number(result.radialDistance).toFixed(3)); return result; } @@ -41,11 +37,24 @@ export const HitTestView = observer(function ({ float }: { float: boolean }) { return (
{ - state.hitResults.hits.map((hit) => -
+ state.hitResults.hits.map((hit) => { + let description = ''; + switch (hit.kind) { + case 'face': + description = `face ${hit.faceId}`; + break; + case 'edge': + description = `edge ${hit.id} on face ${hit.faceId}`; + break; + case 'vertex': + description = `vertex ${hit.id} on face ${hit.faceId}`; + break; + } + return
+
{hit.visibility} {description}
{yaml.stringify(renderHitResult(hit), undefined, 4)}
- ) + }) }
) diff --git a/client/src/components/Viewport.tsx b/client/src/components/Viewport.tsx index 4b3ffff..71d3095 100644 --- a/client/src/components/Viewport.tsx +++ b/client/src/components/Viewport.tsx @@ -12,7 +12,7 @@ export const Viewport = function () { sceneHelper.clearHints(); if (e.hitResults.hits.length) { e.hitResults.hits.forEach((hit) => { - sceneHelper.showPointHint(hit.id!, hit.intersection.point); + sceneHelper.showPointHint(hit.uuid, hit.point, 5, hit.kind === 'vertex' ? 'yellow' : (hit.kind === 'edge' ? 'lime' : 'white')); }) // console.log(e.position); // console.log(e.hitTest.objects.map((o) => o)); @@ -20,7 +20,7 @@ export const Viewport = function () { } // raycaster.setFromCamera(new THREE.Vector2(e.x, e.y), camera); // const hits = raycaster.intersectObjects(sync.meshes); - const hoveredFaceIds = e.hitResults.hits.map((hit) => hit.intersection.object.userData.faceId); + const hoveredFaceIds = e.hitResults.hits.map((hit) => hit.object.userData.faceId); // if (hoveredFaceIds.length) // console.log(hoveredFaceIds); diff --git a/client/src/helpers/circularFrustumIntersect.ts b/client/src/helpers/circularFrustumIntersect.ts index 6547439..d79a9f2 100644 --- a/client/src/helpers/circularFrustumIntersect.ts +++ b/client/src/helpers/circularFrustumIntersect.ts @@ -1,51 +1,25 @@ -import yaml from 'yaml'; import * as THREE from 'three'; import { CONTAINED, ExtendedTriangle, INTERSECTED, NOT_INTERSECTED } from 'three-mesh-bvh'; import { CircularFrustum } from './circularFrustum'; import type { Id } from '../types'; import type { MeshDto } from '../backend/dto'; import { clamp } from '../utils/math'; - -export type TriangleVertexHitDetail = { - kind: 'vertex', - index: 0 | 1 | 2, - pt: THREE.Vector3, -} -export type TriangleEdgeHitDetail = { - kind: 'edge', - index: 0 | 1 | 2, // edge 0=AB, 1=BC, 2=CA - ptIndexA: 0 | 1 | 2, - ptIndexB: 0 | 1 | 2, - ptA: THREE.Vector3, - ptB: THREE.Vector3, -} -export type TriangleFaceHitDetail = { - kind: 'face', -} - -export type TriangleHitDetail = TriangleVertexHitDetail | TriangleEdgeHitDetail | TriangleFaceHitDetail; +import { model } from '../model/model'; export type Visibility = 'visible' | 'backface'; // | 'occluded' -export type Intersection = { +export type BaseHitResult = { + uuid: string, object: THREE.Object3D, point: THREE.Vector3, // world-space closest hit point depth: number, // depth along frustum axis radialDistanceAbsolute: number, // perpendicular distance from frustum axis radialDistance: number, // radialDistanceAbsolute normalized (0..1) to frustum radius at depth - triangle: { a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3 }, - details: TriangleHitDetail, visibility: Visibility, - triangleVertexIds: [Id, Id, Id], -} - -export type BaseHitResult = { - intersection: Intersection, } export type FaceHitResult = BaseHitResult & { kind: 'face', - id?: Id, faceId: Id, } @@ -68,7 +42,7 @@ export type HitResults = { } export type CircularFrustumIntersectionOptions = { - findAll?: boolean; // defaults to false + // findAll?: boolean; // defaults to false filter?: (object: THREE.Object3D) => boolean; // defaults to every object } @@ -85,6 +59,13 @@ export function intersectionResultToBvh(value: IntersectionResult): typeof NOT_I } } +function isTriangleVisible(tri: THREE.Triangle, frustum: CircularFrustum): boolean { + const normal = new THREE.Vector3(); + tri.getNormal(normal); + const facingRatio = normal.dot(frustum.axisNormalized); + return facingRatio < 0; +} + function closestPointOnEdgeToRay( start: THREE.Vector3, end: THREE.Vector3, @@ -106,64 +87,6 @@ function closestPointOnEdgeToRay( return start.clone().addScaledVector(edgeDir, clamp(t, 0, edgeLen) / edgeLen); } -function triangleDetailsByFrustum( - tri: THREE.Triangle, - localFrustum: CircularFrustum, -): TriangleHitDetail[] { - const results: TriangleHitDetail[] = [{ kind: 'face' }]; - - const verts: [THREE.Vector3, 0 | 1 | 2][] = [ - [tri.a, 0], - [tri.b, 1], - [tri.c, 2], - ]; - const edges: [THREE.Vector3, THREE.Vector3, 0 | 1 | 2, 0 | 1 | 2, 0 | 1 | 2][] = [ - [tri.a, tri.b, 0, 0, 1], - [tri.b, tri.c, 1, 1, 2], - [tri.c, tri.a, 2, 2, 0], - ]; - - // A vertex is "in the frustum" if it passes the cone test - const vertexInFrustum = verts.map(([v]) => - CircularFrustumIntersection.pointAxialDepth(v, localFrustum) !== 'NOT_INTERSECTED' - ); - - // Promote to vertex hits - for (const [v, idx] of verts) { - if (vertexInFrustum[idx]) { - results.unshift({ kind: 'vertex', index: idx, pt: v }); - } - } - - // Promote to edge hits: an edge is hit if ANY point along it falls inside the frustum. - // We sample: the two endpoints, the closest point to the axis ray, and the closest to the apex. - for (const [a, b, edgeIdx, ptIndexA, ptIndexB] of edges) { - if (vertexInFrustum[ptIndexA] || vertexInFrustum[ptIndexB]) { - // At least one endpoint inside — edge is hit - results.unshift({ kind: 'edge', index: edgeIdx, ptIndexA, ptIndexB, ptA: a, ptB: b }); - continue; - } - - // Check closest point on edge to frustum axis - const closestToAxis = closestPointOnEdgeToRay(a, b, localFrustum.ray); - if (CircularFrustumIntersection.pointAxialDepth(closestToAxis, localFrustum) !== 'NOT_INTERSECTED') { - results.unshift({ kind: 'edge', index: edgeIdx, ptIndexA, ptIndexB, ptA: a, ptB: b }); - continue; - } - - // Check closest point on edge to apex - const edge = b.clone().sub(a); - const toA = a.clone().sub(localFrustum.apex); - const tApex = clamp(-toA.dot(edge) / edge.lengthSq(), 0, 1); - const closestToApex = a.clone().addScaledVector(edge, tApex); - if (CircularFrustumIntersection.pointAxialDepth(closestToApex, localFrustum) !== 'NOT_INTERSECTED') { - results.unshift({ kind: 'edge', index: edgeIdx, ptIndexA, ptIndexB, ptA: a, ptB: b }); - } - } - - return results; -} - export class CircularFrustumIntersection { public readonly frustum: CircularFrustum; @@ -259,16 +182,18 @@ export class CircularFrustumIntersection { return CircularFrustumIntersection.distanceToPoint(point, this.frustum); } - public intersectMesh( + public intersectMeshFaces( mesh: THREE.Mesh, - findAll: boolean, - ): Intersection[] { + ): FaceHitResult[] { + const findAll = true; + const geometry = mesh.geometry; if (!geometry) return []; const matrix = mesh.matrixWorld; const matrixInverted = matrix.clone().invert(); // world -> local matrix + const worldFrustum = this.frustum; const localFrustum = this.toObjectLocalSpace(matrixInverted); @@ -279,47 +204,14 @@ export class CircularFrustumIntersection { if (this.insersectsSphere(boundingSphere) === 'NOT_INTERSECTED') return []; - function getGeometryVertextIdByIndex(vertexIndex: number): Id { - return (mesh.userData as MeshDto).loop[vertexIndex].vertex; + const bestFaceHits: Record = {}; + + function tryBestTriangleHit(hit: FaceHitResult) { + const bestFaceHit = bestFaceHits[hit.faceId]; + if (!bestFaceHit || bestFaceHit.radialDistance > hit.radialDistance) + bestFaceHits[hit.faceId] = hit; } - function getGeometryVertextIds(triIndex: number): [Id, Id, Id] { - return geometry.index - ? [ - getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3]), - getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 1]), - getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 2]), - ] - : ['', '', '']; - } - - 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(); - } - }; - - function getHitClosestPoint( - triangle: THREE.Triangle, - details: TriangleHitDetail, - faceClosestPoint: THREE.Vector3, - ): THREE.Vector3 { - switch (details.kind) { - case 'face': - return faceClosestPoint; - case 'edge': - return closestPointOnEdgeToRay(details.ptA, details.ptB, worldFrustum.ray); - case 'vertex': - return [triangle.a, triangle.b, triangle.c][details.index]; - } - } - - const results: Intersection[] = []; - if (!geometry.boundsTree) geometry.computeBoundsTree(); const bvh = geometry.boundsTree; @@ -328,171 +220,151 @@ export class CircularFrustumIntersection { bvh.shapecast({ intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)), - intersectsTriangle: (tri: ExtendedTriangle, triIndex: number, contained: boolean) => { - const triangleVertexIds = getGeometryVertextIds(triIndex); + intersectsTriangle: (tri: ExtendedTriangle) => { + const visibility = isTriangleVisible(tri, localFrustum) ? 'visible' : 'backface'; - const normal = new THREE.Vector3(); - tri.getNormal(normal); - const facingRatio = normal.dot(localFrustum.axisNormalized); - // normal orientation is same as frusum axis means triangle is faced way from camera - const visibility: Visibility = facingRatio >= 0 ? 'backface' : 'visible'; + let bestPoint: THREE.Vector3 | undefined; + let bestRadialDistance = Infinity; - if (contained) { - const closestContained = new THREE.Vector3(); - tri.closestPointToPoint(localFrustum.apex, closestContained); - const worldPoint = closestContained.clone().applyMatrix4(mesh.matrixWorld); - const depth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex)); - results.push( - ...triangleDetailsByFrustum(tri, localFrustum) - .map((details) => { - const closestPoint = getHitClosestPoint(tri, details, worldPoint); - const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(closestPoint, worldFrustum); - return { - object: mesh, - point: closestPoint, - depth, - radialDistanceAbsolute, - radialDistance: radialDistanceAbsolute / (depth * Math.tan(worldFrustum.halfAngle)), - triangle: tri, - details, - visibility, - triangleVertexIds, - }; - }), - ); - return !findAll; + function tryBestPoint(facePoint: THREE.Vector3) { + if (CircularFrustumIntersection.pointAxialDepth(facePoint, localFrustum) === 'NOT_INTERSECTED') + return; + const radialDistance = CircularFrustumIntersection.distanceToPoint(facePoint, localFrustum); + if (radialDistance < bestRadialDistance) { + bestRadialDistance = radialDistance; + bestPoint = facePoint; + } } - let bestPoint: { depth: number, local?: THREE.Vector3 } = { depth: Infinity, local: undefined }; - - // step 1: test vertices - 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. - const edges: [THREE.Vector3, THREE.Vector3][] = [ - [tri.a, tri.b], - [tri.b, tri.c], - [tri.c, tri.a], - ]; - - for (const [a, b] of edges) { - const pointOnEdge = closestPointOnEdgeToRay(a, b, localFrustum.ray); - - // Closest point on edge segment to the axis ray - tryBestPoint(pointOnEdge, bestPoint); - - // Closest point on edge to the apex itself - const toA = a.clone().sub(localFrustum.apex); - const edge = b.clone().sub(a); - const tApex = clamp(-toA.dot(edge) / edge.lengthSq(), 0, 1); - 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)) - tryBestPoint(closestOnFace, bestPoint); - - // step 3: large faces that frustum (its axis) passes through + // ray pierces triangle -> internal hit point const faceHit = new THREE.Vector3(); - if (axisRay.intersectTriangle(tri.a, tri.b, tri.c, false, faceHit)) { - tryBestPoint(faceHit, bestPoint); - } + if (localFrustum.ray.intersectTriangle(tri.a, tri.b, tri.c, false, faceHit)) + tryBestPoint(faceHit); - if (bestPoint.local) { - const worldPoint = bestPoint.local.clone().applyMatrix4(mesh.matrixWorld); - const worldDepth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex)); - results.push( - ...triangleDetailsByFrustum(tri, localFrustum) - .map((details) => { - const closestPoint = getHitClosestPoint(tri, details, worldPoint); - const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(closestPoint, worldFrustum); - return { - object: mesh, - point: closestPoint, - depth: worldDepth, - radialDistanceAbsolute, - radialDistance: radialDistanceAbsolute / (worldDepth * Math.tan(worldFrustum.halfAngle)), - triangle: tri, - details, - visibility, - triangleVertexIds, - }; - }), - ); + // ray misses triangle, but cone touches an edge -> external, but close enough, hit point + for (const [a, b] of [[tri.a, tri.b], [tri.b, tri.c], [tri.c, tri.a]] as [THREE.Vector3, THREE.Vector3][]) + tryBestPoint(closestPointOnEdgeToRay(a, b, localFrustum.ray)); + + if (bestPoint) { + const bestPointWorld = bestPoint.clone().applyMatrix4(matrix); + const depth = worldFrustum.axisNormalized.dot(bestPointWorld.clone().sub(worldFrustum.apex)); + const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(bestPointWorld, worldFrustum); + const faceId = (mesh.userData as MeshDto).faceId; + + tryBestTriangleHit({ + kind: 'face', + uuid: faceId, + faceId: faceId, + object: mesh, + point: bestPointWorld, + depth, + radialDistanceAbsolute, + radialDistance: radialDistanceAbsolute / (depth * Math.tan(worldFrustum.halfAngle)), + visibility, + }); return !findAll; } - return false; }, }); + return Object.values(bestFaceHits); + } + + public intersectMesh( + mesh: THREE.Mesh, + ): HitResult[] { + const faceHits = this.intersectMeshFaces(mesh); + const results = [...faceHits] as HitResult[]; + for (const faceHit of faceHits) { + const loop = model.loopByFaceId(faceHit.faceId); + if (!loop) + continue; + + const halfEdges = model.halfEdgesByLoop(loop.id); + const vertices = halfEdges + .map((he) => model.vertexById(he.origin)!) + .map((v) => ({ id: v.id, point: new THREE.Vector3(v.x, v.y, v.z) })); + + const edges = halfEdges + .map((he, idx) => ({ id: he.ownerEdge, a: vertices[idx].point, b: vertices[(idx + 1) % vertices.length].point })); + + for (const vertex of vertices) { + const d = this.pointAxialDepth(vertex.point); + if (d === 'NOT_INTERSECTED') + continue; + const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(vertex.point, this.frustum); + results.push( + { + kind: 'vertex', + uuid: faceHit.faceId + '.' + vertex.id, + id: vertex.id, + faceId: faceHit.faceId, + object: faceHit.object, + visibility: faceHit.visibility, + point: vertex.point, + depth: d, + radialDistanceAbsolute, + radialDistance: radialDistanceAbsolute / (d * Math.tan(this.frustum.halfAngle)), + }, + ); + } + + for (const edge of edges) { + const closestLocal = closestPointOnEdgeToRay(edge.a, edge.b, this.frustum.ray); + const d = this.pointAxialDepth(closestLocal); + if (d === 'NOT_INTERSECTED') + continue; + const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(closestLocal, this.frustum); + results.push( + { + kind: 'edge', + uuid: faceHit.faceId + '.' + edge.id, + id: edge.id, + faceId: faceHit.faceId, + object: faceHit.object, + visibility: faceHit.visibility, + point: closestLocal, + depth: d, + radialDistanceAbsolute, + radialDistance: radialDistanceAbsolute / (d * Math.tan(this.frustum.halfAngle)), + }, + ); + } + + } return results; } public intersectObject( - obj: THREE.Object3D, + object: THREE.Object3D, options: CircularFrustumIntersectionOptions = {}, ): HitResult[] { const results: HitResult[] = []; - obj.traverseVisible((object) => { + object.traverseVisible((object) => { if (options.filter && !options.filter(object)) return; if (!(object instanceof THREE.Mesh)) return; - results.push( - ...this.intersectMesh(object, !!options.findAll) - .map((i) => this.intersectionToHitResult(i)) - .filter((i) => !!i), - ); + results.push(...this.intersectMesh(object)); }); - // sort closest first - results.sort((a, b) => a.intersection.depth - b.intersection.depth); + results.sort((a, b) => a.depth - b.depth); return results; } - private intersectionToHitResult(intersection: Intersection): HitResult | undefined { - const userData = intersection.object.userData as MeshDto; - const faceId = userData.faceId; - const loop = userData.loop; + public intersectObjects( + objects: THREE.Object3D[], + options: CircularFrustumIntersectionOptions = {}, + ): HitResult[] { + const results: HitResult[] = []; - function vertexId(index: number) { - return intersection.triangleVertexIds[index]; - } + for (const object of objects) + results.push(...this.intersectObject(object, options)); - switch (intersection.details.kind) { - case 'face': - return { - kind: 'face', - id: faceId, - faceId, - intersection, - }; - case 'edge': - const triHit = intersection.details; - const edge = loop.find((v) => (v.vertex === vertexId(triHit.ptIndexA)) && (v.vertex2 === vertexId(triHit.ptIndexB)))?.edge; - return edge // undefined for edges created by tesselation like diagonals, etc. - ? { - kind: 'edge', - id: edge, - faceId, - intersection, - } - : undefined; - case 'vertex': - return { - kind: 'vertex', - id: vertexId(intersection.details.index), - faceId, - intersection, - }; - } + results.sort((a, b) => a.depth - b.depth); + return results; } } \ No newline at end of file diff --git a/client/src/helpers/sceneHelper.ts b/client/src/helpers/sceneHelper.ts index 4f2210f..db0e548 100644 --- a/client/src/helpers/sceneHelper.ts +++ b/client/src/helpers/sceneHelper.ts @@ -50,12 +50,8 @@ export class SceneHelper { ): HitResults { this.buildMouseFrustum(mouseNormalized, screenSize); - const hits: HitResult[] = []; - for (const object of this.objects) - hits.push(...this.mouseFrustum.intersectObject(object)); - return { - hits, + hits: this.mouseFrustum.intersectObjects(this.objects), }; } diff --git a/client/src/model/model.ts b/client/src/model/model.ts index 885d2ee..71e3762 100644 --- a/client/src/model/model.ts +++ b/client/src/model/model.ts @@ -92,6 +92,17 @@ export class Model { return this.loops[id]; } + public loopsByFilter(filter: (loop: Loop) => boolean): Loop[] { + return Object.values(this.loops).filter(filter); + } + + public loopByFaceId(faceId: Face['id']): Loop | undefined { + const face = this.faceById(faceId); + if (!face) + return undefined + return this.loopById(face.outerLoop); + } + public faceById(id: Face['id']): Face | undefined { return this.faces[id]; }