diff --git a/src/ada/visit/rendering/resources/index.zip b/src/ada/visit/rendering/resources/index.zip index afc9effda..6862d15d9 100644 Binary files a/src/ada/visit/rendering/resources/index.zip and b/src/ada/visit/rendering/resources/index.zip differ diff --git a/src/frontend/src/components/Menu.tsx b/src/frontend/src/components/Menu.tsx index f7e3b9768..61ed15251 100644 --- a/src/frontend/src/components/Menu.tsx +++ b/src/frontend/src/components/Menu.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import {toggle_info_panel} from "../utils/info_panel_utils"; import ObjectInfoBox from "./object_info_box/ObjectInfoBoxComponent"; import {useObjectInfoStore} from "../state/objectInfoStore"; import AnimationControls from "./viewer/AnimationControls"; diff --git a/src/frontend/src/components/viewer/CameraControls.tsx b/src/frontend/src/components/viewer/CameraControls.tsx index f4b3e38be..5095f2ec8 100644 --- a/src/frontend/src/components/viewer/CameraControls.tsx +++ b/src/frontend/src/components/viewer/CameraControls.tsx @@ -1,53 +1,51 @@ // CameraControls.tsx -import * as THREE from 'three'; -import React, {useEffect} from 'react'; -import {useThree} from '@react-three/fiber'; -import {OrbitControls as OrbitControlsImpl} from 'three-stdlib'; -import {useSelectedObjectStore} from "../../state/selectedObjectStore"; -import {centerViewOnObject} from "../../utils/scene/centerViewOnObject"; +import React, { useEffect } from 'react'; +import { useThree } from '@react-three/fiber'; +import { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; +import { useSelectedObjectStore } from '../../state/useSelectedObjectStore'; +import { centerViewOnSelection } from '../../utils/scene/centerViewOnSelection'; +import { CustomBatchedMesh } from '../../utils/mesh_select/CustomBatchedMesh'; type CameraControlsProps = { orbitControlsRef: React.RefObject; }; -const CameraControls: React.FC = ({orbitControlsRef}) => { - const {camera, gl, scene} = useThree(); +const CameraControls: React.FC = ({ orbitControlsRef }) => { + const { camera, scene } = useThree(); useEffect(() => { - const handlePointerDown = (event: PointerEvent) => { - if (event.ctrlKey && event.button === 0) { - console.log('CTRL+Left Click'); - } - }; - - gl.domElement.addEventListener('pointerdown', handlePointerDown); - const handleKeyDown = (event: KeyboardEvent) => { - - if (event.key.toLowerCase() === 'f' && event.shiftKey) { - centerViewOnObject(orbitControlsRef, camera); - } else if (event.key.toLowerCase() === 'h' && event.shiftKey) { - // Perform an action when "ctrl+h" is pressed - console.log('SHIFT+H pressed'); - // currently_selected?.layers.set(1); - // Example action: Reset the camera position to the default - } else if (event.key.toLowerCase() === 'g' && event.shiftKey) { - // Perform an action when "ctrl+h" is pressed - console.log('SHIFT+g pressed'); - // loop over objects in layers 2 and set them to layer 0 - // Example action: Reset the camera position to the default + if (event.shiftKey && event.key.toLowerCase() === 'h') { + // SHIFT+H pressed - Hide selected draw ranges + const selectedObjects = useSelectedObjectStore.getState().selectedObjects; + selectedObjects.forEach((drawRangeIds, mesh) => { + drawRangeIds.forEach((drawRangeId) => { + mesh.hideDrawRange(drawRangeId); + }); + mesh.deselect(); + }); + useSelectedObjectStore.getState().clearSelectedObjects(); + } else if (event.shiftKey && event.key.toLowerCase() === 'u') { + // SHIFT+U pressed - Unhide all + scene.traverse((object) => { + if (object instanceof CustomBatchedMesh) { + object.unhideAllDrawRanges(); + } + }); + } else if (event.shiftKey && event.key.toLowerCase() === 'f') { + // SHIFT+F pressed - Center view on selection + centerViewOnSelection(orbitControlsRef, camera); } }; window.addEventListener('keydown', handleKeyDown); return () => { - gl.domElement.removeEventListener('pointerdown', handlePointerDown); window.removeEventListener('keydown', handleKeyDown); }; - }, [camera, gl, scene]); + }, [camera, orbitControlsRef, scene]); - return null; // This component doesn't render anything + return null; }; export default CameraControls; diff --git a/src/frontend/src/components/viewer/ThreeModel.tsx b/src/frontend/src/components/viewer/ThreeModel.tsx index f7ac82f6a..ba1ed0b71 100644 --- a/src/frontend/src/components/viewer/ThreeModel.tsx +++ b/src/frontend/src/components/viewer/ThreeModel.tsx @@ -12,7 +12,7 @@ import {useTreeViewStore} from '../../state/treeViewStore'; import {useOptionsStore} from "../../state/optionsStore"; import {buildTreeFromUserData} from '../../utils/tree_view/generateTree'; import {handleClickMesh} from "../../utils/mesh_select/handleClickMesh"; - +import {CustomBatchedMesh} from '../../utils/mesh_select/CustomBatchedMesh'; const ThreeModel: React.FC = ({url}) => { const {raycaster, camera} = useThree(); @@ -23,9 +23,10 @@ const ThreeModel: React.FC = ({url}) => { const {showEdges} = useOptionsStore(); useAnimationEffects(animations, scene); + useEffect(() => { THREE.Object3D.DEFAULT_UP.set(0, 0, 1); - if (scene){ + if (scene) { useModelStore.getState().setScene(scene); } @@ -39,58 +40,92 @@ const ThreeModel: React.FC = ({url}) => { camera.layers.enable(0); camera.layers.enable(1); + const meshesToReplace: { original: THREE.Mesh; parent: THREE.Object3D }[] = []; + scene.traverse((object) => { if (object instanceof THREE.Mesh) { - // Ensure geometry has normals - if (!object.geometry.hasAttribute('normal')) { - object.geometry.computeVertexNormals(); + meshesToReplace.push({original: object, parent: object.parent!}); + } else if (object instanceof THREE.LineSegments) { + object.layers.set(1); + } else if (object instanceof THREE.Points) { + object.layers.set(1); + } + }); + + // Replace meshes with CustomBatchedMesh instances + for (const {original, parent} of meshesToReplace) { + // Extract draw ranges from userData for the given mesh name + const meshName = original.name; + const drawRangesData = scene.userData[`draw_ranges_${meshName}`] as Record; + + // Convert drawRangesData to a Map + const drawRanges = new Map(); + if (drawRangesData) { + for (const [rangeId, [start, count]] of Object.entries(drawRangesData)) { + drawRanges.set(rangeId, [start, count]); } + } - // Set materials to double-sided and enable flat shading - if (Array.isArray(object.material)) { - object.material.forEach((mat) => { + const customMesh = new CustomBatchedMesh( + original.geometry, + original.material, + drawRanges + ); + + // Copy over properties from original mesh to customMesh + customMesh.position.copy(original.position); + customMesh.rotation.copy(original.rotation); + customMesh.scale.copy(original.scale); + customMesh.name = original.name; + customMesh.userData = original.userData; + customMesh.castShadow = original.castShadow; + customMesh.receiveShadow = original.receiveShadow; + customMesh.visible = original.visible; + customMesh.frustumCulled = original.frustumCulled; + customMesh.renderOrder = original.renderOrder; + customMesh.layers.mask = original.layers.mask; + + // Set materials to double-sided and enable flat shading + if (Array.isArray(customMesh.material)) { + customMesh.material.forEach((mat) => { + if (mat instanceof THREE.MeshStandardMaterial) { mat.side = THREE.DoubleSide; mat.flatShading = true; mat.needsUpdate = true; - }); + } else { + console.warn('Material is not an instance of MeshStandardMaterial'); + } + }); + } else { + if (customMesh.material instanceof THREE.MeshStandardMaterial) { + customMesh.material.side = THREE.DoubleSide; + customMesh.material.flatShading = true; + customMesh.material.needsUpdate = true; } else { - object.material.side = THREE.DoubleSide; - object.material.flatShading = true; - object.material.needsUpdate = true; + console.warn('Material is not an instance of MeshStandardMaterial'); } + } - if (showEdges) { - // Create edges geometry and add it as a line segment - const edges = new THREE.EdgesGeometry(object.geometry); - const lineMaterial = new THREE.LineBasicMaterial({color: 0x000000}); - const edgeLine = new THREE.LineSegments(edges, lineMaterial); - - // Make sure the edge line inherits position and rotation of the object - edgeLine.position.copy(object.position); - edgeLine.rotation.copy(object.rotation); - edgeLine.scale.copy(object.scale); - edgeLine.layers.set(1); - // Add edge lines to the scene - scene.add(edgeLine); - } + if (showEdges) { + // Create edges geometry and add it as a line segment + const edges = new THREE.EdgesGeometry(customMesh.geometry); + const lineMaterial = new THREE.LineBasicMaterial({color: 0x000000}); + const edgeLine = new THREE.LineSegments(edges, lineMaterial); - // Enable shadow casting and receiving - // object.castShadow = true; - // object.receiveShadow = true; - } else if (object instanceof THREE.LineSegments) { - // Line segments should by default not be clickable - //object.userData.clickable = false; - object.layers.set(1) - } else if (object instanceof THREE.Points) { - // Set points material to double-sided - console.log(object.material); - object.layers.set(1) - } else { - console.log(`Unknown object type: ${object.type}`); + // Ensure the edge line inherits transformations + edgeLine.position.copy(customMesh.position); + edgeLine.rotation.copy(customMesh.rotation); + edgeLine.scale.copy(customMesh.scale); + edgeLine.layers.set(1); + // Add edge lines to the scene + scene.add(edgeLine); } - }); + // Replace the original mesh with the custom mesh + parent.add(customMesh); + parent.remove(original); + } // Replace black materials with default gray material replaceBlackMaterials(scene); @@ -106,6 +141,7 @@ const ThreeModel: React.FC = ({url}) => { const minY = boundingBox.min.y; const bheight = boundingBox.max.y - minY; translation.y = -minY + bheight * 0.05; + // Apply the translation to the model scene.position.add(translation); @@ -116,9 +152,7 @@ const ThreeModel: React.FC = ({url}) => { // Generate the tree data and update the store const treeData = buildTreeFromUserData(scene); - // const treeData = buildTreeFromScene(scene); - if (treeData) - setTreeData(treeData); // Update the tree view store with the scene graph data + if (treeData) setTreeData(treeData); // Cleanup when the component is unmounted return () => { @@ -136,7 +170,7 @@ const ThreeModel: React.FC = ({url}) => { return ( ); diff --git a/src/frontend/src/state/selectedObjectStore.ts b/src/frontend/src/state/selectedObjectStore.ts deleted file mode 100644 index 8bf20ec52..000000000 --- a/src/frontend/src/state/selectedObjectStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {create} from 'zustand'; -import * as THREE from 'three'; - -type SelectedObjectState = { - selectedObject: THREE.Mesh | null; - setSelectedObject: (mesh: THREE.Mesh | null) => void; - originalMaterial: THREE.MeshBasicMaterial | null; - setOriginalMaterial: (material: THREE.MeshBasicMaterial | null) => void; -}; - -export const useSelectedObjectStore = create((set) => ({ - selectedObject: null, - setSelectedObject: (mesh) => set(() => ({selectedObject: mesh})), - originalMaterial: null, - setOriginalMaterial: (material: THREE.MeshBasicMaterial | null) => set(() => ({originalMaterial: material})), -})); \ No newline at end of file diff --git a/src/frontend/src/state/useSelectedObjectStore.ts b/src/frontend/src/state/useSelectedObjectStore.ts new file mode 100644 index 000000000..0e0480f3c --- /dev/null +++ b/src/frontend/src/state/useSelectedObjectStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; +import { CustomBatchedMesh } from '../utils/mesh_select/CustomBatchedMesh'; + +type SelectedObjectState = { + selectedObjects: Map>; + addSelectedObject: (mesh: CustomBatchedMesh, drawRangeId: string) => void; + removeSelectedObject: (mesh: CustomBatchedMesh, drawRangeId: string) => void; + clearSelectedObjects: () => void; +}; + +export const useSelectedObjectStore = create((set) => ({ + selectedObjects: new Map(), + addSelectedObject: (mesh, drawRangeId) => + set((state) => { + const newMap = new Map(state.selectedObjects); + const existingSet = newMap.get(mesh) || new Set(); + existingSet.add(drawRangeId); + newMap.set(mesh, existingSet); + return { selectedObjects: newMap }; + }), + removeSelectedObject: (mesh, drawRangeId) => + set((state) => { + const newMap = new Map(state.selectedObjects); + const existingSet = newMap.get(mesh); + if (existingSet) { + existingSet.delete(drawRangeId); + if (existingSet.size === 0) { + newMap.delete(mesh); + } else { + newMap.set(mesh, existingSet); + } + } + return { selectedObjects: newMap }; + }), + clearSelectedObjects: () => set({ selectedObjects: new Map() }), +})); diff --git a/src/frontend/src/utils/mesh_select/CustomBatchedMesh.ts b/src/frontend/src/utils/mesh_select/CustomBatchedMesh.ts new file mode 100644 index 000000000..93bd460d6 --- /dev/null +++ b/src/frontend/src/utils/mesh_select/CustomBatchedMesh.ts @@ -0,0 +1,382 @@ +// CustomBatchedMesh.ts +import * as THREE from 'three'; +import { selectedMaterial } from '../default_materials'; // Adjust the import path as needed + +export class CustomBatchedMesh extends THREE.Mesh { + // Original geometry and material + originalGeometry: THREE.BufferGeometry; + originalMaterial: THREE.Material | THREE.Material[]; + + // Map of draw range IDs to their [start, count] in the index buffer + drawRanges: Map; + + // Map of draw range IDs to their corresponding materials + materialsMap: Map; + + // Set of currently highlighted draw range IDs + highlightedDrawRanges: Set; + + // Class properties for raycasting + private _raycast_inverseMatrix = new THREE.Matrix4(); + private _raycast_ray = new THREE.Ray(); + private _raycast_sphere = new THREE.Sphere(); + private _raycast_vA = new THREE.Vector3(); + private _raycast_vB = new THREE.Vector3(); + private _raycast_vC = new THREE.Vector3(); + private _raycast_uvA = new THREE.Vector2(); + private _raycast_uvB = new THREE.Vector2(); + private _raycast_uvC = new THREE.Vector2(); + private _raycast_intersectionPoint = new THREE.Vector3(); + private _raycast_barycoord = new THREE.Vector3(); + + constructor( + geometry: THREE.BufferGeometry, + material: THREE.Material | THREE.Material[], + drawRanges: Map + ) { + // Create a clone of the geometry to avoid modifying the original + const clonedGeometry = geometry.clone(); + + // Initialize the original geometry and material + super(clonedGeometry, material); + + this.originalGeometry = geometry; + this.originalMaterial = material; + this.drawRanges = drawRanges; + this.materialsMap = new Map(); + this.highlightedDrawRanges = new Set(); + + // Clear existing groups in the geometry + this.geometry.clearGroups(); + + // Initialize materials and geometry groups + this.initializeMaterialsAndGroups(); + } + + /** + * Initializes materials and geometry groups for each draw range. + */ + private initializeMaterialsAndGroups(): void { + let materialIndex = 0; + const materialsArray: THREE.Material[] = []; + + // Iterate over the draw ranges and set up materials and groups + this.drawRanges.forEach((range, rangeId) => { + const [start, count] = range; + + // Add a group for this draw range + this.geometry.addGroup(start, count, materialIndex); + + // Clone the original material for this draw range + const rangeMaterial = (Array.isArray(this.originalMaterial) + ? this.originalMaterial[0] + : this.originalMaterial + ).clone(); + + // Optional: Set an identifier for the material (useful for debugging) + (rangeMaterial as any).rangeId = rangeId; + + // Add the material to the materials array + materialsArray.push(rangeMaterial); + + // Map the range ID to the material + this.materialsMap.set(rangeId, rangeMaterial); + + materialIndex++; + }); + + // Assign the materials array to the mesh + this.material = materialsArray; + } + + /** + * Hides the specified draw range by setting its material's visibility to false. + * @param drawRangeId The ID of the draw range to hide. + */ + hideDrawRange(drawRangeId: string): void { + const material = this.materialsMap.get(drawRangeId); + if (material) { + material.visible = false; + } else { + console.warn(`Material for draw range ID ${drawRangeId} not found.`); + } + } + + /** + * Unhides all draw ranges by setting all materials' visibility to true. + */ + unhideAllDrawRanges(): void { + this.materialsMap.forEach((material) => { + material.visible = true; + }); + } + + /** + * Highlights the specified draw ranges by replacing their materials with the selected material. + * @param drawRangeIds An array of draw range IDs to highlight. + */ + highlightDrawRanges(drawRangeIds: string[]): void { + // Clear previous highlights + this.clearHighlights(); + + // Keep track of highlighted draw ranges + this.highlightedDrawRanges = new Set(drawRangeIds); + + drawRangeIds.forEach((rangeId) => { + const materialIndex = this.getMaterialIndexByRangeId(rangeId); + if (materialIndex !== -1) { + // Replace the material with the selected material + (this.material as THREE.Material[])[materialIndex] = selectedMaterial; + } else { + console.warn(`Material index for draw range ID ${rangeId} not found.`); + } + }); + } + + /** + * Clears all highlights by restoring the original materials. + */ + private clearHighlights(): void { + this.highlightedDrawRanges.forEach((rangeId) => { + const originalMaterial = this.materialsMap.get(rangeId); + const materialIndex = this.getMaterialIndexByRangeId(rangeId); + if (originalMaterial && materialIndex !== -1) { + // Restore the original material + (this.material as THREE.Material[])[materialIndex] = originalMaterial; + } + }); + this.highlightedDrawRanges.clear(); + } + + /** + * Deselects all draw ranges by clearing highlights. + */ + deselect(): void { + this.clearHighlights(); + } + + /** + * Gets the material index corresponding to the specified draw range ID. + * @param rangeId The draw range ID. + * @returns The material index or -1 if not found. + */ + private getMaterialIndexByRangeId(rangeId: string): number { + const materialKeys = Array.from(this.materialsMap.keys()); + return materialKeys.indexOf(rangeId); + } + + /** + * Overrides the raycast method to ignore hidden draw ranges. + */ + raycast(raycaster: THREE.Raycaster, intersects: THREE.Intersection[]): void { + const material = this.material; + + if (material === undefined) return; + + // Compute the bounding sphere if necessary + if (this.geometry.boundingSphere === null) this.geometry.computeBoundingSphere(); + + // Check bounding sphere distance to ray + const sphere = this._raycast_sphere.copy(this.geometry.boundingSphere!); + sphere.applyMatrix4(this.matrixWorld); + + if (raycaster.ray.intersectsSphere(sphere) === false) return; + + // Transform the ray into the local space of the mesh + const inverseMatrix = this._raycast_inverseMatrix.copy(this.matrixWorld).invert(); + + const localRay = this._raycast_ray.copy(raycaster.ray).applyMatrix4(inverseMatrix); + + // Determine if we have an array of materials + const isMultiMaterial = Array.isArray(material); + + const materials = isMultiMaterial ? (material as THREE.Material[]) : [material as THREE.Material]; + + // Loop over the geometry's groups + const groups = this.geometry.groups; + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const groupMaterial = group.materialIndex !== undefined ? materials[group.materialIndex] : undefined; + + if (groupMaterial === undefined) continue; + + if (groupMaterial.visible === false) continue; + + // Perform raycasting on this group + this.raycastGroup(localRay, raycaster, group, groupMaterial, intersects); + } + } + + /** + * Performs raycasting on a specific group of the geometry. + */ + private raycastGroup( + localRay: THREE.Ray, + raycaster: THREE.Raycaster, + group: { start: number; count: number; materialIndex: number }, + material: THREE.Material, + intersects: THREE.Intersection[] + ): void { + const geometry = this.geometry as THREE.BufferGeometry; + const index = geometry.index; + const position = geometry.attributes.position; + + if (position === undefined) return; + + const start = group.start; + const end = start + group.count; + + const vA = this._raycast_vA; + const vB = this._raycast_vB; + const vC = this._raycast_vC; + + let intersection; + + if (index !== null) { + // Indexed geometry + const indices = index.array as Uint16Array | Uint32Array; + + for (let i = start; i < end; i += 3) { + const a = indices[i]; + const b = indices[i + 1]; + const c = indices[i + 2]; + + intersection = this.checkBufferGeometryIntersection( + this, + material, + raycaster, + localRay, + position, + a, + b, + c, + group.materialIndex + ); + + if (intersection) { + intersection.faceIndex = Math.floor(i / 3); + intersects.push(intersection); + } + } + } else { + // Non-indexed geometry + for (let i = start; i < end; i += 3) { + const a = i; + const b = i + 1; + const c = i + 2; + + intersection = this.checkBufferGeometryIntersection( + this, + material, + raycaster, + localRay, + position, + a, + b, + c, + group.materialIndex + ); + + if (intersection) { + intersection.faceIndex = Math.floor(i / 3); + intersects.push(intersection); + } + } + } + } + + /** + * Checks for an intersection between a ray and a triangle defined by vertex indices. + */ + private checkBufferGeometryIntersection( + object: THREE.Object3D, + material: THREE.Material, + raycaster: THREE.Raycaster, + ray: THREE.Ray, + position: THREE.BufferAttribute, + a: number, + b: number, + c: number, + materialIndex: number + ): THREE.Intersection | null { + const vA = this._raycast_vA; + const vB = this._raycast_vB; + const vC = this._raycast_vC; + const intersectionPoint = this._raycast_intersectionPoint; + + vA.fromBufferAttribute(position, a); + vB.fromBufferAttribute(position, b); + vC.fromBufferAttribute(position, c); + + let side = material.side; + + if (side === undefined) side = THREE.FrontSide; + + const backfaceCulling = side === THREE.FrontSide; + + const intersect = ray.intersectTriangle( + vC, + vB, + vA, + backfaceCulling, + intersectionPoint + ); + + if (intersect === null) return null; + + intersectionPoint.applyMatrix4(this.matrixWorld); + + const distance = raycaster.ray.origin.distanceTo(intersectionPoint); + + if (distance < raycaster.near || distance > raycaster.far) return null; + + const uvAttribute = this.geometry.attributes.uv; + let uv: THREE.Vector2 | undefined; + + if (uvAttribute) { + const uvA = this._raycast_uvA.fromBufferAttribute(uvAttribute, a); + const uvB = this._raycast_uvB.fromBufferAttribute(uvAttribute, b); + const uvC = this._raycast_uvC.fromBufferAttribute(uvAttribute, c); + + // Compute the UV coordinates at the intersection point + uv = this._uvIntersection(vA, vB, vC, uvA, uvB, uvC, intersectionPoint); + } + + return { + distance: distance, + point: intersectionPoint.clone(), + object: object, + uv: uv, + face: null, // Face3 is deprecated; set to null or provide custom data + faceIndex: -1, // Can set to appropriate face index if needed + } as THREE.Intersection; + } + + /** + * Computes the UV coordinates at the intersection point. + */ + private _uvIntersection( + vA: THREE.Vector3, + vB: THREE.Vector3, + vC: THREE.Vector3, + uvA: THREE.Vector2, + uvB: THREE.Vector2, + uvC: THREE.Vector2, + intersectionPoint: THREE.Vector3 + ): THREE.Vector2 { + const barycoord = THREE.Triangle.getBarycoord( + intersectionPoint, + vA, + vB, + vC, + this._raycast_barycoord + ); + const uv = new THREE.Vector2(); + uvA.multiplyScalar(barycoord.x); + uvB.multiplyScalar(barycoord.y); + uvC.multiplyScalar(barycoord.z); + uv.add(uvA).add(uvB).add(uvC); + return uv; + } +} diff --git a/src/frontend/src/utils/mesh_select/deselectObject.ts b/src/frontend/src/utils/mesh_select/deselectObject.ts index 15577eab2..75b5832f7 100644 --- a/src/frontend/src/utils/mesh_select/deselectObject.ts +++ b/src/frontend/src/utils/mesh_select/deselectObject.ts @@ -1,15 +1,12 @@ -import {useSelectedObjectStore} from "../../state/selectedObjectStore"; -import {defaultMaterial} from "../default_materials"; +import {useSelectedObjectStore} from "../../state/useSelectedObjectStore"; export function deselectObject() { - const selectedObject = useSelectedObjectStore.getState().selectedObject; - const originalMaterial = useSelectedObjectStore.getState().originalMaterial; - if (selectedObject) { - selectedObject.material = originalMaterial ? originalMaterial : defaultMaterial; - useSelectedObjectStore.getState().setOriginalMaterial(null); - useSelectedObjectStore.getState().setSelectedObject(null); - } + const selectedObjects = useSelectedObjectStore.getState().selectedObjects; + selectedObjects.forEach((drawRangeIds, mesh) => { + mesh.deselect(); + }); + useSelectedObjectStore.getState().clearSelectedObjects(); } diff --git a/src/frontend/src/utils/mesh_select/getSelectedMeshDrawRange.ts b/src/frontend/src/utils/mesh_select/getSelectedMeshDrawRange.ts index a76403dbf..4ff05437d 100644 --- a/src/frontend/src/utils/mesh_select/getSelectedMeshDrawRange.ts +++ b/src/frontend/src/utils/mesh_select/getSelectedMeshDrawRange.ts @@ -1,8 +1,11 @@ -import * as THREE from "three"; -import {useModelStore} from "../../state/modelStore"; +import { CustomBatchedMesh } from "./CustomBatchedMesh"; // Adjust the import path +import { useModelStore } from "../../state/modelStore"; -export function getSelectedMeshDrawRange(mesh: THREE.Mesh, faceIndex: number): [string, number, number] | null { - let scene = useModelStore.getState().scene +export function getSelectedMeshDrawRange( + mesh: CustomBatchedMesh, + faceIndex: number +): [string, number, number] | null { + const scene = useModelStore.getState().scene; if (!mesh || !scene?.userData) { return null; @@ -10,7 +13,10 @@ export function getSelectedMeshDrawRange(mesh: THREE.Mesh, faceIndex: number): [ // Extract draw ranges from userData for the given mesh name const meshName = mesh.name; // Assuming mesh name follows the pattern "node0", "node1", etc. - const drawRanges = scene.userData[`draw_ranges_${meshName}`] as Record; + const drawRanges = scene.userData[`draw_ranges_${meshName}`] as Record< + string, + [number, number] + >; if (!drawRanges) { return null; @@ -19,7 +25,7 @@ export function getSelectedMeshDrawRange(mesh: THREE.Mesh, faceIndex: number): [ // Find the draw range that includes the specified face index for (const [rangeId, [start, length]] of Object.entries(drawRanges)) { const end = start + length; - if (faceIndex*3 >= start && faceIndex*3 < end) { + if (faceIndex * 3 >= start && faceIndex * 3 < end) { return [rangeId, start, length]; } } diff --git a/src/frontend/src/utils/mesh_select/handleClickMesh.ts b/src/frontend/src/utils/mesh_select/handleClickMesh.ts index eb1996ce3..5b0c2862e 100644 --- a/src/frontend/src/utils/mesh_select/handleClickMesh.ts +++ b/src/frontend/src/utils/mesh_select/handleClickMesh.ts @@ -1,66 +1,96 @@ -import {ThreeEvent} from "@react-three/fiber"; -import {useSelectedObjectStore} from "../../state/selectedObjectStore"; -import * as THREE from "three"; -import {deselectObject} from "./deselectObject"; -import {useTreeViewStore} from "../../state/treeViewStore"; -import {findNodeById} from "../tree_view/findNodeById"; -import {getSelectedMeshDrawRange} from "./getSelectedMeshDrawRange"; -import {highlightDrawRange} from "./highlightDrawRange"; -import {useObjectInfoStore} from "../../state/objectInfoStore"; -import {useModelStore} from "../../state/modelStore"; +// handleClickMesh.ts +import {ThreeEvent} from '@react-three/fiber'; +import {useSelectedObjectStore} from '../../state/useSelectedObjectStore'; +import {useTreeViewStore} from '../../state/treeViewStore'; +import {findNodeById} from '../tree_view/findNodeById'; +import {getSelectedMeshDrawRange} from './getSelectedMeshDrawRange'; +import {useObjectInfoStore} from '../../state/objectInfoStore'; +import {useModelStore} from '../../state/modelStore'; +import {CustomBatchedMesh} from './CustomBatchedMesh'; export function handleClickMesh(event: ThreeEvent) { event.stopPropagation(); - const selectedObject = useSelectedObjectStore.getState().selectedObject; - const mesh = event.object as THREE.Mesh; - const face_index = event.faceIndex || 0; + // if right-click, return + if (event.button === 2) { + return; + } - let translation = useModelStore.getState().translation + const mesh = event.object as CustomBatchedMesh; + const faceIndex = event.faceIndex || 0; + const shiftKey = event.nativeEvent.shiftKey; + + const translation = useModelStore.getState().translation; // Get the 3D coordinates from the click event - const clickPosition = event.point; // This is the 3D coordinate in world space - console.log("3D Click Position:", clickPosition); + const clickPosition = event.point.clone(); - // Update the object info store + // Adjust for translation if necessary if (translation) { clickPosition.sub(translation); } - useObjectInfoStore.getState().setClickCoordinate(clickPosition); - if (face_index == useObjectInfoStore.getState().faceIndex && (mesh == selectedObject)) { - deselectObject(); - useObjectInfoStore.getState().setFaceIndex(null); - return; - } + // Update the object info store + useObjectInfoStore.getState().setClickCoordinate(clickPosition); - useObjectInfoStore.getState().setFaceIndex(face_index); - let drawRange = getSelectedMeshDrawRange(mesh, face_index); + // Get the draw range for the selected face + const drawRange = getSelectedMeshDrawRange(mesh, faceIndex); if (!drawRange) { - return null; + return; } - highlightDrawRange(mesh, drawRange) - const [rangeId, start, count] = drawRange; - let scene = useModelStore.getState().scene; - let hierarchy: Record = scene?.userData["id_hierarchy"]; - const [node_name, parent_node_name] = hierarchy[rangeId]; - if (node_name) { - // Update the object info store - useObjectInfoStore.getState().setName(node_name); - } + const selectedObjects = useSelectedObjectStore.getState().selectedObjects; + const selectedRanges = selectedObjects.get(mesh); + const isAlreadySelected = selectedRanges ? selectedRanges.has(rangeId) : false; - // Update the tree view selection - const treeViewStore = useTreeViewStore.getState(); - if (treeViewStore.treeData) { - const selectedNode = findNodeById(treeViewStore.treeData, node_name); - if (selectedNode) { - treeViewStore.setSelectedNodeId(selectedNode.id); + if (shiftKey) { + if (isAlreadySelected) { + // If Shift is held and the draw range is already selected, deselect it + useSelectedObjectStore.getState().removeSelectedObject(mesh, rangeId); + selectedRanges?.delete(rangeId); + mesh.highlightDrawRanges(Array.from(selectedRanges || [])); + } else { + // If Shift is held and the draw range is not selected, add it to selection + useSelectedObjectStore.getState().addSelectedObject(mesh, rangeId); + if (!selectedRanges) { + mesh.highlightDrawRanges([rangeId]); + selectedObjects.set(mesh, new Set([rangeId])); + } else { + selectedRanges?.add(rangeId); + mesh.highlightDrawRanges(Array.from(selectedRanges || [])); + } } + } else { + // If Shift is not held, clear previous selections and select the new draw range + // Deselect all previously selected draw ranges + selectedObjects.forEach((ranges, selectedMesh) => { + selectedMesh.deselect(); + }); + useSelectedObjectStore.getState().clearSelectedObjects(); + + // Select the new draw range + const newSet = new Set(); + newSet.add(rangeId); + useSelectedObjectStore.getState().addSelectedObject(mesh, rangeId); + mesh.highlightDrawRanges([rangeId]); } + // Update object info and tree view selection + const scene = useModelStore.getState().scene; + const hierarchy: Record = scene?.userData['id_hierarchy']; + const [nodeName] = hierarchy[rangeId]; -} \ No newline at end of file + if (nodeName) { + useObjectInfoStore.getState().setName(nodeName); + const treeViewStore = useTreeViewStore.getState(); + if (treeViewStore.treeData) { + const selectedNode = findNodeById(treeViewStore.treeData, nodeName); + if (selectedNode) { + treeViewStore.setSelectedNodeId(selectedNode.id); + } + } + } +} diff --git a/src/frontend/src/utils/mesh_select/highlightDrawRange.ts b/src/frontend/src/utils/mesh_select/highlightDrawRange.ts deleted file mode 100644 index 0aaa494c7..000000000 --- a/src/frontend/src/utils/mesh_select/highlightDrawRange.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as THREE from "three"; -import {selectedMaterial} from "../default_materials"; -import {useSelectedObjectStore} from "../../state/selectedObjectStore"; - -export function highlightDrawRange(mesh: THREE.Mesh, drawRange: [string, number, number]): void { - const geometry = mesh.geometry as THREE.BufferGeometry; - - if (!geometry || !drawRange) { - console.warn("Invalid geometry or draw range"); - return; - } - - const alreadySelectedObject = useSelectedObjectStore.getState().selectedObject; - if (alreadySelectedObject) { - if (alreadySelectedObject.geometry) { - alreadySelectedObject.geometry.clearGroups(); - } - - let existing_mat = useSelectedObjectStore.getState().originalMaterial - if (alreadySelectedObject.material && existing_mat) { - alreadySelectedObject.material = existing_mat; - } - - } - - useSelectedObjectStore.getState().setSelectedObject(mesh); - useSelectedObjectStore.getState().setOriginalMaterial(mesh.material as THREE.MeshBasicMaterial); - - const [rangeId, start, count] = drawRange; - - // Clear existing groups - geometry.clearGroups(); - - // Add the original group (everything before the highlight) - if (start > 0) { - geometry.addGroup(0, start, 0); // Material index 0 for the original material - } - - // Add the highlighted group - geometry.addGroup(start, count, 1); // Material index 1 for the selected material - - // Add the rest of the mesh - if (start + count < geometry.index!.count) { - geometry.addGroup(start + count, geometry.index!.count - (start + count), 0); - } - - // Create or update the materials array - const originalMaterial = (mesh.material as THREE.Material[])[0] || (mesh.material as THREE.Material); - - // Set the materials array with the original and selected materials - mesh.material = [originalMaterial, selectedMaterial]; - - // Set needsUpdate for each material - mesh.material.forEach(material => { - material.needsUpdate = true; - }); -} diff --git a/src/frontend/src/utils/scene/centerViewOnObject.ts b/src/frontend/src/utils/scene/centerViewOnObject.ts deleted file mode 100644 index 78788e15f..000000000 --- a/src/frontend/src/utils/scene/centerViewOnObject.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as THREE from "three"; -import {Camera} from "three"; -import React from "react"; -import {OrbitControls as OrbitControlsImpl} from "three-stdlib/controls/OrbitControls"; -import {useObjectInfoStore} from "../../state/objectInfoStore"; -import {getSelectedMeshDrawRange} from "../mesh_select/getSelectedMeshDrawRange"; -import {useSelectedObjectStore} from "../../state/selectedObjectStore"; - -export const centerViewOnObject = ( - orbitControlsRef: React.RefObject, - camera: Camera, - fillFactor: number = 1 // Default to filling the entire view -) => { - const object = useSelectedObjectStore.getState().selectedObject; - const current_object = useSelectedObjectStore.getState().selectedObject; - const face_index = useObjectInfoStore.getState().faceIndex; - - if ( - orbitControlsRef.current && - camera && - current_object && - face_index !== undefined && face_index !== null - ) { - const draw_range = getSelectedMeshDrawRange( - object as THREE.Mesh, - face_index - ); - if (draw_range) { - const [rangeId, start, count] = draw_range; - - const mesh = object as THREE.Mesh; - const geometry = mesh.geometry as THREE.BufferGeometry; - - // Ensure that the geometry has a position attribute - const positionAttr = geometry.getAttribute("position"); - const indexAttr = geometry.getIndex(); - - if (!positionAttr) { - console.warn("Geometry has no position attribute."); - return; - } - - if (!indexAttr) { - console.warn("Geometry has no index buffer."); - return; - } - - const positionArray = positionAttr.array; - const indexArray = indexAttr.array; - const itemSize = positionAttr.itemSize; - - // Validate that start and count are within the index array bounds - if (start < 0 || start + count > indexArray.length) { - console.warn( - `Draw range (start: ${start}, count: ${count}) is out of bounds of the index array (length: ${indexArray.length}).` - ); - return; - } - - // Create a bounding box based on the specified draw range - const boundingBox = new THREE.Box3(); - const vertex = new THREE.Vector3(); - - for (let i = start; i < start + count; i++) { - const index = indexArray[i]; - - // Validate the index - if (index < 0 || index >= positionAttr.count) { - console.warn( - `Index ${index} at position ${i} is out of bounds of the position attribute (count: ${positionAttr.count}). Skipping.` - ); - continue; - } - - // Extract the vertex position at the specified index - vertex.fromBufferAttribute(positionAttr, index); - - // Check for NaN values in the vertex position - if ( - isNaN(vertex.x) || - isNaN(vertex.y) || - isNaN(vertex.z) - ) { - console.warn( - `NaN detected in vertex position at index ${index}. Skipping this vertex.` - ); - continue; - } - - // Apply the object's world matrix to get the world position - mesh.localToWorld(vertex); - boundingBox.expandByPoint(vertex); - } - - // Check if the bounding box is valid - if (!boundingBox.isEmpty()) { - // Compute bounding sphere from bounding box - const boundingSphere = new THREE.Sphere(); - boundingBox.getBoundingSphere(boundingSphere); - - // Get the center of the bounding sphere - const center = boundingSphere.center; - const radius = boundingSphere.radius; - - if (radius === 0 || isNaN(radius)) { - console.warn( - "Object has zero size or invalid radius." - ); - return; - } - - let distance = 0; - if (camera instanceof THREE.PerspectiveCamera) { - const fov = (camera.fov * Math.PI) / 180; // Convert FOV to radians - distance = (radius / Math.sin(fov / 2)) / fillFactor; - } else if (camera instanceof THREE.OrthographicCamera) { - const aspect = camera.right / camera.top; - camera.top = radius / fillFactor; - camera.bottom = -radius / fillFactor; - camera.left = (-radius * aspect) / fillFactor; - camera.right = (radius * aspect) / fillFactor; - camera.updateProjectionMatrix(); - distance = camera.position.distanceTo(center); - } else { - console.warn("Unknown camera type"); - return; - } - - // Calculate the new camera position - const direction = new THREE.Vector3(); - camera.getWorldDirection(direction); - const newPosition = center - .clone() - .sub(direction.multiplyScalar(distance)); - camera.position.copy(newPosition); - - // Ensure the camera's up vector is correct - camera.up.set(0, 1, 0); // Assuming Y-up coordinate system - - // Make the camera look at the center - camera.lookAt(center); - - // Update the camera's projection matrix - camera.updateProjectionMatrix(); - - // Update the orbit controls - orbitControlsRef.current.target.copy(center); - orbitControlsRef.current.update(); - } else { - console.warn( - "Bounding box is empty after processing vertices. Cannot center view." - ); - } - } - } -}; diff --git a/src/frontend/src/utils/scene/centerViewOnSelection.ts b/src/frontend/src/utils/scene/centerViewOnSelection.ts new file mode 100644 index 000000000..b9cc36889 --- /dev/null +++ b/src/frontend/src/utils/scene/centerViewOnSelection.ts @@ -0,0 +1,141 @@ +// centerViewOnSelection.ts +import * as THREE from 'three'; +import {Camera} from 'three'; +import React from 'react'; +import {OrbitControls as OrbitControlsImpl} from 'three-stdlib/controls/OrbitControls'; +import { useSelectedObjectStore } from '../../state/useSelectedObjectStore'; + +export const centerViewOnSelection = ( + orbitControlsRef: React.RefObject, + camera: Camera, + fillFactor: number = 1 // Default to filling the entire view +) => { + const selectedObjects = useSelectedObjectStore.getState().selectedObjects; + + if (orbitControlsRef.current && camera && selectedObjects.size > 0) { + const boundingBox = new THREE.Box3(); + const vertex = new THREE.Vector3(); + + selectedObjects.forEach((drawRangeIds, mesh) => { + const geometry = mesh.geometry as THREE.BufferGeometry; + const positionAttr = geometry.getAttribute('position'); + const indexAttr = geometry.getIndex(); + + if (!positionAttr) { + console.warn('Geometry has no position attribute.'); + return; + } + + if (!indexAttr) { + console.warn('Geometry has no index buffer.'); + return; + } + + const positionArray = positionAttr.array; + const indexArray = indexAttr.array as Uint16Array | Uint32Array; + const itemSize = positionAttr.itemSize; + + drawRangeIds.forEach((drawRangeId) => { + const drawRange = mesh.drawRanges.get(drawRangeId); + if (drawRange) { + const [start, count] = drawRange; + + // Validate that start and count are within the index array bounds + if (start < 0 || start + count > indexArray.length) { + console.warn( + `Draw range (start: ${start}, count: ${count}) is out of bounds of the index array (length: ${indexArray.length}).` + ); + return; + } + + for (let i = start; i < start + count; i++) { + const index = indexArray[i]; + + // Validate the index + if (index < 0 || index >= positionAttr.count) { + console.warn( + `Index ${index} at position ${i} is out of bounds of the position attribute (count: ${positionAttr.count}). Skipping.` + ); + continue; + } + + // Extract the vertex position at the specified index + vertex.fromBufferAttribute(positionAttr, index); + + // Check for NaN values in the vertex position + if (isNaN(vertex.x) || isNaN(vertex.y) || isNaN(vertex.z)) { + console.warn( + `NaN detected in vertex position at index ${index}. Skipping this vertex.` + ); + continue; + } + + // Apply the object's world matrix to get the world position + mesh.localToWorld(vertex); + boundingBox.expandByPoint(vertex); + } + } else { + console.warn(`Draw range ID ${drawRangeId} not found in mesh.`); + } + }); + }); + + // Check if the bounding box is valid + if (!boundingBox.isEmpty()) { + // Compute bounding sphere from bounding box + const boundingSphere = new THREE.Sphere(); + boundingBox.getBoundingSphere(boundingSphere); + + // Get the center of the bounding sphere + const center = boundingSphere.center; + const radius = boundingSphere.radius; + + if (radius === 0 || isNaN(radius)) { + console.warn('Selection has zero size or invalid radius.'); + return; + } + + let distance = 0; + if (camera instanceof THREE.PerspectiveCamera) { + const fov = (camera.fov * Math.PI) / 180; // Convert FOV to radians + distance = (radius / Math.sin(fov / 2)) / fillFactor; + } else if (camera instanceof THREE.OrthographicCamera) { + const aspect = camera.right / camera.top; + camera.top = radius / fillFactor; + camera.bottom = -radius / fillFactor; + camera.left = (-radius * aspect) / fillFactor; + camera.right = (radius * aspect) / fillFactor; + camera.updateProjectionMatrix(); + distance = camera.position.distanceTo(center); + } else { + console.warn('Unknown camera type'); + return; + } + + // Calculate the new camera position + const direction = new THREE.Vector3(); + camera.getWorldDirection(direction); + const newPosition = center.clone().sub(direction.multiplyScalar(distance)); + camera.position.copy(newPosition); + + // Ensure the camera's up vector is correct + camera.up.set(0, 1, 0); // Assuming Y-up coordinate system + + // Make the camera look at the center + camera.lookAt(center); + + // Update the camera's projection matrix + camera.updateProjectionMatrix(); + + // Update the orbit controls + orbitControlsRef.current.target.copy(center); + orbitControlsRef.current.update(); + } else { + console.warn( + 'Bounding box is empty after processing selected vertices. Cannot center view.' + ); + } + } else { + console.warn('No selected objects to center view on.'); + } +}; diff --git a/src/frontend/src/utils/tree_view/handleClickedNode.ts b/src/frontend/src/utils/tree_view/handleClickedNode.ts index c21099c5c..fbd9e39c4 100644 --- a/src/frontend/src/utils/tree_view/handleClickedNode.ts +++ b/src/frontend/src/utils/tree_view/handleClickedNode.ts @@ -3,7 +3,6 @@ import {useTreeViewStore} from "../../state/treeViewStore"; import {getMeshFromName} from "../scene/getMeshFromName"; import * as THREE from 'three'; import {getDrawRangeByName} from "../mesh_select/getDrawRangeByName"; -import {highlightDrawRange} from "../mesh_select/highlightDrawRange"; import {deselectObject} from "../mesh_select/deselectObject"; export function handleClickedNode(event: React.SyntheticEvent, itemIds: string | null) { @@ -24,7 +23,7 @@ export function handleClickedNode(event: React.SyntheticEvent, itemIds: string | // if mesh is not null and mesh is instance of THREE.Mesh if (mesh && !(mesh instanceof THREE.LineSegments) && !(mesh instanceof THREE.Points)) { - highlightDrawRange(mesh, [rangeId, start, count]); + console.log("mesh", mesh); } else { deselectObject();