CAD/client/src/helpers/circularFrustumIntersect.ts

263 lines
11 KiB
TypeScript

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