Add ifcquery clash subcommand for geometric clash detection

Uses ifcopenshell.geom.tree API directly to check an element for
intersections and clearance violations against sibling elements
(--scope storey) or the entire model (--scope all).
This commit is contained in:
Bruno Postle
2026-02-09 18:20:41 +00:00
parent c77983ffe5
commit 12ed2d1ec3
3 changed files with 533 additions and 0 deletions
+27
View File
@@ -24,6 +24,7 @@ import sys
import ifcopenshell
from ifcquery import clash as clash_mod
from ifcquery import info, relations, select, summary, tree
@@ -93,6 +94,14 @@ def main():
relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)")
clash_parser = subparsers.add_parser("clash", help="Check element placement for clashes")
clash_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
clash_parser.add_argument("--clearance", type=float, help="Minimum clearance distance")
clash_parser.add_argument("--tolerance", type=float, default=0.002, help="Intersection tolerance (default: 0.002)")
clash_parser.add_argument(
"--scope", choices=["storey", "all"], default="storey", help="Scope of elements to check (default: storey)"
)
args = parser.parse_args()
try:
@@ -131,6 +140,24 @@ def main():
print(f"Error: Element #{element_id} not found", file=sys.stderr)
sys.exit(1)
result = relations.relations(model, element, traverse=args.traverse)
elif args.command == "clash":
try:
element_id = parse_element_id(args.element_id)
except ValueError:
print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
sys.exit(1)
try:
element = model.by_id(element_id)
except RuntimeError:
print(f"Error: Element #{element_id} not found", file=sys.stderr)
sys.exit(1)
try:
result = clash_mod.clash(
model, element, clearance=args.clearance, tolerance=args.tolerance, scope=args.scope
)
except ImportError:
print("Error: ifcopenshell geometry engine not available (C++ bindings required)", file=sys.stderr)
sys.exit(1)
print(format_output(result, args.output_format))
+174
View File
@@ -0,0 +1,174 @@
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import multiprocessing
import sys
from typing import Any
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.element
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Serialize an element to a compact reference dict."""
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
return result
def _get_scope_elements(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str
) -> tuple[set[ifcopenshell.entity_instance], str]:
"""Return set of elements to check against and the effective scope used.
Returns (elements, effective_scope) where effective_scope may differ from
the requested scope if fallback was needed.
"""
if scope == "storey":
container = ifcopenshell.util.element.get_container(element)
if container is not None:
siblings = set(ifcopenshell.util.element.get_contained(container))
siblings.discard(element)
return siblings, "storey"
else:
print(
f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all",
file=sys.stderr,
)
# scope == "all" or fallback
elements = set(model.by_type("IfcElement"))
elements -= set(model.by_type("IfcFeatureElement"))
elements.discard(element)
return elements, "all"
def _build_tree(
model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]
) -> ifcopenshell.geom.tree | None:
"""Build geometry tree for given elements using iterator.
Returns None if iterator fails to initialize (no geometry available).
"""
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("use-world-coords", True)
geom_tree = ifcopenshell.geom.tree()
iterator = ifcopenshell.geom.iterator(
geom_settings, model, multiprocessing.cpu_count(), include=list(elements)
)
if not iterator.initialize():
return None
while True:
geom_tree.add_element(iterator.get())
if not iterator.next():
break
return geom_tree
def _format_clash(
clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file
) -> dict[str, Any]:
"""Format a single clash result to dict."""
# clash result .a/.b are C++ wrapper entity_instances without .Name;
# look up the Python entity from the model by id for proper serialization
other = model.by_id(clash_result.b.id())
return {
"element": _ref(other),
"type": geom_tree.get_clash_type(clash_result.clash_type),
"distance": clash_result.distance,
"p1": list(clash_result.p1),
"p2": list(clash_result.p2),
}
def clash(
model: ifcopenshell.file,
element: ifcopenshell.entity_instance,
clearance: float | None = None,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes against other elements.
:param model: The IFC model.
:param element: The element to check.
:param clearance: Minimum clearance distance; if provided, runs clearance check.
:param tolerance: Intersection tolerance in meters (default 0.002).
:param scope: Which elements to check against: "storey" or "all".
:return: Dict with clash results suitable for JSON serialization.
"""
result: dict[str, Any] = {"element": _ref(element)}
# Get scope elements
scope_elements, effective_scope = _get_scope_elements(model, element, scope)
result["scope"] = effective_scope
if not scope_elements:
result["pass"] = True
result["checks"] = {
"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}
}
if clearance is not None:
result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []}
return result
# Build geometry tree for target element + scope elements
all_elements = scope_elements | {element}
geom_tree = _build_tree(model, all_elements)
if geom_tree is None:
result["pass"] = None
result["error"] = f"No geometry for element #{element.id()}"
return result
# Run intersection check
intersection_clashes = geom_tree.clash_intersection_many(
[element], list(scope_elements), tolerance=tolerance, check_all=True
)
intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes]
checks: dict[str, Any] = {
"intersection": {
"pass": len(intersection_results) == 0,
"tolerance": tolerance,
"clashes": intersection_results,
}
}
all_pass = len(intersection_results) == 0
# Run clearance check if requested
if clearance is not None:
clearance_clashes = geom_tree.clash_clearance_many(
[element], list(scope_elements), clearance=clearance, check_all=True
)
clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes]
checks["clearance"] = {
"pass": len(clearance_results) == 0,
"clearance": clearance,
"clashes": clearance_results,
}
if clearance_results:
all_pass = False
result["pass"] = all_pass
result["checks"] = checks
return result
+332
View File
@@ -0,0 +1,332 @@
import json
import os
import subprocess
import sys
import tempfile
import numpy as np
import pytest
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
from ifcquery.clash import clash
try:
import ifcopenshell.geom
HAS_GEOM = True
except ImportError:
HAS_GEOM = False
pytestmark = pytest.mark.skipif(not HAS_GEOM, reason="ifcopenshell geometry engine not available")
@pytest.fixture
def model_with_geometry():
"""Create an IFC4 model with walls that have geometric representations."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
# Create geometry context
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
# Wall 1 at origin
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey)
# Wall 2 perpendicular, crossing through wall 1
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey)
matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float)
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2)
# Wall 3 far away (10m offset in Y)
wall3 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall003")
rep3 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall3, representation=rep3)
ifcopenshell.api.spatial.assign_container(f, products=[wall3], relating_structure=storey)
matrix3 = np.eye(4)
matrix3[1, 3] = 10.0 # 10m in Y direction
ifcopenshell.api.geometry.edit_object_placement(f, product=wall3, matrix=matrix3)
# Wall 4 close but not overlapping (0.3m offset in Y, wall thickness is 0.2m)
wall4 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall004")
rep4 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall4, representation=rep4)
ifcopenshell.api.spatial.assign_container(f, products=[wall4], relating_structure=storey)
matrix4 = np.eye(4)
matrix4[1, 3] = 0.3 # 0.3m in Y (gap of 0.1m from wall1)
ifcopenshell.api.geometry.edit_object_placement(f, product=wall4, matrix=matrix4)
return f
@pytest.fixture
def model_two_storeys():
"""Create a model with walls in different storeys."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
storey2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="First Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey1, storey2], relating_object=building)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
# Wall in storey 1
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="GroundWall")
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey1)
# Wall in storey 2, perpendicular and crossing wall1
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="FirstFloorWall")
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey2)
matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float)
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2)
return f
class TestNoClashes:
def test_no_clashes_far_apart(self, model_with_geometry):
wall3 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003"][0]
result = clash(model_with_geometry, wall3)
assert result["pass"] is True
assert result["checks"]["intersection"]["pass"] is True
assert result["checks"]["intersection"]["clashes"] == []
def test_no_clashes_empty_scope(self, model_with_geometry):
"""A model where the element is the only one in scope should pass."""
# Create a model with a single wall
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (
ifc.by_type("IfcApplication") or [None]
)[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="OnlyWall")
rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
result = clash(f, wall)
assert result["pass"] is True
class TestIntersectionDetected:
def test_overlapping_walls(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1)
assert result["pass"] is False
assert result["checks"]["intersection"]["pass"] is False
clashes = result["checks"]["intersection"]["clashes"]
assert len(clashes) > 0
# Wall002 should be in the clashes (it overlaps wall1)
clash_ids = {c["element"]["id"] for c in clashes}
wall2 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002"][0]
assert wall2.id() in clash_ids
def test_clash_has_points(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1)
clashes = result["checks"]["intersection"]["clashes"]
for c in clashes:
assert "p1" in c
assert "p2" in c
assert len(c["p1"]) == 3
assert len(c["p2"]) == 3
assert "type" in c
assert "distance" in c
class TestClearance:
def test_clearance_violation(self, model_with_geometry):
"""Wall004 is 0.1m from wall1; clearance of 0.5m should fail."""
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1, clearance=0.5)
assert "clearance" in result["checks"]
# Wall004 should violate clearance
clearance_clashes = result["checks"]["clearance"]["clashes"]
clash_ids = {c["element"]["id"] for c in clearance_clashes}
wall4 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004"][0]
assert wall4.id() in clash_ids
assert result["checks"]["clearance"]["pass"] is False
def test_clearance_pass(self, model_with_geometry):
"""Wall003 is 10m away; clearance of 0.5m should pass for wall003."""
wall3 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003"][0]
result = clash(model_with_geometry, wall3, clearance=0.5)
assert result["checks"]["clearance"]["pass"] is True
assert result["checks"]["clearance"]["clashes"] == []
def test_clearance_not_included_by_default(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1)
assert "clearance" not in result["checks"]
class TestScope:
def test_scope_storey_excludes_other_storeys(self, model_two_storeys):
wall1 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall"][0]
result = clash(model_two_storeys, wall1, scope="storey")
assert result["scope"] == "storey"
# No clashes because the overlapping wall is in a different storey
assert result["pass"] is True
def test_scope_all_includes_other_storeys(self, model_two_storeys):
wall1 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall"][0]
result = clash(model_two_storeys, wall1, scope="all")
assert result["scope"] == "all"
# Should detect clash with the other-storey wall
assert result["pass"] is False
clash_ids = {c["element"]["id"] for c in result["checks"]["intersection"]["clashes"]}
wall2 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall"][0]
assert wall2.id() in clash_ids
class TestNoGeometry:
def test_no_geometry_error(self, model):
"""Element without geometry reports error."""
wall = model.by_type("IfcWall")[0]
result = clash(model, wall)
assert result["pass"] is None
assert "error" in result
assert "No geometry" in result["error"]
class TestJsonSerializable:
def test_result_serializable(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1)
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert parsed["element"]["type"] == "IfcWall"
def test_clearance_result_serializable(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1, clearance=0.5)
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert "clearance" in parsed["checks"]
class TestCLI:
@staticmethod
def _ifc_path(model):
f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False)
model.write(f.name)
f.close()
return f.name
def test_clash_json(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id())],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["element"]["type"] == "IfcWall"
assert "checks" in data
assert "intersection" in data["checks"]
finally:
os.unlink(path)
def test_clash_with_clearance(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--clearance", "0.5"],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "clearance" in data["checks"]
finally:
os.unlink(path)
def test_clash_scope_all(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--scope", "all"],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["scope"] == "all"
finally:
os.unlink(path)
def test_clash_bad_id(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", "999999"],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Error" in result.stderr
finally:
os.unlink(path)