diff --git a/client/package-lock.json b/client/package-lock.json index 84d7341..8590727 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -16,6 +16,7 @@ "react-dom": "^19.2.6", "sass-embedded": "^1.99.0", "three": "^0.184.0", + "three-mesh-bvh": "^0.9.10", "uuid": "^14.0.0", "verb-nurbs": "^3.0.3" }, @@ -3313,7 +3314,17 @@ "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.10.tgz", + "integrity": "sha512-UOlTgPIeqUURcwaG8knxvBaruwZlC4X3/WSHEFO7rYvMVv/YNUrkfFEszvfj36pXV88dCHoHNnIp0PifkirnTQ==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } }, "node_modules/tinyglobby": { "version": "0.2.16", diff --git a/client/package.json b/client/package.json index 930d355..adef8d9 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "react-dom": "^19.2.6", "sass-embedded": "^1.99.0", "three": "^0.184.0", + "three-mesh-bvh": "^0.9.10", "uuid": "^14.0.0", "verb-nurbs": "^3.0.3" }, diff --git a/client/src/App.scss b/client/src/App.scss index c0c7a1f..1e3303b 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -1,10 +1,18 @@ #viewport { - width: 600px; - height: 600px; background: white; position: absolute; top: 0px; left: 0px; + width: 600px; + height: 600px; +} + +#hit-test { + position: absolute; + top: 600px; + left: 000px; + width: 600px; + font-size: 75%; } #blob-view { diff --git a/client/src/App.tsx b/client/src/App.tsx index b92b3e8..3413d57 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import { Viewport } from './components/Viewport' import { DbView } from './components/DbView' +import { HitTestView } from './components/HitTestView' import './App.scss' @@ -7,6 +8,7 @@ export const App = function () { return (
+
) diff --git a/client/src/backend/dto/index.ts b/client/src/backend/dto/index.ts new file mode 100644 index 0000000..918b977 --- /dev/null +++ b/client/src/backend/dto/index.ts @@ -0,0 +1 @@ +export * from './mesh'; diff --git a/client/src/backend/dto.ts b/client/src/backend/dto/mesh.ts similarity index 90% rename from client/src/backend/dto.ts rename to client/src/backend/dto/mesh.ts index fb74925..0a1e1bb 100644 --- a/client/src/backend/dto.ts +++ b/client/src/backend/dto/mesh.ts @@ -1,4 +1,4 @@ -import type { Mesh, Solid, Surface } from "../types"; +import type { Mesh, Solid, Surface } from "../../types"; export type MeshDto = { vertices: Float32Array; diff --git a/client/src/backend/dto/solid.ts b/client/src/backend/dto/solid.ts new file mode 100644 index 0000000..e135aa7 --- /dev/null +++ b/client/src/backend/dto/solid.ts @@ -0,0 +1,3 @@ +export type SolildDto = { + +} diff --git a/client/src/components/HitTestView.tsx b/client/src/components/HitTestView.tsx new file mode 100644 index 0000000..ff328de --- /dev/null +++ b/client/src/components/HitTestView.tsx @@ -0,0 +1,20 @@ +import { observer } from "mobx-react-lite"; +import { state } from "../state/root"; + +export const HitTestView = observer(function () { + + return ( +
+
+                {
+                    state.hitTest.objects.map((obj) =>
+                        
+ {JSON.stringify(obj.point.toArray())} + {JSON.stringify(obj.object.userData)} +
+ ) + } +
+
+ ) +}); diff --git a/client/src/components/ThreeVIew.tsx b/client/src/components/ThreeVIew.tsx new file mode 100644 index 0000000..a8b2531 --- /dev/null +++ b/client/src/components/ThreeVIew.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef } from "react"; +import * as THREE from 'three'; +import { useInteraction, type InteractionMouseMoveEventArgs } from "../helpers/hooks/useInteration"; +import { db } from "../backend/db"; +import { HitTestFactory, type HitTest } from "../helpers/hitTest"; +import { model } from "../model/model"; +import { Point3dHelper } from "../helpers/point3dHelper"; +import { SceneHelper } from "../helpers/sceneHelper"; + +export type ThreeViewEventArgs = { + camera: THREE.Camera, + scene: THREE.Scene, +} + +export type ThreeViewTickEventArgs = ThreeViewEventArgs & { + deltaTime: number, + absoluteTime: number, +} + +export type ThreeViewMouseMoveEventArgs = ThreeViewEventArgs & { + hitTest: HitTest, +} + +export type ThreeViewProps = { + sceneHelper: SceneHelper, + onTick?: (event: ThreeViewTickEventArgs) => void, + onMouseMove?: (event: ThreeViewMouseMoveEventArgs) => void, + onDispose: (event: ThreeViewEventArgs) => void, +} + +function setupScene(size: { w: number, h: number }): { scene: THREE.Scene, camera: THREE.PerspectiveCamera } { + // --- Scene & Camera --- + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0a0a12); + + const camera = new THREE.PerspectiveCamera(55, size.w / size.h, 0.1, 100); + camera.position.set(4, 3, 6); + + // --- Lights --- + scene.add(new THREE.AmbientLight(0xffffff, 0.3)); + + const dir = new THREE.DirectionalLight(0xffffff, 1.2); + dir.position.set(5, 8, 5); + dir.castShadow = true; + scene.add(dir); + + const pt = new THREE.PointLight(0x5588ff, 1.5, 20); + pt.position.set(-3, 2, -3); + scene.add(pt); + + // --- Floor & Grid --- + const plane = new THREE.Mesh( + new THREE.PlaneGeometry(14, 14), + new THREE.MeshStandardMaterial({ color: 0x080810 }) + ); + plane.rotation.x = -Math.PI / 2; + plane.receiveShadow = true; + // scene.add(plane); + // scene.add(new THREE.GridHelper(14, 14, 0x222233, 0x111122)); + + return { scene, camera }; +} + +export const ThreeView = function (props: ThreeViewProps) { + + const viewportRef = useRef(null); + const canvasRef = useRef(null); + const cameraRef = useRef(null); + + let handleHover: (e: InteractionMouseMoveEventArgs) => void; + + useEffect(() => { + + model.initFromBlob(db.saveToBlob()); + + const container = viewportRef.current!; + const W = container.clientWidth; + const H = container.clientHeight; + + // --- Renderer --- + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(W, H); + renderer.shadowMap.enabled = true; + container.appendChild(renderer.domElement); + + canvasRef.current = renderer.domElement; + + const { scene, camera } = setupScene({ w: W, h: H }); + cameraRef.current = camera; + + props.sceneHelper.initialize(scene); + + const handleWindowResize = () => { + const w = container.clientWidth; + const h = container.clientHeight; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + }; + window.addEventListener("resize", handleWindowResize); + + handleHover = (e: InteractionMouseMoveEventArgs) => { + const hitTest = HitTestFactory.hitTest( + props.sceneHelper, + new THREE.Vector2(e.position.x, e.position.y), + camera, + { tolerancePixels: 3, cameraPixelSize: e.pixelSize } + ); + props.onMouseMove?.({ + camera, + scene, + hitTest, + }); + }; + + // --- Animation loop --- + let lastTime = performance.now(); + let animId: number; + function animate(time: DOMHighResTimeStamp) { + animId = requestAnimationFrame(animate); + const deltaTime = lastTime ? time - lastTime : 0; + lastTime = time; + props.onTick?.({ + camera, + scene, + deltaTime, + absoluteTime: time, + }); + renderer.render(scene, camera); + } + animId = requestAnimationFrame(animate); + + // --- Cleanup --- + return () => { + if (animId) + cancelAnimationFrame(animId); + + container.removeChild(renderer.domElement); + + window.removeEventListener("resize", handleWindowResize); + + renderer.dispose(); + + props.onDispose({ + camera, + scene, + }); + + props.sceneHelper.dispose(); + }; + }, []); + + useInteraction(canvasRef, cameraRef, { + onMouseMove: (e) => handleHover?.(e), + }); + + return ( +
+ +
+ ) +} diff --git a/client/src/components/Viewport.tsx b/client/src/components/Viewport.tsx index 5f418fb..32548bb 100644 --- a/client/src/components/Viewport.tsx +++ b/client/src/components/Viewport.tsx @@ -1,193 +1,43 @@ -import { useEffect, useRef } from "react"; -import * as THREE from 'three'; -import { useInteraction, type InteractionMouseMoveEventArgs } from "../helpers/useInteration"; -import { SceneSync } from "../layers/sceneSync"; -import { GeometryCache } from "../layers/geometryCache"; -import { meshToDto } from "../backend/dto"; -import { db } from "../backend/db"; -import { NURBSBuilder } from "../verb/NURBSBuilder"; -import { MeshService } from "../verb/meshService"; -import { Geometry } from "../backend/geometry/geometry"; +import { useState } from "react"; +import { useSceneHelper } from "../helpers/hooks/useSceneHelper"; +import { Point3dHelper } from "../helpers/point3dHelper"; +import { state } from "../state/root"; +import { ThreeView, type ThreeViewEventArgs, type ThreeViewMouseMoveEventArgs } from "./ThreeVIew"; -export type ViewportProps = { - onHover?: (faceIds: string[]) => void; -} +export const Viewport = function () { -function setupScene(size: { w: number, h: number }): { scene: THREE.Scene, camera: THREE.PerspectiveCamera } { - // --- Scene & Camera --- - const scene = new THREE.Scene(); - scene.background = new THREE.Color(0x0a0a12); + const sceneHelper = useSceneHelper(); - const camera = new THREE.PerspectiveCamera(55, size.w / size.h, 0.1, 100); - camera.position.set(4, 3, 6); + function handleMouseMove(e: ThreeViewMouseMoveEventArgs) { + state.setHitTest(e.hitTest); - // --- Lights --- - scene.add(new THREE.AmbientLight(0xffffff, 0.3)); - - const dir = new THREE.DirectionalLight(0xffffff, 1.2); - dir.position.set(5, 8, 5); - dir.castShadow = true; - scene.add(dir); - - const pt = new THREE.PointLight(0x5588ff, 1.5, 20); - pt.position.set(-3, 2, -3); - scene.add(pt); - - // --- Floor & Grid --- - const plane = new THREE.Mesh( - new THREE.PlaneGeometry(14, 14), - new THREE.MeshStandardMaterial({ color: 0x080810 }) - ); - plane.rotation.x = -Math.PI / 2; - plane.receiveShadow = true; - // scene.add(plane); - // scene.add(new THREE.GridHelper(14, 14, 0x222233, 0x111122)); - - return { scene, camera }; -} - -function initializeSceneObjects(scene: THREE.Scene): { positions: THREE.Vector3[], meshes: THREE.Mesh[], geometries: THREE.BufferGeometry[], materials: THREE.Material[] } { - const materials = [ - new THREE.MeshStandardMaterial({ color: 0x5f77dd, roughness: 0.3, metalness: 0.4 }), - new THREE.MeshStandardMaterial({ color: 0xdd775f, roughness: 0.5, metalness: 0.1 }), - new THREE.MeshStandardMaterial({ color: 0x5fdd99, roughness: 0.2, metalness: 0.6 }), - new THREE.MeshStandardMaterial({ color: 0xddcc5f, roughness: 0.4, metalness: 0.2 }), - ]; - - const geometries = [ - new THREE.BoxGeometry(1, 1, 1), - new THREE.SphereGeometry(0.6, 32, 32), - new THREE.TorusGeometry(0.5, 0.2, 16, 48), - new THREE.ConeGeometry(0.5, 1.2, 32), - new THREE.CylinderGeometry(0.3, 0.5, 1.2, 32), - new THREE.OctahedronGeometry(0.65), - ]; - - const positions: [number, number, number][] = [ - [0, 0.5, 0], [2.5, 0.6, 0], [-2.5, 0.6, 0], - [0, 0.6, 2.5], [2.5, 0.6, -2.5], [-2.5, 0.6, 2.5], - ]; - - const meshes = geometries.map((geo, i) => { - const mesh = new THREE.Mesh(geo, materials[i % materials.length]); - mesh.position.set(...positions[i]); - mesh.castShadow = true; - mesh.receiveShadow = true; - scene.add(mesh); - return mesh; - }); - - return { positions: positions.map(([x, y, z]) => new THREE.Vector3().set(x, y, z)), meshes, geometries, materials }; -} - -export const Viewport = function (props: ViewportProps) { - - const viewportRef = useRef(null); - const canvasRef = useRef(null); - const cameraRef = useRef(null); - - // let hoveredMesh: THREE.Object3D | null = null; - - let handleHover: (e: InteractionMouseMoveEventArgs) => void; - - useEffect(() => { - const container = viewportRef.current!; - const W = container.clientWidth; - const H = container.clientHeight; - - // --- Renderer --- - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setPixelRatio(window.devicePixelRatio); - renderer.setSize(W, H); - renderer.shadowMap.enabled = true; - container.appendChild(renderer.domElement); - - canvasRef.current = renderer.domElement; - - const { scene, camera } = setupScene({ w: W, h: H }); - cameraRef.current = camera; - - - const cache = new GeometryCache(); - const sync = new SceneSync(scene, cache); - - const solid = db.solids[0]; - const meshes = Geometry - .tessellateSolid(solid.id); - meshes - .forEach((mesh) => { - console.log(meshes); - const dto = meshToDto(mesh); - sync.addSolid(dto); - }); - - // sync.setSelected(['48']); - - // const { positions, meshes, geometries, materials } = initializeSceneObjects(scene); - - // --- Resize handler --- - const onResize = () => { - const w = container.clientWidth; - const h = container.clientHeight; - camera.aspect = w / h; - camera.updateProjectionMatrix(); - renderer.setSize(w, h); - }; - window.addEventListener("resize", onResize); - - // --- Hover highlight --- - const raycaster = new THREE.Raycaster(); - handleHover = (e: InteractionMouseMoveEventArgs) => { - raycaster.setFromCamera(new THREE.Vector2(e.x, e.y), camera); - const hits = raycaster.intersectObjects(sync.meshes); - const hoveredFaceIds = hits.map(hit => hit.object.userData.faceId); - if (hoveredFaceIds.length) - console.log(hoveredFaceIds); - props.onHover?.(hoveredFaceIds); - sync.setSelected(hoveredFaceIds); - }; - - // --- Animation loop --- - let lastTime = performance.now(); - let animId: number; - - function animate(time: DOMHighResTimeStamp) { - animId = requestAnimationFrame(animate); - // const deltaTime = lastTime ? time - lastTime : 0; - lastTime = time; - // meshes.forEach((m, i) => { - // m.rotation.y += 0.006 + i * 0.001; - // m.rotation.x += 0.003; - // if (m !== hoveredMesh) { - // m.position.y = positions[i].y + Math.sin(t + i * 1.1) * 0.15; - // } - // }); - renderer.render(scene, camera); + sceneHelper.clearPoints(); + if (e.hitTest.objects.length) { + e.hitTest.objects.forEach((obj) => { + sceneHelper.setPoint(obj.object.uuid, obj.point); + }) + // console.log(e.position); + // console.log(e.hitTest.objects.map((o) => o)); + // console.log(e.hitTest.objects.flatMap((o) => o.point.toArray())); } - animId = requestAnimationFrame(animate); + // raycaster.setFromCamera(new THREE.Vector2(e.x, e.y), camera); + // const hits = raycaster.intersectObjects(sync.meshes); + const hoveredFaceIds = e.hitTest.objects.map(hit => hit.object.userData.faceId); + // if (hoveredFaceIds.length) + // console.log(hoveredFaceIds); - // --- Cleanup --- - return () => { - if (animId) - cancelAnimationFrame(animId); + sceneHelper.setSelection(hoveredFaceIds); + } - container.removeChild(renderer.domElement); + function handleDispose(e: ThreeViewEventArgs): void { + // throw new Error("Function not implemented."); + } - window.removeEventListener("resize", onResize); - - renderer.dispose(); - - sync.dispose(); - }; - }, []); - - useInteraction(canvasRef, cameraRef, { - onMouseMove: (e) => handleHover?.(e), - }); - - return ( -
- -
- ) -} + return (<> + + ); +}; diff --git a/client/src/helpers/hitTest.ts b/client/src/helpers/hitTest.ts new file mode 100644 index 0000000..f0e3bb7 --- /dev/null +++ b/client/src/helpers/hitTest.ts @@ -0,0 +1,80 @@ +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; + +export type HitTest = { + objects: THREE.Intersection[]; +} + +export type HitTestRaycasterOptions = { + cameraPixelSize: THREE.Vector2Like; + tolerancePixels: number; +} + +export type HitTestOptions = HitTestRaycasterOptions & { +} + +export class HitTestFactory { + + private static raycasters: [THREE.Vector2, THREE.Raycaster][] = Array(9).fill(0).map(() => [new THREE.Vector2(), new THREE.Raycaster()]); + + private static setupRaycasters(cursor: THREE.Vector2, camera: THREE.PerspectiveCamera, options: HitTestRaycasterOptions) { + + this.raycasters[0][0].copy(cursor); + this.raycasters[0][1].setFromCamera(cursor, camera); + + const count = HitTestFactory.raycasters.length - 1; + const step = Math.PI * 2 / count; + + for (let angle = 0, idx = 0; idx < count; angle += step, idx++) { + const pos = { + x: Math.cos(angle) * options.tolerancePixels * options.cameraPixelSize.x, + y: Math.sin(angle) * options.tolerancePixels * options.cameraPixelSize.y, + }; + const v = HitTestFactory.raycasters[idx + 1][0]; + v.copy(cursor).add(pos); + HitTestFactory.raycasters[idx + 1][1].setFromCamera(v, camera); + } + } + + public static getRaycasterPosition(index: number): THREE.Vector2 { + return HitTestFactory.raycasters[index][0]; + } + + public static get raycasterCount(): number { + return HitTestFactory.raycasters.length; + } + + public static hitTest(scene: SceneHelper, cursor: THREE.Vector2, camera: THREE.PerspectiveCamera, options: HitTestOptions): HitTest { + + HitTestFactory.setupRaycasters(cursor, camera, options); + + const objects: THREE.Object3D[] = scene.objects; + + const hitTest: Record> = {}; + + HitTestFactory.raycasters.forEach((raycaster) => { + const hits = raycaster[1].intersectObjects(objects); + for (const hit of hits) { + hitTest[hit.object.uuid] = hit; + } + }); + + return { + objects: Object.values(hitTest), + } + } +} diff --git a/client/src/helpers/useInteration.ts b/client/src/helpers/hooks/useInteration.ts similarity index 91% rename from client/src/helpers/useInteration.ts rename to client/src/helpers/hooks/useInteration.ts index 0a9d51d..3c1b44f 100644 --- a/client/src/helpers/useInteration.ts +++ b/client/src/helpers/hooks/useInteration.ts @@ -1,10 +1,13 @@ import { useEffect, type RefObject } from "react"; import * as THREE from "three"; -export type InteractionMouseMoveEventArgs = { x: number, y: number }; +export type InteractionMouseMoveEventArgs = { + position: { x: number, y: number }, + pixelSize: { x: number, y: number }, +}; export type UseInteractionOptions = { - onMouseMove?: (position: InteractionMouseMoveEventArgs) => void, + onMouseMove?: (e: InteractionMouseMoveEventArgs) => void, } export function useInteraction( @@ -66,11 +69,11 @@ export function useInteraction( const onHover = (e: MouseEvent) => { const rect = target.getBoundingClientRect(); - const pos = { + const position = { x: ((e.clientX - rect.left) / rect.width) * 2 - 1, y: -((e.clientY - rect.top) / rect.height) * 2 + 1, }; - options.onMouseMove?.(pos); + options.onMouseMove?.({ position, pixelSize: { x: 2 / rect.width, y: 2 / rect.height } }); }; const onContextMenu = (e: Event) => e.preventDefault(); diff --git a/client/src/helpers/hooks/useSceneHelper.ts b/client/src/helpers/hooks/useSceneHelper.ts new file mode 100644 index 0000000..1db622e --- /dev/null +++ b/client/src/helpers/hooks/useSceneHelper.ts @@ -0,0 +1,9 @@ +import { useState } from "react"; +import { SceneHelper } from "../sceneHelper"; + +export function useSceneHelper(): SceneHelper { + + const [sceneHelper] = useState(new SceneHelper()); + + return sceneHelper; +} diff --git a/client/src/helpers/point2dHelper.ts b/client/src/helpers/point2dHelper.ts new file mode 100644 index 0000000..795fc2f --- /dev/null +++ b/client/src/helpers/point2dHelper.ts @@ -0,0 +1,33 @@ +import * as THREE from "three"; + +export class Point2dHelper { + + private vec = new THREE.Vector3(); // create once and reuse + + private marker = new THREE.Mesh( + new THREE.SphereGeometry(0.01, 8, 8), + new THREE.MeshBasicMaterial({ color: 0xff0000 }) + ); + private camera: THREE.PerspectiveCamera; + + constructor(scene: THREE.Scene, camera: THREE.PerspectiveCamera) { + this.camera = camera; + scene.add(this.marker); + } + + public set(position: THREE.Vector2Like) { + + const targetZ = -10; + + this.vec.set(position.x, position.y, 0.5); + + this.vec.unproject(this.camera); + + this.vec.sub(this.camera.position).normalize(); + + // var distance = -this.camera.position.z / this.vec.z; + var distance = (this.camera.near - this.camera.position.z) / this.vec.z; + + this.marker.position.copy(this.camera.position).add(this.vec.multiplyScalar(distance)); + } +} \ No newline at end of file diff --git a/client/src/helpers/point3dHelper.ts b/client/src/helpers/point3dHelper.ts new file mode 100644 index 0000000..6e7dd95 --- /dev/null +++ b/client/src/helpers/point3dHelper.ts @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..6dc1eaa --- /dev/null +++ b/client/src/helpers/sceneHelper.ts @@ -0,0 +1,46 @@ +import type { Object3D, Object3DEventMap, Scene, Vector3 } from "three"; +import { Point3dHelper } from "./point3dHelper"; +import { SceneSync } from "../layers/sceneSync"; +import { GeometryCache } from "../layers/geometryCache"; +import type { Id } from "../types"; + +export class SceneHelper { + + private sync: SceneSync | undefined; + + private pointHelper: Point3dHelper | undefined; + + constructor() { + + } + + public initialize(scene: Scene) { + this.pointHelper = new Point3dHelper(scene); + + this.sync = new SceneSync(scene, new GeometryCache()); + this.sync.addWholeModel(); + } + + public setSelection(faceIds: Id[]) { + this.sync?.setSelected(faceIds); + } + + public get objects(): Object3D[] { + + return this.sync?.meshes ?? []; + } + + public setPoint(id: string, point: Vector3) { + this.pointHelper?.set(id, point); + } + + public clearPoints() { + this.pointHelper?.dispose(); + } + + public dispose() { + this.sync?.dispose(); + + this.clearPoints(); + } +} diff --git a/client/src/layers/geometryCache.ts b/client/src/layers/geometryCache.ts index 82df0d3..fc38ce7 100644 --- a/client/src/layers/geometryCache.ts +++ b/client/src/layers/geometryCache.ts @@ -1,7 +1,6 @@ import * as THREE from 'three'; import type { MeshDto } from '../backend/dto'; - -export type Id = string; +import type { Id } from '../types'; export class GeometryCache { private readonly _cache = new Map(); diff --git a/client/src/layers/sceneSync.ts b/client/src/layers/sceneSync.ts index a45e8be..749bd9e 100644 --- a/client/src/layers/sceneSync.ts +++ b/client/src/layers/sceneSync.ts @@ -1,10 +1,13 @@ import * as THREE from 'three'; -import type { GeometryCache, Id } from './geometryCache'; -import type { MeshDto } from '../backend/dto'; +import type { GeometryCache } from './geometryCache'; +import { meshToDto, type MeshDto } from '../backend/dto/mesh'; +import { model } from '../model/model'; +import { Geometry } from '../backend/geometry/geometry'; +import type { Id } from '../types'; export class SceneSync { private scene: THREE.Scene; - private meshByFace: Map; + private meshByFace: Record = {}; // faceId → THREE.Mesh private cache: GeometryCache; private _selectedFaceIds: Id[] = []; @@ -16,7 +19,6 @@ export class SceneSync { constructor(scene: THREE.Scene, cache: GeometryCache) { this.scene = scene; this.cache = cache; - this.meshByFace = new Map(); // faceId → THREE.Mesh } public get selectedFaceIds() { @@ -24,7 +26,21 @@ export class SceneSync { } public get meshes(): THREE.Mesh[] { - return Array.from(this.meshByFace.values()); + return Object.values(this.meshByFace); + } + + public get items(): Record { + return this.meshByFace; + } + + addWholeModel() { + for (const id of Object.keys(model.solids)) { + const meshes = Geometry + .tessellateSolid(id) + .map(meshToDto); + for (const mesh of meshes) + this.addSolid(mesh); + } } // Called when FE scene graph syncs from BE @@ -34,12 +50,14 @@ export class SceneSync { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]); - if (this.meshByFace.has(faceId)) + if (this.meshByFace[faceId]) return; const geo = this.cache.getOrCreate(faceId, 0, dto); const mesh = new THREE.Mesh(geo, this.baseMaterial.clone()); mesh.userData.faceId = faceId; + mesh.userData.surfaceId = dto.surfaceId; + mesh.userData.solidId = dto.solidId; // Apply transform (col-major mat4 from BE) const m = new THREE.Matrix4(); @@ -47,11 +65,11 @@ export class SceneSync { mesh.applyMatrix4(m); this.scene.add(mesh); - this.meshByFace.set(faceId, mesh); + this.meshByFace[faceId] = mesh; } updateTransform(faceId: Id, colMajorMat4: number[]) { - const mesh = this.meshByFace.get(faceId); + const mesh = this.meshByFace[faceId]; if (!mesh) return; const m = new THREE.Matrix4().fromArray(colMajorMat4); mesh.matrix.copy(m); @@ -61,7 +79,7 @@ export class SceneSync { setSelected(faceIds: Id[]) { this._selectedFaceIds = faceIds; - for (const [sid, mesh] of this.meshByFace) { + for (const [sid, mesh] of Object.entries(this.meshByFace)) { const mat = mesh.material as THREE.MeshPhongMaterial; if (faceIds.includes(sid)) { @@ -75,8 +93,8 @@ export class SceneSync { } } - public disposeMesh(solidId: Id) { - const mesh = this.meshByFace.get(solidId); + public disposeMesh(faceId: Id) { + const mesh = this.meshByFace[faceId]; if (!mesh) return; @@ -91,13 +109,13 @@ export class SceneSync { else mesh.material.dispose(); - this.cache.unset(solidId, 0); - this.meshByFace.delete(solidId); + this.cache.unset(faceId, 0); + delete (this.meshByFace[faceId]); } public dispose() { - for (const solidId of this.meshByFace.keys()) - this.disposeMesh(solidId); + for (const faceId of Object.keys(this.meshByFace)) + this.disposeMesh(faceId); this.baseMaterial.dispose(); } diff --git a/client/src/model/model.ts b/client/src/model/model.ts new file mode 100644 index 0000000..2023f5a --- /dev/null +++ b/client/src/model/model.ts @@ -0,0 +1,96 @@ +// import { v4 as uuid } from 'uuid'; +import type { Edge, Face, HalfEdge, Id, Loop, Primitive, Solid, Surface, Vertex } from "../types/brep"; + +export type DbBlob = { + vertices: Vertex[], + halfEdges: HalfEdge[], + edges: Edge[], + loops: Loop[], + faces: Face[], + surfaces: Surface[], + solids: Solid[], +} + +export class Model { + public vertices: Record = {}; + public halfEdges: Record = {}; + public edges: Record = {}; + public loops: Record = {}; + public faces: Record = {}; + public surfaces: Record = {}; + public solids: Record = {}; + + public get primitives(): Record { + return { + ...this.vertices, + ...this.halfEdges, + ...this.edges, + ...this.loops, + ...this.faces, + ...this.surfaces, + ...this.solids, + }; + } + + public initFromBlob(value: DbBlob) { + this.vertices = Object.fromEntries(value.vertices.map((v) => [v.id, v])); + this.halfEdges = Object.fromEntries(value.halfEdges.map((v) => [v.id, v])); + this.edges = Object.fromEntries(value.edges.map((v) => [v.id, v])); + this.loops = Object.fromEntries(value.loops.map((v) => [v.id, v])); + this.faces = Object.fromEntries(value.faces.map((v) => [v.id, v])); + this.surfaces = Object.fromEntries(value.surfaces.map((v) => [v.id, v])); + this.solids = Object.fromEntries(value.solids.map((v) => [v.id, v])); + } + + public primitiveById(id: Primitive['id']): Primitive | undefined { + return this.primitives[id]; + } + + public vertexById(id: Vertex['id']): Vertex | undefined { + return this.vertices[id]; + } + + public halfEdgeById(id: HalfEdge['id']): HalfEdge | undefined { + return this.halfEdges[id]; + } + + public halfEdgesByLoop(loopId: string): HalfEdge[] { + const loop = this.loopById(loopId)!; + const startHalfEdgeId = loop.start; + + const halfEdges: HalfEdge[] = []; + + const visited = new Set(); + let halfEdgeId: string | undefined = startHalfEdgeId; + while (halfEdgeId && !visited.has(halfEdgeId)) { + const halfEdge = this.halfEdgeById(halfEdgeId)! as HalfEdge; + halfEdges.push(halfEdge); + visited.add(halfEdgeId); + halfEdgeId = halfEdge.next; + } + + return halfEdges; + } + + public edgeById(id: Edge['id']): Edge | undefined { + return this.edges[id]; + } + + public loopById(id: Loop['id']): Loop | undefined { + return this.loops[id]; + } + + public faceById(id: Face['id']): Face | undefined { + return this.faces[id]; + } + + public surfaceById(id: Surface['id']): Surface | undefined { + return this.surfaces[id]; + } + + public solidById(id: Solid['id']): Solid | undefined { + return this.solids[id]; + } +} + +export const model = new Model(); diff --git a/client/src/state/root.ts b/client/src/state/root.ts index a68c918..cc8697d 100644 --- a/client/src/state/root.ts +++ b/client/src/state/root.ts @@ -1,9 +1,10 @@ import { makeAutoObservable } from "mobx"; import type { Id } from "../types"; +import type { HitTest } from "../helpers/hitTest"; export class Root { - public selectedPrimitiveIds: Id[] = []; + public hitTest: HitTest = { objects: [] }; constructor() { makeAutoObservable(this); @@ -12,6 +13,10 @@ export class Root { public setSelectedPrimitiveIds(value: Id[]) { this.selectedPrimitiveIds = value; } + + public setHitTest(value: HitTest) { + this.hitTest = value; + } } export const state = new Root(); diff --git a/client/src/types/brepdto.ts b/client/src/types/brepdto.ts deleted file mode 100644 index 073954e..0000000 --- a/client/src/types/brepdto.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type Id = string; - -export type Primitive = { - id: Id, - version?: number; -} - -export type Vertex = Primitive & { - x: number, - y: number, - z: number, - ownerHalfEdgeA?: HalfEdge['id'], - ownerHalfEdgeB?: HalfEdge['id'], -} - -export type HalfEdge = Primitive & { - origin: Vertex['id'], - twin?: HalfEdge['id'], - next: HalfEdge['id'], // next in loop - prev: HalfEdge['id'], // prev in loop - ownerLoop: Loop['id'], - // ownerEdge: Edge['id'], -} - -// export type Edge = Primitive & { -// a: HalfEdge['id'], -// b: HalfEdge['id'], -// // crease: boolean, // sharp vs smooth -// ownerLoop: Loop['id'], -// } - -export type Loop = Primitive & { - start: HalfEdge['id'], - ownerFace: Face['id'], -} - -export type Face = Primitive & { - outerLoop: Loop['id'], - holes: Loop['id'][], - ownerSurface: Surface['id'], -} - -export type Surface = Primitive & { - faces: Face['id'][], -} - -export type Solid = Primitive & { - outerSurface: Surface['id'][], - // holes: Surface['id'][], -} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..56c4b9a --- /dev/null +++ b/server/package.json @@ -0,0 +1,13 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs" +}