407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
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,
|
|
}
|
|
|
|
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: 'none' };
|
|
if (onBC) return { kind: 'edge', index: 1, id: 'none' };
|
|
if (onCA) return { kind: 'edge', index: 2, id: 'none' };
|
|
|
|
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',
|
|
intersection,
|
|
id: faceId,
|
|
}];
|
|
if (intersection.triHit?.kind === 'edge') {
|
|
results.unshift({
|
|
kind: 'edge',
|
|
intersection,
|
|
id: intersection.triHit?.id,
|
|
faceId,
|
|
});
|
|
}
|
|
if (intersection.triHit?.kind === 'vertex') {
|
|
results.unshift({
|
|
kind: 'vertex',
|
|
intersection,
|
|
id: intersection.triHit?.id,
|
|
faceId,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
} |