better edge and vertex hittesting
This commit is contained in:
parent
0354391f96
commit
f5f5dcd84f
|
|
@ -15,6 +15,14 @@
|
|||
font-size: 75%;
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
|
||||
white-space: pre;
|
||||
font: 9px RobotoMono;
|
||||
font-weight: 200;
|
||||
line-height: 9px;
|
||||
letter-spacing: 0.25px;
|
||||
// font-variant: small-caps;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
#blob-view {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -3,9 +3,31 @@ import { observer } from "mobx-react-lite";
|
|||
import { state } from "../state/root";
|
||||
import type { CSSProperties } from 'react';
|
||||
import { formatPoint } from '../helpers/stringFormat';
|
||||
import type { HitResult } from '../helpers/circularFrustumIntersect';
|
||||
|
||||
export const HitTestView = observer(function ({ float }: { float: boolean }) {
|
||||
|
||||
function renderHitResult(hit: HitResult) {
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const style: CSSProperties = {};
|
||||
if (float) {
|
||||
style.left = 0; //state.mousePosition.x;
|
||||
|
|
@ -16,22 +38,13 @@ export const HitTestView = observer(function ({ float }: { float: boolean }) {
|
|||
|
||||
return (
|
||||
<div className="hit-test-info" style={style}>
|
||||
<pre style={{ textWrap: 'wrap' }}>
|
||||
{
|
||||
state.hitResults.hits.map((hit) =>
|
||||
<div key={hit.faceId + '-' + hit.id}>
|
||||
<div>{yaml.stringify(
|
||||
{
|
||||
hit: { ...hit, intersection: { ...hit.intersection, point: formatPoint(hit.intersection.point), object: undefined, triangle: undefined } },
|
||||
// userData: hit.intersection.object.userData,
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
)}</div>
|
||||
<div key={hit.faceId + '-' + hit.id} style={{ padding: 2, borderBottom: '1px solid gray'}}>
|
||||
<div>{yaml.stringify(renderHitResult(hit), undefined, 4)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const ThreeView = function (props: ThreeViewProps) {
|
|||
|
||||
let handleClick: (e: InteractionMouseEventArgs) => void;
|
||||
let handleHover: (e: InteractionMouseEventArgs) => void;
|
||||
let handleCameraChange: () => void;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
|
@ -98,7 +99,7 @@ export const ThreeView = function (props: ThreeViewProps) {
|
|||
const { scene, camera } = setupScene({ w: W, h: H });
|
||||
cameraRef.current = camera;
|
||||
|
||||
props.sceneHelper.initialize(scene, camera);
|
||||
props.sceneHelper.initialize(scene, camera, renderer);
|
||||
|
||||
const handleWindowResize = () => {
|
||||
const w = container.clientWidth;
|
||||
|
|
@ -137,6 +138,10 @@ export const ThreeView = function (props: ThreeViewProps) {
|
|||
});
|
||||
};
|
||||
|
||||
handleCameraChange = () => {
|
||||
props.sceneHelper.applyCamera();
|
||||
}
|
||||
|
||||
// --- Animation loop ---
|
||||
let lastTime = performance.now();
|
||||
let animId: number;
|
||||
|
|
@ -177,6 +182,7 @@ export const ThreeView = function (props: ThreeViewProps) {
|
|||
useInteraction(canvasRef, cameraRef, {
|
||||
onMouseMove: (e) => handleHover?.(e),
|
||||
onMouseClick: (e) => handleClick?.(e),
|
||||
onCameraChange: () => handleCameraChange?.(),
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export const Viewport = function () {
|
|||
sceneHelper.clearHints();
|
||||
if (e.hitResults.hits.length) {
|
||||
e.hitResults.hits.forEach((hit) => {
|
||||
sceneHelper.showPointHint(hit.intersection.object.uuid, hit.intersection.point);
|
||||
sceneHelper.showPointHint(hit.id!, hit.intersection.point);
|
||||
})
|
||||
// console.log(e.position);
|
||||
// console.log(e.hitTest.objects.map((o) => o));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import * as THREE from "three";
|
||||
|
||||
export type ThreeHint = {
|
||||
mesh: THREE.Mesh,
|
||||
position: THREE.Vector3Like,
|
||||
options: ThreeHintOptions,
|
||||
}
|
||||
|
||||
export type ThreeBaseHintOptions = {
|
||||
color: THREE.ColorRepresentation,
|
||||
}
|
||||
|
||||
|
||||
export type ThreePointHintOptions = ThreeBaseHintOptions & {
|
||||
kind: 'point',
|
||||
size: number,
|
||||
}
|
||||
|
||||
export type ThreeCircleHintOptions = ThreeBaseHintOptions & {
|
||||
kind: 'circle',
|
||||
radius: number,
|
||||
thickness: number,
|
||||
}
|
||||
|
||||
export type ThreeHintOptions = ThreePointHintOptions | ThreeCircleHintOptions;
|
||||
|
||||
export class ThreeHintDisplay {
|
||||
|
||||
private scene: THREE.Scene;
|
||||
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
|
||||
private readonly baseMaterial = new THREE.MeshBasicMaterial({ color: 'red' });
|
||||
|
||||
private readonly hints: Record<string, ThreeHint> = {};
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
camera: THREE.PerspectiveCamera | THREE.OrthographicCamera,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
) {
|
||||
this.scene = scene;
|
||||
this.camera = camera;
|
||||
this.renderer = renderer;
|
||||
}
|
||||
|
||||
private createGeometry(options: ThreeHintOptions): THREE.BufferGeometry {
|
||||
switch (options.kind) {
|
||||
case 'point':
|
||||
return new THREE.SphereGeometry(1);
|
||||
case 'circle':
|
||||
return new THREE.TorusGeometry(options.radius, options.thickness * options.radius);
|
||||
default:
|
||||
throw new Error('Unknown volatile geometry type');
|
||||
}
|
||||
}
|
||||
|
||||
private ensure(id: string, options: ThreeHintOptions): ThreeHint {
|
||||
if (!this.hints[id]) {
|
||||
const material = this.baseMaterial.clone();
|
||||
material.color.set(options.color);
|
||||
this.hints[id] = {
|
||||
mesh: new THREE.Mesh(
|
||||
this.createGeometry(options),
|
||||
material,
|
||||
),
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
options,
|
||||
};
|
||||
this.scene.add(this.hints[id].mesh);
|
||||
}
|
||||
|
||||
return this.hints[id];
|
||||
}
|
||||
|
||||
private disposeHint(id: string) {
|
||||
const point = this.hints[id];
|
||||
if (point) {
|
||||
this.scene.remove(point.mesh);
|
||||
point.mesh.geometry.dispose();
|
||||
delete (this.hints[id]);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
for (const id in this.hints)
|
||||
this.disposeHint(id);
|
||||
}
|
||||
|
||||
public set(id: string, position: THREE.Vector3Like, options: ThreeHintOptions) {
|
||||
const object = this.ensure(id, options);
|
||||
object.position = position;
|
||||
this.applyCameraToHint(object);
|
||||
}
|
||||
|
||||
private applyCameraToHint(hint: ThreeHint) {
|
||||
|
||||
const rendererSize = new THREE.Vector2();
|
||||
this.renderer.getSize(rendererSize);
|
||||
|
||||
// additional actions
|
||||
switch (hint.options.kind) {
|
||||
case 'point':
|
||||
let scale: number;
|
||||
if (this.camera instanceof THREE.PerspectiveCamera) {
|
||||
const distance = this.camera.position.distanceTo(hint.position);
|
||||
const fovRad = THREE.MathUtils.degToRad(this.camera.fov);
|
||||
scale = (hint.options.size * distance * Math.tan(fovRad / 2)) / (rendererSize.height / 2);
|
||||
}
|
||||
else {
|
||||
scale = (hint.options.size * (this.camera.top - this.camera.bottom)) / rendererSize.height;
|
||||
}
|
||||
hint.mesh.scale.setScalar(scale);
|
||||
break;
|
||||
case 'circle':
|
||||
hint.mesh.lookAt(this.camera.position);
|
||||
break;
|
||||
}
|
||||
|
||||
hint.mesh.position.copy(hint.position);
|
||||
}
|
||||
|
||||
public applyCamera() {
|
||||
for (const hint of Object.values(this.hints))
|
||||
this.applyCameraToHint(hint);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import * as THREE from 'three';
|
||||
|
||||
export class CircularFrustum {
|
||||
public readonly apex = new THREE.Vector3(); // Cone apex (camera position)
|
||||
public readonly axisNormalized = new THREE.Vector3(); // normalized (unit) axis direction (camera → screen point)
|
||||
|
||||
public readonly ray = new THREE.Ray();
|
||||
public halfAngle: number = 0; // Half-angle of the cone in radians
|
||||
|
||||
public cosHalfAngle: number = 0; // cos(halfAngle) — cached
|
||||
public sinHalfAngle: number = 0; // sin(halfAngle) — cached
|
||||
|
||||
|
|
@ -55,7 +54,9 @@ export class CircularFrustum {
|
|||
// console.log(this.apex.toArray());
|
||||
}
|
||||
public getCircleAtDepth(depth: number): { center: THREE.Vector3Like, radius: number } {
|
||||
const center = this.apex.clone().addScaledVector(this.axisNormalized, depth);
|
||||
const center = new THREE.Vector3();
|
||||
this.ray.at(depth, center);
|
||||
|
||||
const radius = Math.tan(this.halfAngle) * depth;
|
||||
|
||||
return {
|
||||
|
|
@ -64,6 +65,14 @@ export class CircularFrustum {
|
|||
}
|
||||
}
|
||||
|
||||
public get apex(): THREE.Vector3 { // Cone apex (camera position)
|
||||
return this.ray.origin;
|
||||
}
|
||||
|
||||
public get axisNormalized(): THREE.Vector3 { // normalized (unit) axis direction (camera → screen point)
|
||||
return this.ray.direction;
|
||||
}
|
||||
|
||||
private set(apex: THREE.Vector3Like, axisNormalized: THREE.Vector3Like, halfAngle: number) {
|
||||
this.apex.copy(apex);
|
||||
this.axisNormalized.copy(axisNormalized);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
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,
|
||||
id?: Id,
|
||||
pt: THREE.Vector3,
|
||||
}
|
||||
export type TriangleEdgeHitDetail = {
|
||||
kind: 'edge',
|
||||
index: 0 | 1 | 2, // edge 0=AB, 1=BC, 2=CA
|
||||
aId?: Id,
|
||||
bId?: Id,
|
||||
ptIndexA: 0 | 1 | 2,
|
||||
ptIndexB: 0 | 1 | 2,
|
||||
ptA: THREE.Vector3,
|
||||
ptB: THREE.Vector3,
|
||||
}
|
||||
export type TriangleFaceHitDetail = {
|
||||
kind: 'face',
|
||||
id?: Id,
|
||||
}
|
||||
|
||||
export type TriangleHitDetail = TriangleVertexHitDetail | TriangleEdgeHitDetail | TriangleFaceHitDetail;
|
||||
|
|
@ -28,9 +31,12 @@ 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 },
|
||||
triHit?: TriangleHitDetail,
|
||||
details: TriangleHitDetail,
|
||||
visibility: Visibility,
|
||||
tiangleVertexIds: [Id, Id, Id],
|
||||
}
|
||||
|
||||
export type BaseHitResult = {
|
||||
|
|
@ -81,45 +87,91 @@ export function intersectionResultToBvh(value: IntersectionResult): typeof NOT_I
|
|||
|
||||
const BARYCENTRIC_EPSILON = 1e-1;
|
||||
|
||||
function classifyTriangleHit(
|
||||
function triangleFaceEdgeVertexHit(
|
||||
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);
|
||||
tri: THREE.Triangle,
|
||||
// vertexIds?: [Id, Id, Id],
|
||||
): TriangleHitDetail[] {
|
||||
const results: TriangleHitDetail[] = [{ kind: 'face' }];
|
||||
|
||||
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 bary = new THREE.Vector3();
|
||||
tri.getBarycoord(point, bary);
|
||||
const [u, v, w] = bary.toArray(); // x = AB, y = AC, z = BC
|
||||
|
||||
//TODO if AB is much longer than AC, epsilon has different world size. need to scale
|
||||
const eps = 1 - BARYCENTRIC_EPSILON;
|
||||
|
||||
const onAB = w < BARYCENTRIC_EPSILON;
|
||||
const onBC = u < BARYCENTRIC_EPSILON;
|
||||
const onCA = v < BARYCENTRIC_EPSILON;
|
||||
|
||||
if (onAB) {
|
||||
results.unshift({
|
||||
kind: 'edge',
|
||||
index: 0,
|
||||
ptIndexA: 0,
|
||||
ptIndexB: 1,
|
||||
ptA: tri.a,
|
||||
ptB: tri.b,
|
||||
// idA: vertexIds?.[0],
|
||||
// idB: vertexIds?.[1],
|
||||
});
|
||||
}
|
||||
if (onBC) {
|
||||
results.unshift({
|
||||
kind: 'edge',
|
||||
index: 1,
|
||||
ptIndexA: 1,
|
||||
ptIndexB: 2,
|
||||
ptA: tri.b,
|
||||
ptB: tri.c,
|
||||
// idA: vertexIds?.[1],
|
||||
// idB: vertexIds?.[2],
|
||||
});
|
||||
}
|
||||
if (onCA) {
|
||||
results.unshift({
|
||||
kind: 'edge',
|
||||
index: 2,
|
||||
ptIndexA: 2,
|
||||
ptIndexB: 0,
|
||||
ptA: tri.c,
|
||||
ptB: tri.a,
|
||||
// idA: vertexIds?.[2],
|
||||
// idB: vertexIds?.[0],
|
||||
});
|
||||
}
|
||||
|
||||
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] };
|
||||
if (onA) results.unshift({ kind: 'vertex', index: 0, pt: tri.a });
|
||||
if (onB) results.unshift({ kind: 'vertex', index: 1, pt: tri.b });
|
||||
if (onC) results.unshift({ kind: 'vertex', index: 2, pt: tri.c });
|
||||
|
||||
const onAB = w < BARYCENTRIC_EPSILON; // u+v≈1, w≈0
|
||||
const onBC = u < BARYCENTRIC_EPSILON;
|
||||
const onCA = v < BARYCENTRIC_EPSILON;
|
||||
return results;
|
||||
}
|
||||
|
||||
if (onAB) return { kind: 'edge', index: 0, aId: vertexIds?.[0], bId: vertexIds?.[1] };
|
||||
if (onBC) return { kind: 'edge', index: 1, aId: vertexIds?.[1], bId: vertexIds?.[2] };
|
||||
if (onCA) return { kind: 'edge', index: 2, aId: vertexIds?.[2], bId: vertexIds?.[0] };
|
||||
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();
|
||||
|
||||
return { kind: 'face' };
|
||||
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);
|
||||
}
|
||||
|
||||
export class CircularFrustumIntersection {
|
||||
|
|
@ -206,6 +258,17 @@ export class CircularFrustumIntersection {
|
|||
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,
|
||||
|
|
@ -227,7 +290,7 @@ export class CircularFrustumIntersection {
|
|||
return [];
|
||||
|
||||
function getGeometryVertextIdByIndex(vertexIndex: number): Id {
|
||||
return mesh.userData.vertexIds[vertexIndex];
|
||||
return (mesh.userData as MeshDto).loop[vertexIndex].vertex;
|
||||
}
|
||||
|
||||
function getGeometryVertextIds(triIndex: number): [Id, Id, Id] {
|
||||
|
|
@ -250,6 +313,21 @@ export class CircularFrustumIntersection {
|
|||
}
|
||||
};
|
||||
|
||||
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)
|
||||
|
|
@ -270,17 +348,28 @@ export class CircularFrustumIntersection {
|
|||
const visibility: Visibility = facingRatio >= 0 ? 'backface' : 'visible';
|
||||
|
||||
if (contained) {
|
||||
const worldPoint = tri.a.clone().applyMatrix4(mesh.matrixWorld);
|
||||
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({
|
||||
results.push(
|
||||
...triangleFaceEdgeVertexHit(closestContained, tri)
|
||||
.map((details) => {
|
||||
const closestPoint = getHitClosestPoint(tri, details, worldPoint);
|
||||
const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(closestPoint, worldFrustum);
|
||||
return {
|
||||
object: mesh,
|
||||
point: worldPoint,
|
||||
point: closestPoint,
|
||||
depth,
|
||||
radialDistanceAbsolute,
|
||||
radialDistance: radialDistanceAbsolute / (depth * Math.tan(worldFrustum.halfAngle)),
|
||||
triangle: tri,
|
||||
triHit: classifyTriangleHit(tri.a, tri, tiangleVertexIds),
|
||||
details,
|
||||
visibility,
|
||||
// vertexIds: tiangleVertexIds,
|
||||
});
|
||||
tiangleVertexIds,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return !findAll;
|
||||
}
|
||||
|
||||
|
|
@ -300,27 +389,15 @@ export class CircularFrustumIntersection {
|
|||
];
|
||||
|
||||
for (const [a, b] of edges) {
|
||||
const edge = b.clone().sub(a);
|
||||
const toA = a.clone().sub(localFrustum.apex);
|
||||
const pointOnEdge = closestPointOnEdgeToRay(a, b, localFrustum.ray);
|
||||
|
||||
// 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()));
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -339,15 +416,24 @@ export class CircularFrustumIntersection {
|
|||
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({
|
||||
results.push(
|
||||
...triangleFaceEdgeVertexHit(bestPoint.local, tri)
|
||||
.map((details) => {
|
||||
const closestPoint = getHitClosestPoint(tri, details, worldPoint);
|
||||
const radialDistanceAbsolute = CircularFrustumIntersection.distanceToPoint(closestPoint, worldFrustum);
|
||||
return {
|
||||
object: mesh,
|
||||
point: worldPoint,
|
||||
point: closestPoint,
|
||||
depth: worldDepth,
|
||||
triangle: { a, b, c },
|
||||
triHit: classifyTriangleHit(bestPoint.local, tri, tiangleVertexIds),
|
||||
radialDistanceAbsolute,
|
||||
radialDistance: radialDistanceAbsolute / (worldDepth * Math.tan(worldFrustum.halfAngle)),
|
||||
triangle: tri,
|
||||
details,
|
||||
visibility,
|
||||
});
|
||||
tiangleVertexIds,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return !findAll;
|
||||
}
|
||||
|
||||
|
|
@ -372,7 +458,8 @@ export class CircularFrustumIntersection {
|
|||
|
||||
results.push(
|
||||
...this.intersectMesh(object, !!options.findAll)
|
||||
.flatMap((i) => this.intersectionToHitResult(i)),
|
||||
.map((i) => this.intersectionToHitResult(i))
|
||||
.filter((i) => !!i),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -381,45 +468,41 @@ export class CircularFrustumIntersection {
|
|||
return results;
|
||||
}
|
||||
|
||||
private intersectionToHitResult(intersection: Intersection): HitResult[] {
|
||||
private intersectionToHitResult(intersection: Intersection): HitResult | undefined {
|
||||
const userData = intersection.object.userData as MeshDto;
|
||||
const faceId = userData.faceId;
|
||||
const loop = userData.loop;
|
||||
|
||||
const results: HitResult[] = [{
|
||||
function vertexId(index: number) {
|
||||
return intersection.tiangleVertexIds[index];
|
||||
}
|
||||
|
||||
switch (intersection.details.kind) {
|
||||
case 'face':
|
||||
return {
|
||||
kind: 'face',
|
||||
id: faceId,
|
||||
faceId,
|
||||
intersection: {
|
||||
...intersection,
|
||||
triHit: undefined,
|
||||
},
|
||||
}];
|
||||
if (intersection.triHit?.kind === 'edge') {
|
||||
const triHit = intersection.triHit;
|
||||
const edge = loop.find((v) => (v.vertex === triHit.aId) && (v.vertex2 === triHit.bId))?.edge;
|
||||
if (edge !== undefined)
|
||||
results.unshift({
|
||||
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: {
|
||||
...intersection,
|
||||
triHit: undefined,
|
||||
},
|
||||
});
|
||||
intersection,
|
||||
}
|
||||
if (intersection.triHit?.kind === 'vertex') {
|
||||
results.unshift({
|
||||
: undefined;
|
||||
case 'vertex':
|
||||
return {
|
||||
kind: 'vertex',
|
||||
id: intersection.triHit?.id,
|
||||
id: vertexId(intersection.details.index),
|
||||
faceId,
|
||||
intersection: {
|
||||
...intersection,
|
||||
triHit: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return results;
|
||||
intersection,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
import { useEffect, type RefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import { normalizeScreenPosition } from "../normalizeScreenPosition";
|
||||
import { formatPoint } from "../stringFormat";
|
||||
|
||||
const CLICK_THRESHOLD = 2; // px
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
export type InteractionMouseEventArgs = {
|
||||
screenPosition: THREE.Vector2Like,
|
||||
position: THREE.Vector2Like,
|
||||
|
|
@ -18,6 +14,7 @@ export type InteractionMouseEventArgs = {
|
|||
export type UseInteractionOptions = {
|
||||
onMouseClick?: (e: InteractionMouseEventArgs) => void,
|
||||
onMouseMove?: (e: InteractionMouseEventArgs) => void,
|
||||
onCameraChange?: () => void,
|
||||
}
|
||||
|
||||
export function useInteraction(
|
||||
|
|
@ -56,6 +53,7 @@ export function useInteraction(
|
|||
const z = targetPoint.z + radius * Math.sin(elevation);
|
||||
camera.position.set(x, y, z)
|
||||
camera.lookAt(targetPoint);
|
||||
options.onCameraChange?.();
|
||||
}
|
||||
updateCamera();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import type { ColorRepresentation, Object3D, Object3DEventMap, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, Vector3Like } from "three";
|
||||
import type { ColorRepresentation, Object3D, Object3DEventMap, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, Vector3Like, WebGLRenderer } from "three";
|
||||
import { SceneSync } from "../layers/sceneSync";
|
||||
import { GeometryCache } from "../layers/geometryCache";
|
||||
import type { Id } from "../types";
|
||||
import { CircularFrustumIntersection, type Intersection, type HitResults, type HitResult } from "./circularFrustumIntersect";
|
||||
import { CircularFrustumIntersection, type HitResults, type HitResult } from "./circularFrustumIntersect";
|
||||
import { CircularFrustum } from "./circularFrustum";
|
||||
import './bvh';
|
||||
import { VolatileGeometryHelper, type VolatileGeometryOptions } from "./volatileGeometryHelper";
|
||||
import { ThreeHintDisplay, type ThreeHintOptions } from "./ThreeHintDisplay";
|
||||
|
||||
export class SceneHelper {
|
||||
|
||||
private sync: SceneSync | undefined;
|
||||
|
||||
private hints: VolatileGeometryHelper | undefined;
|
||||
private hints: ThreeHintDisplay | undefined;
|
||||
private camera: PerspectiveCamera | OrthographicCamera | undefined;
|
||||
|
||||
private mouseFrustum = new CircularFrustumIntersection(new CircularFrustum());
|
||||
|
|
@ -19,8 +19,9 @@ export class SceneHelper {
|
|||
public initialize(
|
||||
scene: Scene,
|
||||
camera: PerspectiveCamera | OrthographicCamera,
|
||||
renderer: WebGLRenderer,
|
||||
) {
|
||||
this.hints = new VolatileGeometryHelper(scene, camera);
|
||||
this.hints = new ThreeHintDisplay(scene, camera, renderer);
|
||||
this.camera = camera;
|
||||
|
||||
this.sync = new SceneSync(scene, new GeometryCache());
|
||||
|
|
@ -67,11 +68,11 @@ export class SceneHelper {
|
|||
return this.sync?.meshes ?? [];
|
||||
}
|
||||
|
||||
public showHint(id: string, position: Vector3Like, options: VolatileGeometryOptions) {
|
||||
public showHint(id: string, position: Vector3Like, options: ThreeHintOptions) {
|
||||
this.hints?.set(id, position, options);
|
||||
}
|
||||
|
||||
public showPointHint(id: string, position: Vector3Like, size: number = 0.05, color: ColorRepresentation = 0xffffff) {
|
||||
public showPointHint(id: string, position: Vector3Like, size: number = 5, color: ColorRepresentation = 0xffffff) {
|
||||
this.hints?.set(id, position, { kind: "point", size, color });
|
||||
}
|
||||
|
||||
|
|
@ -103,4 +104,8 @@ export class SceneHelper {
|
|||
|
||||
this.clearHints();
|
||||
}
|
||||
|
||||
public applyCamera() {
|
||||
this.hints?.applyCamera();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Vector3Like } from "three";
|
||||
|
||||
export function formatPoint(point: Vector3Like): string {
|
||||
return [point.x, point.y, point.z].map((v) => v.toFixed(3)).join('; ');
|
||||
return [point.x, point.y, point.z].map((v) => v.toFixed(3)).join(', ');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
import * as THREE from "three";
|
||||
|
||||
export type VolatileGeometry = {
|
||||
mesh: THREE.Mesh,
|
||||
}
|
||||
|
||||
export type VolatileGeometryBaseOptions = {
|
||||
color: THREE.ColorRepresentation,
|
||||
}
|
||||
|
||||
|
||||
export type VolatileGeometryPointOptions = VolatileGeometryBaseOptions & {
|
||||
kind: 'point',
|
||||
size: number,
|
||||
}
|
||||
|
||||
export type VolatileGeometryCicleOptions = VolatileGeometryBaseOptions & {
|
||||
kind: 'circle',
|
||||
radius: number,
|
||||
thickness: number,
|
||||
}
|
||||
|
||||
export type VolatileGeometryOptions = VolatileGeometryPointOptions | VolatileGeometryCicleOptions;
|
||||
|
||||
export class VolatileGeometryHelper {
|
||||
|
||||
private scene: THREE.Scene;
|
||||
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
|
||||
|
||||
private readonly baseMaterial = new THREE.MeshBasicMaterial({ color: 'red' });
|
||||
|
||||
private readonly markers: Record<string, VolatileGeometry> = {};
|
||||
|
||||
constructor(scene: THREE.Scene, camera: THREE.PerspectiveCamera | THREE.OrthographicCamera) {
|
||||
this.scene = scene;
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
private createGeometry(options: VolatileGeometryOptions): THREE.BufferGeometry {
|
||||
switch (options.kind) {
|
||||
case 'point':
|
||||
return new THREE.SphereGeometry(options.size);
|
||||
case 'circle':
|
||||
return new THREE.TorusGeometry(options.radius, options.thickness * options.radius);
|
||||
default:
|
||||
throw new Error('Unknown volatile geometry type');
|
||||
}
|
||||
}
|
||||
|
||||
private ensure(id: string, options: VolatileGeometryOptions): VolatileGeometry {
|
||||
if (!this.markers[id]) {
|
||||
const material = this.baseMaterial.clone();
|
||||
material.color.set(options.color);
|
||||
this.markers[id] = {
|
||||
mesh: new THREE.Mesh(
|
||||
this.createGeometry(options),
|
||||
material,
|
||||
),
|
||||
};
|
||||
this.scene.add(this.markers[id].mesh);
|
||||
}
|
||||
|
||||
return this.markers[id];
|
||||
}
|
||||
|
||||
private disposeGeometry(id: string) {
|
||||
const point = this.markers[id];
|
||||
if (point) {
|
||||
this.scene.remove(point.mesh);
|
||||
point.mesh.geometry.dispose();
|
||||
delete (this.markers[id]);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
for (const id in this.markers)
|
||||
this.disposeGeometry(id);
|
||||
}
|
||||
|
||||
public set(id: string, position: THREE.Vector3Like, options: VolatileGeometryOptions) {
|
||||
const point = this.ensure(id, options);
|
||||
|
||||
// additional actions
|
||||
switch (options.kind) {
|
||||
case 'circle':
|
||||
point.mesh.lookAt(this.camera.position);
|
||||
break;
|
||||
}
|
||||
|
||||
point.mesh.position.copy(position);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,22 @@
|
|||
@font-face {
|
||||
font-family: 'RobotoMono';
|
||||
src: url('assets/RobotoMono-VariableFont_wght.ttf');
|
||||
}
|
||||
|
||||
:root {
|
||||
background: #101020;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* pre {
|
||||
font: 8px Tahoma;
|
||||
font-weight: 100;
|
||||
line-height: 10px;
|
||||
letter-spacing: 1px;
|
||||
font-variant: small-caps; */
|
||||
/* text */
|
||||
/* } */
|
||||
|
||||
/* :root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
|
|
|
|||
|
|
@ -11,5 +11,5 @@ export type Mesh = {
|
|||
faceId: Face['id'];
|
||||
surfaceId: Surface['id'];
|
||||
solidId: Solid['id'];
|
||||
loop: { edge: Edge['id'], halfEdge: HalfEdge['id'], vertex: Vertex['id'] }[];
|
||||
loop: { edge: Edge['id'], halfEdge: HalfEdge['id'], vertex: Vertex['id'], vertex2: Vertex['id'] }[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from './math';
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './three';
|
||||
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './2d';
|
||||
Loading…
Reference in New Issue