diff --git a/client/src/App.scss b/client/src/App.scss
index 713e48a..d180f04 100644
--- a/client/src/App.scss
+++ b/client/src/App.scss
@@ -11,10 +11,18 @@
position: absolute;
top: 0px;
left: 0px;
-
+
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 {
diff --git a/client/src/assets/RobotoMono-VariableFont_wght.ttf b/client/src/assets/RobotoMono-VariableFont_wght.ttf
new file mode 100644
index 0000000..3a2d704
Binary files /dev/null and b/client/src/assets/RobotoMono-VariableFont_wght.ttf differ
diff --git a/client/src/components/HitTestView.tsx b/client/src/components/HitTestView.tsx
index 43a7b97..ee71411 100644
--- a/client/src/components/HitTestView.tsx
+++ b/client/src/components/HitTestView.tsx
@@ -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 (
-
- {
- state.hitResults.hits.map((hit) =>
-
-
{yaml.stringify(
- {
- hit: { ...hit, intersection: { ...hit.intersection, point: formatPoint(hit.intersection.point), object: undefined, triangle: undefined } },
- // userData: hit.intersection.object.userData,
- },
- undefined,
- 2,
- )}
-
- )
- }
-
+ {
+ state.hitResults.hits.map((hit) =>
+
+
{yaml.stringify(renderHitResult(hit), undefined, 4)}
+
+ )
+ }
)
});
diff --git a/client/src/components/ThreeVIew.tsx b/client/src/components/ThreeVIew.tsx
index 7b81e3f..3527a1b 100644
--- a/client/src/components/ThreeVIew.tsx
+++ b/client/src/components/ThreeVIew.tsx
@@ -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 (
diff --git a/client/src/components/Viewport.tsx b/client/src/components/Viewport.tsx
index 8acded9..4b3ffff 100644
--- a/client/src/components/Viewport.tsx
+++ b/client/src/components/Viewport.tsx
@@ -12,8 +12,8 @@ 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));
// console.log(e.hitTest.objects.flatMap((o) => o.point.toArray()));
diff --git a/client/src/helpers/ThreeHintDisplay.ts b/client/src/helpers/ThreeHintDisplay.ts
new file mode 100644
index 0000000..3ddbc7e
--- /dev/null
+++ b/client/src/helpers/ThreeHintDisplay.ts
@@ -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 = {};
+
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/client/src/helpers/circularFrustum.ts b/client/src/helpers/circularFrustum.ts
index b607668..72b3eca 100644
--- a/client/src/helpers/circularFrustum.ts
+++ b/client/src/helpers/circularFrustum.ts
@@ -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);
diff --git a/client/src/helpers/circularFrustumIntersect.ts b/client/src/helpers/circularFrustumIntersect.ts
index c469618..cde76ad 100644
--- a/client/src/helpers/circularFrustumIntersect.ts
+++ b/client/src/helpers/circularFrustumIntersect.ts
@@ -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({
- object: mesh,
- point: worldPoint,
- depth,
- triangle: tri,
- triHit: classifyTriangleHit(tri.a, tri, tiangleVertexIds),
- visibility,
- // vertexIds: tiangleVertexIds,
- });
+ results.push(
+ ...triangleFaceEdgeVertexHit(closestContained, tri)
+ .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,
+ 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);
- }
+ 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({
- object: mesh,
- point: worldPoint,
- depth: worldDepth,
- triangle: { a, b, c },
- triHit: classifyTriangleHit(bestPoint.local, tri, tiangleVertexIds),
- visibility,
- });
+ results.push(
+ ...triangleFaceEdgeVertexHit(bestPoint.local, tri)
+ .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,
+ 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[] = [{
- 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({
- kind: 'edge',
- id: edge,
+ function vertexId(index: number) {
+ return intersection.tiangleVertexIds[index];
+ }
+
+ switch (intersection.details.kind) {
+ case 'face':
+ return {
+ kind: 'face',
+ id: faceId,
faceId,
- intersection: {
- ...intersection,
- triHit: undefined,
- },
- });
+ 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,
+ };
}
- if (intersection.triHit?.kind === 'vertex') {
- results.unshift({
- kind: 'vertex',
- id: intersection.triHit?.id,
- faceId,
- intersection: {
- ...intersection,
- triHit: undefined,
- },
- });
- }
- return results;
}
}
\ No newline at end of file
diff --git a/client/src/helpers/hooks/useInteration.ts b/client/src/helpers/hooks/useInteration.ts
index 8814b90..3c03d43 100644
--- a/client/src/helpers/hooks/useInteration.ts
+++ b/client/src/helpers/hooks/useInteration.ts
@@ -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();
diff --git a/client/src/helpers/sceneHelper.ts b/client/src/helpers/sceneHelper.ts
index c509df2..4f2210f 100644
--- a/client/src/helpers/sceneHelper.ts
+++ b/client/src/helpers/sceneHelper.ts
@@ -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();
+ }
}
diff --git a/client/src/helpers/stringFormat.ts b/client/src/helpers/stringFormat.ts
index f701f2e..a95c642 100644
--- a/client/src/helpers/stringFormat.ts
+++ b/client/src/helpers/stringFormat.ts
@@ -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(', ');
}
diff --git a/client/src/helpers/volatileGeometryHelper.ts b/client/src/helpers/volatileGeometryHelper.ts
deleted file mode 100644
index 8efc8ee..0000000
--- a/client/src/helpers/volatileGeometryHelper.ts
+++ /dev/null
@@ -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 = {};
-
- 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);
- }
-}
\ No newline at end of file
diff --git a/client/src/index.css b/client/src/index.css
index 8d8f072..26e67b8 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -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;
diff --git a/client/src/types/geometry.ts b/client/src/types/geometry.ts
index 60c32a5..201112e 100644
--- a/client/src/types/geometry.ts
+++ b/client/src/types/geometry.ts
@@ -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'] }[];
};
diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts
new file mode 100644
index 0000000..3531606
--- /dev/null
+++ b/client/src/utils/index.ts
@@ -0,0 +1 @@
+export * from './math';
diff --git a/client/src/utils/math/index.ts b/client/src/utils/math/index.ts
new file mode 100644
index 0000000..e9f81d0
--- /dev/null
+++ b/client/src/utils/math/index.ts
@@ -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))
+}
diff --git a/client/src/helpers/2d.ts b/client/src/utils/math/three/2d.ts
similarity index 100%
rename from client/src/helpers/2d.ts
rename to client/src/utils/math/three/2d.ts
diff --git a/client/src/utils/math/three/index.ts b/client/src/utils/math/three/index.ts
new file mode 100644
index 0000000..6e9d3aa
--- /dev/null
+++ b/client/src/utils/math/three/index.ts
@@ -0,0 +1 @@
+export * from './2d';