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 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', id?: Id, } 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 triangle: { a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3 }, triHit?: TriangleHitDetail, visibility: Visibility, } 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; } } const BARYCENTRIC_EPSILON = 1e-1; function classifyTriangleHit( point: THREE.Vector3, tri: ExtendedTriangle, vertexIds?: [Id, Id, 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: `${vertexIds?.[0]}-${vertexIds?.[1]}` }; if (onBC) return { kind: 'edge', index: 1, id: `${vertexIds?.[1]}-${vertexIds?.[2]}` }; if (onCA) return { kind: 'edge', index: 2, id: `${vertexIds?.[2]}-${vertexIds?.[0]}` }; return { kind: 'face' }; } 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); } 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.vertexIds[vertexIndex]; } 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(); } }; 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 tiangleVertexIds = 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 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, triHit: classifyTriangleHit(tri.a, tri, tiangleVertexIds), visibility, // vertexIds: tiangleVertexIds, }); 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 edge = b.clone().sub(a); const toA = a.clone().sub(localFrustum.apex); // Closest point on edge segment to the axis ray const edgeDir = edge.clone().normalize(); const axisDotEdge = localFrustum.axisNormalized.dot(edgeDir); const denom = 1 - axisDotEdge * axisDotEdge; if (Math.abs(denom) > 1e-10) { const t = ( localFrustum.axisNormalized.dot(toA) * axisDotEdge - toA.dot(edgeDir) ) / denom; const edgeLen = edge.length(); const tClamped = Math.max(0, Math.min(edgeLen, t)); const pointOnEdge = a.clone().addScaledVector(edgeDir, tClamped); tryBestPoint(pointOnEdge, bestPoint); } // Closest point on edge to the apex itself const tApex = Math.max(0, Math.min(1, -toA.dot(edge) / edge.lengthSq())); 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)); const { a, b, c } = tri; results.push({ object: mesh, point: worldPoint, depth: worldDepth, triangle: { a, b, c }, triHit: classifyTriangleHit(bestPoint.local, tri, tiangleVertexIds), visibility, }); 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) .flatMap((i) => this.intersectionToHitResult(i)), ); }); // sort closest first results.sort((a, b) => a.intersection.depth - b.intersection.depth); return results; } private intersectionToHitResult(intersection: Intersection): HitResult[] { const faceId = intersection.object.userData.faceId; const results: HitResult[] = [{ kind: 'face', id: faceId, faceId, intersection: { ...intersection, triHit: undefined, }, }]; if (intersection.triHit?.kind === 'edge') { results.unshift({ kind: 'edge', id: intersection.triHit?.id, faceId, intersection: { ...intersection, triHit: undefined, }, }); } if (intersection.triHit?.kind === 'vertex') { results.unshift({ kind: 'vertex', id: intersection.triHit?.id, faceId, intersection: { ...intersection, triHit: undefined, }, }); } return results; } }