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 16:43:28 +02:00
parent bf09d2a5dc
commit 0f3d99db57
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)
)
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")

View file

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

View file

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