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; export type Visibility = 'visible' | 'backface'; // | 'occluded' export type Intersection = { 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, } export type EdgeHitResult = BaseHitResult & { kind: 'edge', id?: Id, faceId: Id, } export type VertexHitResult = BaseHitResult & { kind: 'vertex', id?: Id, faceId: Id, } export type HitResult = FaceHitResult | EdgeHitResult | VertexHitResult; export type HitResults = { hits: HitResult[]; } export type CircularFrustumIntersectionOptions = { findAll?: boolean; // defaults to false filter?: (object: THREE.Object3D) => boolean; // defaults to every object } export type IntersectionResult = 'NOT_INTERSECTED' | 'INTERSECTED' | 'CONTAINED'; export function intersectionResultToBvh(value: IntersectionResult): typeof NOT_INTERSECTED | typeof INTERSECTED | typeof CONTAINED { switch (value) { case 'NOT_INTERSECTED': return NOT_INTERSECTED; case 'INTERSECTED': return INTERSECTED; case 'CONTAINED': return CONTAINED; } } function closestPointOnEdgeToRay( start: THREE.Vector3, end: THREE.Vector3, ray: THREE.Ray, ): THREE.Vector3 { const edgeDir = end.clone().sub(start); const edgeLen = end.clone().sub(start).length(); const toA = start.clone().sub(ray.origin); const axisDotEdge = ray.direction.dot(edgeDir); const denom = 1 - axisDotEdge * axisDotEdge; let t: number; if (Math.abs(denom) > 1e-10) t = (ray.direction.dot(toA) * axisDotEdge - toA.dot(edgeDir)) / denom; else t = 0; // edge is parallel to axis ray — any point works, pick closest endpoint 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; constructor(frustum: CircularFrustum) { this.frustum = frustum; } public insersectsSphere(sphere: THREE.Sphere): 'NOT_INTERSECTED' | number { return CircularFrustumIntersection.insersectsSphere(sphere, this.frustum); } /** * sphere and frustum both should be in the same coordinate space (local or world) * * Uses the Barros / van den Bergen separating-axis approach: * - Check whether the sphere centre is inside the cone (fast path) * - Otherwise check the distance from the sphere centre to the * nearest cone surface (lateral face + apex cap) * * @returns axial depth of sphere center or NOT_INTERSECTED */ public static insersectsSphere(sphere: THREE.Sphere, frustum: CircularFrustum): 'NOT_INTERSECTED' | number { const toCenter = sphere.center.clone().sub(frustum.apex); const axialDist = toCenter.dot(frustum.axisNormalized); if (axialDist + sphere.radius < 0) // behind the apex entirely return 'NOT_INTERSECTED'; const lateralDist = toCenter.clone().addScaledVector(frustum.axisNormalized, -axialDist).length(); const distToConeEdge = lateralDist * frustum.cosHalfAngle - axialDist * frustum.sinHalfAngle; if (distToConeEdge > sphere.radius) // fully outside lateral surface return 'NOT_INTERSECTED'; return axialDist; } public intersectsBox(box: THREE.Box3): IntersectionResult { return CircularFrustumIntersection.intersectsBox(box, this.frustum); } // box and this.frustum both should be in the same coordinate space (local or world) public static intersectsBox(box: THREE.Box3, frustum: CircularFrustum): IntersectionResult { const sphere = new THREE.Sphere(); box.getBoundingSphere(sphere); if (CircularFrustumIntersection.insersectsSphere(sphere, frustum) === 'NOT_INTERSECTED') return 'NOT_INTERSECTED'; // Check if all 8 corners are inside — if so, CONTAINED const corners = Array(8) .fill(0) .map((_, i) => new THREE.Vector3( i & 1 ? box.max.x : box.min.x, i & 2 ? box.max.y : box.min.y, i & 4 ? box.max.z : box.min.z, )); const allInside = corners.every((c) => CircularFrustumIntersection.pointAxialDepth(c, frustum) !== 'NOT_INTERSECTED'); return allInside ? 'CONTAINED' : 'INTERSECTED'; } public pointAxialDepth(point: THREE.Vector3): 'NOT_INTERSECTED' | number { return CircularFrustumIntersection.pointAxialDepth(point, this.frustum); } public static pointAxialDepth(point: THREE.Vector3, frustum: CircularFrustum): 'NOT_INTERSECTED' | number { const toPoint = point.clone().sub(frustum.apex); const dist = toPoint.length(); if (dist === 0) return 0; const axialDist = toPoint.dot(frustum.axisNormalized); const cosAngle = axialDist / dist; return cosAngle >= frustum.cosHalfAngle ? axialDist : 'NOT_INTERSECTED'; } //world-space to an object's local space private toObjectLocalSpace(invWorldMatrix: THREE.Matrix4): CircularFrustum { return this.frustum.transform(invWorldMatrix); } // distance from point to frustum axis where it is the closest private static distanceToPoint(point: THREE.Vector3, frustum: CircularFrustum): number { const toPoint = point.clone().sub(frustum.apex); const axial = toPoint.dot(frustum.axisNormalized); return toPoint.addScaledVector(frustum.axisNormalized, -axial).length(); } private distanceToPoint(point: THREE.Vector3): number { return CircularFrustumIntersection.distanceToPoint(point, this.frustum); } public intersectMesh( mesh: THREE.Mesh, findAll: boolean, ): Intersection[] { 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); // quick check for bounding sphere if (!geometry.boundingSphere) geometry.computeBoundingSphere(); const boundingSphere = geometry.boundingSphere!.clone().applyMatrix4(matrix); if (this.insersectsSphere(boundingSphere) === 'NOT_INTERSECTED') return []; function getGeometryVertextIdByIndex(vertexIndex: number): Id { return (mesh.userData as MeshDto).loop[vertexIndex].vertex; } 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; if (!bvh) throw new Error('No BVH found for a mesh'); bvh.shapecast({ intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)), intersectsTriangle: (tri: ExtendedTriangle, triIndex: number, contained: boolean) => { const triangleVertexIds = getGeometryVertextIds(triIndex); 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'; 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; } 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 const faceHit = new THREE.Vector3(); if (axisRay.intersectTriangle(tri.a, tri.b, tri.c, false, faceHit)) { tryBestPoint(faceHit, bestPoint); } 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, }; }), ); return !findAll; } return false; }, }); return results; } public intersectObject( obj: THREE.Object3D, options: CircularFrustumIntersectionOptions = {}, ): HitResult[] { const results: HitResult[] = []; obj.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), ); }); // sort closest first results.sort((a, b) => a.intersection.depth - b.intersection.depth); return results; } private intersectionToHitResult(intersection: Intersection): HitResult | undefined { const userData = intersection.object.userData as MeshDto; const faceId = userData.faceId; const loop = userData.loop; function vertexId(index: number) { return intersection.triangleVertexIds[index]; } 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, }; } } }