diff --git a/client/src/components/HitTestView.tsx b/client/src/components/HitTestView.tsx
index e8a65c0..5da0728 100644
--- a/client/src/components/HitTestView.tsx
+++ b/client/src/components/HitTestView.tsx
@@ -11,21 +11,17 @@ export const HitTestView = observer(function ({ float }: { float: boolean }) {
const result = JSON.parse(JSON.stringify(hit)) as HitResult;
const resultAny = result as any;
- delete (resultAny.intersection.object);
- delete (resultAny.intersection.triangle);
- delete (resultAny.intersection.details.kind);
- delete (resultAny.intersection.details.index);
- delete (resultAny.intersection.triangleVertexIds);
- delete (resultAny.intersection.details);
+ delete (resultAny.object);
+ delete (resultAny.uuid);
+ delete (resultAny.id);
+ delete (resultAny.faceId);
+ delete (resultAny.kind);
+ delete (resultAny.visibility);
- resultAny.intersection.point = formatPoint(resultAny.intersection.point)
- resultAny.intersection.depth = Number(Number(resultAny.intersection.depth).toFixed(3));
- resultAny.intersection.radialDistanceAbsolute = Number(Number(resultAny.intersection.radialDistanceAbsolute).toFixed(3));
- resultAny.intersection.radialDistance = Number(Number(resultAny.intersection.radialDistance).toFixed(3));
-
- if (result.kind === 'edge') {
- delete (resultAny.intersection.triangle);
- }
+ resultAny.point = formatPoint(resultAny.point)
+ resultAny.depth = Number(Number(resultAny.depth).toFixed(10));
+ resultAny.radialDistanceAbsolute = Number(Number(result.radialDistanceAbsolute).toFixed(3));
+ resultAny.radialDistance = Number(Number(result.radialDistance).toFixed(3));
return result;
}
@@ -41,11 +37,24 @@ export const HitTestView = observer(function ({ float }: { float: boolean }) {
return (
{
- state.hitResults.hits.map((hit) =>
-
+ state.hitResults.hits.map((hit) => {
+ let description = '';
+ switch (hit.kind) {
+ case 'face':
+ description = `face ${hit.faceId}`;
+ break;
+ case 'edge':
+ description = `edge ${hit.id} on face ${hit.faceId}`;
+ break;
+ case 'vertex':
+ description = `vertex ${hit.id} on face ${hit.faceId}`;
+ break;
+ }
+ return
+
{hit.visibility} {description}
{yaml.stringify(renderHitResult(hit), undefined, 4)}
- )
+ })
}
)
diff --git a/client/src/components/Viewport.tsx b/client/src/components/Viewport.tsx
index 4b3ffff..71d3095 100644
--- a/client/src/components/Viewport.tsx
+++ b/client/src/components/Viewport.tsx
@@ -12,7 +12,7 @@ export const Viewport = function () {
sceneHelper.clearHints();
if (e.hitResults.hits.length) {
e.hitResults.hits.forEach((hit) => {
- sceneHelper.showPointHint(hit.id!, hit.intersection.point);
+ sceneHelper.showPointHint(hit.uuid, hit.point, 5, hit.kind === 'vertex' ? 'yellow' : (hit.kind === 'edge' ? 'lime' : 'white'));
})
// console.log(e.position);
// console.log(e.hitTest.objects.map((o) => o));
@@ -20,7 +20,7 @@ export const Viewport = function () {
}
// raycaster.setFromCamera(new THREE.Vector2(e.x, e.y), camera);
// const hits = raycaster.intersectObjects(sync.meshes);
- const hoveredFaceIds = e.hitResults.hits.map((hit) => hit.intersection.object.userData.faceId);
+ const hoveredFaceIds = e.hitResults.hits.map((hit) => hit.object.userData.faceId);
// if (hoveredFaceIds.length)
// console.log(hoveredFaceIds);
diff --git a/client/src/helpers/circularFrustumIntersect.ts b/client/src/helpers/circularFrustumIntersect.ts
index 6547439..d79a9f2 100644
--- a/client/src/helpers/circularFrustumIntersect.ts
+++ b/client/src/helpers/circularFrustumIntersect.ts
@@ -1,51 +1,25 @@
-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;
+import { model } from '../model/model';
export type Visibility = 'visible' | 'backface'; // | 'occluded'
-export type Intersection = {
+export type BaseHitResult = {
+ uuid: string,
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,
}
@@ -68,7 +42,7 @@ export type HitResults = {
}
export type CircularFrustumIntersectionOptions = {
- findAll?: boolean; // defaults to false
+ // findAll?: boolean; // defaults to false
filter?: (object: THREE.Object3D) => boolean; // defaults to every object
}
@@ -85,6 +59,13 @@ export function intersectionResultToBvh(value: IntersectionResult): typeof NOT_I
}
}
+function isTriangleVisible(tri: THREE.Triangle, frustum: CircularFrustum): boolean {
+ const normal = new THREE.Vector3();
+ tri.getNormal(normal);
+ const facingRatio = normal.dot(frustum.axisNormalized);
+ return facingRatio < 0;
+}
+
function closestPointOnEdgeToRay(
start: THREE.Vector3,
end: THREE.Vector3,
@@ -106,64 +87,6 @@ function closestPointOnEdgeToRay(
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;
@@ -259,16 +182,18 @@ export class CircularFrustumIntersection {
return CircularFrustumIntersection.distanceToPoint(point, this.frustum);
}
- public intersectMesh(
+ public intersectMeshFaces(
mesh: THREE.Mesh,
- findAll: boolean,
- ): Intersection[] {
+ ): FaceHitResult[] {
+ const findAll = true;
+
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);
@@ -279,47 +204,14 @@ export class CircularFrustumIntersection {
if (this.insersectsSphere(boundingSphere) === 'NOT_INTERSECTED')
return [];
- function getGeometryVertextIdByIndex(vertexIndex: number): Id {
- return (mesh.userData as MeshDto).loop[vertexIndex].vertex;
+ const bestFaceHits: Record
= {};
+
+ function tryBestTriangleHit(hit: FaceHitResult) {
+ const bestFaceHit = bestFaceHits[hit.faceId];
+ if (!bestFaceHit || bestFaceHit.radialDistance > hit.radialDistance)
+ bestFaceHits[hit.faceId] = hit;
}
- 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;
@@ -328,171 +220,151 @@ export class CircularFrustumIntersection {
bvh.shapecast({
intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)),
- intersectsTriangle: (tri: ExtendedTriangle, triIndex: number, contained: boolean) => {
- const triangleVertexIds = getGeometryVertextIds(triIndex);
+ intersectsTriangle: (tri: ExtendedTriangle) => {
+ const visibility = isTriangleVisible(tri, localFrustum) ? 'visible' : 'backface';
- 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';
+ let bestPoint: THREE.Vector3 | undefined;
+ let bestRadialDistance = Infinity;
- 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;
+ function tryBestPoint(facePoint: THREE.Vector3) {
+ if (CircularFrustumIntersection.pointAxialDepth(facePoint, localFrustum) === 'NOT_INTERSECTED')
+ return;
+ const radialDistance = CircularFrustumIntersection.distanceToPoint(facePoint, localFrustum);
+ if (radialDistance < bestRadialDistance) {
+ bestRadialDistance = radialDistance;
+ bestPoint = facePoint;
+ }
}
- 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
+ // ray pierces triangle -> internal hit point
const faceHit = new THREE.Vector3();
- if (axisRay.intersectTriangle(tri.a, tri.b, tri.c, false, faceHit)) {
- tryBestPoint(faceHit, bestPoint);
- }
+ if (localFrustum.ray.intersectTriangle(tri.a, tri.b, tri.c, false, faceHit))
+ tryBestPoint(faceHit);
- 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,
- };
- }),
- );
+ // ray misses triangle, but cone touches an edge -> external, but close enough, hit point
+ for (const [a, b] of [[tri.a, tri.b], [tri.b, tri.c], [tri.c, tri.a]] as [THREE.Vector3, THREE.Vector3][])
+ tryBestPoint(closestPointOnEdgeToRay(a, b, localFrustum.ray));
+
+ if (bestPoint) {
+ const bestPointWorld = bestPoint.clone().applyMatrix4(matrix);
+ const depth = worldFrustum.axisNormalized.dot(bestPointWorld.clone().sub(worldFrustum.apex));
+ const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(bestPointWorld, worldFrustum);
+ const faceId = (mesh.userData as MeshDto).faceId;
+
+ tryBestTriangleHit({
+ kind: 'face',
+ uuid: faceId,
+ faceId: faceId,
+ object: mesh,
+ point: bestPointWorld,
+ depth,
+ radialDistanceAbsolute,
+ radialDistance: radialDistanceAbsolute / (depth * Math.tan(worldFrustum.halfAngle)),
+ visibility,
+ });
return !findAll;
}
-
return false;
},
});
+ return Object.values(bestFaceHits);
+ }
+
+ public intersectMesh(
+ mesh: THREE.Mesh,
+ ): HitResult[] {
+ const faceHits = this.intersectMeshFaces(mesh);
+ const results = [...faceHits] as HitResult[];
+ for (const faceHit of faceHits) {
+ const loop = model.loopByFaceId(faceHit.faceId);
+ if (!loop)
+ continue;
+
+ const halfEdges = model.halfEdgesByLoop(loop.id);
+ const vertices = halfEdges
+ .map((he) => model.vertexById(he.origin)!)
+ .map((v) => ({ id: v.id, point: new THREE.Vector3(v.x, v.y, v.z) }));
+
+ const edges = halfEdges
+ .map((he, idx) => ({ id: he.ownerEdge, a: vertices[idx].point, b: vertices[(idx + 1) % vertices.length].point }));
+
+ for (const vertex of vertices) {
+ const d = this.pointAxialDepth(vertex.point);
+ if (d === 'NOT_INTERSECTED')
+ continue;
+ const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(vertex.point, this.frustum);
+ results.push(
+ {
+ kind: 'vertex',
+ uuid: faceHit.faceId + '.' + vertex.id,
+ id: vertex.id,
+ faceId: faceHit.faceId,
+ object: faceHit.object,
+ visibility: faceHit.visibility,
+ point: vertex.point,
+ depth: d,
+ radialDistanceAbsolute,
+ radialDistance: radialDistanceAbsolute / (d * Math.tan(this.frustum.halfAngle)),
+ },
+ );
+ }
+
+ for (const edge of edges) {
+ const closestLocal = closestPointOnEdgeToRay(edge.a, edge.b, this.frustum.ray);
+ const d = this.pointAxialDepth(closestLocal);
+ if (d === 'NOT_INTERSECTED')
+ continue;
+ const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(closestLocal, this.frustum);
+ results.push(
+ {
+ kind: 'edge',
+ uuid: faceHit.faceId + '.' + edge.id,
+ id: edge.id,
+ faceId: faceHit.faceId,
+ object: faceHit.object,
+ visibility: faceHit.visibility,
+ point: closestLocal,
+ depth: d,
+ radialDistanceAbsolute,
+ radialDistance: radialDistanceAbsolute / (d * Math.tan(this.frustum.halfAngle)),
+ },
+ );
+ }
+
+ }
return results;
}
public intersectObject(
- obj: THREE.Object3D,
+ object: THREE.Object3D,
options: CircularFrustumIntersectionOptions = {},
): HitResult[] {
const results: HitResult[] = [];
- obj.traverseVisible((object) => {
+ object.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),
- );
+ results.push(...this.intersectMesh(object));
});
- // sort closest first
- results.sort((a, b) => a.intersection.depth - b.intersection.depth);
+ results.sort((a, b) => a.depth - b.depth);
return results;
}
- private intersectionToHitResult(intersection: Intersection): HitResult | undefined {
- const userData = intersection.object.userData as MeshDto;
- const faceId = userData.faceId;
- const loop = userData.loop;
+ public intersectObjects(
+ objects: THREE.Object3D[],
+ options: CircularFrustumIntersectionOptions = {},
+ ): HitResult[] {
+ const results: HitResult[] = [];
- function vertexId(index: number) {
- return intersection.triangleVertexIds[index];
- }
+ for (const object of objects)
+ results.push(...this.intersectObject(object, options));
- 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,
- };
- }
+ results.sort((a, b) => a.depth - b.depth);
+ return results;
}
}
\ No newline at end of file
diff --git a/client/src/helpers/sceneHelper.ts b/client/src/helpers/sceneHelper.ts
index 4f2210f..db0e548 100644
--- a/client/src/helpers/sceneHelper.ts
+++ b/client/src/helpers/sceneHelper.ts
@@ -50,12 +50,8 @@ export class SceneHelper {
): HitResults {
this.buildMouseFrustum(mouseNormalized, screenSize);
- const hits: HitResult[] = [];
- for (const object of this.objects)
- hits.push(...this.mouseFrustum.intersectObject(object));
-
return {
- hits,
+ hits: this.mouseFrustum.intersectObjects(this.objects),
};
}
diff --git a/client/src/model/model.ts b/client/src/model/model.ts
index 885d2ee..71e3762 100644
--- a/client/src/model/model.ts
+++ b/client/src/model/model.ts
@@ -92,6 +92,17 @@ export class Model {
return this.loops[id];
}
+ public loopsByFilter(filter: (loop: Loop) => boolean): Loop[] {
+ return Object.values(this.loops).filter(filter);
+ }
+
+ public loopByFaceId(faceId: Face['id']): Loop | undefined {
+ const face = this.faceById(faceId);
+ if (!face)
+ return undefined
+ return this.loopById(face.outerLoop);
+ }
+
public faceById(id: Face['id']): Face | undefined {
return this.faces[id];
}