Fix duplicate elements and robust GlobalId lookup
Some checks are pending
CI / backend-lint-and-test (push) Waiting to run

- 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
This commit is contained in:
warnason 2026-04-22 13:47:21 +02:00
parent bcdb0602c2
commit 58dbb72646
3 changed files with 118 additions and 58 deletions

View file

@ -65,7 +65,7 @@ async def get_element_by_global_id(
.where(Element.global_id == global_id) .where(Element.global_id == global_id)
) )
result = await db.execute(query) result = await db.execute(query)
element = result.scalar_one_or_none() element = result.scalars().first()
if not element: if not element:
raise HTTPException(status_code=404, detail="Element not found") raise HTTPException(status_code=404, detail="Element not found")

View file

@ -42,13 +42,18 @@ async def parse_ifc_file(
# Extract elements # Extract elements
element_types = [ element_types = [
"IfcWall", "IfcWallStandardCase", "IfcDoor", "IfcWindow", "IfcSlab", "IfcWall", "IfcDoor", "IfcWindow", "IfcSlab",
"IfcColumn", "IfcBeam", "IfcStair", "IfcRoof", "IfcColumn", "IfcBeam", "IfcStair", "IfcRoof",
"IfcSpace", "IfcFurnishingElement", "IfcBuildingElementProxy", "IfcSpace", "IfcFurnishingElement", "IfcBuildingElementProxy",
] ]
seen_global_ids = set()
for ifc_type in element_types: for ifc_type in element_types:
for ifc_element in ifc.by_type(ifc_type): 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) storey = _get_storey(ifc_element)
element = Element( element = Element(

View file

@ -7,10 +7,13 @@ export function useViewer() {
let model = null let model = null
let raycaster = new THREE.Raycaster() let raycaster = new THREE.Raycaster()
let mouse = new THREE.Vector2() let mouse = new THREE.Vector2()
let selectedMesh = null let selectedMeshes = []
let originalMaterials = new Map() let originalMaterials = new Map()
let onElementClick = null let onElementClick = null
// Map: GlobalId -> array of meshes belonging to that element
let globalIdMeshMap = new Map()
const highlightMaterial = new THREE.MeshStandardMaterial({ const highlightMaterial = new THREE.MeshStandardMaterial({
color: 0x00aaff, color: 0x00aaff,
emissive: 0x003366, emissive: 0x003366,
@ -21,11 +24,9 @@ export function useViewer() {
function init(container, onClick) { function init(container, onClick) {
onElementClick = onClick onElementClick = onClick
// Scene
scene = new THREE.Scene() scene = new THREE.Scene()
scene.background = new THREE.Color(0x1a1a2e) scene.background = new THREE.Color(0x1a1a2e)
// Camera
camera = new THREE.PerspectiveCamera( camera = new THREE.PerspectiveCamera(
60, 60,
container.clientWidth / container.clientHeight, container.clientWidth / container.clientHeight,
@ -34,14 +35,12 @@ export function useViewer() {
) )
camera.position.set(15, 15, 15) camera.position.set(15, 15, 15)
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true }) renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(container.clientWidth, container.clientHeight) renderer.setSize(container.clientWidth, container.clientHeight)
renderer.setPixelRatio(window.devicePixelRatio) renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = true renderer.shadowMap.enabled = true
container.appendChild(renderer.domElement) container.appendChild(renderer.domElement)
// Controls
controls = new OrbitControls(camera, renderer.domElement) controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true controls.enableDamping = true
controls.dampingFactor = 0.05 controls.dampingFactor = 0.05
@ -51,7 +50,6 @@ export function useViewer() {
controls.target.set(0, 3, 0) controls.target.set(0, 3, 0)
controls.update() controls.update()
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambientLight) scene.add(ambientLight)
@ -64,18 +62,13 @@ export function useViewer() {
dirLight2.position.set(-10, 20, -10) dirLight2.position.set(-10, 20, -10)
scene.add(dirLight2) scene.add(dirLight2)
// Grid
const grid = new THREE.GridHelper(50, 50, 0x333355, 0x222244) const grid = new THREE.GridHelper(50, 50, 0x333355, 0x222244)
scene.add(grid) scene.add(grid)
// Click handler
renderer.domElement.addEventListener('click', onCanvasClick) renderer.domElement.addEventListener('click', onCanvasClick)
renderer.domElement.addEventListener('dblclick', onCanvasDoubleClick) renderer.domElement.addEventListener('dblclick', onCanvasDoubleClick)
// Resize handler
window.addEventListener('resize', () => onResize(container)) window.addEventListener('resize', () => onResize(container))
// Start render loop
animate() animate()
} }
@ -85,6 +78,7 @@ export function useViewer() {
scene.remove(model) scene.remove(model)
model = null model = null
originalMaterials.clear() originalMaterials.clear()
globalIdMeshMap.clear()
} }
const loader = new GLTFLoader() const loader = new GLTFLoader()
@ -93,14 +87,8 @@ export function useViewer() {
(gltf) => { (gltf) => {
model = gltf.scene model = gltf.scene
// Store original materials for highlight/unhighlight // Build GlobalId -> meshes map
model.traverse((child) => { buildGlobalIdMap(model)
if (child.isMesh) {
originalMaterials.set(child, child.material.clone())
child.castShadow = true
child.receiveShadow = true
}
})
scene.add(model) scene.add(model)
fitCameraToModel() 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() { function fitCameraToModel() {
if (!model) return if (!model) return
@ -150,21 +220,11 @@ export function useViewer() {
if (intersects.length > 0) { if (intersects.length > 0) {
const mesh = intersects[0].object const mesh = intersects[0].object
highlightElement(mesh) const globalId = findGlobalIdForMesh(mesh)
// Walk up to find the named parent (GlobalId) if (globalId) {
let globalId = null highlightByGlobalId(globalId)
let current = mesh if (onElementClick) onElementClick(globalId)
while (current) {
if (current.name && current.name.length >= 22) {
globalId = current.name
break
}
current = current.parent
}
if (globalId && onElementClick) {
onElementClick(globalId)
} }
} else { } else {
clearHighlight() clearHighlight()
@ -197,17 +257,14 @@ export function useViewer() {
function animatePivot(newTarget) { function animatePivot(newTarget) {
const startTarget = controls.target.clone() const startTarget = controls.target.clone()
const startCamera = camera.position.clone() const startCamera = camera.position.clone()
// Keep the same camera-to-target offset vector
const offset = startCamera.clone().sub(startTarget) const offset = startCamera.clone().sub(startTarget)
const duration = 300 // ms const duration = 300
const startTime = performance.now() const startTime = performance.now()
function step(now) { function step(now) {
const elapsed = now - startTime const elapsed = now - startTime
const t = Math.min(elapsed / duration, 1) const t = Math.min(elapsed / duration, 1)
// Ease-out cubic
const ease = 1 - Math.pow(1 - t, 3) const ease = 1 - Math.pow(1 - t, 3)
controls.target.lerpVectors(startTarget, newTarget, ease) controls.target.lerpVectors(startTarget, newTarget, ease)
@ -222,33 +279,31 @@ export function useViewer() {
requestAnimationFrame(step) requestAnimationFrame(step)
} }
function highlightElement(mesh) { /**
clearHighlight() * Highlight all meshes belonging to an IFC element by its GlobalId.
selectedMesh = mesh * This is the single source of truth for highlighting used by both
mesh.material = highlightMaterial * 3D click and sidebar click.
} */
function highlightByGlobalId(globalId) { function highlightByGlobalId(globalId) {
if (!model) return
clearHighlight() clearHighlight()
model.traverse((child) => { const meshes = globalIdMeshMap.get(globalId)
if (child.name === globalId && child.isMesh) { if (!meshes || meshes.length === 0) return
highlightElement(child)
} else if (child.name === globalId) { for (const mesh of meshes) {
child.traverse((sub) => { mesh.material = highlightMaterial
if (sub.isMesh) highlightElement(sub) selectedMeshes.push(mesh)
})
} }
})
} }
function clearHighlight() { function clearHighlight() {
if (selectedMesh && originalMaterials.has(selectedMesh)) { for (const mesh of selectedMeshes) {
selectedMesh.material = originalMaterials.get(selectedMesh) if (originalMaterials.has(mesh)) {
selectedMesh = null mesh.material = originalMaterials.get(mesh)
} }
} }
selectedMeshes = []
}
function onResize(container) { function onResize(container) {
camera.aspect = container.clientWidth / container.clientHeight camera.aspect = container.clientWidth / container.clientHeight