Fix duplicate elements and robust GlobalId lookup
Some checks failed
CI / backend-lint-and-test (push) Has been cancelled
Some checks failed
CI / backend-lint-and-test (push) Has been cancelled
- 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:
parent
e423c9680c
commit
99fd82a025
3 changed files with 118 additions and 58 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,32 +279,30 @@ 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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue