import * as THREE from 'three'; import { CONTAINED, ExtendedTriangle, INTERSECTED, NOT_INTERSECTED } from 'three-mesh-bvh'; import { CircularFrustum } from './circularFrustum'; export type HitResults = { hits: HitResult[]; } 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 } 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; } } 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'; } // ─── Local frustum construction ────────────────────────────────────────────── /** * Transform a world-space CircularFrustum into an object's local space. * Note: halfAngle is only preserved exactly under uniform scale. */ private toObjectLocalSpace(invWorldMatrix: THREE.Matrix4): CircularFrustum { return this.frustum.transform(invWorldMatrix); } public intersectMesh( mesh: THREE.Mesh, findAll: boolean, ): HitResult[] { 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 []; const results: HitResult[] = []; 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, _index: number, contained: boolean) => { 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 }); 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(); } }; // 1. Test vertices tryPoint(tri.a); tryPoint(tri.b); tryPoint(tri.c); // 2. For each edge, find the point closest to the frustum axis ray, // and also the point closest to the apex. // This catches triangles that straddle the cone surface. 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); tryPoint(pointOnEdge); } // 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)); } // 3. Closest point on the triangle face to the apex const closestOnFace = new THREE.Vector3(); tri.closestPointToPoint(localFrustum.apex, closestOnFace); if (!isNaN(closestOnFace.x)) tryPoint(closestOnFace); if (bestLocal !== undefined) { const worldPoint = (bestLocal as THREE.Vector3).clone().applyMatrix4(mesh.matrixWorld); const worldDepth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex)); results.push({ object: mesh, point: worldPoint, depth: worldDepth, triangle: tri }); return !findAll; } return false; }, }); // } else { // // ── Fallback: bounding box only ─────────────────────────────────── // if (!geometry.boundingBox) // geometry.computeBoundingBox(); // const worldBox = geometry.boundingBox!.clone().applyMatrix4(mesh.matrixWorld); // const boxResult = this.intersectsBox(worldBox.clone()); // if (boxResult !== NOT_INTERSECTED) { // const center = new THREE.Vector3(); // worldBox.getCenter(center); // const depth = this.frustum.axis.dot(center.clone().sub(this.frustum.apex)); // results.push({ object: mesh, point: center, depth }); // } // } 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)); }); // sort closest first results.sort((a, b) => a.depth - b.depth); return results; } }