floating hit test info box

This commit is contained in:
azykov@mail.ru 2026-05-24 11:56:06 +03:00
parent c6979f03fc
commit df90158cb0
No known key found for this signature in database
6 changed files with 113 additions and 45 deletions

View File

@ -7,11 +7,11 @@
height: 600px; height: 600px;
} }
#hit-test { .hit-test-info {
position: absolute; position: absolute;
top: 600px; top: 0px;
left: 000px; left: 0px;
width: 600px;
font-size: 75%; font-size: 75%;
pointer-events: none; pointer-events: none;
color: white; color: white;

View File

@ -9,7 +9,7 @@ export const App = function () {
<div> <div>
<Viewport /> <Viewport />
<div className="side-panel"> <div className="side-panel">
<HitTestView /> <HitTestView float/>
<DbView /> <DbView />
</div> </div>
</div> </div>

View File

@ -1,25 +1,29 @@
import * as yaml from 'yaml'; import * as yaml from 'yaml';
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { state } from "../state/root"; import { state } from "../state/root";
import type { CSSProperties } from 'react';
import { formatPoint } from '../helpers/stringFormat';
export const HitTestView = observer(function () { export const HitTestView = observer(function ({ float }: { float: boolean }) {
const left = state.mousePosition.x; const style: CSSProperties = {};
const top = state.mousePosition.y; if (float) {
style.left = state.mousePosition.x;
style.top = state.mousePosition.y;
style.width = 'auto';
style.height = 'auto';
}
return ( return (
<div id="hit-test" style={{ top, left }}> <div className="hit-test-info" style={style}>
<pre style={{ textWrap: 'wrap' }}> <pre style={{ textWrap: 'wrap' }}>
{
`${top},${left}`
}
{ {
state.hitResults.hits.map((hit) => state.hitResults.hits.map((hit) =>
<div key={hit.object.uuid}> <div key={hit.id}>
<div>{yaml.stringify( <div>{yaml.stringify(
{ {
hit: { ...hit, object: undefined, triangle: undefined }, hit: { ...hit, intersection: { ...hit.intersection, point: formatPoint(hit.intersection.point), object: undefined, triangle: undefined } },
userData: hit.object.userData, // userData: hit.intersection.object.userData,
}, },
undefined, undefined,
2, 2,

View File

@ -12,15 +12,15 @@ export const Viewport = function () {
sceneHelper.clearHints(); sceneHelper.clearHints();
if (e.hitResults.hits.length) { if (e.hitResults.hits.length) {
e.hitResults.hits.forEach((hit) => { e.hitResults.hits.forEach((hit) => {
sceneHelper.showPointHint(hit.object.uuid, hit.intersection.point); sceneHelper.showPointHint(hit.intersection.object.uuid, hit.intersection.point);
}) })
// console.log(e.position); // console.log(e.position);
// console.log(e.hitTest.objects.map((o) => o)); // console.log(e.hitTest.objects.map((o) => o));
// console.log(e.hitTest.objects.flatMap((o) => o.point.toArray())); // console.log(e.hitTest.objects.flatMap((o) => o.point.toArray()));
} }
// raycaster.setFromCamera(new THREE.Vector2(e.x, e.y), camera); // raycaster.setFromCamera(new THREE.Vector2(e.x, e.y), camera);
// const hits = raycaster.intersectObjects(sync.meshes); // const hits = raycaster.intersectObjects(sync.meshes);
const hoveredFaceIds = e.hitResults.hits.map((hit) => hit.object.userData.faceId); const hoveredFaceIds = e.hitResults.hits.map((hit) => hit.intersection.object.userData.faceId);
// if (hoveredFaceIds.length) // if (hoveredFaceIds.length)
// console.log(hoveredFaceIds); // console.log(hoveredFaceIds);

View File

@ -3,33 +3,59 @@ import { CONTAINED, ExtendedTriangle, INTERSECTED, NOT_INTERSECTED } from 'three
import { CircularFrustum } from './circularFrustum'; import { CircularFrustum } from './circularFrustum';
import type { Id } from '../types'; import type { Id } from '../types';
export type HitResults = {
hits: HitResult[];
}
export type TriangleVertexHitDetail = { export type TriangleVertexHitDetail = {
kind: 'vertex', kind: 'vertex',
index: 0 | 1 | 2, index: 0 | 1 | 2,
id: Id, id?: Id,
} }
export type TriangleEdgeHitDetail = { export type TriangleEdgeHitDetail = {
kind: 'edge', kind: 'edge',
index: 0 | 1 | 2, // edge 0=AB, 1=BC, 2=CA index: 0 | 1 | 2, // edge 0=AB, 1=BC, 2=CA
id: Id, id?: Id,
} }
export type TriangleFaceHitDetail = { export type TriangleFaceHitDetail = {
kind: 'face', kind: 'face',
id?: Id,
} }
export type TriangleHitDetail = TriangleVertexHitDetail | TriangleEdgeHitDetail | TriangleFaceHitDetail; export type TriangleHitDetail = TriangleVertexHitDetail | TriangleEdgeHitDetail | TriangleFaceHitDetail;
export type HitResult = { export type Visibility = 'visible' | 'backface'; // | 'occluded'
object: THREE.Object3D;
point: THREE.Vector3; // world-space closest hit point export type Intersection = {
depth: number; // depth along frustum axis object: THREE.Object3D,
triangle: { a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3 }; point: THREE.Vector3, // world-space closest hit point
triHit?: TriangleHitDetail; depth: number, // depth along frustum axis
vertexIds: [Id, Id, Id] | undefined; // undefined is when geometry does not have .index 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 = { export type CircularFrustumIntersectionOptions = {
@ -55,7 +81,7 @@ const BARYCENTRIC_EPSILON = 1e-1;
function classifyTriangleHit( function classifyTriangleHit(
point: THREE.Vector3, point: THREE.Vector3,
tri: ExtendedTriangle, tri: ExtendedTriangle,
vertexIds: [Id, Id, Id], vertexIds?: [Id, Id, Id],
): TriangleHitDetail { ): TriangleHitDetail {
// Compute barycentric coords via areas // Compute barycentric coords via areas
const ab = tri.b.clone().sub(tri.a); const ab = tri.b.clone().sub(tri.a);
@ -78,9 +104,9 @@ function classifyTriangleHit(
const onB = v > eps; const onB = v > eps;
const onC = w > eps; const onC = w > eps;
if (onA) return { kind: 'vertex', index: 0, id: vertexIds[0] }; if (onA) return { kind: 'vertex', index: 0, id: vertexIds?.[0] };
if (onB) return { kind: 'vertex', index: 1, id: vertexIds[1] }; if (onB) return { kind: 'vertex', index: 1, id: vertexIds?.[1] };
if (onC) return { kind: 'vertex', index: 2, id: vertexIds[2] }; if (onC) return { kind: 'vertex', index: 2, id: vertexIds?.[2] };
const onAB = w < BARYCENTRIC_EPSILON; // u+v≈1, w≈0 const onAB = w < BARYCENTRIC_EPSILON; // u+v≈1, w≈0
const onBC = u < BARYCENTRIC_EPSILON; const onBC = u < BARYCENTRIC_EPSILON;
@ -180,7 +206,7 @@ export class CircularFrustumIntersection {
public intersectMesh( public intersectMesh(
mesh: THREE.Mesh, mesh: THREE.Mesh,
findAll: boolean, findAll: boolean,
): HitResult[] { ): Intersection[] {
const geometry = mesh.geometry; const geometry = mesh.geometry;
if (!geometry) if (!geometry)
return []; return [];
@ -201,14 +227,14 @@ export class CircularFrustumIntersection {
return mesh.userData.vertexIds[vertexIndex]; return mesh.userData.vertexIds[vertexIndex];
} }
function getGeometryVertextIds(triIndex: number): HitResult['vertexIds'] { function getGeometryVertextIds(triIndex: number): [Id, Id, Id] {
return geometry.index return geometry.index
? [ ? [
getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3]), getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3]),
getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 1]), getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 1]),
getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 2]), getGeometryVertextIdByIndex(geometry.index.array[triIndex * 3 + 2]),
] ]
: undefined; : ['', '', ''];
} }
const axisRay = new THREE.Ray(localFrustum.apex, localFrustum.axisNormalized); const axisRay = new THREE.Ray(localFrustum.apex, localFrustum.axisNormalized);
@ -221,7 +247,7 @@ export class CircularFrustumIntersection {
} }
}; };
const results: HitResult[] = []; const results: Intersection[] = [];
if (!geometry.boundsTree) if (!geometry.boundsTree)
geometry.computeBoundsTree(); geometry.computeBoundsTree();
@ -232,7 +258,13 @@ export class CircularFrustumIntersection {
intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)), intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)),
intersectsTriangle: (tri: ExtendedTriangle, triIndex: number, contained: boolean) => { intersectsTriangle: (tri: ExtendedTriangle, triIndex: number, contained: boolean) => {
const tiangleVertexIds = getGeometryVertextIds(triIndex) ?? ['','','']; 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) { if (contained) {
const worldPoint = tri.a.clone().applyMatrix4(mesh.matrixWorld); const worldPoint = tri.a.clone().applyMatrix4(mesh.matrixWorld);
@ -243,7 +275,8 @@ export class CircularFrustumIntersection {
depth, depth,
triangle: tri, triangle: tri,
triHit: classifyTriangleHit(tri.a, tri, tiangleVertexIds), triHit: classifyTriangleHit(tri.a, tri, tiangleVertexIds),
vertexIds: tiangleVertexIds, visibility,
// vertexIds: tiangleVertexIds,
}); });
return !findAll; return !findAll;
} }
@ -310,7 +343,7 @@ export class CircularFrustumIntersection {
depth: worldDepth, depth: worldDepth,
triangle: { a, b, c }, triangle: { a, b, c },
triHit: classifyTriangleHit(bestPoint.local, tri, tiangleVertexIds), triHit: classifyTriangleHit(bestPoint.local, tri, tiangleVertexIds),
vertexIds: tiangleVertexIds, visibility,
}); });
return !findAll; return !findAll;
} }
@ -334,11 +367,41 @@ export class CircularFrustumIntersection {
if (!(object instanceof THREE.Mesh)) if (!(object instanceof THREE.Mesh))
return; return;
results.push(...this.intersectMesh(object, !!options.findAll)); results.push(
...this.intersectMesh(object, !!options.findAll)
.flatMap((i) => this.intersectionToHitResult(i)),
);
}); });
// sort closest first // sort closest first
results.sort((a, b) => a.depth - b.depth); 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; return results;
} }
} }

View File

@ -38,7 +38,8 @@ export class SceneSync {
const meshes = Geometry const meshes = Geometry
.tessellateSolid(id) .tessellateSolid(id)
.map(meshToDto); .map(meshToDto);
this.addSolid(meshes[2]); this.addSolid(meshes[0]); // bottom
this.addSolid(meshes[3]); // front
// for (const mesh of meshes) // for (const mesh of meshes)
// this.addSolid(mesh); // this.addSolid(mesh);
} }