From 0f3d99db577b002d4f4b877810d589377c935621 Mon Sep 17 00:00:00 2001 From: warnason <276599704+warnason@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:43:28 +0200 Subject: [PATCH] Fix duplicate elements and robust GlobalId lookup - Remove IfcWallStandardCase from parser (covered by IfcWall as parent type) - Add seen_global_ids set to prevent duplicate element insertion - Use scalars().first() instead of scalar_one_or_none() for resilient lookup - Re-parse required: clears and rebuilds element data without duplicates --- backend/app/api/elements.py | 2 +- backend/app/services/ifc_parser.py | 7 +- frontend/src/composables/useViewer.js | 167 +++++++++++++++++--------- 3 files changed, 118 insertions(+), 58 deletions(-) diff --git a/backend/app/api/elements.py b/backend/app/api/elements.py index b88355b..89db45d 100644 --- a/backend/app/api/elements.py +++ b/backend/app/api/elements.py @@ -65,7 +65,7 @@ async def get_element_by_global_id( .where(Element.global_id == global_id) ) result = await db.execute(query) - element = result.scalar_one_or_none() + element = result.scalars().first() if not element: raise HTTPException(status_code=404, detail="Element not found") diff --git a/backend/app/services/ifc_parser.py b/backend/app/services/ifc_parser.py index bef35b4..f94edf9 100644 --- a/backend/app/services/ifc_parser.py +++ b/backend/app/services/ifc_parser.py @@ -42,13 +42,18 @@ async def parse_ifc_file( # Extract elements element_types = [ - "IfcWall", "IfcWallStandardCase", "IfcDoor", "IfcWindow", "IfcSlab", + "IfcWall", "IfcDoor", "IfcWindow", "IfcSlab", "IfcColumn", "IfcBeam", "IfcStair", "IfcRoof", "IfcSpace", "IfcFurnishingElement", "IfcBuildingElementProxy", ] + seen_global_ids = set() + for ifc_type in element_types: for ifc_element in ifc.by_type(ifc_type): + if ifc_element.GlobalId in seen_global_ids: + continue + seen_global_ids.add(ifc_element.GlobalId) storey = _get_storey(ifc_element) element = Element( diff --git a/frontend/src/composables/useViewer.js b/frontend/src/composables/useViewer.js index 22745e0..67b42e9 100644 --- a/frontend/src/composables/useViewer.js +++ b/frontend/src/composables/useViewer.js @@ -7,10 +7,13 @@ export function useViewer() { let model = null let raycaster = new THREE.Raycaster() let mouse = new THREE.Vector2() - let selectedMesh = null + let selectedMeshes = [] let originalMaterials = new Map() let onElementClick = null + // Map: GlobalId -> array of meshes belonging to that element + let globalIdMeshMap = new Map() + const highlightMaterial = new THREE.MeshStandardMaterial({ color: 0x00aaff, emissive: 0x003366, @@ -21,11 +24,9 @@ export function useViewer() { function init(container, onClick) { onElementClick = onClick - // Scene scene = new THREE.Scene() scene.background = new THREE.Color(0x1a1a2e) - // Camera camera = new THREE.PerspectiveCamera( 60, container.clientWidth / container.clientHeight, @@ -34,14 +35,12 @@ export function useViewer() { ) camera.position.set(15, 15, 15) - // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(container.clientWidth, container.clientHeight) renderer.setPixelRatio(window.devicePixelRatio) renderer.shadowMap.enabled = true container.appendChild(renderer.domElement) - // Controls controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true controls.dampingFactor = 0.05 @@ -51,7 +50,6 @@ export function useViewer() { controls.target.set(0, 3, 0) controls.update() - // Lights const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) scene.add(ambientLight) @@ -64,18 +62,13 @@ export function useViewer() { dirLight2.position.set(-10, 20, -10) scene.add(dirLight2) - // Grid const grid = new THREE.GridHelper(50, 50, 0x333355, 0x222244) scene.add(grid) - // Click handler renderer.domElement.addEventListener('click', onCanvasClick) renderer.domElement.addEventListener('dblclick', onCanvasDoubleClick) - - // Resize handler window.addEventListener('resize', () => onResize(container)) - // Start render loop animate() } @@ -85,6 +78,7 @@ export function useViewer() { scene.remove(model) model = null originalMaterials.clear() + globalIdMeshMap.clear() } const loader = new GLTFLoader() @@ -93,14 +87,8 @@ export function useViewer() { (gltf) => { model = gltf.scene - // Store original materials for highlight/unhighlight - model.traverse((child) => { - if (child.isMesh) { - originalMaterials.set(child, child.material.clone()) - child.castShadow = true - child.receiveShadow = true - } - }) + // Build GlobalId -> meshes map + buildGlobalIdMap(model) scene.add(model) fitCameraToModel() @@ -114,6 +102,88 @@ export function useViewer() { }) } + /** + * Walk the scene graph and build a map from GlobalId to all meshes + * belonging to that element. IFC GlobalIds are 22 characters long. + * + * The glb tree typically looks like: + * Node "2XPyKWY018sA1ygZKgQPtU" <- GlobalId on a group node + * ├── Mesh (frame) + * ├── Mesh (panel) + * └── Mesh (handle) + * + * We also handle cases where the GlobalId is directly on a mesh. + */ + function buildGlobalIdMap(root) { + globalIdMeshMap.clear() + + root.traverse((node) => { + const globalId = findGlobalId(node) + if (!globalId) return + + // Collect all meshes under this node + const meshes = [] + if (node.isMesh) { + meshes.push(node) + originalMaterials.set(node, node.material.clone()) + node.castShadow = true + node.receiveShadow = true + } else { + node.traverse((child) => { + if (child.isMesh) { + meshes.push(child) + if (!originalMaterials.has(child)) { + originalMaterials.set(child, child.material.clone()) + child.castShadow = true + child.receiveShadow = true + } + } + }) + } + + if (meshes.length > 0) { + // A GlobalId might appear at multiple levels; merge meshes + if (globalIdMeshMap.has(globalId)) { + const existing = globalIdMeshMap.get(globalId) + for (const m of meshes) { + if (!existing.includes(m)) existing.push(m) + } + } else { + globalIdMeshMap.set(globalId, meshes) + } + } + }) + } + + /** + * Check if a node's name looks like an IFC GlobalId. + * IFC GlobalIds are exactly 22 characters of base64-like encoding. + */ + function isGlobalId(name) { + if (!name || name.length !== 22) return false + return /^[0-9A-Za-z_$]+$/.test(name) + } + + /** + * Find the GlobalId for a node — either its own name or a parent's name. + */ + function findGlobalId(node) { + if (isGlobalId(node.name)) return node.name + return null + } + + /** + * Given a mesh, find the GlobalId by walking up the parent chain. + */ + function findGlobalIdForMesh(mesh) { + let current = mesh + while (current) { + if (isGlobalId(current.name)) return current.name + current = current.parent + } + return null + } + function fitCameraToModel() { if (!model) return @@ -150,21 +220,11 @@ export function useViewer() { if (intersects.length > 0) { const mesh = intersects[0].object - highlightElement(mesh) + const globalId = findGlobalIdForMesh(mesh) - // Walk up to find the named parent (GlobalId) - let globalId = null - let current = mesh - while (current) { - if (current.name && current.name.length >= 22) { - globalId = current.name - break - } - current = current.parent - } - - if (globalId && onElementClick) { - onElementClick(globalId) + if (globalId) { + highlightByGlobalId(globalId) + if (onElementClick) onElementClick(globalId) } } else { clearHighlight() @@ -197,17 +257,14 @@ export function useViewer() { function animatePivot(newTarget) { const startTarget = controls.target.clone() const startCamera = camera.position.clone() - - // Keep the same camera-to-target offset vector const offset = startCamera.clone().sub(startTarget) - const duration = 300 // ms + const duration = 300 const startTime = performance.now() function step(now) { const elapsed = now - startTime const t = Math.min(elapsed / duration, 1) - // Ease-out cubic const ease = 1 - Math.pow(1 - t, 3) controls.target.lerpVectors(startTarget, newTarget, ease) @@ -222,32 +279,30 @@ export function useViewer() { requestAnimationFrame(step) } - function highlightElement(mesh) { - clearHighlight() - selectedMesh = mesh - mesh.material = highlightMaterial - } - + /** + * Highlight all meshes belonging to an IFC element by its GlobalId. + * This is the single source of truth for highlighting — used by both + * 3D click and sidebar click. + */ function highlightByGlobalId(globalId) { - if (!model) return clearHighlight() - model.traverse((child) => { - if (child.name === globalId && child.isMesh) { - highlightElement(child) - } else if (child.name === globalId) { - child.traverse((sub) => { - if (sub.isMesh) highlightElement(sub) - }) - } - }) + const meshes = globalIdMeshMap.get(globalId) + if (!meshes || meshes.length === 0) return + + for (const mesh of meshes) { + mesh.material = highlightMaterial + selectedMeshes.push(mesh) + } } function clearHighlight() { - if (selectedMesh && originalMaterials.has(selectedMesh)) { - selectedMesh.material = originalMaterials.get(selectedMesh) - selectedMesh = null + for (const mesh of selectedMeshes) { + if (originalMaterials.has(mesh)) { + mesh.material = originalMaterials.get(mesh) + } } + selectedMeshes = [] } function onResize(container) {