basic hit testing

This commit is contained in:
azykov@mail.ru 2026-05-20 20:43:52 +03:00
parent aa17ecd4c9
commit bd5fa12a1e
No known key found for this signature in database
22 changed files with 625 additions and 261 deletions

View File

@ -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",

View File

@ -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"
},

View File

@ -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 {

View File

@ -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 (
<div>
<Viewport />
<HitTestView />
<DbView />
</div>
)

View File

@ -0,0 +1 @@
export * from './mesh';

View File

@ -1,4 +1,4 @@
import type { Mesh, Solid, Surface } from "../types";
import type { Mesh, Solid, Surface } from "../../types";
export type MeshDto = {
vertices: Float32Array;

View File

@ -0,0 +1,3 @@
export type SolildDto = {
}

View File

@ -0,0 +1,20 @@
import { observer } from "mobx-react-lite";
import { state } from "../state/root";
export const HitTestView = observer(function () {
return (
<div id="hit-test">
<pre>
{
state.hitTest.objects.map((obj) =>
<div key={obj.object.uuid}>
{JSON.stringify(obj.point.toArray())}
{JSON.stringify(obj.object.userData)}
</div>
)
}
</pre>
</div>
)
});

View File

@ -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<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(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 (
<div id="viewport" ref={viewportRef}>
</div>
)
}

View File

@ -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<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(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 (
<div id="viewport" ref={viewportRef}>
</div>
)
}
return (<>
<ThreeView
sceneHelper={sceneHelper}
onMouseMove={handleMouseMove}
onDispose={handleDispose}
/>
</>);
};

View File

@ -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<THREE.Object3D>[];
}
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<string, THREE.Intersection<THREE.Object3D>> = {};
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),
}
}
}

View File

@ -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();

View File

@ -0,0 +1,9 @@
import { useState } from "react";
import { SceneHelper } from "../sceneHelper";
export function useSceneHelper(): SceneHelper {
const [sceneHelper] = useState<SceneHelper>(new SceneHelper());
return sceneHelper;
}

View File

@ -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));
}
}

View File

@ -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<string, Point3d> = {};
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);
}
}

View File

@ -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<Object3DEventMap>[] {
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();
}
}

View File

@ -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<string, THREE.BufferGeometry>();

View File

@ -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<Id, THREE.Mesh>;
private meshByFace: Record<Id, THREE.Mesh> = {}; // 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<Id, THREE.Mesh> {
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();
}

96
client/src/model/model.ts Normal file
View File

@ -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<Id, Vertex> = {};
public halfEdges: Record<Id, HalfEdge> = {};
public edges: Record<Id, Edge> = {};
public loops: Record<Id, Loop> = {};
public faces: Record<Id, Face> = {};
public surfaces: Record<Id, Surface> = {};
public solids: Record<Id, Solid> = {};
public get primitives(): Record<Id, Primitive> {
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<string>();
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();

View File

@ -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();

View File

@ -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'][],
}

13
server/package.json Normal file
View File

@ -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"
}