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';