From c8fdeafe3f7cac5793c4b752f0ceb5c742035be1 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Thu, 21 May 2026 18:39:42 +0300 Subject: [PATCH] cylindrical frustum hit testing --- client/src/components/ThreeVIew.tsx | 9 +- client/src/components/Viewport.tsx | 8 +- client/src/helpers/bvh.ts | 16 ++ client/src/helpers/circularFrustum.ts | 84 +++++++ .../src/helpers/circularFrustumIntersect.ts | 230 ++++++++++++++++++ client/src/helpers/hitTest.ts | 15 +- client/src/helpers/hooks/useInteration.ts | 23 +- client/src/helpers/normalizeScreenPosition.ts | 27 ++ client/src/helpers/point3dHelper.ts | 53 ---- client/src/helpers/sceneHelper.ts | 83 ++++++- client/src/helpers/volatileGeometryHelper.ts | 92 +++++++ client/src/layers/sceneSync.ts | 5 +- 12 files changed, 543 insertions(+), 102 deletions(-) create mode 100644 client/src/helpers/bvh.ts create mode 100644 client/src/helpers/circularFrustum.ts create mode 100644 client/src/helpers/circularFrustumIntersect.ts create mode 100644 client/src/helpers/normalizeScreenPosition.ts delete mode 100644 client/src/helpers/point3dHelper.ts create mode 100644 client/src/helpers/volatileGeometryHelper.ts diff --git a/client/src/components/ThreeVIew.tsx b/client/src/components/ThreeVIew.tsx index e765c8e..c1bc179 100644 --- a/client/src/components/ThreeVIew.tsx +++ b/client/src/components/ThreeVIew.tsx @@ -90,7 +90,7 @@ export const ThreeView = function (props: ThreeViewProps) { const { scene, camera } = setupScene({ w: W, h: H }); cameraRef.current = camera; - props.sceneHelper.initialize(scene); + props.sceneHelper.initialize(scene, camera); const handleWindowResize = () => { const w = container.clientWidth; @@ -102,6 +102,13 @@ export const ThreeView = function (props: ThreeViewProps) { window.addEventListener("resize", handleWindowResize); handleHover = (e: InteractionMouseEventArgs) => { + + const ht = props.sceneHelper.hitTest( + e.position, + e.screenSize, + ); + console.log(JSON.stringify(ht.map((h) => h.object.userData))); + const hitTest = HitTestFactory.hitTest( props.sceneHelper, new THREE.Vector2(e.position.x, e.position.y), diff --git a/client/src/components/Viewport.tsx b/client/src/components/Viewport.tsx index b8de551..f35f356 100644 --- a/client/src/components/Viewport.tsx +++ b/client/src/components/Viewport.tsx @@ -1,6 +1,6 @@ import { useSceneHelper } from "../helpers/hooks/useSceneHelper"; import { state } from "../state/root"; -import { ThreeView, type ThreeViewEventArgs, type ThreeViewMouseEventArgs } from "./ThreeVIew"; +import { ThreeView, type ThreeViewEventArgs, type ThreeViewMouseEventArgs } from "./ThreeView"; export const Viewport = function () { @@ -9,10 +9,10 @@ export const Viewport = function () { function handleMouseMove(e: ThreeViewMouseEventArgs) { state.setHitTest(e.hitTest); - sceneHelper.clearPoints(); + sceneHelper.clear(); if (e.hitTest.objects.length) { e.hitTest.objects.forEach((obj) => { - sceneHelper.setPoint(obj.object.uuid, obj.point); + sceneHelper.showPoint(obj.object.uuid, obj.point); }) // console.log(e.position); // console.log(e.hitTest.objects.map((o) => o)); @@ -25,6 +25,8 @@ export const Viewport = function () { // console.log(hoveredFaceIds); sceneHelper.setSelection(hoveredFaceIds); + + sceneHelper.showMouseFrustum(); } function handleDispose(e: ThreeViewEventArgs): void { diff --git a/client/src/helpers/bvh.ts b/client/src/helpers/bvh.ts new file mode 100644 index 0000000..1e0ac18 --- /dev/null +++ b/client/src/helpers/bvh.ts @@ -0,0 +1,16 @@ +import * as THREE from 'three'; +import { + computeBoundsTree, disposeBoundsTree, + computeBatchedBoundsTree, disposeBatchedBoundsTree, acceleratedRaycast, +} from 'three-mesh-bvh'; + +// Add the extension functions +THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; +THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree; +THREE.Mesh.prototype.raycast = acceleratedRaycast; + +THREE.BatchedMesh.prototype.computeBoundsTree = computeBatchedBoundsTree; +THREE.BatchedMesh.prototype.disposeBoundsTree = disposeBatchedBoundsTree; +THREE.BatchedMesh.prototype.raycast = acceleratedRaycast; + +export const dummy = {}; diff --git a/client/src/helpers/circularFrustum.ts b/client/src/helpers/circularFrustum.ts new file mode 100644 index 0000000..b607668 --- /dev/null +++ b/client/src/helpers/circularFrustum.ts @@ -0,0 +1,84 @@ +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 halfAngle: number = 0; // Half-angle of the cone in radians + public cosHalfAngle: number = 0; // cos(halfAngle) — cached + public sinHalfAngle: number = 0; // sin(halfAngle) — cached + + /** + * Build a CircularFrustum from a screen-space point and pixel threshold. + * + * @param screenPointNormalized point in normalised device coordinates (NDC) [-1, 1] + * @param camera PerspectiveCamera or OrthographicCamera + * @param thresholdPx Screen-space radius in pixels + * @param screenSize renderer viewport size in pixels (e.g. from viewport.getBoundingClientRect()) + */ + public setFromScreenPoint( + screenPointNormalized: THREE.Vector2Like, // in [-1, 1] space + screenSize: THREE.Vector2Like, + camera: THREE.PerspectiveCamera | THREE.OrthographicCamera, + thresholdPx: number, // in screen pixels + ): void { + // cone apex + camera.getWorldPosition(this.apex); + + // cone central ray (axis) + const centerNDC = new THREE.Vector3(screenPointNormalized.x, screenPointNormalized.y, 0.5); // center + const centerWorld = centerNDC.clone().unproject(camera); + this.axisNormalized.copy(centerWorld.sub(this.apex)).normalize(); + + // frustum near circle bottommost point + const bottomNDC = centerNDC.clone().add({ x: 0, y: (thresholdPx / screenSize.y) * 2, z: 0 }); + const bottomWorld = bottomNDC.clone().unproject(camera); + const downAxis = bottomWorld.sub(this.apex).normalize(); + + // cone half-angle + const halfAngle = Math.acos(THREE.MathUtils.clamp(this.axisNormalized.dot(downAxis), -1, 1)); + this.set(this.apex, this.axisNormalized, halfAngle); + + // console.log({ + // screenPointNormalized, + // screenSize, + // thresholdPx, + // centerNDC: centerNDC.toArray(), + // centerWorld: centerWorld.toArray(), + // bottomNDC: bottomNDC.toArray(), + // bottomWorld: bottomWorld.toArray(), + // downAxis: downAxis.toArray(), + // apex: this.apex.toArray(), + // axisNormalized: this.axisNormalized, + // halfAngle, + // }); + // console.log(this.apex.toArray()); + } + public getCircleAtDepth(depth: number): { center: THREE.Vector3Like, radius: number } { + const center = this.apex.clone().addScaledVector(this.axisNormalized, depth); + const radius = Math.tan(this.halfAngle) * depth; + + return { + center, + radius, + } + } + + private set(apex: THREE.Vector3Like, axisNormalized: THREE.Vector3Like, halfAngle: number) { + this.apex.copy(apex); + this.axisNormalized.copy(axisNormalized); + this.halfAngle = halfAngle; + this.cosHalfAngle = Math.cos(this.halfAngle); + this.sinHalfAngle = Math.sin(this.halfAngle); + } + + public transform(matrix: THREE.Matrix4): CircularFrustum { + const transformed = new CircularFrustum(); + + const localApex = this.apex.clone().applyMatrix4(matrix); + const localAxis = this.axisNormalized.clone().transformDirection(matrix).normalize(); + transformed.set(localApex, localAxis, this.halfAngle); + + return transformed; + } +} diff --git a/client/src/helpers/circularFrustumIntersect.ts b/client/src/helpers/circularFrustumIntersect.ts new file mode 100644 index 0000000..ea1d3ba --- /dev/null +++ b/client/src/helpers/circularFrustumIntersect.ts @@ -0,0 +1,230 @@ +import * as THREE from 'three'; +import { CONTAINED, ExtendedTriangle, INTERSECTED, NOT_INTERSECTED } from 'three-mesh-bvh'; +import { CircularFrustum } from './circularFrustum'; + +export type FrustumHitResult = { + object: THREE.Object3D; + point: THREE.Vector3; // world-space closest hit point + depth: number; // depth along frustum axis + triangle?: ExtendedTriangle; // only present when BVH was used +} + +export type CircularFrustumIntersectionOptions = { + findAll?: boolean; // defaults to false + filter?: (object: THREE.Object3D) => boolean; // defaults to every object +} + +export type IntersectionResult = 'NOT_INTERSECTED' | 'INTERSECTED' | 'CONTAINED'; + +export function intersectionResultToBvh(value: IntersectionResult): typeof NOT_INTERSECTED | typeof INTERSECTED | typeof CONTAINED { + switch (value) { + case 'NOT_INTERSECTED': + return NOT_INTERSECTED; + case 'INTERSECTED': + return INTERSECTED; + case 'CONTAINED': + return CONTAINED; + } +} + +export class CircularFrustumIntersection { + public readonly frustum: CircularFrustum; + + constructor(frustum: CircularFrustum) { + this.frustum = frustum; + } + + public insersectsSphere(sphere: THREE.Sphere): 'NOT_INTERSECTED' | number { + return CircularFrustumIntersection.insersectsSphere(sphere, this.frustum); + } + + /** + * sphere and frustum both should be in the same coordinate space (local or world) + * + * Uses the Barros / van den Bergen separating-axis approach: + * - Check whether the sphere centre is inside the cone (fast path) + * - Otherwise check the distance from the sphere centre to the + * nearest cone surface (lateral face + apex cap) + * + * @returns axial depth of sphere center or NOT_INTERSECTED + */ + public static insersectsSphere(sphere: THREE.Sphere, frustum: CircularFrustum): 'NOT_INTERSECTED' | number { + const toCenter = sphere.center.clone().sub(frustum.apex); + const axialDist = toCenter.dot(frustum.axisNormalized); + + if (axialDist + sphere.radius < 0) // behind the apex entirely + return 'NOT_INTERSECTED'; + + const lateralDist = toCenter.clone().addScaledVector(frustum.axisNormalized, -axialDist).length(); + const distToConeEdge = lateralDist * frustum.cosHalfAngle - axialDist * frustum.sinHalfAngle; + + if (distToConeEdge > sphere.radius) // fully outside lateral surface + return 'NOT_INTERSECTED'; + + return axialDist; + } + + public intersectsBox(box: THREE.Box3): IntersectionResult { + return CircularFrustumIntersection.intersectsBox(box, this.frustum); + } + + // box and this.frustum both should be in the same coordinate space (local or world) + public static intersectsBox(box: THREE.Box3, frustum: CircularFrustum): IntersectionResult { + const sphere = new THREE.Sphere(); + box.getBoundingSphere(sphere); + if (CircularFrustumIntersection.insersectsSphere(sphere, frustum) === 'NOT_INTERSECTED') + return 'NOT_INTERSECTED'; + + // Check if all 8 corners are inside — if so, CONTAINED + const corners = Array(8) + .fill(0) + .map((_, i) => new THREE.Vector3( + i & 1 ? box.max.x : box.min.x, + i & 2 ? box.max.y : box.min.y, + i & 4 ? box.max.z : box.min.z, + )); + const allInside = corners.every((c) => CircularFrustumIntersection.pointAxialDepth(c, frustum) !== 'NOT_INTERSECTED'); + return allInside + ? 'CONTAINED' + : 'INTERSECTED'; + } + + public pointAxialDepth(point: THREE.Vector3): 'NOT_INTERSECTED' | number { + return CircularFrustumIntersection.pointAxialDepth(point, this.frustum); + } + + public static pointAxialDepth(point: THREE.Vector3, frustum: CircularFrustum): 'NOT_INTERSECTED' | number { + const toPoint = point.clone().sub(frustum.apex); + const dist = toPoint.length(); + if (dist === 0) + return 0; + + const axialDist = toPoint.dot(frustum.axisNormalized); + const cosAngle = axialDist / dist; + return cosAngle >= frustum.cosHalfAngle + ? axialDist + : 'NOT_INTERSECTED'; + } + + // ─── Local frustum construction ────────────────────────────────────────────── + + /** + * Transform a world-space CircularFrustum into an object's local space. + * Note: halfAngle is only preserved exactly under uniform scale. + */ + private toObjectLocalSpace(invWorldMatrix: THREE.Matrix4): CircularFrustum { + return this.frustum.transform(invWorldMatrix); + } + + public intersectMesh( + mesh: THREE.Mesh, + findAll: boolean, + ): FrustumHitResult[] { + const geometry = mesh.geometry; + if (!geometry) + return []; + + const matrix = mesh.matrixWorld; + const matrixInverted = matrix.clone().invert(); // world -> local matrix + const worldFrustum = this.frustum; + const localFrustum = this.toObjectLocalSpace(matrixInverted); + + // quick check for bounding sphere + if (!geometry.boundingSphere) + geometry.computeBoundingSphere(); + const boundingSphere = geometry.boundingSphere!.clone().applyMatrix4(matrix); + if (this.insersectsSphere(boundingSphere) === 'NOT_INTERSECTED') + return []; + + const results: FrustumHitResult[] = []; + + if (!geometry.boundsTree) + geometry.computeBoundsTree(); + const bvh = geometry.boundsTree; + if (!bvh) + throw new Error('No BVH found for a mesh'); + bvh.shapecast({ + intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)), + + intersectsTriangle: (tri: ExtendedTriangle, _index: number, contained: boolean) => { + // If the whole node was CONTAINED, every triangle is inside — fast path + if (contained) { + const worldPoint = tri.a.clone().applyMatrix4(mesh.matrixWorld); + const depth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex)); + results.push({ object: mesh, point: worldPoint, depth, triangle: tri }); + return !findAll; // stop if we only need first hit + } + + // Test all three vertices; take the closest that's inside + let bestDepth = Infinity; + let bestLocal: THREE.Vector3 | null = null; + + for (const v of [tri.a, tri.b, tri.c] as THREE.Vector3[]) { + const d = CircularFrustumIntersection.pointAxialDepth(v, localFrustum); + if (d !== 'NOT_INTERSECTED') { + if (d < bestDepth) { + bestDepth = d; + bestLocal = v; + } + } + } + + // Also test closest point on triangle to the frustum axis ray + const ray = new THREE.Ray(localFrustum.apex, localFrustum.axisNormalized); + const closest = new THREE.Vector3(); + tri.closestPointToPoint(ray.origin, closest); // ExtendedTriangle has this + const d = CircularFrustumIntersection.pointAxialDepth(closest, localFrustum); + if (d !== 'NOT_INTERSECTED') { + if (d < bestDepth) { + bestDepth = d; + bestLocal = closest; + } + } + + if (bestLocal) { + const worldPoint = bestLocal.clone().applyMatrix4(mesh.matrixWorld); + const worldDepth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex)); + results.push({ object: mesh, point: worldPoint, depth: worldDepth, triangle: tri }); + return !findAll; + } + + return false; + }, + }); + // } else { + // // ── Fallback: bounding box only ─────────────────────────────────── + // if (!geometry.boundingBox) + // geometry.computeBoundingBox(); + // const worldBox = geometry.boundingBox!.clone().applyMatrix4(mesh.matrixWorld); + // const boxResult = this.intersectsBox(worldBox.clone()); + // if (boxResult !== NOT_INTERSECTED) { + // const center = new THREE.Vector3(); + // worldBox.getCenter(center); + // const depth = this.frustum.axis.dot(center.clone().sub(this.frustum.apex)); + // results.push({ object: mesh, point: center, depth }); + // } + // } + + return results; + } + + public intersectObject( + obj: THREE.Object3D, + options: CircularFrustumIntersectionOptions = {}, + ): FrustumHitResult[] { + const results: FrustumHitResult[] = []; + + obj.traverseVisible((object) => { + if (options.filter && !options.filter(object)) + return; + if (!(object instanceof THREE.Mesh)) + return; + + results.push(...this.intersectMesh(object, !!options.findAll)); + }); + + // sort closest first + results.sort((a, b) => a.depth - b.depth); + return results; + } +} \ No newline at end of file diff --git a/client/src/helpers/hitTest.ts b/client/src/helpers/hitTest.ts index f0e3bb7..c72b72b 100644 --- a/client/src/helpers/hitTest.ts +++ b/client/src/helpers/hitTest.ts @@ -1,19 +1,6 @@ import * as THREE from 'three'; -import { - computeBoundsTree, disposeBoundsTree, - computeBatchedBoundsTree, disposeBatchedBoundsTree, acceleratedRaycast, -} from 'three-mesh-bvh'; -import type { SceneSync } from '../layers/sceneSync'; import type { SceneHelper } from './sceneHelper'; - -// Add the extension functions -THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; -THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree; -THREE.Mesh.prototype.raycast = acceleratedRaycast; - -THREE.BatchedMesh.prototype.computeBoundsTree = computeBatchedBoundsTree; -THREE.BatchedMesh.prototype.disposeBoundsTree = disposeBatchedBoundsTree; -THREE.BatchedMesh.prototype.raycast = acceleratedRaycast; +import './bvh'; export type HitTest = { objects: THREE.Intersection[]; diff --git a/client/src/helpers/hooks/useInteration.ts b/client/src/helpers/hooks/useInteration.ts index 77f0d77..7a7d404 100644 --- a/client/src/helpers/hooks/useInteration.ts +++ b/client/src/helpers/hooks/useInteration.ts @@ -1,11 +1,13 @@ import { useEffect, type RefObject } from "react"; import * as THREE from "three"; +import { normalizeScreenPosition } from "../normalizeScreenPosition"; const CLICK_THRESHOLD = 2; // px export type InteractionMouseEventArgs = { - position: { x: number, y: number }, - pixelSize: { x: number, y: number }, + position: THREE.Vector2Like, + screenSize: THREE.Vector2Like, + pixelSize: THREE.Vector2Like, }; export type UseInteractionOptions = { @@ -57,14 +59,8 @@ export function useInteraction( const onMouseUp = (e: MouseEvent) => { isDragging = false; - if ((e.clientX - startX < CLICK_THRESHOLD) && (e.clientY - startY < CLICK_THRESHOLD)) { - const rect = target.getBoundingClientRect(); - const position = { - x: ((e.clientX - rect.left) / rect.width) * 2 - 1, - y: -((e.clientY - rect.top) / rect.height) * 2 + 1, - }; - options.onMouseClick?.({ position, pixelSize: { x: 2 / rect.width, y: 2 / rect.height } }); - } + if ((e.clientX - startX < CLICK_THRESHOLD) && (e.clientY - startY < CLICK_THRESHOLD)) + options.onMouseClick?.(normalizeScreenPosition({ x: e.clientX, y: e.clientY }, target)); }; const onMouseMove = (e: MouseEvent) => { @@ -84,12 +80,7 @@ export function useInteraction( }; const onHover = (e: MouseEvent) => { - const rect = target.getBoundingClientRect(); - const position = { - x: ((e.clientX - rect.left) / rect.width) * 2 - 1, - y: -((e.clientY - rect.top) / rect.height) * 2 + 1, - }; - options.onMouseMove?.({ position, pixelSize: { x: 2 / rect.width, y: 2 / rect.height } }); + options.onMouseMove?.(normalizeScreenPosition({ x: e.clientX, y: e.clientY }, target)); }; const onContextMenu = (e: Event) => e.preventDefault(); diff --git a/client/src/helpers/normalizeScreenPosition.ts b/client/src/helpers/normalizeScreenPosition.ts new file mode 100644 index 0000000..5f61365 --- /dev/null +++ b/client/src/helpers/normalizeScreenPosition.ts @@ -0,0 +1,27 @@ +import type { Vector2Like } from "three"; + +export type NormalizedScreenPosition = { + position: Vector2Like, + screenSize: Vector2Like, + pixelSize: Vector2Like, +} + +export function normalizeScreenPosition(pos: Vector2Like, viewport: DOMRect | HTMLElement): NormalizedScreenPosition { + let rect: DOMRect; + if (viewport instanceof DOMRect) + rect = viewport; + else + rect = viewport.getBoundingClientRect(); + + return { + position: { + x: ((pos.x - rect.left) / rect.width) * 2 - 1, + y: -((pos.y - rect.top) / rect.height) * 2 + 1, + }, + screenSize: { x: rect.width, y: rect.height }, + pixelSize: { + x: 2 / rect.width, + y: 2 / rect.height, + } + }; +} \ No newline at end of file diff --git a/client/src/helpers/point3dHelper.ts b/client/src/helpers/point3dHelper.ts deleted file mode 100644 index 6e7dd95..0000000 --- a/client/src/helpers/point3dHelper.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as THREE from "three"; - -export type Point3d = { - position: THREE.Vector3, - mesh: THREE.Mesh, -} - -export class Point3dHelper { - - private scene: THREE.Scene; - private readonly baseMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); - - private readonly markers: Record = {}; - - constructor(scene: THREE.Scene) { - this.scene = scene; - } - - private ensure(id: string): Point3d { - if (!this.markers[id]) { - this.markers[id] = { - position: new THREE.Vector3(), - mesh: new THREE.Mesh( - new THREE.SphereGeometry(0.1, 8, 8), - this.baseMaterial, - ), - }; - this.scene.add(this.markers[id].mesh); - } - - return this.markers[id]; - } - - private disposePoint(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.disposePoint(id); - } - - public set(id: string, position: THREE.Vector3Like) { - const point = this.ensure(id); - point.position.copy(position); - point.mesh.position.copy(position); - } -} \ No newline at end of file diff --git a/client/src/helpers/sceneHelper.ts b/client/src/helpers/sceneHelper.ts index 6dc1eaa..361b311 100644 --- a/client/src/helpers/sceneHelper.ts +++ b/client/src/helpers/sceneHelper.ts @@ -1,26 +1,60 @@ -import type { Object3D, Object3DEventMap, Scene, Vector3 } from "three"; -import { Point3dHelper } from "./point3dHelper"; +import type { ColorRepresentation, Object3D, Object3DEventMap, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, Vector3, Vector3Like } from "three"; import { SceneSync } from "../layers/sceneSync"; import { GeometryCache } from "../layers/geometryCache"; import type { Id } from "../types"; +import { CircularFrustumIntersection, type FrustumHitResult } from "./circularFrustumIntersect"; +import { CircularFrustum } from "./circularFrustum"; +import './bvh'; +import { VolatileGeometryHelper, type VolatileGeometryOptions } from "./volatileGeometryHelper"; export class SceneHelper { private sync: SceneSync | undefined; - private pointHelper: Point3dHelper | undefined; + private hints: VolatileGeometryHelper | undefined; + private camera: PerspectiveCamera | OrthographicCamera | undefined; - constructor() { + private mouseFrustum = new CircularFrustumIntersection(new CircularFrustum()); - } - - public initialize(scene: Scene) { - this.pointHelper = new Point3dHelper(scene); + public initialize( + scene: Scene, + camera: PerspectiveCamera | OrthographicCamera, + ) { + this.hints = new VolatileGeometryHelper(scene, camera); + this.camera = camera; this.sync = new SceneSync(scene, new GeometryCache()); this.sync.addWholeModel(); } + public buildMouseFrustum( + mouseNormalized: Vector2Like, + screenSize: Vector2Like, + radius: number = 15, + ): void { + if (!this.camera) + throw new Error('Camera is not initialized'); + + this.mouseFrustum.frustum.setFromScreenPoint( + mouseNormalized, + screenSize, + this.camera, + radius, + ); + } + + public hitTest( + mouseNormalized: Vector2Like, + screenSize: Vector2Like, + ): FrustumHitResult[] { + this.buildMouseFrustum(mouseNormalized, screenSize); + + const result: FrustumHitResult[] = []; + for (const object of this.objects) + result.push(...this.mouseFrustum.intersectObject(object)); + return result; + } + public setSelection(faceIds: Id[]) { this.sync?.setSelected(faceIds); } @@ -30,17 +64,40 @@ export class SceneHelper { return this.sync?.meshes ?? []; } - public setPoint(id: string, point: Vector3) { - this.pointHelper?.set(id, point); + public showHint(id: string, position: Vector3Like, options: VolatileGeometryOptions) { + this.hints?.set(id, position, options); } - public clearPoints() { - this.pointHelper?.dispose(); + public showPoint(id: string, position: Vector3Like, size: number = 0.05, color: ColorRepresentation = 0xffffff) { + this.hints?.set(id, position, { kind: "point", size, color }); + } + + public showCircle(id: string, position: Vector3Like, radius: number, thickness: number = 0.1, color: ColorRepresentation = 0xffffff) { + this.hints?.set(id, position, { kind: "circle", radius, thickness, color }); + } + + public showMouseFrustum() { + if (!this.camera) + throw new Error('Camera is not initialized'); + + const frustum = this.mouseFrustum.frustum; + const cameraDepth = this.camera.far - this.camera.near; + const nearDepth = this.camera.near + cameraDepth * 0.01; + const farDepth = this.camera.far - cameraDepth * 0.01; + const near = frustum.getCircleAtDepth(nearDepth); + const far = frustum.getCircleAtDepth(farDepth); + + this.showCircle('hittest_near', near.center, near.radius, 0.05); + this.showCircle('hittest_far', far.center, far.radius, 0.1, 'red'); + } + + public clear() { + this.hints?.dispose(); } public dispose() { this.sync?.dispose(); - this.clearPoints(); + this.clear(); } } diff --git a/client/src/helpers/volatileGeometryHelper.ts b/client/src/helpers/volatileGeometryHelper.ts new file mode 100644 index 0000000..8efc8ee --- /dev/null +++ b/client/src/helpers/volatileGeometryHelper.ts @@ -0,0 +1,92 @@ +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/layers/sceneSync.ts b/client/src/layers/sceneSync.ts index 749bd9e..bebbe14 100644 --- a/client/src/layers/sceneSync.ts +++ b/client/src/layers/sceneSync.ts @@ -38,8 +38,9 @@ export class SceneSync { const meshes = Geometry .tessellateSolid(id) .map(meshToDto); - for (const mesh of meshes) - this.addSolid(mesh); + this.addSolid(meshes[2]); + // for (const mesh of meshes) + // this.addSolid(mesh); } }