CAD/client/src/helpers/circularFrustumIntersect.ts

498 lines
19 KiB
TypeScript

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