From 4d09a47118eed91807bd6fff710e19828bf78d23 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Tue, 26 May 2026 14:01:55 +0300 Subject: [PATCH] hovered edge highlight --- client/src/components/Viewport.tsx | 10 +- client/src/helpers/ThreeHintDisplay.ts | 121 ++++++++++++++---- client/src/helpers/sceneHelper.ts | 86 ++++++------- client/src/layers/geometryCache.ts | 34 +++-- .../src/layers/{sceneSync.ts => meshCache.ts} | 73 +++++------ 5 files changed, 193 insertions(+), 131 deletions(-) rename client/src/layers/{sceneSync.ts => meshCache.ts} (60%) diff --git a/client/src/components/Viewport.tsx b/client/src/components/Viewport.tsx index 71d3095..6555c62 100644 --- a/client/src/components/Viewport.tsx +++ b/client/src/components/Viewport.tsx @@ -1,4 +1,5 @@ import { useSceneHelper } from "../helpers/hooks/useSceneHelper"; +import { model } from "../model/model"; import { state } from "../state/root"; import { ThreeView, type ThreeViewEventArgs, type ThreeViewMouseEventArgs } from "./ThreeView"; @@ -9,10 +10,13 @@ export const Viewport = function () { function handleMouseMove(e: ThreeViewMouseEventArgs) { state.setHitTest(e.screenPosition, e.hitResults); - sceneHelper.clearHints(); + sceneHelper.hints.clear(); if (e.hitResults.hits.length) { e.hitResults.hits.forEach((hit) => { - sceneHelper.showPointHint(hit.uuid, hit.point, 5, hit.kind === 'vertex' ? 'yellow' : (hit.kind === 'edge' ? 'lime' : 'white')); + sceneHelper.hints.showPoint(hit.uuid + '-point', hit.point, 5, hit.kind === 'vertex' ? 'yellow' : (hit.kind === 'edge' ? 'lime' : 'white')); + if (hit.kind === 'edge') { + sceneHelper.hints.showEdge(hit.uuid + '-edge', hit.id); + } }) // console.log(e.position); // console.log(e.hitTest.objects.map((o) => o)); @@ -26,7 +30,7 @@ export const Viewport = function () { sceneHelper.setSelection(hoveredFaceIds); - sceneHelper.showMouseFrustumHint(); + sceneHelper.updateMouseFrustumHint(); } function handleDispose(e: ThreeViewEventArgs): void { diff --git a/client/src/helpers/ThreeHintDisplay.ts b/client/src/helpers/ThreeHintDisplay.ts index 3ddbc7e..acf7edd 100644 --- a/client/src/helpers/ThreeHintDisplay.ts +++ b/client/src/helpers/ThreeHintDisplay.ts @@ -1,7 +1,13 @@ import * as THREE from "three"; +import type { CircularFrustum } from "./circularFrustum"; +import { model } from "../model/model"; +import type { Id } from "../types"; +import { Line2 } from 'three/addons/lines/Line2.js'; +import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; +import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js"; export type ThreeHint = { - mesh: THREE.Mesh, + object: THREE.Object3D, position: THREE.Vector3Like, options: ThreeHintOptions, } @@ -10,7 +16,6 @@ export type ThreeBaseHintOptions = { color: THREE.ColorRepresentation, } - export type ThreePointHintOptions = ThreeBaseHintOptions & { kind: 'point', size: number, @@ -22,15 +27,23 @@ export type ThreeCircleHintOptions = ThreeBaseHintOptions & { thickness: number, } -export type ThreeHintOptions = ThreePointHintOptions | ThreeCircleHintOptions; +export type ThreeLineHintOptions = ThreeBaseHintOptions & { + kind: 'line', + end: THREE.Vector3Like, + extendStart: boolean, + extendEnd: boolean, + thickness: number, +} + +export type ThreeHintOptions = ThreePointHintOptions | ThreeCircleHintOptions | ThreeLineHintOptions; 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 baseMeshMaterial = new THREE.MeshBasicMaterial({ color: 'red' }); + private readonly baseLineMaterial = new LineMaterial({ color: 'red' }); private readonly hints: Record = {}; @@ -44,10 +57,12 @@ export class ThreeHintDisplay { this.renderer = renderer; } - private createGeometry(options: ThreeHintOptions): THREE.BufferGeometry { + private createGeometry(options: ThreeHintOptions): THREE.BufferGeometry | LineGeometry { switch (options.kind) { case 'point': return new THREE.SphereGeometry(1); + case 'line': + return new LineGeometry().setPositions([0, 0, 0, 0, 0, 0]); case 'circle': return new THREE.TorusGeometry(options.radius, options.thickness * options.radius); default: @@ -57,42 +72,85 @@ export class ThreeHintDisplay { private ensure(id: string, options: ThreeHintOptions): ThreeHint { if (!this.hints[id]) { - const material = this.baseMaterial.clone(); + const material = options.kind === 'line' + ? this.baseLineMaterial.clone() + : this.baseMeshMaterial.clone(); material.color.set(options.color); + this.hints[id] = { - mesh: new THREE.Mesh( - this.createGeometry(options), - material, - ), + object: options.kind === 'line' + ? new Line2(this.createGeometry(options) as LineGeometry, material as LineMaterial) + : new THREE.Mesh(this.createGeometry(options), material), position: { x: 0, y: 0, z: 0 }, options, }; - this.scene.add(this.hints[id].mesh); + this.scene.add(this.hints[id].object); } return this.hints[id]; } - private disposeHint(id: string) { - const point = this.hints[id]; - if (point) { - this.scene.remove(point.mesh); - point.mesh.geometry.dispose(); + private hide(id: string) { + const hint = this.hints[id]; + if (hint) { + this.scene.remove(hint.object); + (hint.object as (THREE.Line | THREE.Mesh)).geometry.dispose(); delete (this.hints[id]); } } - public dispose() { + public clear() { for (const id in this.hints) - this.disposeHint(id); + this.hide(id); } - public set(id: string, position: THREE.Vector3Like, options: ThreeHintOptions) { + public show(id: string, position: THREE.Vector3Like, options: ThreeHintOptions) { const object = this.ensure(id, options); object.position = position; this.applyCameraToHint(object); } + public showPoint(id: string, position: THREE.Vector3Like, size: number = 5, color: THREE.ColorRepresentation = 0xffffff) { + this.show(id, position, { kind: "point", size, color }); + } + + public showCircle(id: string, position: THREE.Vector3Like, radius: number, thickness: number = 0.1, color: THREE.ColorRepresentation = 0xffffff) { + this.show(id, position, { kind: "circle", radius, thickness, color }); + } + + public showLine(id: string, start: THREE.Vector3Like, end: THREE.Vector3Like, thickness: number = 1, color: THREE.ColorRepresentation = 0xffffff) { + this.show(id, start, { kind: "line", end, color, thickness, extendStart: true, extendEnd: true }); + } + + public showEdge(id: string, edgeId: Id) { + const edge = model.edgeById(edgeId); + if (!edge?.a || !edge?.b) + return; + + const heA = model.halfEdgeById(edge.a); + const heB = model.halfEdgeById(edge.b); + if (!heA || !heB) + return; + + const vA = model.vertexById(heA.origin); + const vB = model.vertexById(heB.origin); + if (!vA || !vB) + return; + + this.showLine(id, vA, vB, 1, 'red'); + } + + public showFrustum(id: string, frustum: CircularFrustum) { + 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(id + '_near', near.center, near.radius, 0.05); + this.showCircle(id + '_far', far.center, far.radius, 0.1, 'red'); + } + private applyCameraToHint(hint: ThreeHint) { const rendererSize = new THREE.Vector2(); @@ -110,14 +168,31 @@ export class ThreeHintDisplay { else { scale = (hint.options.size * (this.camera.top - this.camera.bottom)) / rendererSize.height; } - hint.mesh.scale.setScalar(scale); + hint.object.scale.setScalar(scale); break; case 'circle': - hint.mesh.lookAt(this.camera.position); + hint.object.lookAt(this.camera.position); + break; + case 'line': + const line = hint.object as Line2; + const dir = new THREE.Vector3().subVectors(hint.position, hint.options.end); + + const BIG = 1e3; // large enough to leave the frustum + + const start = new THREE.Vector3().copy(hint.position); + if (hint.options.extendStart) + start.addScaledVector(dir, -BIG) + const end = new THREE.Vector3().copy(hint.options.end); + if (hint.options.extendEnd) + end.addScaledVector(dir, BIG); + + line.geometry.setFromPoints([start.clone().sub(hint.position), new THREE.Vector3().copy(end).sub(hint.position)]); + (line.material as LineMaterial).linewidth = hint.options.thickness; + line.computeLineDistances(); // required for dashed material to work break; } - hint.mesh.position.copy(hint.position); + hint.object.position.copy(hint.position); } public applyCamera() { diff --git a/client/src/helpers/sceneHelper.ts b/client/src/helpers/sceneHelper.ts index db0e548..8586a36 100644 --- a/client/src/helpers/sceneHelper.ts +++ b/client/src/helpers/sceneHelper.ts @@ -1,17 +1,20 @@ -import type { ColorRepresentation, Object3D, Object3DEventMap, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, Vector3Like, WebGLRenderer } from "three"; -import { SceneSync } from "../layers/sceneSync"; +import type { Object3D, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, WebGLRenderer } from "three"; +import { MeshCache } from "../layers/meshCache"; import { GeometryCache } from "../layers/geometryCache"; import type { Id } from "../types"; -import { CircularFrustumIntersection, type HitResults, type HitResult } from "./circularFrustumIntersect"; +import { CircularFrustumIntersection, type HitResults } from "./circularFrustumIntersect"; import { CircularFrustum } from "./circularFrustum"; import './bvh'; -import { ThreeHintDisplay, type ThreeHintOptions } from "./ThreeHintDisplay"; +import { ThreeHintDisplay } from "./ThreeHintDisplay"; +import { model } from "../model/model"; +import { Geometry } from "../backend/geometry/geometry"; +import { meshToDto } from "../backend/dto"; export class SceneHelper { - private sync: SceneSync | undefined; + private meshes: MeshCache | undefined; - private hints: ThreeHintDisplay | undefined; + public _hints: ThreeHintDisplay | undefined; private camera: PerspectiveCamera | OrthographicCamera | undefined; private mouseFrustum = new CircularFrustumIntersection(new CircularFrustum()); @@ -21,11 +24,26 @@ export class SceneHelper { camera: PerspectiveCamera | OrthographicCamera, renderer: WebGLRenderer, ) { - this.hints = new ThreeHintDisplay(scene, camera, renderer); + this._hints = new ThreeHintDisplay(scene, camera, renderer); this.camera = camera; - this.sync = new SceneSync(scene, new GeometryCache()); - this.sync.addWholeModel(); + this.meshes = new MeshCache(scene, new GeometryCache()); + for (const id of Object.keys(model.solids)) { + const meshes = Geometry + .tessellateSolid(id) + .map(meshToDto); + // this.sync.addSolid(meshes[0]); // bottom + // this.sync.addSolid(meshes[3]); // front + for (const mesh of meshes) + this.meshes.addMesh(mesh.faceId, mesh); + } + } + + public get hints(): ThreeHintDisplay { + if (!this._hints) + throw new Error('SceneHelper is not initiallized'); + + return this._hints; } public buildMouseFrustum( @@ -34,7 +52,7 @@ export class SceneHelper { radius: number = 5, ): void { if (!this.camera) - throw new Error('Camera is not initialized'); + throw new Error('SceneHelper is not initiallized'); this.mouseFrustum.frustum.setFromScreenPoint( mouseNormalized, @@ -56,52 +74,32 @@ export class SceneHelper { } public setSelection(faceIds: Id[]) { - this.sync?.setSelected(faceIds); + if (!this.meshes) + throw new Error('SceneHelper is not initiallized'); + + this.meshes.selection = faceIds; } - public get objects(): Object3D[] { + public get objects(): Object3D[] { + if (!this.meshes) + throw new Error('SceneHelper is not initiallized'); - return this.sync?.meshes ?? []; + return this.meshes.meshes; } - public showHint(id: string, position: Vector3Like, options: ThreeHintOptions) { - this.hints?.set(id, position, options); - } - - public showPointHint(id: string, position: Vector3Like, size: number = 5, color: ColorRepresentation = 0xffffff) { - this.hints?.set(id, position, { kind: "point", size, color }); - } - - public showCircleHint(id: string, position: Vector3Like, radius: number, thickness: number = 0.1, color: ColorRepresentation = 0xffffff) { - this.hints?.set(id, position, { kind: "circle", radius, thickness, color }); - } - - public showMouseFrustumHint() { + public updateMouseFrustumHint() { if (!this.camera) - throw new Error('Camera is not initialized'); + throw new Error('SceneHelper is not initiallized'); - 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.showCircleHint('hittest_near', near.center, near.radius, 0.05); - this.showCircleHint('hittest_far', far.center, far.radius, 0.1, 'red'); - } - - public clearHints() { - this.hints?.dispose(); + this.hints.showFrustum('mouse', this.mouseFrustum.frustum); } public dispose() { - this.sync?.dispose(); - - this.clearHints(); + this.meshes?.dispose(); + this.hints.clear(); } public applyCamera() { - this.hints?.applyCamera(); + this._hints?.applyCamera(); } } diff --git a/client/src/layers/geometryCache.ts b/client/src/layers/geometryCache.ts index fc38ce7..eed381b 100644 --- a/client/src/layers/geometryCache.ts +++ b/client/src/layers/geometryCache.ts @@ -1,49 +1,47 @@ import * as THREE from 'three'; import type { MeshDto } from '../backend/dto'; -import type { Id } from '../types'; export class GeometryCache { - private readonly _cache = new Map(); + private readonly items = new Map(); - private key(solidId: Id, lod: number) { - return `${solidId}:${lod}`; + private key(id: string, lod: number) { + return `${id}:${lod}`; } - public has(solidId: Id, lod: number): boolean { - return this._cache.has(this.key(solidId, lod)); + public has(id: string, lod: number): boolean { + return this.items.has(this.key(id, lod)); } - public get(solidId: Id, lod: number): THREE.BufferGeometry | undefined { - return this._cache.get(this.key(solidId, lod)); + public get(id: string, lod: number): THREE.BufferGeometry | undefined { + return this.items.get(this.key(id, lod)); } - public set(solidId: Id, lod: number, payload: THREE.BufferGeometry) { - this._cache.set(this.key(solidId, lod), payload); + public set(id: string, lod: number, payload: THREE.BufferGeometry) { + this.items.set(this.key(id, lod), payload); } - public getOrCreate(solidId: Id, lod: number, dto: MeshDto): THREE.BufferGeometry { - let geometry = this.get(solidId, lod); + public getOrCreate(id: string, lod: number, dto: MeshDto): THREE.BufferGeometry { + let geometry = this.get(id, lod); if (geometry) return geometry; geometry = new THREE.BufferGeometry(); - geometry.userData.solidId = solidId; - // geometry.userData.faceIds = dto.faceIds; + geometry.userData.id = id; geometry.setAttribute('position', new THREE.BufferAttribute(dto.vertices, 3)); geometry.setAttribute('normal', new THREE.BufferAttribute(dto.normals, 3)); geometry.setIndex(new THREE.BufferAttribute(dto.indices, 1)); - this.set(solidId, lod, geometry); + this.set(id, lod, geometry); return geometry; } - unset(solidId: Id, lod: number) { - const geometry = this.get(solidId, lod); + unset(id: string, lod: number) { + const geometry = this.get(id, lod); if (geometry) { geometry.dispose(); - this._cache.delete(this.key(solidId, lod)); + this.items.delete(this.key(id, lod)); } } } \ No newline at end of file diff --git a/client/src/layers/sceneSync.ts b/client/src/layers/meshCache.ts similarity index 60% rename from client/src/layers/sceneSync.ts rename to client/src/layers/meshCache.ts index b07ff68..b442f41 100644 --- a/client/src/layers/sceneSync.ts +++ b/client/src/layers/meshCache.ts @@ -1,29 +1,28 @@ import * as THREE from 'three'; 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 MeshDto } from '../backend/dto/mesh'; import type { Id } from '../types'; -export class SceneSync { +export class MeshCache { private scene: THREE.Scene; - private meshByFace: Record = {}; // faceId → THREE.Mesh private cache: GeometryCache; - private _selectedFaceIds: Id[] = []; + private selectedIds: Id[] = []; + + public readonly items: Record = {}; // faceId → THREE.Mesh private readonly baseMaterial = new THREE.ShaderMaterial({ side: THREE.DoubleSide, transparent: true, depthWrite: false, - wireframe: true, + wireframe: false, uniforms: { frontColor: { value: new THREE.Color(0x88ccff) }, - frontOpacity: { value: 0.6 }, + frontOpacity: { value: 0.9 }, backColor: { value: new THREE.Color(0xcc88ff) }, backOpacity: { value: 0.2 }, selected: { value: 0 }, - selectedOpacity: { value: 0.8 }, + selectedOpacity: { value: 1 }, }, vertexShader: ` varying vec3 vNormal; @@ -57,59 +56,47 @@ export class SceneSync { } public get selectedFaceIds() { - return this._selectedFaceIds; + return this.selectedIds; + } + + public get ids(): string[] { + return Object.keys(this.items); } public get meshes(): THREE.Mesh[] { - 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); - this.addSolid(meshes[0]); // bottom - this.addSolid(meshes[3]); // front - // for (const mesh of meshes) - // this.addSolid(mesh); - } + return Object.values(this.items); } // Called when FE scene graph syncs from BE - addSolid(dto: MeshDto) { - const faceId = dto.faceId; - - if (this.meshByFace[faceId]) + addMesh(id: string, dto: MeshDto) { + if (this.items[id]) return; - const geo = this.cache.getOrCreate(faceId, 0, dto); + const geo = this.cache.getOrCreate(id, 0, dto); + const geoVCount = geo.attributes.position.count; geo.setAttribute('selected', new THREE.BufferAttribute(new Float32Array(geoVCount), 1)); + const mesh = new THREE.Mesh(geo, this.baseMaterial); mesh.userData = dto mesh.userData.vertexIds = dto.loop.map((v) => v.vertex); this.scene.add(mesh); - this.meshByFace[faceId] = mesh; + this.items[id] = mesh; } - setSelected(faceIds: Id[]) { - this._selectedFaceIds = faceIds; + public set selection(ids: string[]) { + this.selectedIds = ids; - for (const [sid, mesh] of Object.entries(this.meshByFace)) { + for (const [id, mesh] of Object.entries(this.items)) { const attr = mesh.geometry.attributes.selected; - attr.array.fill(faceIds.includes(sid) ? 1.0 : 0.0); + attr.array.fill(ids.includes(id) ? 1.0 : 0.0); attr.needsUpdate = true; // required! } } - public disposeMesh(faceId: Id) { - const mesh = this.meshByFace[faceId]; + public disposeItem(id: string) { + const mesh = this.items[id]; if (!mesh) return; @@ -124,13 +111,13 @@ export class SceneSync { else mesh.material.dispose(); - this.cache.unset(faceId, 0); - delete (this.meshByFace[faceId]); + this.cache.unset((mesh.userData as MeshDto).faceId, 0); + delete (this.items[id]); } public dispose() { - for (const faceId of Object.keys(this.meshByFace)) - this.disposeMesh(faceId); + for (const id of this.ids) + this.disposeItem(id); this.baseMaterial.dispose(); }