diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml
index 0f6bb702e9..4c6cb9312c 100644
--- a/.github/workflows/build_osx.yml
+++ b/.github/workflows/build_osx.yml
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: mac-${{ matrix.arch }}
diff --git a/.github/workflows/build_pyodide.yml b/.github/workflows/build_pyodide.yml
index 7da3bb408c..a5e80a32df 100644
--- a/.github/workflows/build_pyodide.yml
+++ b/.github/workflows/build_pyodide.yml
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml
index 710b918564..9ac489378b 100644
--- a/.github/workflows/build_rocky.yml
+++ b/.github/workflows/build_rocky.yml
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml
index b54e62ccef..208e1da9da 100644
--- a/.github/workflows/build_rocky_arm.yml
+++ b/.github/workflows/build_rocky_arm.yml
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml
index c2ae75143d..7880296a2a 100644
--- a/.github/workflows/build_win.yml
+++ b/.github/workflows/build_win.yml
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
diff --git a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml
index 5c1b80bcc6..c34cf50e0f 100644
--- a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml
+++ b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml
@@ -24,7 +24,7 @@ jobs:
if: |
github.repository == 'IfcOpenShell/IfcOpenShell'
steps:
- - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
+ - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
diff --git a/.github/workflows/ci-ifcopenshell-conda-daily.yml b/.github/workflows/ci-ifcopenshell-conda-daily.yml
index b648538ed7..3d18316963 100644
--- a/.github/workflows/ci-ifcopenshell-conda-daily.yml
+++ b/.github/workflows/ci-ifcopenshell-conda-daily.yml
@@ -84,7 +84,7 @@ jobs:
run: |
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
- - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
+ - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
diff --git a/.github/workflows/ci-ifcopenshell-docker.yml b/.github/workflows/ci-ifcopenshell-docker.yml
index 2eee5eff1a..df672139b8 100644
--- a/.github/workflows/ci-ifcopenshell-docker.yml
+++ b/.github/workflows/ci-ifcopenshell-docker.yml
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
-
name: Build ifcopenshell
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb044eeab3..a49c92fcd7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml
index 4aebe4266e..d7107c410d 100644
--- a/.github/workflows/publish-pyodide-demo-app.yml
+++ b/.github/workflows/publish-pyodide-demo-app.yml
@@ -31,7 +31,7 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Setup Pages
- uses: actions/configure-pages@v5
+ uses: actions/configure-pages@v6
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v4
@@ -47,4 +47,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
index 395086c420..80fbb01a00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@
/src/ifcmax/out/
/src/ifcwrap/out/
/src/qtviewer/out/
+/src/ifctester/webapp/public/pyodide/
/win/BuildDepsCache*.txt
@@ -85,10 +86,11 @@ src/bonsai/bonsai/translations.py
# bonsai external dependencies (cloned for just ty checks)
src/bonsai/external_dependencies/
-# bonsai test temp files
+# bonsai test temp/cache files
src/bonsai/test/files/temp
-src/bonsai/test/files/basic.ifc.cache.blend
-src/bonsai/test/files/basic.ifc.cache.sqlite
+src/bonsai/test/files/*.cache.blend
+src/bonsai/test/files/*.cache.json
+src/bonsai/test/files/*.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
diff --git a/pyproject.toml b/pyproject.toml
index a306ffa166..330f6150d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
- "ruff==0.15.6",
+ "ruff==0.15.8",
"poethepoet",
"gersemi==0.26.1",
]
diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py
index 2c6a6de2cf..45ee8c60e2 100644
--- a/src/bonsai/bonsai/bim/module/boundary/operator.py
+++ b/src/bonsai/bonsai/bim/module/boundary/operator.py
@@ -377,6 +377,8 @@ class EnableEditingBoundary(bpy.types.Operator):
obj = tool.Ifc.get_object(entity)
if entity and obj:
setattr(bprops, blender_property, obj)
+ bprops.physical_or_virtual = boundary.PhysicalOrVirtualBoundary or "NOTDEFINED"
+ bprops.internal_or_external = boundary.InternalOrExternalBoundary or "NOTDEFINED"
return {"FINISHED"}
@@ -392,6 +394,8 @@ class DisableEditingBoundary(bpy.types.Operator):
bprops.is_editing = False
for ifc_attribute, blender_property in EDITABLE_ATTRIBUTES.items():
setattr(bprops, blender_property, None)
+ bprops.physical_or_virtual = "NOTDEFINED"
+ bprops.internal_or_external = "NOTDEFINED"
return {"FINISHED"}
@@ -411,6 +415,8 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator):
obj = getattr(bprops, blender_property, None)
entity = tool.Ifc.get_entity(obj)
attributes[blender_property] = entity
+ attributes["physical_or_virtual"] = bprops.physical_or_virtual
+ attributes["internal_or_external"] = bprops.internal_or_external
ifcopenshell.api.boundary.edit_attributes(tool.Ifc.get(), entity=boundary, **attributes)
bpy.ops.bim.disable_editing_boundary()
return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/boundary/prop.py b/src/bonsai/bonsai/bim/module/boundary/prop.py
index 2e9ab8c975..7b4f5069ad 100644
--- a/src/bonsai/bonsai/bim/module/boundary/prop.py
+++ b/src/bonsai/bonsai/bim/module/boundary/prop.py
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union
import bpy
from bpy.props import (
BoolProperty,
+ EnumProperty,
PointerProperty,
)
from bpy.types import PropertyGroup
@@ -50,12 +51,43 @@ def element_filter(self: "BIMObjectBoundaryProperties", object: bpy.types.Object
return False
+def get_internal_or_external_items(
+ self: "BIMObjectBoundaryProperties", context: bpy.types.Context | None
+) -> list[tuple[str, str, str]]:
+ items = [
+ ("INTERNAL", "Internal", ""),
+ ("EXTERNAL", "External", ""),
+ ]
+ ifc = tool.Ifc.get()
+ if not ifc or ifc.schema != "IFC2X3":
+ items += [
+ ("EXTERNAL_EARTH", "External Earth", ""),
+ ("EXTERNAL_WATER", "External Water", ""),
+ ("EXTERNAL_FIRE", "External Fire", ""),
+ ]
+ items.append(("NOTDEFINED", "Not Defined", ""))
+ return items
+
+
class BIMObjectBoundaryProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing")
relating_space: PointerProperty(name="RelatingSpace", type=bpy.types.Object, poll=space_filter)
related_building_element: PointerProperty(name="RelatedBuildingElement", type=bpy.types.Object, poll=element_filter)
parent_boundary: PointerProperty(name="ParentBoundary", type=bpy.types.Object, poll=boundary_filter)
corresponding_boundary: PointerProperty(name="CorrespondingBoundary", type=bpy.types.Object, poll=boundary_filter)
+ physical_or_virtual: EnumProperty(
+ name="PhysicalOrVirtualBoundary",
+ items=[
+ ("PHYSICAL", "Physical", ""),
+ ("VIRTUAL", "Virtual", ""),
+ ("NOTDEFINED", "Not Defined", ""),
+ ],
+ default="NOTDEFINED",
+ )
+ internal_or_external: EnumProperty(
+ name="InternalOrExternalBoundary",
+ items=get_internal_or_external_items,
+ )
if TYPE_CHECKING:
is_editing: bool
@@ -63,6 +95,8 @@ class BIMObjectBoundaryProperties(PropertyGroup):
related_building_element: Union[bpy.types.Object, None]
parent_boundary: Union[bpy.types.Object, None]
corresponding_boundary: Union[bpy.types.Object, None]
+ physical_or_virtual: str
+ internal_or_external: str # values depend on schema: IFC2X3 omits EXTERNAL_EARTH/WATER/FIRE
class BIMBoundaryProperties(PropertyGroup):
diff --git a/src/bonsai/bonsai/bim/module/boundary/ui.py b/src/bonsai/bonsai/bim/module/boundary/ui.py
index 0ca21b9ce5..91990a5eb0 100644
--- a/src/bonsai/bonsai/bim/module/boundary/ui.py
+++ b/src/bonsai/bonsai/bim/module/boundary/ui.py
@@ -77,6 +77,10 @@ class BIM_PT_Boundary(Panel):
self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element")
self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary")
self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary")
+ row = self.layout.row()
+ row.prop(self.bprops, "physical_or_virtual")
+ row = self.layout.row()
+ row.prop(self.bprops, "internal_or_external")
else:
row = self.layout.row()
row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit")
@@ -84,6 +88,8 @@ class BIM_PT_Boundary(Panel):
self.draw_relation_data(boundary, "RelatedBuildingElement")
self.draw_relation_data(boundary, "ParentBoundary")
self.draw_relation_data(boundary, "CorrespondingBoundary")
+ self.draw_enum_data(boundary, "PhysicalOrVirtualBoundary")
+ self.draw_enum_data(boundary, "InternalOrExternalBoundary")
if hasattr(boundary, "InnerBoundaries"):
for i, inner_boundary in enumerate(getattr(boundary, "InnerBoundaries", ())):
row = self.layout.row(align=True)
@@ -110,6 +116,11 @@ class BIM_PT_Boundary(Panel):
else:
row.label(text="")
+ def draw_enum_data(self, boundary, ifc_attribute: str):
+ row = self.layout.row(align=True)
+ row.label(text=ifc_attribute)
+ row.label(text=getattr(boundary, ifc_attribute, "") or "")
+
def draw_relation_editor(self, boundary, ifc_attribute: str, blender_property: str):
if hasattr(boundary, ifc_attribute):
row = self.layout.row(align=True)
diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py
index e33fe2cf5d..be491e756b 100644
--- a/src/bonsai/bonsai/bim/module/model/polyline.py
+++ b/src/bonsai/bonsai/bim/module/model/polyline.py
@@ -421,7 +421,7 @@ class PolylineOperator:
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
- return {"RUNNING_MODAL"}
+ return {"RUNNING_MODAL"}
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
props = tool.Model.get_model_props()
@@ -461,6 +461,7 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
+ tool.Raycast.clear_snap_objs()
self.visible_objs = tool.Raycast.get_visible_objects(context)
for obj in self.visible_objs:
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py
index 5f7cf7699d..828fc2c21f 100644
--- a/src/bonsai/bonsai/bim/module/model/workspace.py
+++ b/src/bonsai/bonsai/bim/module/model/workspace.py
@@ -943,7 +943,7 @@ class EditObjectUI:
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
- cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
+ cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
)
if AuthoringData.data["is_flippable_element"]:
diff --git a/src/bonsai/bonsai/core/covering.py b/src/bonsai/bonsai/core/covering.py
index a8c7a73f25..2ab0d39cc4 100644
--- a/src/bonsai/bonsai/core/covering.py
+++ b/src/bonsai/bonsai/core/covering.py
@@ -51,21 +51,11 @@ def add_instance_flooring_covering_from_cursor(
if isinstance(space_polygon, str):
return
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
- name = "Covering"
- mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
-
- obj = spatial.get_named_obj_from_mesh(name, mesh)
-
+ obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
def add_instance_ceiling_covering_from_cursor(
@@ -95,21 +85,11 @@ def add_instance_ceiling_covering_from_cursor(
if isinstance(space_polygon, str):
return
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
- name = "Covering"
- mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
-
- obj = spatial.get_named_obj_from_mesh(name, mesh)
-
+ obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None:
@@ -127,19 +107,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
if isinstance(space_polygon, str):
return
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
-
- name = "Aux"
- mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
- mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
- obj = spatial.get_named_obj_from_mesh(name, mesh)
-
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points)
- body = spatial.get_body_representation(active_obj)
- spatial.regen_obj_representation(active_obj, body)
+ spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True)
# TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS
@@ -151,22 +119,13 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
- bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
- obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
-
- spatial.set_obj_origin_to_bboxcenter(obj)
+ obj = spatial.create_object(name)
+ spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
-
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
-
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
def add_instance_ceiling_coverings_from_walls(
@@ -179,22 +138,13 @@ def add_instance_ceiling_coverings_from_walls(
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
- bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
- obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
-
- spatial.set_obj_origin_to_bboxcenter(obj)
+ obj = spatial.create_object(name)
+ spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
-
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
-
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
class NoDefaultContainer(Exception):
diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py
index 5086a8a872..5ce6d7c253 100644
--- a/src/bonsai/bonsai/core/spatial.py
+++ b/src/bonsai/bonsai/core/spatial.py
@@ -219,13 +219,8 @@ def generate_space(
else:
assert space_polygon
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True)
-
- mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm)
-
if element and element.is_a("IfcSpace"):
- mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
- spatial.edit_active_space_obj_from_mesh(mesh)
+ spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
spatial.translate_obj_to_z_location(active_obj, z)
else:
if relating_type:
@@ -233,12 +228,13 @@ def generate_space(
else:
name = "Space"
- obj = spatial.get_named_obj_from_mesh(name, mesh)
+ obj = spatial.create_object(name)
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
+ spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
if relating_type:
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
@@ -257,16 +253,16 @@ def generate_spaces_from_walls(
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
- bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False)
-
name = "Space" + str(i)
- obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
-
- spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj)
+ obj = spatial.create_object(name)
+ spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
+ element = ifc.get_entity(obj)
+ spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False)
+
def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None:
model = ifc.get()
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index 09a4fe90f0..f815bf0963 100644
--- a/src/bonsai/bonsai/core/tool.py
+++ b/src/bonsai/bonsai/core/tool.py
@@ -999,14 +999,12 @@ class Spatial:
def get_purged_inner_holes_poly(cls, union_geom, min_area): pass
def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass
def get_buffered_poly_from_linear_ring(cls, linear_ring): pass
- def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass
- def get_named_obj_from_bmesh(cls, name, bmesh): pass
- def get_named_obj_from_mesh(cls, name, mesh): pass
- def get_named_mesh_from_bmesh(cls, name, bmesh): pass
- def get_transformed_mesh_from_local_to_global(cls, mesh): pass
- def edit_active_space_obj_from_mesh(cls, mesh): pass
- def set_obj_origin_to_bboxcenter(cls, obj): pass
- def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass
+ def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass
+ def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass
+ def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass
+ def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass
+ def create_object(cls, name): pass
+ def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass
def get_selected_objects(cls): pass
def get_active_obj(cls): pass
@@ -1014,14 +1012,9 @@ class Spatial:
def get_active_obj_height(cls): pass
def get_relating_type_id(cls): pass
def translate_obj_to_z_location(cls, obj, z): pass
- def get_2d_vertices_from_obj(cls, obj): pass
- def get_scaled_2d_vertices(cls, points): pass
- def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass
- def get_body_representation(cls, obj): pass
def assign_ifcspace_class_to_obj(cls, obj): pass
def assign_type_to_obj(cls, obj): pass
def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass
- def regen_obj_representation(cls, obj, body): pass
def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass
def toggle_hide_spaces(cls, spaces): pass
def set_default_container(cls, container): pass
diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py
index d98aaf26ab..70dae00438 100644
--- a/src/bonsai/bonsai/tool/raycast.py
+++ b/src/bonsai/bonsai/tool/raycast.py
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+from __future__ import annotations
+
+import math
from typing import Union
import bmesh
@@ -41,6 +44,7 @@ class Raycast(bonsai.core.tool.Raycast):
(0, -offset),
(offset, -offset),
)
+ snap_objs = []
@classmethod
def get_visible_objects(cls, context: bpy.types.Context):
@@ -69,8 +73,14 @@ class Raycast(bonsai.core.tool.Raycast):
rv3d = context.region_data
assert rv3d
view_location = rv3d.view_matrix.inverted().translation
+ view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
obj_matrix = obj.matrix_world.copy()
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
+ bbox_edges = [
+ (0,1),(1,2),(2,3),(3,0),
+ (4,5),(5,6),(6,7),(7,4),
+ (0,4),(1,5),(2,6),(3,7)
+ ]
transposed_bbox: list[Vector] = []
bbox_2d: list[float] = []
@@ -94,8 +104,23 @@ class Raycast(bonsai.core.tool.Raycast):
for v in bbox:
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
- if coord_2d is not None:
- transposed_bbox.append(coord_2d)
+ transposed_bbox.append(coord_2d)
+
+ if not any(transposed_bbox):
+ transposed_bbox = []
+ # If there are None values in transposed_bbox it means that there are vertices behind the camera
+ # so we get the intersection of the edge with the region border
+ # new_bbox = []
+ if any(transposed_bbox) and not all(transposed_bbox):
+ new_bbox = transposed_bbox.copy()
+ new_bbox = [x for x in new_bbox if x is not None]
+ for edge in bbox_edges:
+ if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
+ point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]])
+ if point:
+ new_bbox.append(point)
+ if new_bbox:
+ transposed_bbox = new_bbox
region = context.region
borders = (0, region.width, 0, region.height)
@@ -117,6 +142,99 @@ class Raycast(bonsai.core.tool.Raycast):
return (obj, bbox_2d)
return None
+ def intersect_edge_region_border(region, space, rv3d, v1, v2):
+ def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
+ a_view = view_matrix @ p_world_a
+ b_view = view_matrix @ p_world_b
+ z_near = -clip_start
+ za = a_view.z
+ zb = b_view.z
+ denom = (zb - za)
+ if denom == 0.0:
+ return None, None
+ t = (z_near - za) / denom
+ if t < 0.0 or t > 1.0:
+ return None, None
+ p_view = a_view.lerp(b_view, t)
+ cam_world = view_matrix.inverted()
+ p_world = cam_world @ p_view
+ return p_world, t
+
+ def is_inside_region(pt2d, region):
+ return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
+
+ def clamp_to_region_border(point2d, region):
+ x, y = point2d
+ x_clamped = max(0.0, min(region.width, x))
+ y_clamped = max(0.0, min(region.height, y))
+ return Vector((x_clamped, y_clamped))
+
+ def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
+ """
+ Use iterative approach: move t toward 0. Returns the first point that is inside region border
+ """
+ t = initial_t_on_segment
+ for i in range(max_iters):
+ test_3d = p1.lerp(p2, t)
+ test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
+ if test_2d is not None and is_inside_region(test_2d, region):
+ return test_3d, test_2d, t
+ # move t toward 0 by reducing it by a fraction of its current value
+ t -= step
+ # if t is already very small, break
+ if t <= 1e-6:
+ break
+
+ return None, None, None
+
+ # Ensures that all the calculation uses the same direction based on which point is on the screen
+ if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
+ onscreen_vert = v1
+ offscreen_vert = v2
+ else:
+ onscreen_vert = v2
+ offscreen_vert = v1
+ # v2, v1 = v1, v2
+
+ clip_start = space.clip_start
+ view_mat = rv3d.view_matrix
+ inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
+
+ if inter_world is None:
+ print("No intersection with viewport near plane found for the segment.")
+ return
+
+ init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
+
+ if init_2d is not None and is_inside_region(init_2d, region):
+ final_world = inter_world
+ final_2d = init_2d
+ final_t = initial_t
+ else:
+ found_world, found_2d, found_t = find_nearby_onscreen_point(
+ region, rv3d,
+ onscreen_vert, offscreen_vert,
+ t_on_ab,
+ max_iters=600, step=0.01
+ )
+ if found_world is None:
+ if init_2d is None:
+ print("Initial projection invalid and iterative search failed.")
+ return
+ # fallback: clamp projected point to border via manual mapping
+ final_2d = clamp_to_region_border(init_2d, region)
+ final_world = None
+ final_t = None
+ # print("Iterative search failed; using clamped 2D:", final_2d)
+ else:
+ final_world = found_world
+ final_2d = found_2d
+ final_t = found_t
+ # print(f"Found onscreen point at t={final_t:.4f}")
+
+ # print("Final 2D:", final_2d)
+ return final_2d, v2
+
@classmethod
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
x, y = mouse_pos
@@ -232,6 +350,161 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
+ @classmethod
+ def ray_cast_by_proximity_2d(
+ cls,
+ context: bpy.types.Context,
+ event: bpy.types.Event,
+ snap_obj: SnapObj,
+ ):
+
+ def divide_vector(start, end, n):
+ points = []
+ delta = (end - start) / n
+ for i in range(1, n):
+ point = start + i * delta
+ points.append(point)
+ return points
+
+ region = context.region
+ rv3d = context.region_data
+ mouse_pos = event.mouse_region_x, event.mouse_region_y
+ ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
+ points = []
+
+ try:
+ loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
+ except:
+ loc = Vector((0, 0, 0))
+
+ verts_2d = [
+ view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
+ ] # Numpy version is worst in performance
+
+ intersected = snap_obj.raycast_boxes(
+ context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
+ )
+ edges = []
+ for it in intersected:
+ edges.extend(it.edges)
+ edges = set(edges)
+
+ edge_verts = {}
+ for e in edges:
+ verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
+ verts = snap_obj.obj.data.vertices
+ v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
+ v1_2d = verts_2d[verts_idx[0]]
+ v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
+ v2_2d = verts_2d[verts_idx[1]]
+ if (v1_2d is None) ^ (v2_2d is None):
+ point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
+ if v1_2d is None:
+ edge_verts[e] = (point, v2_2d)
+ else:
+ edge_verts[e] = (v1_2d, point)
+ else:
+ edge_verts[e] = (v1_2d, v2_2d)
+
+ snap_threshold = 10.0
+
+ for i, point in enumerate(verts_2d):
+ if not point:
+ continue
+ distance = (Vector(mouse_pos) - point).length
+ if distance <= snap_threshold:
+ snap_point = {
+ "object": snap_obj.obj,
+ "type": "Vertex",
+ "point": snap_obj.verts_3d[i],
+ "distance": distance / 10,
+ }
+ points.append(snap_point)
+
+ count = 0
+ selected_edges = {}
+ for e in edges:
+ p0, p1 = edge_verts[e]
+ p0x, p0y = p0
+ p1x, p1y = p1
+ px, py = mouse_pos
+
+ # segment vector = p1 - p0
+ sx = p1x - p0x
+ sy = p1y - p0y
+
+ # seg length squared
+ seg_len_sq = sx * sx + sy * sy
+
+ if seg_len_sq == 0.0:
+ # degenerate segment: return distance to p0
+ dx = px - p0x
+ dy = py - p0y
+ dist = math.hypot(dx, dy)
+ return dist, (p0x, p0y), 0.0
+
+ # project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
+ apx = px - p0x
+ apy = py - p0y
+ t = (apx * sx + apy * sy) / seg_len_sq
+
+ # clamp to segment
+ if t <= 0.0:
+ t_clamped = 0.0
+ cx, cy = p0x, p0y
+ elif t >= 1.0:
+ t_clamped = 1.0
+ cx, cy = p1x, p1y
+ else:
+ t_clamped = t
+ cx = p0x + sx * t_clamped
+ cy = p0y + sy * t_clamped
+
+ dx = px - cx
+ dy = py - cy
+ dist = math.hypot(dx, dy)
+ if dist <= snap_threshold:
+ selected_edges[dist] = e
+
+ if selected_edges:
+ min_dist = float("inf")
+ for key in selected_edges:
+ if key < min_dist:
+ min_dist = key
+
+ idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
+ edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
+ division_points = divide_vector(
+ edge_verts[0], edge_verts[1], 2
+ ) # TODO Make it work for different divisions
+ for division_point in division_points:
+ intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
+ distance = (division_point - intersection).length
+ if distance < snap_threshold:
+ snap_point = {
+ "object": snap_obj.obj,
+ "type": "Edge Center",
+ "point": division_point.copy(),
+ "distance": distance,
+ }
+ points.append(snap_point)
+
+ intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
+ if intersection[0]:
+ if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
+ distance = (intersection[1] - intersection[0]).length
+ if distance < snap_threshold:
+ snap_point = {
+ "object": snap_obj.obj,
+ "type": "Edge",
+ "point": intersection[1].copy(),
+ "edge_verts": edge_verts,
+ "distance": distance,
+ }
+ points.append(snap_point)
+
+ return points
+
@classmethod
def ray_cast_by_proximity(
cls,
@@ -457,7 +730,8 @@ class Raycast(bonsai.core.tool.Raycast):
if bbox_2d:
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
- objs_to_raycast.append(obj)
+ snap_obj = cls.create_snap_obj(obj)
+ objs_to_raycast.append(snap_obj)
return objs_to_raycast
@@ -474,12 +748,6 @@ class Raycast(bonsai.core.tool.Raycast):
face_index = None
# Wireframes
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
- snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
- if snap_points:
- hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
- if hit:
- hit_world = obj.original.matrix_world @ hit
- return obj, hit_world, face_index
return None, None, None
# Meshes
else:
@@ -514,19 +782,20 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
- for obj in objs_to_raycast:
+ for snap_obj in objs_to_raycast:
if not include_wireframes and (
- obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
+ snap_obj.obj.type in {"EMPTY", "CURVE"}
+ or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
):
continue
- snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
+ hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if best_obj is None or length_squared < best_length_squared:
best_length_squared = length_squared
- best_obj = snap_obj
+ best_obj = hit_obj
best_hit = hit
best_face_index = face_index
@@ -536,6 +805,81 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
+ @classmethod
+ def ray_cast_and_get_closest_to_camera_snaps(
+ cls,
+ context: bpy.types.Context,
+ event: bpy.types.Event,
+ objs_to_raycast: list[bpy.types.Object],
+ ) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
+ closest_length_squared = 1.0
+ closest_obj = None
+ closest_hit = None
+ closest_face_index = None
+
+ ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
+
+ closest_snaps = []
+ hit = None
+
+ for snap_obj in objs_to_raycast:
+ if (snap_obj.obj.type in {"EMPTY", "CURVE"}
+ or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
+ ):
+ # For wireframe objects we have to test all the snaps to see which is closer
+ snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
+ closest_wf_hit = None
+ closest_wf_length_squared = 1.0
+ closest_wf_point = None
+ if snap_points:
+ for point in snap_points:
+ point["group"] = "Wireframe"
+ closest_snaps.append(point)
+ length = (point["point"] - ray_origin).length_squared
+ if closest_wf_hit is None or length < closest_wf_length_squared:
+ closest_wf_length_squared = length
+ closest_wf_hit = point["point"]
+ closest_wf_point = point
+
+ if closest_wf_point:
+ hit_obj = closest_wf_point["object"]
+ hit = closest_wf_point["point"]
+ face_index = None
+
+
+ else:
+ # Solid objects
+ hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
+
+ if hit:
+ snap_point = {
+ "point": hit,
+ "type": "Face",
+ "group": "Object",
+ "object": hit_obj,
+ "face_index": face_index,
+ "distance": 9, # High value so it has low priority
+ }
+ closest_snaps.append(snap_point)
+
+
+ # Here we test which is closer, including wireframe and solid objects
+ if hit is not None:
+ length_squared = (hit - ray_origin).length_squared
+ if closest_obj is None or length_squared < closest_length_squared:
+ closest_length_squared = length_squared
+ closest_obj = hit_obj
+ closest_hit = hit
+ closest_face_index = face_index
+
+ # Label snaps from the closest object
+ if closest_obj is not None:
+ for snap in closest_snaps:
+ if snap["object"] == closest_obj:
+ snap["is_closest_to_camera"] = True
+
+ return closest_snaps
+
@classmethod
def calculate_snap_threshold(cls, view_distance):
snap_threshold = view_distance / 100
@@ -547,3 +891,260 @@ class Raycast(bonsai.core.tool.Raycast):
if lens < 50:
snap_threshold *= value
return snap_threshold
+
+ @classmethod
+ def create_snap_obj(cls, obj):
+ for snap_obj in cls.snap_objs:
+ if obj.name == snap_obj.obj.name:
+ return snap_obj
+ snap_obj = SnapObj(obj)
+ cls.snap_objs.append(snap_obj)
+ return snap_obj
+
+ @classmethod
+ def clear_snap_objs(cls):
+ TreeNode.__clear_all__()
+ SnapObj.__clear_all__()
+ cls.snap_objs.clear()
+
+
+class TreeNode:
+ all = []
+
+ def __init__(self, box: tuple):
+ self.__class__.all.append(self)
+ self.box = box
+ self.child_a = None
+ self.child_b = None
+ self.edges = []
+
+ def __clear_all__():
+ for instance in TreeNode.all:
+ del instance
+ TreeNode.all.clear()
+
+
+class SnapObj:
+ max_depth = 9
+ all = []
+
+ def __init__(self, obj: bpy.types.Object):
+ self.__class__.all.append(self)
+ self.obj = obj
+ self.root = self._create_root_node()
+ self.root.edges = [e.index for e in obj.data.edges]
+ self.split_box(self.root, 0)
+ self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
+ self.snap_points = []
+
+ def __clear_all__():
+ for instance in SnapObj.all:
+ del instance
+ SnapObj.all.clear()
+
+ def _create_root_node(self) -> TreeNode:
+ bbox = tool.Blender.get_object_bounding_box(self.obj)
+ min_point = self.obj.matrix_world @ bbox["min_point"]
+ max_point = self.obj.matrix_world @ bbox["max_point"]
+ new_bbox = self.expand_bounding_box((min_point, max_point))
+ return TreeNode(new_bbox)
+
+ def divide_bounding_box_along_longest_axis(
+ self, min_pt: Vector, max_pt: Vector
+ ) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
+ """
+ Divide a bounding box into two equal parts along the axis with the longest dimension.
+
+ Args:
+ min_pt: The minimum point of the bounding box.
+ max_pt: The maximum point of the bounding box.
+
+ Returns:
+ list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
+ """
+
+ # Calculate the dimensions of the box
+ dx = max_pt.x - min_pt.x
+ dy = max_pt.y - min_pt.y
+ dz = max_pt.z - min_pt.z
+
+ # Determine the axis with the longest dimension
+ if dx >= dy and dx >= dz:
+ # Divide along the x-axis
+ mid_x = min_pt.x + dx / 2
+ box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
+ box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
+ elif dy >= dx and dy >= dz:
+ # Divide along the y-axis
+ mid_y = min_pt.y + dy / 2
+ box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
+ box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
+ else:
+ # Divide along the z-axis
+ mid_z = min_pt.z + dz / 2
+ box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
+ box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
+
+ return [box1, box2]
+
+ def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
+ """
+ Expand a 3D bounding box by a given offset.
+
+ Args:
+ min_pt: The minimum point of the bounding box.
+ max_pt: The maximum point of the bounding box.
+ offset: The offset to expand the bounding box by.
+
+ Returns:
+ tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
+ """
+
+ min_pt, max_pt = box
+ # Calculate the new minimum and maximum points
+ new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
+ new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
+
+ return new_min_pt, new_max_pt
+
+ def split_box(self, parent: TreeNode, depth: int):
+ """
+ Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
+
+ Args:
+ parent: the TreeNode instance that represents the parent node of a BVH Tree.
+ depth: the depth of the BVH Tree no be used in recursion.
+ """
+ if depth > self.max_depth:
+ return
+ box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
+ parent.child_a = TreeNode(box_a)
+ parent.child_b = TreeNode(box_b)
+ edges_a = []
+ edges_b = []
+ for e in parent.edges:
+ verts_idx = [v for v in self.obj.data.edges[e].vertices]
+ verts_coords = []
+ for idx in verts_idx:
+ if idx < len(self.obj.data.vertices):
+ verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
+ if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
+ edges_a.append(e)
+ if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
+ edges_b.append(e)
+ parent.child_a.edges = edges_a
+ parent.child_b.edges = edges_b
+ self.split_box(parent.child_a, depth + 1)
+ self.split_box(parent.child_b, depth + 1)
+
+ def raycast_box(
+ self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
+ ) -> bool:
+ """
+ Raycast bounding box.
+
+ Args:
+ context: Blender context.
+ event: Blender event.
+ node: a TreeNode instance.
+ rays: tuple containing ray origin and ray direction
+
+ Returns:
+ True if hits the box or False otherwise.
+ """
+ box = node.box
+ min_v = box[0]
+ max_v = box[1]
+ t_min = 0.0
+ t_max = float("inf")
+ ray_origin, ray_dir = rays
+ inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
+ # X
+ tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
+ tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
+ tmin = min(tx1, tx2)
+ tmax = max(tx1, tx2)
+ # Y
+ ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
+ ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
+ tmin = max(tmin, min(ty1, ty2))
+ tmax = min(tmax, max(ty1, ty2))
+ # Z
+ tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
+ tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
+ tmin = max(tmin, min(tz1, tz2))
+ tmax = min(tmax, max(tz1, tz2))
+ return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
+
+ def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
+ """
+ Check if a line segment intersects an axis-aligned bounding box (AABB).
+
+ Args:
+ v1: The first endpoint of the line segment as a mathutils.Vector.
+ v2: The second endpoint of the line segment as a mathutils.Vector.
+ box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
+
+ Returns:
+ bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
+ """
+ bmin, bmax = box
+ dir = v2 - v1
+ tmin = 0.0
+ tmax = 1.0
+
+ for i in range(3):
+ if abs(dir[i]) < 1e-12:
+ # Line is parallel to slab. If origin not within slab -> no hit.
+ if v1[i] < bmin[i] or v1[i] > bmax[i]:
+ return False
+ else:
+ ood = 1.0 / dir[i]
+ t1 = (bmin[i] - v1[i]) * ood
+ t2 = (bmax[i] - v1[i]) * ood
+ if t1 > t2:
+ t1, t2 = t2, t1
+ if t1 > tmin:
+ tmin = t1
+ if t2 < tmax:
+ tmax = t2
+ if tmin > tmax:
+ return False
+
+ # If any overlap in [0,1] exists, there's intersection
+ return (tmax >= 0.0) and (tmin <= 1.0)
+
+ def raycast_boxes(
+ self,
+ context: bpy.types.Context,
+ event: bpy.Types.Event,
+ node: TreeNode,
+ intersected: Union[TreeNode] = [],
+ rays: tuple[Vector, Vector] = (),
+ ) -> Union[TreeNode]:
+ """
+ Raycast bounding box subdivisions recursively.
+
+ Args:
+ context: Blender context.
+ event: Blender event.
+ node: a TreeNode instance.
+ intersected: list of intersected boxes to use in recursion.
+ rays: tuple containing ray origin and ray direction
+
+ Returns:
+ tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
+ """
+ if not node.child_a:
+ intersected.append(node)
+ return intersected
+
+ intersects_a = self.raycast_box(context, event, node.child_a, rays)
+ intersects_b = self.raycast_box(context, event, node.child_b, rays)
+ if intersects_a:
+ intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
+
+ if intersects_b:
+ intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
+
+ return intersected
diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py
index 02f484d388..f464779ea3 100644
--- a/src/bonsai/bonsai/tool/snap.py
+++ b/src/bonsai/bonsai/tool/snap.py
@@ -360,6 +360,7 @@ class Snap(bonsai.core.tool.Snap):
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
return plane_origin, plane_normal
+
# Polyline
polyline_props = tool.Model.get_polyline_props()
try:
@@ -388,58 +389,31 @@ class Snap(bonsai.core.tool.Snap):
# Objects
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
- # Wireframes
- # For wireframe we have to get all the objects so we can further calculate edge intersection
+ closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
+ detected_snaps.extend(closest_snaps)
+
+ xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe)
+
for snap_obj in objs_to_raycast:
- if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
- snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
- if snap_points:
- for point in snap_points:
- point["group"] = "Wireframe"
- detected_snaps.append(point)
-
- if (space.shading.type == "SOLID" and space.shading.show_xray) or (
- space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
- ):
- results = []
- for obj in objs_to_raycast:
- results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
- else:
- results = []
- results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
-
- for result in results:
- snap_obj = result[0]
- hit = result[1]
- face_index = result[2]
- if hit is not None:
- # Wireframes
- if snap_obj.type in {"EMPTY", "CURVE"} or (
- snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
- ):
- continue
- # Meshes
- else:
- # Add face snap
- snap_point = {
- "point": hit,
- "type": "Face",
- "group": "Object",
- "object": snap_obj,
- "face_index": face_index,
- "distance": 9, # High value so it has low priority
- }
- detected_snaps.append(snap_point)
-
- # Add vertex and edge snap
- snap_points = tool.Raycast.ray_cast_by_proximity(
- context, event, snap_obj, snap_obj.data.polygons[face_index]
- )
- if snap_points:
- for point in snap_points:
- point["group"] = "Object"
- detected_snaps.append(point)
-
+ for snap in closest_snaps:
+ if snap_obj.obj == snap["object"]:
+ if xray_mode:
+ if "face_index" in snap and snap["face_index"] is not None:
+ snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
+ for point in snap_points:
+ point["group"] = "Object"
+ detected_snaps.append(point)
+ else:
+ # If it is a solid object that is closest to camera it ignores all the rest
+ if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
+ closest_snap = [snap] # discards objects that aren't the closest
+ if "face_index" in snap and snap["face_index"] is not None:
+ snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
+ for point in snap_points:
+ point["group"] = "Object"
+ closest_snap.append(point)
+ detected_snaps = closest_snap
+
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
cut_snaps = []
diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py
index b185b9ab59..7bbdb28904 100644
--- a/src/bonsai/bonsai/tool/spatial.py
+++ b/src/bonsai/bonsai/tool/spatial.py
@@ -21,13 +21,13 @@ from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Generator, Iterable
-from math import pi
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.attribute
+import ifcopenshell.api.geometry
import ifcopenshell.api.type
import ifcopenshell.geom
import ifcopenshell.util.classification
@@ -991,114 +991,107 @@ class Spatial(bonsai.core.tool.Spatial):
return poly
@classmethod
- def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh:
- """
- :param h: Height, in meters.
- :param polygon_is_si: Should be True if `poly` is defined in meters.
- """
- mat = Matrix()
- bm = bmesh.new()
- bm.verts.index_update()
- bm.edges.index_update()
-
- mat_invert = mat.inverted()
- si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
- new_verts = [
- # bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1]
- bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion))
- for v in shapely.get_exterior_ring(poly).coords[0:-1]
- ]
- [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
- bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
-
- bm.verts.index_update()
- bm.edges.index_update()
-
- bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
- bmesh.ops.triangle_fill(bm, edges=bm.edges)
- bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges)
-
- if h != 0:
- extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
- extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)]
- bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts)
-
- bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
-
- return bm
-
- @classmethod
- def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object:
- mesh = cls.get_named_mesh_from_bmesh(name, bmesh)
- obj = cls.get_named_obj_from_mesh(name, mesh)
- return obj
-
- @classmethod
- def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
+ def create_object(cls, name: str) -> bpy.types.Object:
+ mesh = bpy.data.meshes.new(name=name)
obj = bpy.data.objects.new(name, mesh)
return obj
@classmethod
- def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh:
- mesh = bpy.data.meshes.new(name=name)
- bmesh.to_mesh(mesh)
- bmesh.free()
- return mesh
+ def set_obj_origin_to_polygon_center(
+ cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True
+ ) -> None:
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
+ centroid = poly.centroid
+ if polygon_is_si:
+ obj.location = Vector((centroid.x, centroid.y, 0))
+ else:
+ obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
+
@classmethod
- def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
- active_obj = cls.get_active_obj()
- mat = active_obj.matrix_world
- mesh.transform(mat.inverted())
- mesh.update()
- return mesh
+ def get_2d_vertices_from_polygon(
+ cls,
+ poly: Polygon,
+ obj: bpy.types.Object,
+ polygon_is_si: bool = True,
+ ) -> list[list[float]]:
+ """Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units.
+
+ :param poly: The polygon in world space.
+ :param obj: The Blender object whose local space is used.
+ :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
+ :return: List of [x, y] coordinates (not closed).
+ """
+ ifc_file = tool.Ifc.get()
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
+ bpy.context.view_layer.update()
+ mat_inv = obj.matrix_world.inverted()
+ coords_2d = []
+ for v in shapely.get_exterior_ring(poly).coords[:-1]:
+ world_si = Vector((v[0], v[1], 0))
+ if not polygon_is_si:
+ world_si = world_si * unit_scale
+ local_si = mat_inv @ world_si
+ coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale])
+ return coords_2d
@classmethod
- def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None:
- active_obj = bpy.context.active_object
- old_mesh = active_obj.data
- old_mesh_name = old_mesh.name
- assert active_obj and isinstance(old_mesh, bpy.types.Mesh)
- tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id
- tool.Geometry.change_object_data(active_obj, mesh, is_global=True)
- tool.Ifc.edit(active_obj)
- tool.Blender.remove_data_block(old_mesh)
- # Rename after old mesh is removed to avoid .001 suffix.
- mesh.name = old_mesh_name
+ def set_extrusion_representation_from_polygon(
+ cls,
+ obj: bpy.types.Object,
+ element: ifcopenshell.entity_instance,
+ poly: Polygon,
+ depth_ifc: float,
+ polygon_is_si: bool = True,
+ ) -> None:
+ """Create or replace the IFC body representation from a polygon extrusion.
+
+ :param obj: The Blender object.
+ :param element: The IFC product entity.
+ :param poly: The polygon in world space.
+ :param depth_ifc: The extrusion depth in IFC file units.
+ :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
+ """
+ ifc_file = tool.Ifc.get()
+ builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
+
+ coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si)
+
+ curve = builder.polyline(coords_2d, closed=True)
+ item = builder.extrude(curve, magnitude=depth_ifc)
+
+ old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
+ if old_body:
+ context = old_body.ContextOfItems
+ ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
+ ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
+ else:
+ context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
+
+ new_body = builder.get_representation(context, item)
+ ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
+ bonsai.core.geometry.switch_representation(
+ tool.Ifc,
+ tool.Geometry,
+ obj=obj,
+ representation=new_body,
+ )
@classmethod
- def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None:
- mat = obj.matrix_world
- inverted = mat.inverted()
- local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
- global_bbox_center = mat @ local_bbox_center
+ def set_space_representation_from_polygon(
+ cls,
+ obj: bpy.types.Object,
+ element: ifcopenshell.entity_instance,
+ poly: Polygon,
+ h: float,
+ polygon_is_si: bool = True,
+ ) -> None:
+ """Create or replace the IFC body representation of a space from a polygon.
- oldLoc = obj.location
- newLoc = global_bbox_center
- diff = newLoc - oldLoc
- for vert in obj.data.vertices:
- aux_vector = mat @ vert.co
- aux_vector = aux_vector - diff
- vert.co = inverted @ aux_vector
- obj.location = newLoc
-
- @classmethod
- def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
- mat = obj.matrix_world
- inverted = mat.inverted()
- local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
- global_bbox_center = mat @ local_bbox_center
- global_obj_origin = global_bbox_center
- global_obj_origin.z = 0
-
- oldLoc = obj.location
- newLoc = global_obj_origin
- diff = newLoc - oldLoc
- for vert in obj.data.vertices:
- aux_vector = mat @ vert.co
- aux_vector = aux_vector - diff
- vert.co = inverted @ aux_vector
- obj.location = newLoc
+ :param h: The height in SI (meters).
+ """
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
+ cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
@classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
@@ -1145,65 +1138,53 @@ class Spatial(bonsai.core.tool.Spatial):
if z != 0:
obj.location = obj.location + Vector((0, 0, z))
- @classmethod
- def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]:
- points = []
- vectors = [v.co for v in obj.data.vertices.values()]
- for vector in vectors:
- points.append(vector.xy)
-
- points.append(vectors[0].xy)
- return points
-
- @classmethod
- def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]:
- model = tool.Ifc.get()
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model)
- _points = []
- for p in points:
- _p = list(p)
- _p[0] /= unit_scale
- _p[1] /= unit_scale
- _points.append(_p)
- return _points
-
- @classmethod
- def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None:
- body = cls.get_body_representation(obj)
- model = tool.Ifc.get()
- extrusion = tool.Model.get_extrusion(body)
- area = extrusion.SweptArea
- old_area = area.OuterCurve
-
- builder = ifcopenshell.util.shape_builder.ShapeBuilder(model)
- outer_curve = builder.polyline(vertices, closed=True)
-
- area.OuterCurve = outer_curve
- ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area)
-
- @classmethod
- def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
- element = tool.Ifc.get_entity(obj)
- return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
-
@classmethod
def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None:
- bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace")
+ bonsai.core.root.assign_class(
+ tool.Ifc,
+ tool.Collector,
+ tool.Root,
+ obj=obj,
+ ifc_class="IfcSpace",
+ should_add_representation=False,
+ )
@classmethod
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
- # TODO this code looks in the wrong spot and suspicious
props = tool.Model.get_model_props()
ifc_file = tool.Ifc.get()
relating_type_id = props.relating_type_id
- relating_type = tool.Ifc.get().by_id(int(relating_type_id))
+ relating_type = ifc_file.by_id(int(relating_type_id))
ifc_class = relating_type.is_a()
instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0]
- bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class)
+ bonsai.core.root.assign_class(
+ tool.Ifc,
+ tool.Collector,
+ tool.Root,
+ obj=obj,
+ ifc_class=instance_class,
+ should_add_representation=False,
+ )
element = tool.Ifc.get_entity(obj)
assert element
ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type)
+ @classmethod
+ def set_covering_representation_from_polygon(
+ cls,
+ obj: bpy.types.Object,
+ poly: Polygon,
+ polygon_is_si: bool = True,
+ ) -> None:
+ """Create the covering body representation from a polygon, extruded by the type's material layer thickness."""
+ element = tool.Ifc.get_entity(obj)
+ relating_type = ifcopenshell.util.element.get_type(element)
+ material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True)
+ depth = 0.0
+ if material and material.is_a("IfcMaterialLayerSet"):
+ depth = sum(layer.LayerThickness for layer in material.MaterialLayers)
+ cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si)
+
@classmethod
def assign_relating_type_to_element(
cls,
@@ -1214,14 +1195,6 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
- @classmethod
- def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None:
- bonsai.core.geometry.switch_representation(
- tool.Ifc,
- tool.Geometry,
- obj=obj,
- representation=body,
- )
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
diff --git a/src/bonsai/test/bim/feature/covering.feature b/src/bonsai/test/bim/feature/covering.feature
index 4eb97649da..cefa554d41 100644
--- a/src/bonsai/test/bim/feature/covering.feature
+++ b/src/bonsai/test/bim/feature/covering.feature
@@ -2,7 +2,7 @@
Feature: Covering
Covers covering tool.
-Scenario: Execute generate flooring coverings from walls
+Scenario: Add flooring from walls
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
@@ -24,9 +24,9 @@ Scenario: Execute generate flooring coverings from walls
And the cursor is at "0,2.0,0"
And I set "scene.BIMModelProperties.length" to "1.9"
And I press "bim.add_occurrence"
- # add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type.
+ # Set COV30 predefined type to FLOORING.
And the object "IfcCoveringType/COV30" is selected
- And I look at the "Attributes" panel
+ And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "FLOORING"
And I click "Save Attributes"
@@ -42,3 +42,108 @@ Scenario: Execute generate flooring coverings from walls
Then the object "IfcCovering/Covering0" exists
And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0"
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
+
+Scenario: Add ceiling from walls
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ And I press "bim.change_layer_length(length=3.6)"
+ And the cursor is at "3.6,0.1,3"
+ And I set "scene.BIMModelProperties.length" to "2.0"
+ And I press "bim.add_occurrence"
+ And the cursor is at "3.5,2.1,3"
+ And I set "scene.BIMModelProperties.length" to "3.5"
+ And I press "bim.add_occurrence"
+ And the cursor is at "0,2.0,0"
+ And I set "scene.BIMModelProperties.length" to "1.9"
+ And I press "bim.add_occurrence"
+ # Set COV30 predefined type to CEILING.
+ And the object "IfcCoveringType/COV30" is selected
+ And I look at the "Object Attributes" panel
+ And I click "Edit"
+ And I set the "PredefinedType" property to "CEILING"
+ And I click "Save Attributes"
+ # Run the operator with ceiling height = 2.7 (default).
+ When the object "IfcWall/Wall" is selected
+ And additionally the object "IfcWall/Wall.001" is selected
+ And additionally the object "IfcWall/Wall.002" is selected
+ And additionally the object "IfcWall/Wall.003" is selected
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_instance_ceiling_coverings_from_walls"
+ Then the object "IfcCovering/Covering0" exists
+ And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7"
+ And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
+
+Scenario: Add flooring from cursor
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the cursor is at "1.1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.001" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the cursor is at "0,.9,0"
+ And I press "bim.add_occurrence"
+ And the cursor is at "-1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.003" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the object "IfcWall/Wall.003" is moved to "0,0,0"
+ # Set COV30 predefined type to FLOORING.
+ And the object "IfcCoveringType/COV30" is selected
+ And I look at the "Object Attributes" panel
+ And I click "Edit"
+ And I set the "PredefinedType" property to "FLOORING"
+ And I click "Save Attributes"
+ # Generate covering from cursor inside the room.
+ When the cursor is at "0.5,0.5,0"
+ And I deselect all objects
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_instance_flooring_covering_from_cursor"
+ Then the object "IfcCovering/Covering" exists
+ And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
+
+Scenario: Add ceiling from cursor
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the cursor is at "1.1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.001" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the cursor is at "0,.9,0"
+ And I press "bim.add_occurrence"
+ And the cursor is at "-1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.003" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the object "IfcWall/Wall.003" is moved to "0,0,0"
+ # Set COV30 predefined type to CEILING.
+ And the object "IfcCoveringType/COV30" is selected
+ And I look at the "Object Attributes" panel
+ And I click "Edit"
+ And I set the "PredefinedType" property to "CEILING"
+ And I click "Save Attributes"
+ # Generate covering from cursor inside the room.
+ When the cursor is at "0.5,0.5,0"
+ And I deselect all objects
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_instance_ceiling_covering_from_cursor"
+ Then the object "IfcCovering/Covering" exists
+ And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature
index ad5b25bd07..064620a574 100644
--- a/src/bonsai/test/bim/feature/model.feature
+++ b/src/bonsai/test/bim/feature/model.feature
@@ -396,6 +396,44 @@ Scenario: Add a slab
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
+Scenario: Extend walls to underside
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the object "IfcSlab/Slab" is moved to "0,0,2.5"
+ When the object "IfcWall/Wall" is selected
+ And additionally the object "IfcSlab/Slab" is selected
+ And I look at the tool header
+ And I click "Extend To Underside"
+ Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
+
+Scenario: Extend walls to underside - extending to a tessellated gable roof
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ # Create gable roof: a cube turned into a prism with a ridge.
+ And I add a cube of size "1" at "0.5,0.05,3"
+ And the object "Cube" is selected
+ And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
+ And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
+ And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
+ And I press "bim.assign_class"
+ When the object "IfcWall/Wall" is selected
+ And additionally the object "IfcRoof/Cube" is selected
+ And I look at the tool header
+ And I click "Extend To Underside"
+ Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
+
Scenario: Enable editing a slab profile
Given an empty IFC project
And I load the demo construction library
diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py
index b7dc932024..79e8494441 100644
--- a/src/bonsai/test/bim/test_feature.py
+++ b/src/bonsai/test/bim/test_feature.py
@@ -133,7 +133,11 @@ class PanelSpy:
self.spied_labels.append(kwargs["text"])
return self
elif self.spied_attr == "prop":
- props, name = args
+ if args:
+ props, name = args
+ else:
+ props = kwargs.get("data")
+ name = kwargs.get("property")
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
@@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None:
panel_spy.refresh_spy()
+@given(parsers.parse("I look at the tool header"))
+@when(parsers.parse("I look at the tool header"))
+@then(parsers.parse("I look at the tool header"))
+def i_look_at_the_tool_header() -> None:
+ from bonsai.bim.module.model.workspace import EditObjectUI
+
+ class MockRegion:
+ type = "UI"
+
+ class MockContext:
+ def __getattr__(self, name):
+ if name == "region":
+ return MockRegion()
+ return getattr(bpy.context, name)
+
+ global panel_spy
+ panel_spy = PanelSpy(EditObjectUI)
+ panel_spy.is_spy_dirty = False
+ panel_spy.spied_attr = None
+ panel_spy.spied_labels = []
+ panel_spy.spied_props = []
+ panel_spy.spied_operators = []
+ panel_spy.spied_lists = []
+ EditObjectUI.draw(MockContext(), panel_spy)
+
+
@given(parsers.parse('I open the "{name}" menu'))
@when(parsers.parse('I open the "{name}" menu'))
@then(parsers.parse('I open the "{name}" menu'))
diff --git a/src/common.mk b/src/common.mk
index 24537d06de..765655339b 100644
--- a/src/common.mk
+++ b/src/common.mk
@@ -28,6 +28,7 @@ dist:
mkdir -p dist
cp -r $(PACKAGE_NAME) build/
cp pyproject.toml build/
+ if [ -f README.md ]; then cp README.md build/; fi
ifeq ($(IS_STABLE), TRUE)
$(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml
ifdef IS_MODULE
diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md
new file mode 100644
index 0000000000..19b1ec8e7a
--- /dev/null
+++ b/src/ifcedit/README.md
@@ -0,0 +1,305 @@
+
+# ifcedit
+
+A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
+shell commands. Functions are auto-discovered at runtime via introspection --
+no hardcoded list to maintain.
+
+## Installation
+
+```bash
+pip install ifcedit
+```
+
+Requires `ifcopenshell`.
+
+## Usage
+
+```
+ifcedit [options] [--format json|text]
+```
+
+Three subcommands: `list` to discover functions, `docs` to read their
+documentation, and `run` to execute them.
+
+## Subcommands
+
+### list
+
+Discover available API modules and their functions.
+
+**List all modules:**
+
+```bash
+ifcedit list
+```
+
+```json
+[
+ {
+ "module": "root",
+ "description": "Functions for creating project-level entities",
+ "functions": ["create_entity", "remove_product", "copy_class"],
+ "count": 3
+ },
+ {
+ "module": "spatial",
+ "description": "Functions for managing spatial relationships",
+ "functions": ["assign_container", "unassign_container"],
+ "count": 2
+ }
+]
+```
+
+**List functions in a module:**
+
+```bash
+ifcedit list root
+```
+
+```json
+[
+ {
+ "name": "create_entity",
+ "description": "Create an IFC entity with optional initial attributes",
+ "params": [
+ {"name": "ifc_class", "type": "str", "required": true},
+ {"name": "name", "type": "Optional[str]"}
+ ]
+ }
+]
+```
+
+### docs
+
+Show full documentation for a specific function, including parameter
+descriptions from docstrings and return type.
+
+```bash
+ifcedit docs root.create_entity
+```
+
+```json
+{
+ "module": "root",
+ "function": "create_entity",
+ "description": "Create an IFC entity with optional initial attributes",
+ "long_description": "This function creates a new entity instance...",
+ "params": [
+ {
+ "name": "ifc_class",
+ "type": "str",
+ "required": true,
+ "description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
+ },
+ {
+ "name": "name",
+ "type": "Optional[str]",
+ "description": "Optional name attribute"
+ }
+ ],
+ "return_type": "ifcopenshell.entity_instance",
+ "return_description": "The newly created entity instance"
+}
+```
+
+### run
+
+Execute an API function against an IFC file. Parameters are passed as
+`--key value` pairs after the function name.
+
+```bash
+ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
+```
+
+```json
+{
+ "ok": true,
+ "result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
+}
+```
+
+**Options:**
+
+- `-o, --output ` -- write to a different file instead of overwriting the input
+- `--dry-run` -- validate parameters without executing or saving
+
+```bash
+# Save to a new file
+ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
+
+# Validate without executing
+ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
+```
+
+Dry-run output shows the resolved parameters:
+
+```json
+{
+ "ok": true,
+ "dry_run": true,
+ "module": "root",
+ "function": "create_entity",
+ "args": {"ifc_class": "IfcWall", "name": "My Wall"}
+}
+```
+
+## Parameter type coercion
+
+CLI strings are automatically converted to the types expected by each API
+function, using the function's type annotations:
+
+| Type | CLI input | Python value |
+|------|-----------|--------------|
+| `str` | `"hello"` | `"hello"` |
+| `int` | `"42"` or `"#42"` | `42` |
+| `float` | `"3.14"` | `3.14` |
+| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
+| `Optional[X]` | `"none"` | `None` |
+| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
+| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
+| `dict` | `'{"key": "val"}'` | parsed JSON object |
+| `Literal["A", "B"]` | `"A"` | validated against allowed values |
+
+## Examples
+
+```bash
+# Create a project
+ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
+
+# Assign an element to a storey
+ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
+
+# Assign multiple elements at once
+ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
+
+# Add a property set
+ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
+
+# Edit properties
+ifcedit run model.ifc pset.edit_pset --pset 15 \
+ --properties '{"IsExternal": true, "FireRating": "2HR"}'
+```
+
+### foreach
+
+Apply an API function to each element in a JSON array read from stdin.
+`{field}` placeholders in argument values are substituted with fields from
+each JSON object. The model is opened once and saved once regardless of how
+many elements are processed.
+
+```bash
+ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
+```
+
+```json
+{"ok": true, "count": 36, "errors": []}
+```
+
+Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`,
+`{type}`, and `{name}`:
+
+```bash
+ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
+ --product {id} --attributes '{"Name": "Door"}'
+```
+
+**Options:**
+
+- `-o, --output ` -- write to a different file instead of overwriting the input
+
+**Output:**
+
+- `count` -- number of elements successfully processed
+- `errors` -- list of per-element failures, each with `index`, `item`, and `error`; processing continues past errors
+
+```json
+{
+ "ok": false,
+ "count": 34,
+ "errors": [
+ {"index": 2, "item": {"id": 55, "type": "IfcWindow", "name": "W03"}, "error": "Entity #55 not found in model"}
+ ]
+}
+```
+
+Exit code is 1 if any element failed.
+
+### quantify
+
+Run quantity take-off (QTO) on an IFC file, computing physical measurements
+(volume, area, length, count, weight) and writing them back as
+`IfcElementQuantity` property sets. Uses `ifc5d` rules.
+
+**List available rules:**
+
+```bash
+ifcedit quantify list
+```
+
+```json
+[
+ {"name": "IFC4QtoBaseQuantities"},
+ {"name": "IFC4X3QtoBaseQuantities"}
+]
+```
+
+**Run QTO on a file:**
+
+```bash
+ifcedit quantify run model.ifc IFC4QtoBaseQuantities
+ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
+ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
+```
+
+```json
+{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
+```
+
+Options:
+
+- `--selector ` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
+- `-o, --output ` -- write to a different file instead of overwriting the input
+
+Note: `quantify run` writes geometry-based measurements and requires the
+IfcOpenShell C++ geometry bindings for elements with computed quantities.
+
+## Error handling
+
+Errors are reported in the JSON response:
+
+```json
+{
+ "ok": false,
+ "error": "Entity #999 not found in model"
+}
+```
+
+Exit code is 0 on success, 1 on error.
+
+## Relationship to ifcquery
+
+`ifcedit` and `ifcquery` are complementary tools:
+
+- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
+- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
+
+A typical workflow: inspect with `ifcquery`, look up the right API function
+with `ifcedit docs`, then apply changes with `ifcedit run`.
+
+The two tools also compose directly in shell scripts. Use `ifcquery --format ids`
+to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON
+into `ifcedit foreach` to apply an operation to every matching element:
+
+```bash
+# Aggregate — pass all IDs as a list parameter
+ifcedit run model.ifc spatial.unassign_container \
+ --products "$(ifcquery model.ifc --format ids select 'IfcWall')"
+
+# Fan-out — one operation per element, model opened and saved once
+ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
+```
+
+## License
+
+LGPLv3+ -- see the IfcOpenShell project license.
diff --git a/src/ifcedit/ifcedit/__init__.py b/src/ifcedit/ifcedit/__init__.py
new file mode 100644
index 0000000000..eac2eac799
--- /dev/null
+++ b/src/ifcedit/ifcedit/__init__.py
@@ -0,0 +1,20 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit 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.
+#
+# IfcEdit 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 IfcEdit. If not, see .
+
+__version__ = version = "0.0.0"
diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py
new file mode 100644
index 0000000000..1b14863e5b
--- /dev/null
+++ b/src/ifcedit/ifcedit/__main__.py
@@ -0,0 +1,265 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit 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.
+#
+# IfcEdit 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 IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+
+import ifcopenshell
+
+from ifcedit.discover import function_docs, list_functions, list_modules
+from ifcedit.quantify import list_rules, run_quantify
+from ifcedit.run import run_api
+from ifcedit.foreach import run_foreach
+
+
+def format_output(data, fmt: str) -> str:
+ if fmt == "json":
+ return json.dumps(data, indent=2, ensure_ascii=False)
+ elif fmt == "text":
+ return _format_text(data)
+ return json.dumps(data, indent=2, ensure_ascii=False)
+
+
+def _format_text(data, indent: int = 0) -> str:
+ prefix = " " * indent
+ lines = []
+ if isinstance(data, dict):
+ for key, value in data.items():
+ if isinstance(value, (dict, list)):
+ lines.append(f"{prefix}{key}:")
+ lines.append(_format_text(value, indent + 1))
+ else:
+ lines.append(f"{prefix}{key}: {value}")
+ elif isinstance(data, list):
+ for item in data:
+ if isinstance(item, dict):
+ lines.append(_format_text(item, indent))
+ lines.append("")
+ else:
+ lines.append(f"{prefix}- {item}")
+ else:
+ lines.append(f"{prefix}{data}")
+ return "\n".join(lines)
+
+
+def cmd_list(args):
+ if args.module:
+ try:
+ functions = list_functions(args.module)
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(format_output(functions, args.output_format))
+ else:
+ modules = list_modules()
+ print(format_output(modules, args.output_format))
+
+
+def cmd_docs(args):
+ parts = args.function_path.split(".")
+ if len(parts) != 2:
+ print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
+ sys.exit(1)
+ module, function = parts
+ try:
+ docs = function_docs(module, function)
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(format_output(docs, args.output_format))
+
+
+def cmd_run(args, extra_args):
+ try:
+ model = ifcopenshell.open(args.ifc_file)
+ except Exception as e:
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ parts = args.function_path.split(".")
+ if len(parts) != 2:
+ print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
+ sys.exit(1)
+ module, function = parts
+
+ # Parse extra --key value arguments into a dict
+ raw_kwargs = _parse_extra_args(extra_args)
+
+ if args.dry_run:
+ result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
+ else:
+ result = run_api(model, module, function, raw_kwargs)
+
+ if result["ok"]:
+ output_path = args.output or args.ifc_file
+ model.write(output_path)
+
+ print(format_output(result, args.output_format))
+ if not result["ok"]:
+ sys.exit(1)
+
+
+def _parse_extra_args(extra: list[str]) -> dict[str, str]:
+ """Parse a list of ['--key', 'value', ...] into a dict."""
+ kwargs = {}
+ i = 0
+ while i < len(extra):
+ arg = extra[i]
+ if arg.startswith("--"):
+ key = arg[2:]
+ if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
+ kwargs[key] = extra[i + 1]
+ i += 2
+ else:
+ # Flag without value — treat as "true"
+ kwargs[key] = "true"
+ i += 1
+ else:
+ print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
+ sys.exit(1)
+ return kwargs
+
+
+def cmd_foreach(args, extra_args):
+ try:
+ model = ifcopenshell.open(args.ifc_file)
+ except Exception as e:
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ parts = args.function_path.split(".")
+ if len(parts) != 2:
+ print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
+ sys.exit(1)
+ module, function = parts
+
+ raw_kwargs_template = _parse_extra_args(extra_args)
+
+ try:
+ stdin_data = json.load(sys.stdin)
+ except json.JSONDecodeError as e:
+ print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if not isinstance(stdin_data, list):
+ print("Error: stdin must be a JSON array", file=sys.stderr)
+ sys.exit(1)
+
+ result = run_foreach(model, module, function, raw_kwargs_template, stdin_data)
+
+ if result["ok"]:
+ output_path = args.output or args.ifc_file
+ model.write(output_path)
+
+ print(format_output(result, args.output_format))
+ if not result["ok"]:
+ sys.exit(1)
+
+
+def cmd_quantify(args, extra_args):
+ if args.quantify_command == "list":
+ result = list_rules()
+ print(format_output(result, args.output_format))
+ elif args.quantify_command == "run":
+ try:
+ model = ifcopenshell.open(args.ifc_file)
+ except Exception as e:
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
+ sys.exit(1)
+ selector = args.selector or None
+ result = run_quantify(model, args.rule_name, selector=selector)
+ if result["ok"]:
+ output_path = args.output or args.ifc_file
+ model.write(output_path)
+ print(format_output(result, args.output_format))
+ if not result["ok"]:
+ sys.exit(1)
+ else:
+ print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
+ sys.exit(1)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ prog="ifcedit",
+ description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
+ )
+ parser.add_argument(
+ "--format",
+ choices=["json", "text"],
+ default="json",
+ dest="output_format",
+ help="Output format (default: json)",
+ )
+
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ # list
+ list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
+ list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
+
+ # docs
+ docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
+ docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
+
+ # run
+ run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
+ run_parser.add_argument("ifc_file", help="Path to the IFC file")
+ run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
+ run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
+ run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
+
+ # foreach
+ foreach_parser = subparsers.add_parser(
+ "foreach",
+ help="Apply an API function to each element in a JSON array read from stdin",
+ )
+ foreach_parser.add_argument("ifc_file", help="Path to the IFC file")
+ foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)")
+ foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
+
+ # quantify
+ quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
+ quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
+ quantify_sub.add_parser("list", help="List available QTO rule names")
+ qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
+ qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
+ qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
+ qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
+ qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
+
+ args, extra = parser.parse_known_args()
+
+ if args.command == "list":
+ cmd_list(args)
+ elif args.command == "docs":
+ cmd_docs(args)
+ elif args.command == "run":
+ cmd_run(args, extra)
+ elif args.command == "foreach":
+ cmd_foreach(args, extra)
+ elif args.command == "quantify":
+ cmd_quantify(args, extra)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py
new file mode 100644
index 0000000000..5a410ec186
--- /dev/null
+++ b/src/ifcedit/ifcedit/coerce.py
@@ -0,0 +1,180 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit 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.
+#
+# IfcEdit 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 IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import json
+import typing
+
+import ifcopenshell
+
+
+def coerce_value(
+ value_str: str,
+ type_hint,
+ model: ifcopenshell.file | None = None,
+ lookup_file: ifcopenshell.file | None = None,
+):
+ """Convert a CLI string argument to the proper Python type based on a type hint.
+
+ Args:
+ value_str: The raw string from the CLI.
+ type_hint: The type annotation from the function signature.
+ model: The main open IFC model, needed to resolve entity instance references by ID.
+ lookup_file: Override file for entity resolution (e.g. a library file for
+ project.append_asset). When provided, entity IDs are looked up here instead
+ of in model.
+
+ Returns:
+ The converted Python value.
+
+ Raises:
+ ValueError: If the value cannot be converted.
+ TypeError: If the type hint is not supported.
+ """
+ # When a library file has been opened, entity IDs are resolved from it, not the main model.
+ effective_lookup = lookup_file if lookup_file is not None else model
+
+ if type_hint is None:
+ return value_str
+
+ origin = typing.get_origin(type_hint)
+ args = typing.get_args(type_hint)
+
+ # Union / Optional
+ if origin is typing.Union:
+ non_none_types = [a for a in args if a is not type(None)]
+ if value_str.lower() == "none":
+ if type(None) in args:
+ return None
+ # Try each non-None type in order
+ for t in non_none_types:
+ try:
+ return coerce_value(value_str, t, model, lookup_file)
+ except (ValueError, TypeError):
+ continue
+ raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
+
+ # Literal
+ if origin is typing.Literal:
+ allowed = args
+ if value_str in [str(a) for a in allowed]:
+ # return the actual literal value with proper type
+ for a in allowed:
+ if str(a) == value_str:
+ return a
+ raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
+
+ # list types
+ if origin is list:
+ if args and _is_entity_type(args[0]):
+ return _coerce_entity_list(value_str, effective_lookup)
+ if args:
+ items = _split_list(value_str)
+ return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
+ return _split_list(value_str)
+
+ # dict types
+ if origin is dict:
+ return _floatify_numeric_lists(json.loads(value_str))
+
+ # Simple types
+ if type_hint is str:
+ return value_str
+ if type_hint is int:
+ return int(value_str.lstrip("#"))
+ if type_hint is float:
+ return float(value_str)
+ if type_hint is bool:
+ return value_str.lower() in ("true", "1", "yes")
+
+ # ifcopenshell.file — open from path string
+ if type_hint is ifcopenshell.file:
+ return ifcopenshell.open(value_str)
+
+ # entity_instance
+ if _is_entity_type(type_hint):
+ return _coerce_entity(value_str, effective_lookup)
+
+ # Fallback: try json.loads for complex types, then plain string
+ try:
+ return json.loads(value_str)
+ except (json.JSONDecodeError, TypeError):
+ return value_str
+
+
+def _is_entity_type(hint) -> bool:
+ """Check if a type hint refers to ifcopenshell.entity_instance."""
+ if hint is ifcopenshell.entity_instance:
+ return True
+ if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
+ return True
+ return False
+
+
+def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
+ """Resolve a step ID string like '123' or '#123' to an entity instance."""
+ if lookup_file is None:
+ raise ValueError("Cannot resolve entity reference without an IFC model")
+ if isinstance(value_str, int):
+ entity_id = value_str
+ else:
+ entity_id = int(value_str.strip().lstrip("#"))
+ try:
+ return lookup_file.by_id(entity_id)
+ except RuntimeError:
+ raise ValueError(f"Entity #{entity_id} not found in model")
+
+
+def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]:
+ """Resolve a comma-separated list of step IDs to entity instances."""
+ items = _split_list(value_str)
+ return [_coerce_entity(item.strip(), lookup_file) for item in items]
+
+
+def _floatify_numeric_lists(obj):
+ """Recursively convert lists of numbers to lists of floats.
+
+ IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
+ attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
+ for whole numbers like 0, which causes a TypeError at the binding level.
+ """
+ if isinstance(obj, dict):
+ return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
+ if (
+ isinstance(obj, list)
+ and obj
+ and all(isinstance(v, (int, float)) for v in obj)
+ and any(isinstance(v, float) for v in obj)
+ ):
+ return [float(v) for v in obj]
+ return obj
+
+
+def _split_list(value_str: str) -> list[str]:
+ """Split a comma-separated string, handling JSON arrays too."""
+ value_str = value_str.strip()
+ if value_str.startswith("["):
+ try:
+ parsed = json.loads(value_str)
+ if isinstance(parsed, list):
+ return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
+ except json.JSONDecodeError:
+ pass
+ return [item.strip() for item in value_str.split(",") if item.strip()]
diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py
new file mode 100644
index 0000000000..b26a93f6c6
--- /dev/null
+++ b/src/ifcedit/ifcedit/discover.py
@@ -0,0 +1,282 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit 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.
+#
+# IfcEdit 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 IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import re
+import typing
+from pathlib import Path
+
+
+def _api_package_path() -> Path:
+ """Return the filesystem path to the ifcopenshell.api package."""
+ import ifcopenshell.api
+
+ return Path(ifcopenshell.api.__file__).parent
+
+
+def list_modules() -> list[dict]:
+ """List all API modules with their function counts and descriptions.
+
+ Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
+ """
+ api_path = _api_package_path()
+ modules = []
+ for child in sorted(api_path.iterdir()):
+ if not child.is_dir() or child.name.startswith("_"):
+ continue
+ init_file = child / "__init__.py"
+ if not init_file.exists():
+ continue
+ try:
+ mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
+ except Exception:
+ continue
+ all_names = getattr(mod, "__all__", [])
+ if not all_names:
+ continue
+ description = ""
+ if mod.__doc__:
+ description = mod.__doc__.strip().split("\n")[0]
+ modules.append(
+ {
+ "module": child.name,
+ "description": description,
+ "functions": list(all_names),
+ "count": len(all_names),
+ }
+ )
+ return modules
+
+
+def list_functions(module: str) -> list[dict]:
+ """List functions in an API module with one-line descriptions and parameter info.
+
+ Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
+ """
+ mod = importlib.import_module(f"ifcopenshell.api.{module}")
+ all_names = getattr(mod, "__all__", [])
+ functions = []
+ for name in all_names:
+ fn = _get_underlying_function(module, name)
+ if fn is None:
+ continue
+ description = ""
+ if fn.__doc__:
+ description = fn.__doc__.strip().split("\n")[0]
+ params = _extract_params(fn)
+ functions.append(
+ {
+ "name": name,
+ "description": description,
+ "params": params,
+ }
+ )
+ return functions
+
+
+def function_docs(module: str, function: str) -> dict:
+ """Full documentation for a single API function.
+
+ Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
+ """
+ fn = _get_underlying_function(module, function)
+ if fn is None:
+ raise ValueError(f"Function '{module}.{function}' not found")
+
+ description = ""
+ long_description = ""
+ if fn.__doc__:
+ description, long_description = _parse_docstring_body(fn.__doc__)
+
+ params = _extract_params(fn)
+ param_descriptions = _parse_param_docs(fn.__doc__ or "")
+ for param in params:
+ if param["name"] in param_descriptions:
+ param["description"] = param_descriptions[param["name"]]
+
+ return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
+ return_description = _parse_return_doc(fn.__doc__ or "")
+
+ result = {
+ "module": module,
+ "function": function,
+ "description": description,
+ "long_description": long_description,
+ "params": params,
+ }
+ if return_type:
+ result["return_type"] = return_type
+ if return_description:
+ result["return_description"] = return_description
+ return result
+
+
+def _get_underlying_function(module: str, function: str):
+ """Get the actual function object (unwrapping the listener wrapper if needed)."""
+ try:
+ fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
+ fn = getattr(fn_module, function, None)
+ return fn
+ except (ImportError, AttributeError):
+ return None
+
+
+def _extract_params(fn) -> list[dict]:
+ """Extract parameter info from a function's signature and type hints."""
+ sig = inspect.signature(fn)
+ try:
+ hints = typing.get_type_hints(fn)
+ except Exception:
+ hints = {}
+
+ params = []
+ for name, param in sig.parameters.items():
+ if name == "file" or name == "self":
+ continue
+ info = {"name": name}
+ if name in hints:
+ info["type"] = _format_type_hint(hints[name])
+ if param.default is not inspect.Parameter.empty:
+ info["default"] = _serialize_default(param.default)
+ else:
+ info["required"] = True
+ params.append(info)
+ return params
+
+
+def _format_type_hint(hint) -> str | None:
+ """Format a type hint to a readable string."""
+ import ifcopenshell
+
+ if hint is None:
+ return None
+ if hint is type(None):
+ return "None"
+ # ifcopenshell.file params are passed as a file path string
+ if hint is ifcopenshell.file:
+ return "file_path"
+ origin = typing.get_origin(hint)
+ args = typing.get_args(hint)
+
+ # Union (including Optional)
+ if origin is typing.Union:
+ formatted = [_format_type_hint(a) for a in args]
+ # Optional[X] is Union[X, None] — render as "Optional[X]"
+ if len(formatted) == 2 and "None" in formatted:
+ inner = next(f for f in formatted if f != "None")
+ return f"Optional[{inner}]"
+ return " | ".join(formatted)
+
+ # Literal
+ if origin is typing.Literal:
+ values = ", ".join(repr(a) for a in args)
+ return f"Literal[{values}]"
+
+ # Generic types (list, dict, etc.)
+ if origin is not None:
+ origin_name = getattr(origin, "__name__", str(origin))
+ if args:
+ inner = ", ".join(_format_type_hint(a) for a in args)
+ return f"{origin_name}[{inner}]"
+ return origin_name
+
+ # Simple types
+ return getattr(hint, "__name__", str(hint))
+
+
+def _serialize_default(value):
+ """Serialize a default value to something JSON-friendly."""
+ if value is None:
+ return None
+ if isinstance(value, (str, int, float, bool)):
+ return value
+ return repr(value)
+
+
+def _parse_docstring_body(docstring: str) -> tuple[str, str]:
+ """Parse the summary and long description from a docstring."""
+ lines = docstring.strip().split("\n")
+ summary = lines[0].strip() if lines else ""
+ body_lines = []
+ in_body = False
+ for line in lines[1:]:
+ stripped = line.strip()
+ if stripped.startswith(":param") or stripped.startswith(":return"):
+ break
+ if stripped.startswith("Example"):
+ break
+ if not in_body and not stripped:
+ in_body = True
+ continue
+ if in_body:
+ body_lines.append(stripped)
+
+ long_description = " ".join(body_lines).strip()
+ # collapse multiple spaces
+ long_description = re.sub(r"\s+", " ", long_description)
+ return summary, long_description
+
+
+_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
+
+
+def _parse_param_docs(docstring: str) -> dict[str, str]:
+ """Extract :param name: description lines from a docstring."""
+ params = {}
+ current_param = None
+ current_lines = []
+ for line in docstring.split("\n"):
+ stripped = line.strip()
+ match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
+ if match:
+ if current_param:
+ params[current_param] = " ".join(current_lines).strip()
+ current_param = match.group(1)
+ current_lines = [match.group(2)]
+ elif current_param and stripped and not _FIELD_MARKER.match(stripped):
+ current_lines.append(stripped)
+ elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param):
+ if current_param:
+ params[current_param] = " ".join(current_lines).strip()
+ current_param = None
+ current_lines = []
+ if current_param:
+ params[current_param] = " ".join(current_lines).strip()
+ # collapse whitespace
+ return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
+
+
+def _parse_return_doc(docstring: str) -> str:
+ """Extract :return: description from a docstring."""
+ lines = []
+ in_return = False
+ for line in docstring.split("\n"):
+ stripped = line.strip()
+ match = re.match(r":return:\s*(.*)", stripped)
+ if match:
+ in_return = True
+ lines = [match.group(1)]
+ elif in_return:
+ if _FIELD_MARKER.match(stripped) or stripped == "":
+ break
+ lines.append(stripped)
+ return re.sub(r"\s+", " ", " ".join(lines).strip())
diff --git a/src/ifcedit/ifcedit/foreach.py b/src/ifcedit/ifcedit/foreach.py
new file mode 100644
index 0000000000..587d611c3f
--- /dev/null
+++ b/src/ifcedit/ifcedit/foreach.py
@@ -0,0 +1,76 @@
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit 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.
+#
+# IfcEdit 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 IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import ifcopenshell
+
+from ifcedit.run import run_api
+
+
+def _substitute(template: str, item: dict) -> str:
+ """Replace {key} placeholders in template with values from item."""
+ for key, value in item.items():
+ template = template.replace(f"{{{key}}}", str(value))
+ return template
+
+
+def run_foreach(
+ model: ifcopenshell.file,
+ module: str,
+ function: str,
+ raw_kwargs_template: dict[str, str],
+ items: list[dict],
+) -> dict:
+ """Apply an API function to each item in a list, substituting {field} placeholders.
+
+ Opens the model once, applies the mutation for every item, and returns a summary.
+ The caller is responsible for saving the model.
+
+ Args:
+ model: The open IFC model (mutated in place).
+ module: API module name (e.g. "root").
+ function: Function name (e.g. "remove_product").
+ raw_kwargs_template: Arg templates with {field} placeholders, e.g. {"product": "{id}"}.
+ items: List of dicts (e.g. from ifcquery select output).
+
+ Returns:
+ {"ok": True, "count": N, "errors": []} on full success,
+ {"ok": False, "count": N, "errors": [{...}]} if any item failed.
+ """
+ errors = []
+ count = 0
+
+ for i, item in enumerate(items):
+ if not isinstance(item, dict):
+ errors.append({"index": i, "item": item, "error": "item is not a dict"})
+ continue
+
+ substituted = {k: _substitute(v, item) for k, v in raw_kwargs_template.items()}
+ result = run_api(model, module, function, substituted)
+
+ if result["ok"]:
+ count += 1
+ else:
+ errors.append({"index": i, "item": item, "error": result["error"]})
+
+ return {
+ "ok": len(errors) == 0,
+ "count": count,
+ "errors": errors,
+ }
diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py
new file mode 100644
index 0000000000..f85475e22c
--- /dev/null
+++ b/src/ifcedit/ifcedit/quantify.py
@@ -0,0 +1,37 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+
+AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
+
+
+def list_rules() -> list[dict[str, str]]:
+ """Return a list of available quantification rule names."""
+ return [{"name": name} for name in AVAILABLE_RULES]
+
+
+def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
+ """Run quantity take-off on the model using the named rule.
+
+ Modifies the model in-place by adding/updating IfcElementQuantity psets.
+ Returns a summary dict with ok, rule, and elements_quantified.
+ """
+ from ifc5d.qto import edit_qtos, quantify
+ from ifc5d.qto import rules as rule_sets
+
+ if rule not in rule_sets:
+ return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
+
+ import ifcopenshell.util.selector
+
+ if selector:
+ elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
+ else:
+ elements = set(model.by_type("IfcElement"))
+
+ results = quantify(model, elements, rule_sets[rule])
+ edit_qtos(model, results)
+ return {"ok": True, "rule": rule, "elements_quantified": len(results)}
diff --git a/src/ifcedit/ifcedit/run.py b/src/ifcedit/ifcedit/run.py
new file mode 100644
index 0000000000..91d8be6774
--- /dev/null
+++ b/src/ifcedit/ifcedit/run.py
@@ -0,0 +1,148 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit 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.
+#
+# IfcEdit 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 IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import typing
+
+import ifcopenshell
+
+from ifcedit.coerce import coerce_value
+
+
+def _is_file_type(hint) -> bool:
+ """Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file])."""
+ if hint is ifcopenshell.file:
+ return True
+ origin = typing.get_origin(hint)
+ args = typing.get_args(hint)
+ if origin is typing.Union and ifcopenshell.file in args:
+ return True
+ return False
+
+
+def run_api(
+ model: ifcopenshell.file,
+ module: str,
+ function: str,
+ raw_kwargs: dict[str, str],
+) -> dict:
+ """Execute an ifcopenshell.api function with CLI-provided string arguments.
+
+ Args:
+ model: The open IFC model.
+ module: API module name (e.g. "root").
+ function: Function name (e.g. "create_entity").
+ raw_kwargs: String keyword arguments from the CLI.
+
+ Returns:
+ A dict with {"ok": True, "result": ...} on success,
+ or {"ok": False, "error": "..."} on failure.
+ """
+ try:
+ fn = _import_function(module, function)
+ except (ImportError, AttributeError) as e:
+ return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
+
+ try:
+ hints = typing.get_type_hints(fn)
+ except Exception:
+ hints = {}
+
+ sig = inspect.signature(fn)
+ coerced_kwargs = {}
+
+ # Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset).
+ # The opened file is then used as the lookup file for entity resolution in pass 2.
+ opened_files: list[ifcopenshell.file] = []
+ for name, value_str in raw_kwargs.items():
+ if name not in sig.parameters:
+ return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
+ hint = hints.get(name)
+ if not _is_file_type(hint):
+ continue
+ try:
+ coerced = coerce_value(value_str, hint, model)
+ coerced_kwargs[name] = coerced
+ if isinstance(coerced, ifcopenshell.file):
+ opened_files.append(coerced)
+ except (ValueError, TypeError) as e:
+ return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
+
+ # Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened
+ # library file (if any), since you are always appending from another file, never
+ # from the current model.
+ lookup_file = opened_files[0] if opened_files else None
+ for name, value_str in raw_kwargs.items():
+ if name in coerced_kwargs:
+ continue
+ if name not in sig.parameters:
+ return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
+ hint = hints.get(name)
+ try:
+ coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file)
+ except (ValueError, TypeError) as e:
+ return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
+
+ # Determine if the function takes 'file' as its first parameter
+ first_param = next(iter(sig.parameters), None)
+ try:
+ if first_param == "file":
+ result = fn(model, **coerced_kwargs)
+ else:
+ result = fn(**coerced_kwargs)
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return {"ok": True, "result": serialize_result(result)}
+
+
+def _import_function(module: str, function: str):
+ """Import and return the underlying function from ifcopenshell.api."""
+ fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
+ fn = getattr(fn_module, function)
+ return fn
+
+
+def serialize_result(value) -> object:
+ """Serialize an API result to a JSON-friendly structure."""
+ if value is None:
+ return None
+ if isinstance(value, ifcopenshell.entity_instance):
+ return _serialize_entity(value)
+ if isinstance(value, (list, tuple, set, frozenset)):
+ return [serialize_result(item) for item in value]
+ if isinstance(value, dict):
+ return {str(k): serialize_result(v) for k, v in value.items()}
+ if isinstance(value, (str, int, float, bool)):
+ return value
+ return str(value)
+
+
+def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
+ """Serialize an entity instance to a summary dict."""
+ result = {
+ "id": entity.id(),
+ "type": entity.is_a(),
+ }
+ if hasattr(entity, "Name") and entity.Name:
+ result["name"] = entity.Name
+ return result
diff --git a/src/ifcedit/pyproject.toml b/src/ifcedit/pyproject.toml
new file mode 100644
index 0000000000..f50c0b3cc4
--- /dev/null
+++ b/src/ifcedit/pyproject.toml
@@ -0,0 +1,33 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ifcedit"
+version = "0.0.0"
+authors = [
+ { name="Bruno Postle", email="bruno@postle.net" },
+]
+description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
+readme = "README.md"
+keywords = ["IFC", "BIM", "API"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
+]
+dependencies = ["ifcopenshell", "ifc5d"]
+
+[project.scripts]
+ifcedit = "ifcedit.__main__:main"
+
+[project.urls]
+Homepage = "http://ifcopenshell.org"
+Documentation = "https://docs.ifcopenshell.org"
+Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
+
+[tool.setuptools.packages.find]
+include = ["ifcedit*"]
+exclude = ["test*"]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
diff --git a/src/ifcedit/tests/__init__.py b/src/ifcedit/tests/__init__.py
new file mode 100644
index 0000000000..0a3bc271a0
--- /dev/null
+++ b/src/ifcedit/tests/__init__.py
@@ -0,0 +1 @@
+# This file was generated with the assistance of an AI coding tool.
diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py
new file mode 100644
index 0000000000..241220b145
--- /dev/null
+++ b/src/ifcedit/tests/conftest.py
@@ -0,0 +1,63 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.aggregate
+import ifcopenshell.api.material
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.pset
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+
+@pytest.fixture
+def model():
+ """Create an IFC4 model with a spatial hierarchy and a 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="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)
+
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
+ ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
+
+ return f
+
+
+@pytest.fixture
+def model_file(model, tmp_path):
+ """Write the model fixture to a temp file and return the path."""
+ path = tmp_path / "test.ifc"
+ model.write(str(path))
+ return str(path)
+
+
+@pytest.fixture
+def library():
+ """Create an IFC4 library with a single IfcWallType asset."""
+ lib = 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]
+ ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary")
+ ifcopenshell.api.unit.assign_unit(lib)
+ ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01")
+ return lib
+
+
+@pytest.fixture
+def library_file(library, tmp_path):
+ """Write the library fixture to a temp file and return the path."""
+ path = tmp_path / "library.ifc"
+ library.write(str(path))
+ return str(path)
diff --git a/src/ifcedit/tests/test_coerce.py b/src/ifcedit/tests/test_coerce.py
new file mode 100644
index 0000000000..4ee6895717
--- /dev/null
+++ b/src/ifcedit/tests/test_coerce.py
@@ -0,0 +1,158 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+from typing import Literal, Optional, Union
+
+import ifcopenshell
+import ifcopenshell.api.project
+import pytest
+
+from ifcedit.coerce import coerce_value
+
+
+class TestStringCoercion:
+ def test_plain_string(self):
+ assert coerce_value("hello", str) == "hello"
+
+ def test_empty_string(self):
+ assert coerce_value("", str) == ""
+
+
+class TestIntCoercion:
+ def test_plain_int(self):
+ assert coerce_value("42", int) == 42
+
+ def test_hash_prefix(self):
+ assert coerce_value("#42", int) == 42
+
+ def test_negative(self):
+ assert coerce_value("-5", int) == -5
+
+
+class TestFloatCoercion:
+ def test_plain_float(self):
+ assert coerce_value("3.14", float) == pytest.approx(3.14)
+
+ def test_integer_as_float(self):
+ assert coerce_value("5", float) == 5.0
+
+
+class TestBoolCoercion:
+ def test_true_values(self):
+ for val in ("true", "True", "TRUE", "1", "yes"):
+ assert coerce_value(val, bool) is True
+
+ def test_false_values(self):
+ for val in ("false", "False", "0", "no"):
+ assert coerce_value(val, bool) is False
+
+
+class TestOptionalCoercion:
+ def test_optional_string(self):
+ assert coerce_value("hello", Optional[str]) == "hello"
+
+ def test_optional_none(self):
+ assert coerce_value("none", Optional[str]) is None
+ assert coerce_value("None", Optional[str]) is None
+
+ def test_optional_int(self):
+ assert coerce_value("42", Optional[int]) == 42
+
+
+class TestUnionCoercion:
+ def test_union_str_int(self):
+ # Tries str first (or int first depending on order), both work
+ result = coerce_value("hello", Union[str, int])
+ assert result == "hello"
+
+ def test_union_int_none(self):
+ result = coerce_value("42", Union[int, None])
+ assert result == 42
+
+
+class TestLiteralCoercion:
+ def test_valid_literal(self):
+ assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
+
+ def test_invalid_literal(self):
+ with pytest.raises(ValueError, match="not one of"):
+ coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
+
+
+class TestDictCoercion:
+ def test_json_dict(self):
+ result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
+ assert result == {"IsExternal": True, "FireRating": "2HR"}
+
+ def test_mixed_float_int_list_coerced_to_float(self):
+ # [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
+ # so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
+ result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
+ assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
+ assert all(isinstance(v, float) for v in result["DirectionRatios"])
+
+ def test_pure_int_list_not_coerced(self):
+ # All-integer lists (e.g. face indices) must stay as ints
+ result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
+ assert result["CoordIndex"] == [0, 1, 2]
+ assert all(isinstance(v, int) for v in result["CoordIndex"])
+
+
+class TestListCoercion:
+ def test_comma_separated(self):
+ result = coerce_value("a,b,c", list[str])
+ assert result == ["a", "b", "c"]
+
+ def test_json_array(self):
+ result = coerce_value("[1, 2, 3]", list[int])
+ assert result == [1, 2, 3]
+
+
+class TestEntityCoercion:
+ def test_entity_by_id(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
+ assert result == wall
+
+ def test_entity_with_hash(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
+ assert result == wall
+
+ def test_entity_not_found(self, model):
+ with pytest.raises(ValueError, match="not found"):
+ coerce_value("999999", ifcopenshell.entity_instance, model)
+
+ def test_entity_list(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
+ assert len(result) == 1
+ assert result[0] == wall
+
+ def test_entity_list_multiple(self, model):
+ wall = model.by_type("IfcWall")[0]
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
+ assert len(result) == 2
+
+ def test_entity_no_model(self):
+ with pytest.raises(ValueError, match="without an IFC model"):
+ coerce_value("42", ifcopenshell.entity_instance, None)
+
+
+class TestFileCoercion:
+ def test_opens_file_from_path(self, model_file):
+ result = coerce_value(model_file, ifcopenshell.file)
+ assert isinstance(result, ifcopenshell.file)
+
+ def test_entity_from_lookup_file(self, model_file):
+ lib = ifcopenshell.open(model_file)
+ wall = lib.by_type("IfcWall")[0]
+ empty_model = ifcopenshell.api.project.create_file()
+ result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib)
+ assert result.id() == wall.id()
+ assert result.is_a("IfcWall")
+
+
+class TestFallback:
+ def test_no_type_hint(self):
+ assert coerce_value("hello", None) == "hello"
diff --git a/src/ifcedit/tests/test_discover.py b/src/ifcedit/tests/test_discover.py
new file mode 100644
index 0000000000..7b77cb5b7e
--- /dev/null
+++ b/src/ifcedit/tests/test_discover.py
@@ -0,0 +1,104 @@
+# This file was generated with the assistance of an AI coding tool.
+from ifcedit.discover import function_docs, list_functions, list_modules
+
+
+class TestListModules:
+ def test_returns_list(self):
+ result = list_modules()
+ assert isinstance(result, list)
+ assert len(result) > 0
+
+ def test_module_structure(self):
+ result = list_modules()
+ for entry in result:
+ assert "module" in entry
+ assert "description" in entry
+ assert "functions" in entry
+ assert "count" in entry
+ assert isinstance(entry["functions"], list)
+ assert entry["count"] == len(entry["functions"])
+
+ def test_known_modules_present(self):
+ result = list_modules()
+ module_names = [m["module"] for m in result]
+ for expected in ("root", "spatial", "pset", "aggregate", "unit"):
+ assert expected in module_names
+
+ def test_root_module_has_functions(self):
+ result = list_modules()
+ root = next(m for m in result if m["module"] == "root")
+ assert "create_entity" in root["functions"]
+ assert root["count"] >= 3
+
+
+class TestListFunctions:
+ def test_root_functions(self):
+ result = list_functions("root")
+ assert isinstance(result, list)
+ names = [f["name"] for f in result]
+ assert "create_entity" in names
+
+ def test_function_structure(self):
+ result = list_functions("root")
+ for fn in result:
+ assert "name" in fn
+ assert "description" in fn
+ assert "params" in fn
+
+ def test_create_entity_params(self):
+ result = list_functions("root")
+ create = next(f for f in result if f["name"] == "create_entity")
+ param_names = [p["name"] for p in create["params"]]
+ assert "ifc_class" in param_names
+ assert "name" in param_names
+
+ def test_pset_functions(self):
+ result = list_functions("pset")
+ names = [f["name"] for f in result]
+ assert "add_pset" in names
+ assert "edit_pset" in names
+
+
+class TestFunctionDocs:
+ def test_create_entity_docs(self):
+ result = function_docs("root", "create_entity")
+ assert result["module"] == "root"
+ assert result["function"] == "create_entity"
+ assert result["description"]
+ assert isinstance(result["params"], list)
+ assert len(result["params"]) > 0
+
+ def test_params_have_types(self):
+ result = function_docs("root", "create_entity")
+ for param in result["params"]:
+ assert "name" in param
+ assert "type" in param
+
+ def test_params_have_descriptions(self):
+ result = function_docs("root", "create_entity")
+ ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
+ assert "description" in ifc_class
+ assert len(ifc_class["description"]) > 0
+
+ def test_return_type(self):
+ result = function_docs("root", "create_entity")
+ assert "return_type" in result
+
+ def test_assign_container_docs(self):
+ result = function_docs("spatial", "assign_container")
+ assert result["module"] == "spatial"
+ param_names = [p["name"] for p in result["params"]]
+ assert "products" in param_names
+ assert "relating_structure" in param_names
+
+ def test_unknown_function_raises(self):
+ import pytest
+
+ with pytest.raises(ValueError, match="not found"):
+ function_docs("root", "nonexistent_function")
+
+ def test_edit_pset_docs(self):
+ result = function_docs("pset", "edit_pset")
+ param_names = [p["name"] for p in result["params"]]
+ assert "pset" in param_names
+ assert "properties" in param_names
diff --git a/src/ifcedit/tests/test_foreach.py b/src/ifcedit/tests/test_foreach.py
new file mode 100644
index 0000000000..e51c3c03be
--- /dev/null
+++ b/src/ifcedit/tests/test_foreach.py
@@ -0,0 +1,79 @@
+# Tests for ifcedit.foreach
+import ifcopenshell
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+from ifcedit.foreach import _substitute, run_foreach
+
+
+@pytest.fixture
+def model(model):
+ return model
+
+
+class TestSubstitute:
+ def test_single_field(self):
+ assert _substitute("--product {id}", {"id": 42}) == "--product 42"
+
+ def test_multiple_fields(self):
+ result = _substitute("{type} #{id} ({name})", {"id": 5, "type": "IfcWall", "name": "W1"})
+ assert result == "IfcWall #5 (W1)"
+
+ def test_no_placeholder(self):
+ assert _substitute("hello", {"id": 1}) == "hello"
+
+ def test_unknown_placeholder_unchanged(self):
+ assert _substitute("{unknown}", {"id": 1}) == "{unknown}"
+
+
+class TestRunForeach:
+ def _items(self, model, ifc_class):
+ return [{"id": e.id(), "type": e.is_a(), "name": e.Name} for e in model.by_type(ifc_class)]
+
+ def test_rename_single(self, model):
+ items = self._items(model, "IfcWall")
+ result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items)
+ assert result["ok"] is True
+ assert result["count"] == 1
+ assert result["errors"] == []
+ assert model.by_type("IfcWall")[0].Name == "R"
+
+ def test_rename_multiple(self, model):
+ items = self._items(model, "IfcElement")
+ result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items)
+ assert result["ok"] is True
+ assert result["count"] == len(items)
+
+ def test_empty_list(self, model):
+ result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, [])
+ assert result["ok"] is True
+ assert result["count"] == 0
+ assert result["errors"] == []
+
+ def test_bad_id_collects_error(self, model):
+ items = [{"id": 999999, "type": "IfcWall", "name": "X"}]
+ result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, items)
+ assert result["ok"] is False
+ assert result["count"] == 0
+ assert len(result["errors"]) == 1
+ assert result["errors"][0]["index"] == 0
+
+ def test_non_dict_item_collects_error(self, model):
+ result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, ["not_a_dict"])
+ assert result["ok"] is False
+ assert len(result["errors"]) == 1
+
+ def test_partial_failure_counts_successes(self, model):
+ wall_id = model.by_type("IfcWall")[0].id()
+ items = [
+ {"id": wall_id, "type": "IfcWall", "name": "W"},
+ {"id": 999999, "type": "IfcWall", "name": "Bad"},
+ ]
+ result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items)
+ assert result["ok"] is False
+ assert result["count"] == 1
+ assert len(result["errors"]) == 1
diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py
new file mode 100644
index 0000000000..63bf75a217
--- /dev/null
+++ b/src/ifcedit/tests/test_main.py
@@ -0,0 +1,181 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+import subprocess
+import sys
+
+import ifcopenshell
+import pytest
+
+
+def run_ifcedit(*args, stdin=None):
+ """Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcedit", *args],
+ capture_output=True,
+ text=True,
+ input=stdin,
+ )
+ return result.stdout, result.stderr, result.returncode
+
+
+class TestListCommand:
+ def test_list_all_modules(self):
+ stdout, stderr, rc = run_ifcedit("list")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert isinstance(data, list)
+ module_names = [m["module"] for m in data]
+ assert "root" in module_names
+ assert "spatial" in module_names
+
+ def test_list_module_functions(self):
+ stdout, stderr, rc = run_ifcedit("list", "root")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert isinstance(data, list)
+ names = [f["name"] for f in data]
+ assert "create_entity" in names
+
+ def test_list_text_format(self):
+ stdout, stderr, rc = run_ifcedit("--format", "text", "list")
+ assert rc == 0
+ assert "root" in stdout
+
+
+class TestDocsCommand:
+ def test_docs_create_entity(self):
+ stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["module"] == "root"
+ assert data["function"] == "create_entity"
+ assert "params" in data
+
+ def test_docs_invalid_path(self):
+ stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
+ assert rc != 0
+ assert "module.function" in stderr
+
+ def test_docs_unknown_function(self):
+ stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
+ assert rc != 0
+
+
+class TestRunCommand:
+ def test_create_entity(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["result"]["type"] == "IfcWall"
+ assert data["result"]["name"] == "CLIWall"
+
+ def test_dry_run(self, model_file):
+ stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["dry_run"] is True
+
+ def test_output_to_different_file(self, model_file, tmp_path):
+ output = str(tmp_path / "output.ifc")
+ stdout, stderr, rc = run_ifcedit(
+ "run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+
+ import os
+
+ assert os.path.exists(output)
+
+ def test_run_error_bad_function(self, model_file):
+ stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
+ assert rc != 0
+
+ def test_run_invalid_function_path(self, model_file):
+ stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
+ assert rc != 0
+ assert "module.function" in stderr
+
+
+class TestForeachCommand:
+ def _select_json(self, model, ifc_class):
+ """Build a JSON array like ifcquery select would produce."""
+ elements = model.by_type(ifc_class)
+ return json.dumps([{"id": e.id(), "type": e.is_a(), "name": getattr(e, "Name", None)} for e in elements])
+
+ def test_foreach_rename(self, model, model_file):
+ walls_json = self._select_json(model, "IfcWall")
+ stdout, stderr, rc = run_ifcedit(
+ "foreach", model_file, "attribute.edit_attributes",
+ "--product", "{id}", "--attributes", '{"Name": "Renamed"}',
+ stdin=walls_json,
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["count"] == 1
+ assert data["errors"] == []
+ updated = ifcopenshell.open(model_file)
+ assert updated.by_type("IfcWall")[0].Name == "Renamed"
+
+ def test_foreach_multiple_elements(self, model, model_file):
+ # Build a two-item list by selecting all IfcObject (includes spatial structure + elements)
+ elements_json = self._select_json(model, "IfcObject")
+ items = json.loads(elements_json)
+ assert len(items) >= 2
+ stdout, stderr, rc = run_ifcedit(
+ "foreach", model_file, "attribute.edit_attributes",
+ "--product", "{id}", "--attributes", '{"Name": "Bulk"}',
+ stdin=elements_json,
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["count"] == len(items)
+
+ def test_foreach_empty_list(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "foreach", model_file, "attribute.edit_attributes",
+ "--product", "{id}", "--attributes", '{"Name": "X"}',
+ stdin="[]",
+ )
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["count"] == 0
+
+ def test_foreach_invalid_json_stdin(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "foreach", model_file, "root.remove_product", "--product", "{id}",
+ stdin="not json",
+ )
+ assert rc != 0
+ assert "Error" in stderr
+
+ def test_foreach_not_array_stdin(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "foreach", model_file, "root.remove_product", "--product", "{id}",
+ stdin='{"id": 1}',
+ )
+ assert rc != 0
+ assert "Error" in stderr
+
+ def test_foreach_output_to_different_file(self, model, model_file, tmp_path):
+ import os
+ output = str(tmp_path / "out.ifc")
+ walls_json = self._select_json(model, "IfcWall")
+ stdout, stderr, rc = run_ifcedit(
+ "foreach", model_file, "attribute.edit_attributes",
+ "-o", output,
+ "--product", "{id}", "--attributes", '{"Name": "OutFile"}',
+ stdin=walls_json,
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ assert os.path.exists(output)
+ updated = ifcopenshell.open(output)
+ assert updated.by_type("IfcWall")[0].Name == "OutFile"
diff --git a/src/ifcedit/tests/test_quantify.py b/src/ifcedit/tests/test_quantify.py
new file mode 100644
index 0000000000..f4335fb2a7
--- /dev/null
+++ b/src/ifcedit/tests/test_quantify.py
@@ -0,0 +1,87 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+import ifcopenshell
+import ifcopenshell.api.aggregate
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify
+
+
+class TestListRules:
+ def test_returns_list(self):
+ result = list_rules()
+ assert isinstance(result, list)
+
+ def test_each_entry_has_name(self):
+ result = list_rules()
+ for entry in result:
+ assert "name" in entry
+
+ def test_ifc4_rule_present(self):
+ result = list_rules()
+ names = [r["name"] for r in result]
+ assert "IFC4QtoBaseQuantities" in names
+
+ def test_ifc4x3_rule_present(self):
+ result = list_rules()
+ names = [r["name"] for r in result]
+ assert "IFC4X3QtoBaseQuantities" in names
+
+
+@pytest.fixture
+def quantify_model():
+ """Create an IFC4 model with a wall element."""
+ 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)
+
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
+ ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
+
+ return f
+
+
+class TestRunQuantify:
+ def test_returns_ok_true(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
+ assert result["ok"] is True
+
+ def test_returns_rule_name(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
+ assert result["rule"] == "IFC4QtoBaseQuantities"
+
+ def test_returns_elements_quantified(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
+ assert "elements_quantified" in result
+ assert isinstance(result["elements_quantified"], int)
+
+ def test_unknown_rule_returns_error(self, quantify_model):
+ result = run_quantify(quantify_model, "NonExistentRule")
+ assert result["ok"] is False
+ assert "error" in result
+
+ def test_selector_restricts_elements(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall")
+ assert result["ok"] is True
+ assert result["rule"] == "IFC4QtoBaseQuantities"
+
+ def test_empty_selector_runs_on_all(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
+ assert result["ok"] is True
diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py
new file mode 100644
index 0000000000..80881e879f
--- /dev/null
+++ b/src/ifcedit/tests/test_run.py
@@ -0,0 +1,97 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.project
+import ifcopenshell.api.pset
+import ifcopenshell.api.root
+
+from ifcedit.run import run_api, serialize_result
+
+
+class TestRunApi:
+ def test_create_entity(self, model):
+ result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"})
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcWall"
+ assert result["result"]["name"] == "NewWall"
+ assert isinstance(result["result"]["id"], int)
+
+ def test_create_entity_default_class(self, model):
+ result = run_api(model, "root", "create_entity", {})
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcBuildingElementProxy"
+
+ def test_assign_container(self, model):
+ wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2")
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = run_api(
+ model,
+ "spatial",
+ "assign_container",
+ {"products": str(wall.id()), "relating_structure": str(storey.id())},
+ )
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcRelContainedInSpatialStructure"
+
+ def test_add_pset(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"})
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcPropertySet"
+
+ def test_unknown_function(self, model):
+ result = run_api(model, "root", "nonexistent", {})
+ assert result["ok"] is False
+ assert "Cannot find" in result["error"]
+
+ def test_unknown_parameter(self, model):
+ result = run_api(model, "root", "create_entity", {"bogus_param": "value"})
+ assert result["ok"] is False
+ assert "Unknown parameter" in result["error"]
+
+ def test_bad_entity_reference(self, model):
+ result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"})
+ assert result["ok"] is False
+ assert "not found" in result["error"]
+
+
+class TestAppendAsset:
+ def test_append_asset_from_library(self, model, library_file):
+ lib = ifcopenshell.open(library_file)
+ wall_type = lib.by_type("IfcWallType")[0]
+ result = run_api(
+ model,
+ "project",
+ "append_asset",
+ {"library": library_file, "element": str(wall_type.id())},
+ )
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcWallType"
+ assert model.by_type("IfcWallType"), "wall type should have been appended to the model"
+
+
+class TestSerializeResult:
+ def test_none(self):
+ assert serialize_result(None) is None
+
+ def test_string(self):
+ assert serialize_result("hello") == "hello"
+
+ def test_int(self):
+ assert serialize_result(42) == 42
+
+ def test_entity(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = serialize_result(wall)
+ assert result["id"] == wall.id()
+ assert result["type"] == "IfcWall"
+ assert result["name"] == "Wall001"
+
+ def test_list(self, model):
+ walls = model.by_type("IfcWall")
+ result = serialize_result(walls)
+ assert isinstance(result, list)
+ assert all(isinstance(r, dict) for r in result)
+
+ def test_dict(self):
+ result = serialize_result({"key": "value"})
+ assert result == {"key": "value"}
diff --git a/src/ifcmcp/README.md b/src/ifcmcp/README.md
new file mode 100644
index 0000000000..3867f7b79c
--- /dev/null
+++ b/src/ifcmcp/README.md
@@ -0,0 +1,401 @@
+
+# ifcmcp
+
+An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`,
+holding the IFC model in memory across tool calls for fast interactive editing
+sessions.
+
+## Installation
+
+```bash
+pip install ifcmcp
+```
+
+Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
+
+## Running the server
+
+```bash
+python3 -m ifcmcp
+```
+
+This starts the server on stdio transport, suitable for use with Claude Code
+or any MCP client.
+
+### Claude Code configuration
+
+Use the `claude mcp add` command:
+
+```bash
+claude mcp add --transport stdio ifc -- python3 -m ifcmcp
+```
+
+Or create a `.mcp.json` file in your project root:
+
+```json
+{
+ "mcpServers": {
+ "ifc": {
+ "type": "stdio",
+ "command": "python3",
+ "args": ["-m", "ifcmcp"]
+ }
+ }
+}
+```
+
+After adding the server, restart Claude Code for the tools to become available.
+Then load a model by asking Claude to use `ifc_load`:
+
+```
+load model.ifc using ifc_load
+```
+
+## Tools
+
+### Session
+
+#### ifc_new
+
+Create a new empty IFC model in memory, replacing any currently loaded model.
+
+```
+ifc_new()
+ifc_new(schema="IFC4X3")
+```
+
+Default schema is `IFC4`.
+
+#### ifc_load
+
+Open an IFC file into memory.
+
+```
+ifc_load(path="/path/to/model.ifc")
+-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
+```
+
+#### ifc_reset
+
+Unload the current model from memory, freeing all session state.
+
+```
+ifc_reset()
+```
+
+#### ifc_save
+
+Write the in-memory model to disk. Empty path overwrites the original file.
+
+```
+ifc_save()
+ifc_save(path="/path/to/output.ifc")
+```
+
+### Query tools
+
+All query tools require a model to be loaded first via `ifc_load`.
+
+#### ifc_summary
+
+Model overview: schema, entity counts, project info.
+
+```json
+{
+ "schema": "IFC4",
+ "total_entities": 1847,
+ "project": {"id": 1, "name": "Office Building"},
+ "types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36}
+}
+```
+
+#### ifc_tree
+
+Full spatial hierarchy from IfcProject down through sites, buildings, storeys,
+and contained elements.
+
+```json
+{
+ "id": 1,
+ "type": "IfcProject",
+ "name": "Office Building",
+ "children": [
+ {
+ "id": 2,
+ "type": "IfcSite",
+ "children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}]
+ }
+ ]
+}
+```
+
+#### ifc_info
+
+Deep inspection of an entity by step ID: attributes, property sets, type,
+material, container, and 4x4 placement matrix.
+
+```
+ifc_info(element_id=10)
+```
+
+#### ifc_select
+
+Filter elements using ifcopenshell selector syntax.
+
+```
+ifc_select(query="IfcWall")
+ifc_select(query="IfcWindow")
+```
+
+Returns a sorted list of `{"id", "type", "name"}` references.
+
+#### ifc_relations
+
+Show all relationships for an element: hierarchy, children, type, groups,
+systems, material, connections.
+
+```
+ifc_relations(element_id=10)
+ifc_relations(element_id=10, traverse="up")
+```
+
+With `traverse="up"`, walks the hierarchy from element up to IfcProject.
+
+#### ifc_contexts
+
+List all geometric representation contexts and subcontexts in the loaded model.
+
+```
+ifc_contexts()
+```
+
+#### ifc_materials
+
+List all materials and material sets in the loaded model, with their assigned elements.
+
+```
+ifc_materials()
+```
+
+#### ifc_clash
+
+Check an element for geometric intersections and clearance violations.
+
+```
+ifc_clash(element_id=10)
+ifc_clash(element_id=10, clearance=0.5, scope="all")
+```
+
+Parameters:
+
+- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check)
+- `tolerance` -- intersection tolerance in meters (default: 0.002)
+- `scope` -- `"storey"` or `"all"` (default: `"storey"`)
+
+#### ifc_validate
+
+Check the model for schema and constraint violations.
+
+```
+ifc_validate()
+ifc_validate(express_rules=True)
+```
+
+Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`.
+
+#### ifc_schedule
+
+List all work schedules and their nested task trees.
+
+```
+ifc_schedule()
+ifc_schedule(max_depth=1) # top-level phases only
+```
+
+`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced
+with `{"truncated": true, "count": N}` so you know children exist without
+fetching them all. Omit for unlimited depth.
+
+#### ifc_cost
+
+List all cost schedules and their nested cost item trees.
+
+```
+ifc_cost()
+ifc_cost(max_depth=2) # top two levels of the BoQ
+```
+
+`max_depth` limits cost item expansion, same truncation convention as
+`ifc_schedule`.
+
+#### ifc_schema
+
+Return IFC class documentation for any entity type, using the loaded model's
+schema version.
+
+```
+ifc_schema(entity_type="IfcWall")
+ifc_schema(entity_type="IfcBuildingStorey")
+```
+
+Returns description, predefined types, spec URL, and attribute descriptions.
+Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
+
+#### ifc_quantify
+
+Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule.
+Computes physical measurements (volume, area, length, count, weight) and
+writes them back as `IfcElementQuantity` property sets. Modifies the model
+in-place -- call `ifc_save()` when done.
+
+```
+ifc_quantify(rule="IFC4QtoBaseQuantities")
+ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
+```
+
+Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
+
+`selector` is an optional ifcopenshell selector to restrict which elements
+are quantified (default: all `IfcElement`).
+
+Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
+
+### Drawing and rendering tools
+
+#### ifc_plot
+
+Generate a 2D technical drawing of the loaded model and return it as an inline image.
+
+```
+ifc_plot()
+ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg")
+ifc_plot(element_ids=[10, 11], view="floorplan")
+```
+
+Parameters:
+
+- `selector` -- ifcopenshell selector to restrict plotted elements
+- `element_ids` -- step IDs of elements to highlight; others are faded
+- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"`
+- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420)
+- `scale` -- model-to-paper ratio (default: 0.01 = 1:100)
+- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024)
+- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG)
+
+Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`.
+
+#### ifc_render
+
+Render the loaded model to a 3D PNG image.
+
+```
+ifc_render()
+ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png")
+ifc_render(element_ids=[10, 11], view="south")
+```
+
+Parameters:
+
+- `selector` -- ifcopenshell selector to restrict rendered elements
+- `element_ids` -- step IDs of elements to highlight; others are shown translucent
+- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"`
+- `output_path` -- optional path to save the PNG to disk
+
+Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
+
+### Shape builder tools
+
+#### ifc_shape_list
+
+List all available `ShapeBuilder` methods with brief descriptions.
+
+```
+ifc_shape_list()
+```
+
+#### ifc_shape_docs
+
+Show full documentation for a specific `ShapeBuilder` method.
+
+```
+ifc_shape_docs(method="extrude")
+ifc_shape_docs(method="create_ellipse")
+```
+
+#### ifc_shape
+
+Execute a `ShapeBuilder` method on the loaded model.
+
+```
+ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}')
+```
+
+`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`).
+
+### Edit discovery tools
+
+#### ifc_list
+
+List all API modules, or functions within a specific module.
+
+```
+ifc_list() # all modules
+ifc_list(module="root") # functions in the root module
+```
+
+#### ifc_docs
+
+Show full documentation for an API function including parameters, types,
+defaults, and descriptions.
+
+```
+ifc_docs(function_path="root.create_entity")
+```
+
+### Edit execution
+
+#### ifc_edit
+
+Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
+JSON string with string values that get coerced by ifcedit's type system.
+
+```
+ifc_edit(
+ function_path="root.create_entity",
+ params='{"ifc_class": "IfcWall", "name": "My Wall"}'
+)
+```
+
+Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
+
+Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
+
+**Parameter coercion:**
+
+| Type | JSON value | Python value |
+|------|------------|--------------|
+| `entity_instance` | `"42"` | resolved from model by step ID |
+| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
+| `dict` | `'{"key": "val"}'` | parsed JSON object |
+| `bool` | `"true"` | `True` |
+| `Optional[X]` | `"none"` | `None` |
+
+## Typical workflow
+
+1. **Load** a model: `ifc_load`
+2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
+3. **Validate** if needed: `ifc_validate`
+4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
+5. **Look up IFC classes**: `ifc_schema`
+6. **Find** the right API function: `ifc_list`, `ifc_docs`
+7. **Edit** the model: `ifc_edit`
+8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
+9. **Verify** changes with query tools
+10. **Save** when satisfied: `ifc_save`
+
+The model stays in memory across all calls, so multi-step editing sessions
+are fast -- no file I/O between operations.
+
+## License
+
+LGPLv3+ -- see the IfcOpenShell project license.
diff --git a/src/ifcmcp/ifcmcp/__init__.py b/src/ifcmcp/ifcmcp/__init__.py
new file mode 100644
index 0000000000..92e1c230b6
--- /dev/null
+++ b/src/ifcmcp/ifcmcp/__init__.py
@@ -0,0 +1,20 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcMCP - MCP server for IFC building models
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcMCP.
+#
+# IfcMCP 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.
+#
+# IfcMCP 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 IfcMCP. If not, see .
+
+__version__ = version = "0.0.0"
diff --git a/src/ifcmcp/ifcmcp/__main__.py b/src/ifcmcp/ifcmcp/__main__.py
new file mode 100644
index 0000000000..47419dc209
--- /dev/null
+++ b/src/ifcmcp/ifcmcp/__main__.py
@@ -0,0 +1,48 @@
+# This file was generated with the assistance of an AI coding tool.
+import argparse
+
+from ifcmcp import __version__
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ prog="python3 -m ifcmcp",
+ description=(
+ "ifcmcp — MCP server for IFC building models.\n\n"
+ "Runs a Model Context Protocol server over stdio so that MCP clients\n"
+ "can query and edit IFC files without writing them to disk between\n"
+ "operations.\n\n"
+ "Add to .mcp.json to configure:\n"
+ ' {"mcpServers": {"ifc": {"type": "stdio", "command": "python3", "args": ["-m", "ifcmcp"]}}}'
+ ),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--version", action="version", version=f"ifcmcp {__version__}")
+ parser.add_argument(
+ "--transport",
+ choices=["stdio", "sse", "streamable-http"],
+ default="stdio",
+ help="MCP transport to use (default: stdio)",
+ )
+
+ args = parser.parse_args()
+
+ try:
+ from mcp.server.fastmcp import FastMCP # noqa: F401
+ except ImportError:
+ import sys
+ print(
+ "error: the 'mcp' package is required to run the server.\n"
+ "Install it with: pip install mcp",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ from ifcmcp.server import build_server
+
+ server = build_server()
+ server.run(transport=args.transport)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py
new file mode 100644
index 0000000000..ed9f91c3c9
--- /dev/null
+++ b/src/ifcmcp/ifcmcp/core.py
@@ -0,0 +1,731 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+# inside ifcmcp/core.py
+import json
+from collections.abc import Callable # noqa: F401 — Callable used in helpers below
+from dataclasses import dataclass
+from typing import Any
+
+import ifcopenshell
+from ifcedit.discover import function_docs, list_functions, list_modules
+from ifcedit.quantify import run_quantify
+from ifcedit.run import run_api
+from ifcquery import clash as clash_mod
+from ifcquery import contexts as contexts_mod
+from ifcquery import cost as cost_mod
+from ifcquery import (
+ info,
+ relations,
+ schedule,
+ schema,
+ select,
+ summary,
+ tree,
+)
+from ifcquery import (
+ materials as materials_mod,
+)
+from ifcquery import (
+ plot as plot_mod,
+)
+from ifcquery import (
+ render as render_mod,
+)
+from ifcquery import validate as validate_mod
+
+
+def _jsonify(x: Any) -> Any:
+ """Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
+ if x is None or isinstance(x, (str, int, float, bool)):
+ return x
+
+ # numpy arrays (and any array-like with tolist)
+ if hasattr(x, "tolist"):
+ return x.tolist()
+
+ # IfcOpenShell entity instances: normalize
+ if isinstance(x, ifcopenshell.entity_instance):
+ return {
+ "id": int(x.id()),
+ "type": x.is_a(),
+ "repr": str(x),
+ "name": getattr(x, "Name", None),
+ }
+
+ if isinstance(x, dict):
+ return {str(k): _jsonify(v) for k, v in x.items()}
+
+ if isinstance(x, (list, tuple, set)):
+ return [_jsonify(v) for v in x]
+
+ # Try JSON as-is, else fallback to string
+ try:
+ json.dumps(x)
+ return x
+ except Exception:
+ return str(x)
+
+
+# ---------------------------------------------------------------------------
+# Shape builder helpers
+# ---------------------------------------------------------------------------
+
+
+def _list_shape_methods() -> list[dict]:
+ """Introspect ShapeBuilder and return a summary of all public methods."""
+ import inspect
+
+ from ifcedit.discover import _extract_params
+ from ifcopenshell.util.shape_builder import ShapeBuilder
+
+ results = []
+ for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction):
+ if name.startswith("_"):
+ continue
+ doc = fn.__doc__ or ""
+ description = doc.strip().split("\n")[0] if doc.strip() else ""
+ results.append({"method": name, "description": description, "params": _extract_params(fn)})
+ return results
+
+
+def _shape_method_docs(method_name: str) -> dict:
+ """Return full documentation for a single ShapeBuilder method."""
+ import typing
+
+ from ifcedit.discover import (
+ _extract_params,
+ _format_type_hint,
+ _parse_docstring_body,
+ _parse_param_docs,
+ _parse_return_doc,
+ )
+ from ifcopenshell.util.shape_builder import ShapeBuilder
+
+ if method_name.startswith("_"):
+ raise ValueError(f"ShapeBuilder has no method '{method_name}'")
+ fn = getattr(ShapeBuilder, method_name, None)
+ if fn is None:
+ raise ValueError(f"ShapeBuilder has no method '{method_name}'")
+
+ doc = fn.__doc__ or ""
+ description, long_description = _parse_docstring_body(doc)
+ params = _extract_params(fn)
+ for param in params:
+ param_desc = _parse_param_docs(doc)
+ if param["name"] in param_desc:
+ param["description"] = param_desc[param["name"]]
+
+ try:
+ hints = typing.get_type_hints(fn)
+ except Exception:
+ hints = {}
+
+ result: dict[str, Any] = {
+ "method": method_name,
+ "description": description,
+ "long_description": long_description,
+ "params": params,
+ }
+ return_type = _format_type_hint(hints.get("return"))
+ if return_type:
+ result["return_type"] = return_type
+ return_description = _parse_return_doc(doc)
+ if return_description:
+ result["return_description"] = return_description
+ return result
+
+
+def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict:
+ """Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method."""
+ import inspect
+ import typing
+
+ sig = inspect.signature(fn)
+ try:
+ hints = typing.get_type_hints(fn)
+ except Exception:
+ hints = {}
+
+ return {
+ key: _coerce_shape_value(value, hints.get(key), model)
+ for key, value in raw_kwargs.items()
+ if key in sig.parameters and key != "self"
+ }
+
+
+def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any:
+ """Convert a single JSON-parsed value to the correct Python type."""
+ import typing
+
+ if hint is None or value is None:
+ return value
+
+ origin = typing.get_origin(hint)
+ args = typing.get_args(hint)
+
+ # Optional[X] / Union — try each non-None branch in order
+ if origin is typing.Union:
+ if value is None:
+ return None
+ for t in (a for a in args if a is not type(None)):
+ try:
+ return _coerce_shape_value(value, t, model)
+ except (ValueError, TypeError):
+ continue
+ return value
+
+ # entity_instance: resolve integer or "#N" string step ID
+ if hint is ifcopenshell.entity_instance or (
+ isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance)
+ ):
+ entity_id = int(str(value).lstrip("#"))
+ entity = model.by_id(entity_id)
+ if entity is None:
+ raise ValueError(f"Entity #{entity_id} not found in model")
+ return entity
+
+ # Sequence[entity_instance]: resolve each element in the list
+ import collections.abc
+
+ if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str):
+ if args and (
+ args[0] is ifcopenshell.entity_instance
+ or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance))
+ ):
+ if isinstance(value, (list, tuple)):
+ return [_coerce_shape_value(v, args[0], model) for v in value]
+
+ # bool: JSON gives actual bools; also accept string representations
+ if hint is bool:
+ if isinstance(value, bool):
+ return value
+ return str(value).lower() in ("true", "1", "yes")
+
+ # Everything else (float, int, VectorType lists, dicts, Literals) passes through
+ return value
+
+
+class IfcSessionError(RuntimeError):
+ pass
+
+
+@dataclass
+class IfcSession:
+ """In-memory IFC session (no FastMCP dependency).
+
+ Designed to work in:
+ - FastMCP server (single global session)
+ - Embedded runtimes like Pyodide (one session per browser tab/worker)
+ """
+
+ model: ifcopenshell.file | None = None
+ model_path: str | None = None
+
+ # -----------------
+ # Session lifecycle
+ # -----------------
+ def _require_model(self) -> ifcopenshell.file:
+ if self.model is None:
+ raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.")
+ return self.model
+
+ def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
+ """Create a new empty IFC model in memory."""
+ self.model = ifcopenshell.file(schema=schema)
+ self.model_path = None
+ return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
+
+ def ifc_load(self, path: str) -> str:
+ """Open an IFC file into memory. Returns confirmation string."""
+ self.model = ifcopenshell.open(path)
+ self.model_path = path
+ count = sum(1 for _ in self.model)
+ return f"Loaded {path}: schema {self.model.schema}, {count} entities"
+
+ def ifc_save(self, path: str = "") -> str:
+ """Write the in-memory model to disk. Empty path overwrites the original file."""
+ model = self._require_model()
+ target = path if path else self.model_path
+ if not target:
+ raise IfcSessionError("No path specified and no original path available.")
+ model.write(target)
+ return f"Saved to {target}"
+
+ def ifc_reset(self) -> dict[str, Any]:
+ """Drop the in-memory model."""
+ self.model = None
+ self.model_path = None
+ return {"ok": True}
+
+ # -------------
+ # Query tools
+ # -------------
+ def ifc_summary(self) -> dict[str, Any]:
+ """Model overview: schema, entity counts, project info."""
+ return summary.summary(self._require_model())
+
+ def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
+ """Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
+ return tree.tree(self._require_model())
+
+ def ifc_info(self, element_id: int) -> dict[str, Any]:
+ """Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
+ model = self._require_model()
+ element = model.by_id(element_id)
+ if element is None:
+ raise IfcSessionError(f"Element #{element_id} not found.")
+ return info.info(model, element)
+
+ def ifc_select(self, query: str) -> list[dict[str, Any]]:
+ """Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
+ return select.select(self._require_model(), query)
+
+ def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
+ """Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
+ model = self._require_model()
+ element = model.by_id(element_id)
+ if element is None:
+ raise IfcSessionError(f"Element #{element_id} not found.")
+ return relations.relations(model, element, traverse=traverse if traverse else None)
+
+ def ifc_clash(
+ self,
+ element_id: int,
+ clearance: float = 0.0,
+ tolerance: float = 0.002,
+ scope: str = "storey",
+ ) -> dict[str, Any]:
+ """Check element for geometric clashes. clearance=0.0 means no clearance check."""
+ model = self._require_model()
+ element = model.by_id(element_id)
+ if element is None:
+ raise IfcSessionError(f"Element #{element_id} not found.")
+ return clash_mod.clash(
+ model,
+ element,
+ clearance=clearance if clearance and clearance > 0.0 else None,
+ tolerance=tolerance,
+ scope=scope,
+ )
+
+ def ifc_contexts(self) -> list[dict[str, Any]]:
+ """List all geometric representation contexts and subcontexts with their step IDs."""
+ return contexts_mod.contexts(self._require_model())
+
+ def ifc_materials(self) -> list[dict[str, Any]]:
+ """List all materials and material sets (layers, constituents, profiles)."""
+ return materials_mod.materials(self._require_model())
+
+ # ------------------------
+ # Edit discovery + execute
+ # ------------------------
+ def ifc_list(self, module: str = "") -> list[dict]:
+ """List all API modules, or functions within a module. Empty module = all modules."""
+ return list_functions(module) if module else list_modules()
+
+ def ifc_docs(self, function_path: str) -> dict:
+ """Show full documentation for an API function. Input format: 'module.function'."""
+ module, function = function_path.split(".", 1)
+ return function_docs(module, function)
+
+ def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
+ """Execute an ifcopenshell.api mutation.
+
+ params may be:
+ - JSON string
+ - dict (from tool calling / JS)
+ - JsProxy (handled upstream in embedded.py)
+ """
+ model = self._require_model()
+ module, function = function_path.split(".", 1)
+
+ if isinstance(params, str):
+ raw_kwargs = json.loads(params) if params.strip() else {}
+ elif isinstance(params, dict):
+ raw_kwargs = params
+ else:
+ # e.g. list/None/etc
+ raw_kwargs = dict(params) if params is not None else {}
+
+ res = run_api(model, module, function, raw_kwargs)
+ return _jsonify(res)
+
+ # ------------------------
+ # Extended query + edit tools
+ # ------------------------
+ def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
+ """Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
+ return validate_mod.validate(self._require_model(), express_rules=express_rules)
+
+ def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
+ """List work schedules and nested tasks from the model.
+
+ max_depth limits subtask expansion (None = unlimited). At the cutoff,
+ subtasks is replaced with {"truncated": True, "count": N}.
+ """
+ return schedule.schedule(self._require_model(), max_depth=max_depth)
+
+ def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
+ """List cost schedules and nested cost items from the model.
+
+ max_depth limits cost item expansion (None = unlimited). At the cutoff,
+ subitems is replaced with {"truncated": True, "count": N}.
+ """
+ return cost_mod.cost(self._require_model(), max_depth=max_depth)
+
+ def ifc_schema(self, entity_type: str) -> dict[str, Any]:
+ """Return IFC class documentation for entity_type using the model's schema version."""
+ return schema.schema(self._require_model(), entity_type)
+
+ def ifc_plot(
+ self,
+ selector: str = "",
+ element_ids: list[int] | None = None,
+ view: str = "floorplan",
+ width_mm: float = 297.0,
+ height_mm: float = 420.0,
+ scale: float = 1.0 / 100.0,
+ png_width: int = 1024,
+ png_height: int = 1024,
+ output_format: str = "png",
+ ) -> bytes:
+ """Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes.
+
+ Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG
+ when output_format is 'png'.
+
+ :param selector: ifcopenshell selector to restrict plotted elements
+ (e.g. ``'IfcWall'``). Omit to plot the whole model.
+ :param element_ids: Step IDs of elements to highlight. Other elements
+ are faded to 10% opacity so the subject stands out.
+ :param view: Drawing view — ``floorplan`` (default), ``elevation``,
+ ``section``, or ``auto``.
+ :param width_mm: Paper width in mm (default 297 = A4).
+ :param height_mm: Paper height in mm (default 420 = A4).
+ :param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
+ :param png_width: Raster output width in pixels (default 1024).
+ :param png_height: Raster output height in pixels (default 1024).
+ :param output_format: ``'svg'`` or ``'png'`` (default ``'png'``).
+ :return: SVG or PNG bytes depending on output_format.
+ """
+ model = self._require_model()
+ return plot_mod.plot(
+ model,
+ output_format=output_format,
+ selector=selector if selector else None,
+ element_ids=element_ids,
+ view=view,
+ width_mm=width_mm,
+ height_mm=height_mm,
+ scale=scale,
+ png_width=png_width,
+ png_height=png_height,
+ )
+
+ def ifc_render(
+ self,
+ selector: str = "",
+ element_ids: list[int] | None = None,
+ view: str = "iso",
+ ) -> bytes:
+ """Render the loaded model to a PNG image and return raw bytes.
+
+ :param selector: ifcopenshell selector to restrict rendered elements
+ (e.g. ``'IfcWall'``). Omit to render the whole model.
+ :param element_ids: Step IDs of elements to highlight. Other elements
+ are rendered in translucent grey.
+ :param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
+ ``east``, or ``west``.
+ :return: PNG image as raw bytes.
+ """
+ model = self._require_model()
+ return render_mod.render(
+ model,
+ selector=selector if selector else None,
+ element_ids=element_ids,
+ view=view,
+ )
+
+ # ------------------------
+ # Shape builder tools
+ # ------------------------
+ def ifc_shape_list(self) -> list[dict]:
+ """List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
+ return _list_shape_methods()
+
+ def ifc_shape_docs(self, method: str) -> dict:
+ """Full documentation for a ShapeBuilder method: params, types, return value."""
+ return _shape_method_docs(method)
+
+ def ifc_shape(self, method: str, params: Any = "{}") -> dict:
+ """Call a ShapeBuilder method by name. Returns the created entity's step ID.
+
+ params is a JSON string of keyword arguments. Pass entity references as integer
+ step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
+ """
+ model = self._require_model()
+
+ from ifcopenshell.util.shape_builder import ShapeBuilder
+
+ if method.startswith("_"):
+ raise IfcSessionError(f"Private method '{method}' is not accessible")
+ fn = getattr(ShapeBuilder, method, None)
+ if fn is None:
+ return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"}
+
+ if isinstance(params, str):
+ raw_kwargs = json.loads(params) if params.strip() else {}
+ elif isinstance(params, dict):
+ raw_kwargs = params
+ else:
+ raw_kwargs = {}
+
+ try:
+ coerced = _coerce_shape_params(fn, raw_kwargs, model)
+ result = fn(ShapeBuilder(model), **coerced)
+ return {"ok": True, "result": _jsonify(result)}
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
+ """Run quantity take-off on the model using the named rule.
+
+ Modifies the model in-place; call ifc_save() after.
+ """
+ model = self._require_model()
+ return run_quantify(model, rule, selector=selector if selector else None)
+
+ # ------------------------
+ # Generic dispatcher + tool specs for LLMs
+ # ------------------------
+ def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
+ args = args or {}
+ fn = getattr(self, name, None)
+ if not callable(fn):
+ raise IfcSessionError(f"Unknown tool: {name}")
+ return _jsonify(fn(**args))
+
+ def openai_tools(self) -> list[dict[str, Any]]:
+ """Tool schemas in the OpenAI 'Responses API' format (type=function)."""
+ # Keep schemas tight so the model calls tools correctly.
+ return [
+ {
+ "type": "function",
+ "name": "ifc_new",
+ "description": "Create a new empty IFC model in memory.",
+ "parameters": {
+ "type": "object",
+ "properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
+ "required": [],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_summary",
+ "description": "Get a concise overview of the loaded IFC model.",
+ "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
+ },
+ {
+ "type": "function",
+ "name": "ifc_tree",
+ "description": "Get the full spatial hierarchy tree.",
+ "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
+ },
+ {
+ "type": "function",
+ "name": "ifc_select",
+ "description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
+ "parameters": {
+ "type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_info",
+ "description": "Inspect an entity by STEP id.",
+ "parameters": {
+ "type": "object",
+ "properties": {"element_id": {"type": "integer"}},
+ "required": ["element_id"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_relations",
+ "description": "Get relationships for an element. traverse='up' walks to IfcProject.",
+ "parameters": {
+ "type": "object",
+ "properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
+ "required": ["element_id"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_clash",
+ "description": "Run clash/clearance checks for an element.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "element_id": {"type": "integer"},
+ "clearance": {"type": "number"},
+ "tolerance": {"type": "number"},
+ "scope": {"type": "string", "description": "storey or all"},
+ },
+ "required": ["element_id"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_contexts",
+ "description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.",
+ "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
+ },
+ {
+ "type": "function",
+ "name": "ifc_materials",
+ "description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.",
+ "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
+ },
+ {
+ "type": "function",
+ "name": "ifc_list",
+ "description": "List ifcopenshell.api modules or functions within a module.",
+ "parameters": {
+ "type": "object",
+ "properties": {"module": {"type": "string"}},
+ "required": [],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_docs",
+ "description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
+ "parameters": {
+ "type": "object",
+ "properties": {"function_path": {"type": "string"}},
+ "required": ["function_path"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_edit",
+ "description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
+ "parameters": {
+ "type": "object",
+ "properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
+ "required": ["function_path"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_validate",
+ "description": "Validate the loaded model. Returns valid bool and list of issues.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}
+ },
+ "required": [],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_schedule",
+ "description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "max_depth": {
+ "type": "integer",
+ "description": "Max levels of subtask expansion (omit for unlimited)",
+ }
+ },
+ "required": [],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_cost",
+ "description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "max_depth": {
+ "type": "integer",
+ "description": "Max levels of cost item expansion (omit for unlimited)",
+ }
+ },
+ "required": [],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_schema",
+ "description": "Return IFC class documentation for an entity type.",
+ "parameters": {
+ "type": "object",
+ "properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}},
+ "required": ["entity_type"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_quantify",
+ "description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
+ "selector": {
+ "type": "string",
+ "description": "ifcopenshell selector to restrict elements (default: all IfcElement)",
+ },
+ },
+ "required": ["rule"],
+ "additionalProperties": False,
+ },
+ },
+ {
+ "type": "function",
+ "name": "ifc_render",
+ "description": (
+ "Render the loaded IFC model to a PNG image for visual inspection. "
+ "Use selector to restrict which elements are rendered (e.g. a single storey). "
+ "Use element_ids to highlight elements against a greyed-out background. "
+ "Returns base64-encoded PNG bytes."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
+ "element_ids": {
+ "type": "array",
+ "items": {"type": "integer"},
+ "description": "Step IDs of elements to highlight",
+ },
+ "view": {
+ "type": "string",
+ "enum": ["iso", "top", "south", "north", "east", "west"],
+ "description": "Camera angle (default: iso)",
+ },
+ },
+ "required": [],
+ "additionalProperties": False,
+ },
+ },
+ ]
diff --git a/src/ifcmcp/ifcmcp/embedded.py b/src/ifcmcp/ifcmcp/embedded.py
new file mode 100644
index 0000000000..4ad99ba1fa
--- /dev/null
+++ b/src/ifcmcp/ifcmcp/embedded.py
@@ -0,0 +1,60 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from ifcmcp.core import IfcSession
+
+session = IfcSession()
+
+# Optional imports only available under Pyodide
+try:
+ from pyodide.ffi import JsProxy, to_py # type: ignore
+except Exception: # pragma: no cover
+ JsProxy = None # type: ignore
+ to_py = None # type: ignore
+
+
+def _coerce_args(args: Any) -> dict[str, Any]:
+ """Convert JS objects / JsProxy / mappings into a real Python dict."""
+ if args is None:
+ return {}
+
+ # Pyodide: JS object arrives as JsProxy; convert recursively to Python.
+ if JsProxy is not None and isinstance(args, JsProxy):
+ # dict_converter=dict ensures JS object -> Python dict (not Map)
+ return to_py(args, dict_converter=dict)
+
+ # Already a Python dict
+ if isinstance(args, dict):
+ return args
+
+ # Any Mapping-like object
+ if isinstance(args, Mapping):
+ return dict(args)
+
+ # Last resort: try dict() coercion
+ try:
+ return dict(args)
+ except Exception as e:
+ raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e
+
+
+def tools_openai() -> list[dict[str, Any]]:
+ return session.openai_tools()
+
+
+def call_tool(name: str, args: Any = None) -> dict[str, Any]:
+ """
+ Non-throwing tool dispatcher.
+ Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...}
+ """
+ try:
+ py_args = _coerce_args(args)
+ data = session.dispatch(name, py_args)
+ return {"ok": True, "data": data}
+
+ except Exception as e:
+ # Keep it short; avoid full tracebacks in tool output unless debugging.
+ return {"ok": False, "error_type": type(e).__name__, "error": str(e)}
diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py
new file mode 100644
index 0000000000..08ba86a2cb
--- /dev/null
+++ b/src/ifcmcp/ifcmcp/server.py
@@ -0,0 +1,231 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+import base64
+from typing import Any
+
+from ifcmcp.core import IfcSession
+
+try:
+ from mcp.server.fastmcp import FastMCP # type: ignore
+ from mcp.types import ImageContent # type: ignore
+except Exception: # pragma: no cover
+ FastMCP = None # type: ignore
+ ImageContent = None # type: ignore
+
+
+def build_server() -> Any:
+ """Create the FastMCP server if the dependency is available."""
+ if FastMCP is None:
+ raise ImportError(
+ "FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)."
+ )
+
+ session = IfcSession()
+
+ server = FastMCP(
+ name="ifc-mcp",
+ instructions=(
+ "MCP server for querying and editing IFC building models. "
+ "Load a file first with ifc_load, then use query/edit tools. "
+ "Save changes with ifc_save."
+ ),
+ )
+
+ # ---- Lifecycle ----
+ @server.tool()
+ def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
+ return session.ifc_new(schema=schema)
+
+ @server.tool()
+ def ifc_load(path: str) -> str:
+ return session.ifc_load(path)
+
+ @server.tool()
+ def ifc_save(path: str = "") -> str:
+ return session.ifc_save(path)
+
+ @server.tool()
+ def ifc_reset() -> dict[str, Any]:
+ return session.ifc_reset()
+
+ # ---- Query ----
+ @server.tool()
+ def ifc_summary() -> dict[str, Any]:
+ return session.ifc_summary()
+
+ @server.tool()
+ def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
+ return session.ifc_tree()
+
+ @server.tool()
+ def ifc_info(element_id: int) -> dict[str, Any]:
+ return session.ifc_info(element_id)
+
+ @server.tool()
+ def ifc_select(query: str) -> list[dict[str, Any]]:
+ return session.ifc_select(query)
+
+ @server.tool()
+ def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
+ return session.ifc_relations(element_id, traverse=traverse)
+
+ @server.tool()
+ def ifc_clash(
+ element_id: int,
+ clearance: float = 0.0,
+ tolerance: float = 0.002,
+ scope: str = "storey",
+ ) -> dict[str, Any]:
+ return session.ifc_clash(
+ element_id=element_id,
+ clearance=clearance,
+ tolerance=tolerance,
+ scope=scope,
+ )
+
+ @server.tool()
+ def ifc_contexts() -> list[dict[str, Any]]:
+ return session.ifc_contexts()
+
+ @server.tool()
+ def ifc_materials() -> list[dict[str, Any]]:
+ return session.ifc_materials()
+
+ # ---- Edit ----
+ @server.tool()
+ def ifc_list(module: str = "") -> list[dict]:
+ return session.ifc_list(module=module)
+
+ @server.tool()
+ def ifc_docs(function_path: str) -> dict:
+ return session.ifc_docs(function_path=function_path)
+
+ @server.tool()
+ def ifc_edit(function_path: str, params: str = "{}") -> dict:
+ return session.ifc_edit(function_path=function_path, params=params)
+
+ # ---- Extended query + edit ----
+ @server.tool()
+ def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
+ return session.ifc_validate(express_rules=express_rules)
+
+ @server.tool()
+ def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
+ return session.ifc_schedule(max_depth=max_depth)
+
+ @server.tool()
+ def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
+ return session.ifc_cost(max_depth=max_depth)
+
+ @server.tool()
+ def ifc_schema(entity_type: str) -> dict[str, Any]:
+ return session.ifc_schema(entity_type=entity_type)
+
+ @server.tool()
+ def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
+ return session.ifc_quantify(rule=rule, selector=selector)
+
+ # ---- Shape builder ----
+ @server.tool()
+ def ifc_shape_list() -> list[dict]:
+ return session.ifc_shape_list()
+
+ @server.tool()
+ def ifc_shape_docs(method: str) -> dict:
+ return session.ifc_shape_docs(method=method)
+
+ @server.tool()
+ def ifc_shape(method: str, params: str = "{}") -> dict:
+ return session.ifc_shape(method=method, params=params)
+
+ @server.tool(structured_output=False)
+ def ifc_plot(
+ selector: str = "",
+ element_ids: list[int] | None = None,
+ view: str = "floorplan",
+ width_mm: float = 297.0,
+ height_mm: float = 420.0,
+ scale: float = 1.0 / 100.0,
+ png_width: int = 1024,
+ png_height: int = 1024,
+ output_path: str = "",
+ ) -> list[ImageContent]:
+ """Generate a 2D technical drawing of the loaded IFC model.
+
+ Returns an inline PNG image (floor plan, elevation, or section) that the
+ LLM can inspect to understand the 2D layout of the model. If
+ ``output_path`` is provided the drawing is also saved to disk — as SVG
+ when the path ends in ``.svg``, otherwise as PNG.
+
+ :param selector: ifcopenshell selector to restrict plotted elements
+ (e.g. ``'IfcWall'``). Omit to plot the whole model.
+ :param element_ids: Step IDs of elements to highlight. Other elements
+ are faded so the subject stands out.
+ :param view: Drawing view — ``floorplan`` (default), ``elevation``,
+ ``section``, or ``auto``.
+ :param width_mm: Paper width in mm (default 297 = A4 landscape width).
+ :param height_mm: Paper height in mm (default 420 = A4 landscape height).
+ :param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
+ :param png_width: Raster output width in pixels (default 1024).
+ :param png_height: Raster output height in pixels (default 1024).
+ :param output_path: Optional file path to save the drawing to disk.
+ """
+ png_bytes = session.ifc_plot(
+ selector=selector,
+ element_ids=element_ids,
+ view=view,
+ width_mm=width_mm,
+ height_mm=height_mm,
+ scale=scale,
+ png_width=png_width,
+ png_height=png_height,
+ output_format="png",
+ )
+ if output_path:
+ if output_path.endswith(".svg"):
+ svg_bytes = session.ifc_plot(
+ selector=selector,
+ element_ids=element_ids,
+ view=view,
+ width_mm=width_mm,
+ height_mm=height_mm,
+ scale=scale,
+ output_format="svg",
+ )
+ with open(output_path, "wb") as f:
+ f.write(svg_bytes)
+ else:
+ with open(output_path, "wb") as f:
+ f.write(png_bytes)
+ return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
+
+ @server.tool(structured_output=False)
+ def ifc_render(
+ selector: str = "",
+ element_ids: list[int] | None = None,
+ view: str = "iso",
+ output_path: str = "",
+ ) -> list[ImageContent]:
+ """Render the loaded IFC model to a PNG image.
+
+ Returns an inline image the LLM can inspect to understand the spatial
+ layout of the model or a specific element in context. If
+ ``output_path`` is provided the PNG is also saved to that file path.
+
+ :param selector: ifcopenshell selector to restrict rendered elements
+ (e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
+ Omit to render the whole model.
+ :param element_ids: Step IDs of elements to highlight. Other elements
+ are rendered in translucent grey so the subject stands out.
+ :param view: Camera angle — ``iso`` (default), ``top``, ``south``,
+ ``north``, ``east``, or ``west``.
+ :param output_path: Optional file path to save the PNG to disk.
+ """
+ png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
+ if output_path:
+ with open(output_path, "wb") as f:
+ f.write(png_bytes)
+ return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
+
+ return server
diff --git a/src/ifcmcp/pyproject.toml b/src/ifcmcp/pyproject.toml
new file mode 100644
index 0000000000..1cfe734e4b
--- /dev/null
+++ b/src/ifcmcp/pyproject.toml
@@ -0,0 +1,36 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ifcmcp"
+version = "0.0.0"
+authors = [
+ { name="Bruno Postle", email="bruno@postle.net" },
+]
+description = "MCP server for querying and editing IFC building models"
+readme = "README.md"
+keywords = ["IFC", "BIM", "MCP"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
+]
+dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
+
+[project.optional-dependencies]
+mcp = ["mcp"]
+
+[project.scripts]
+ifcmcp = "ifcmcp.__main__:main"
+
+[project.urls]
+Homepage = "http://ifcopenshell.org"
+Documentation = "https://docs.ifcopenshell.org"
+Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
+
+[tool.setuptools.packages.find]
+include = ["ifcmcp*"]
+exclude = ["test*"]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
diff --git a/src/ifcmcp/tests/__init__.py b/src/ifcmcp/tests/__init__.py
new file mode 100644
index 0000000000..0a3bc271a0
--- /dev/null
+++ b/src/ifcmcp/tests/__init__.py
@@ -0,0 +1 @@
+# This file was generated with the assistance of an AI coding tool.
diff --git a/src/ifcmcp/tests/conftest.py b/src/ifcmcp/tests/conftest.py
new file mode 100644
index 0000000000..9fae4aef92
--- /dev/null
+++ b/src/ifcmcp/tests/conftest.py
@@ -0,0 +1,59 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.aggregate
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+from ifcmcp.core import IfcSession
+
+
+@pytest.fixture
+def session():
+ return IfcSession()
+
+
+@pytest.fixture
+def model():
+ """IFC4 model with a spatial hierarchy, a wall, and a slab."""
+ 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)
+
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
+ ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
+
+ slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
+ ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
+
+ return f
+
+
+@pytest.fixture
+def model_file(model, tmp_path):
+ """Write the model fixture to a temp file and return the path."""
+ path = tmp_path / "test.ifc"
+ model.write(str(path))
+ return str(path)
+
+
+@pytest.fixture
+def loaded_session(model):
+ """An IfcSession with an in-memory model already loaded (no file path)."""
+ s = IfcSession()
+ s.model = model
+ return s
diff --git a/src/ifcmcp/tests/test_edit.py b/src/ifcmcp/tests/test_edit.py
new file mode 100644
index 0000000000..778b232249
--- /dev/null
+++ b/src/ifcmcp/tests/test_edit.py
@@ -0,0 +1,96 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+
+import ifcopenshell
+import pytest
+
+from ifcmcp.core import IfcSession, IfcSessionError
+
+
+class TestNoModel:
+ def test_edit_no_model(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_edit("root.create_entity")
+
+
+class TestList:
+ def test_list_all_modules(self, loaded_session):
+ result = loaded_session.ifc_list()
+ assert isinstance(result, list)
+ assert len(result) > 0
+ modules = [m["module"] for m in result]
+ assert "root" in modules
+ assert "spatial" in modules
+
+ def test_list_module_functions(self, loaded_session):
+ result = loaded_session.ifc_list(module="root")
+ assert isinstance(result, list)
+ names = [f["name"] for f in result]
+ assert "create_entity" in names
+
+ def test_list_empty_string_returns_modules(self, loaded_session):
+ result = loaded_session.ifc_list(module="")
+ assert isinstance(result, list)
+ assert any(m["module"] == "root" for m in result)
+
+
+class TestDocs:
+ def test_docs_create_entity(self, loaded_session):
+ result = loaded_session.ifc_docs("root.create_entity")
+ assert result["module"] == "root"
+ assert result["function"] == "create_entity"
+ assert "params" in result
+
+ def test_docs_bad_format(self, loaded_session):
+ with pytest.raises(ValueError):
+ loaded_session.ifc_docs("no_dot_here")
+
+
+class TestEdit:
+ def test_create_entity(self, loaded_session):
+ result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"}))
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcWall"
+ assert result["result"]["name"] == "NewWall"
+
+ def test_create_entity_default_params(self, loaded_session):
+ result = loaded_session.ifc_edit("root.create_entity", "{}")
+ assert result["ok"] is True
+
+ def test_unknown_function(self, loaded_session):
+ result = loaded_session.ifc_edit("root.nonexistent", "{}")
+ assert result["ok"] is False
+ assert "Cannot find" in result["error"]
+
+ def test_unknown_parameter(self, loaded_session):
+ result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"}))
+ assert result["ok"] is False
+ assert "Unknown parameter" in result["error"]
+
+ def test_bad_json(self, loaded_session):
+ with pytest.raises(json.JSONDecodeError):
+ loaded_session.ifc_edit("root.create_entity", "not json")
+
+ def test_edit_does_not_save(self, loaded_session, tmp_path):
+ """Verify that ifc_edit mutates the in-memory model but does not write to disk."""
+ path = str(tmp_path / "test.ifc")
+ loaded_session.model.write(path)
+ loaded_session.model_path = path
+
+ before_count = sum(1 for _ in loaded_session.model)
+ loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
+ after_count = sum(1 for _ in loaded_session.model)
+ assert after_count == before_count + 1
+
+ on_disk = ifcopenshell.open(path)
+ disk_count = sum(1 for _ in on_disk)
+ assert disk_count == before_count
+
+ def test_assign_container(self, loaded_session):
+ wall = loaded_session.model.by_type("IfcWall")[0]
+ storey = loaded_session.model.by_type("IfcBuildingStorey")[0]
+ result = loaded_session.ifc_edit(
+ "spatial.assign_container",
+ json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
+ )
+ assert result["ok"] is True
diff --git a/src/ifcmcp/tests/test_query.py b/src/ifcmcp/tests/test_query.py
new file mode 100644
index 0000000000..e0a28ae1c0
--- /dev/null
+++ b/src/ifcmcp/tests/test_query.py
@@ -0,0 +1,113 @@
+# This file was generated with the assistance of an AI coding tool.
+import pytest
+
+from ifcmcp.core import IfcSessionError
+
+
+class TestNoModel:
+ """All query tools should fail when no model is loaded."""
+
+ def test_summary_no_model(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_summary()
+
+ def test_tree_no_model(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_tree()
+
+ def test_info_no_model(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_info(1)
+
+ def test_select_no_model(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_select("IfcWall")
+
+ def test_relations_no_model(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_relations(1)
+
+
+class TestSummary:
+ def test_schema(self, loaded_session):
+ result = loaded_session.ifc_summary()
+ assert result["schema"] == "IFC4"
+
+ def test_total_entities(self, loaded_session):
+ result = loaded_session.ifc_summary()
+ assert result["total_entities"] > 0
+
+ def test_project_name(self, loaded_session):
+ result = loaded_session.ifc_summary()
+ assert result["project"]["name"] == "TestProject"
+
+ def test_type_counts(self, loaded_session):
+ result = loaded_session.ifc_summary()
+ assert result["types"]["IfcWall"] == 1
+ assert result["types"]["IfcSlab"] == 1
+
+
+class TestTree:
+ def test_root_is_project(self, loaded_session):
+ result = loaded_session.ifc_tree()
+ assert result["type"] == "IfcProject"
+ assert result["name"] == "TestProject"
+
+ def test_hierarchy_depth(self, loaded_session):
+ result = loaded_session.ifc_tree()
+ site = result["children"][0]
+ assert site["type"] == "IfcSite"
+ building = site["children"][0]
+ assert building["type"] == "IfcBuilding"
+ storey = building["children"][0]
+ assert storey["type"] == "IfcBuildingStorey"
+
+
+class TestInfo:
+ def test_wall_info(self, loaded_session):
+ wall = loaded_session.model.by_type("IfcWall")[0]
+ result = loaded_session.ifc_info(wall.id())
+ assert result["id"] == wall.id()
+ assert result["type"] == "IfcWall"
+
+ def test_invalid_id(self, loaded_session):
+ with pytest.raises(Exception):
+ loaded_session.ifc_info(999999)
+
+
+class TestSelect:
+ def test_select_walls(self, loaded_session):
+ result = loaded_session.ifc_select("IfcWall")
+ assert len(result) == 1
+ assert result[0]["type"] == "IfcWall"
+ assert result[0]["name"] == "Wall001"
+
+ def test_select_slabs(self, loaded_session):
+ result = loaded_session.ifc_select("IfcSlab")
+ assert len(result) == 1
+ assert result[0]["name"] == "Slab001"
+
+ def test_select_no_match(self, loaded_session):
+ result = loaded_session.ifc_select("IfcWindow")
+ assert result == []
+
+
+class TestRelations:
+ def test_wall_relations(self, loaded_session):
+ wall = loaded_session.model.by_type("IfcWall")[0]
+ result = loaded_session.ifc_relations(wall.id())
+ assert result["id"] == wall.id()
+ assert result["type"] == "IfcWall"
+ assert "hierarchy" in result
+
+ def test_traverse_up(self, loaded_session):
+ wall = loaded_session.model.by_type("IfcWall")[0]
+ result = loaded_session.ifc_relations(wall.id(), traverse="up")
+ assert isinstance(result, list)
+ assert result[0]["type"] == "IfcWall"
+ assert result[-1]["type"] == "IfcProject"
+
+ def test_traverse_empty_string_means_no_traverse(self, loaded_session):
+ wall = loaded_session.model.by_type("IfcWall")[0]
+ result = loaded_session.ifc_relations(wall.id(), traverse="")
+ assert isinstance(result, dict)
diff --git a/src/ifcmcp/tests/test_server.py b/src/ifcmcp/tests/test_server.py
new file mode 100644
index 0000000000..ed41434df7
--- /dev/null
+++ b/src/ifcmcp/tests/test_server.py
@@ -0,0 +1,106 @@
+# This file was generated with the assistance of an AI coding tool.
+from unittest.mock import patch
+
+import pytest
+
+from ifcmcp.server import build_server
+
+
+class TestServerRegistration:
+ def test_server_name(self):
+ server = build_server()
+ assert server.name == "ifc-mcp"
+
+ def test_all_tools_registered(self):
+ server = build_server()
+ tools = [t.name for t in server._tool_manager.list_tools()]
+ expected = [
+ "ifc_load",
+ "ifc_save",
+ "ifc_summary",
+ "ifc_tree",
+ "ifc_info",
+ "ifc_select",
+ "ifc_relations",
+ "ifc_clash",
+ "ifc_list",
+ "ifc_docs",
+ "ifc_edit",
+ ]
+ for name in expected:
+ assert name in tools, f"Tool {name} not registered"
+
+
+@pytest.fixture
+def tool_fns():
+ """Return a dict of tool name → raw function from a freshly built server."""
+ server = build_server()
+ return {t.name: t.fn for t in server._tool_manager.list_tools()}
+
+
+PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE"
+SVG_FAKE = b""
+
+
+class TestRenderOutputPath:
+ def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
+ with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
+ tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="")
+ assert list(tmp_path.iterdir()) == []
+
+ def test_png_output_path_writes_file(self, tool_fns, tmp_path):
+ out = str(tmp_path / "render.png")
+ with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
+ tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out)
+ assert open(out, "rb").read() == PNG_FAKE
+
+
+class TestPlotOutputPath:
+ def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
+ with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
+ tool_fns["ifc_plot"](
+ selector="",
+ element_ids=None,
+ view="floorplan",
+ width_mm=297.0,
+ height_mm=420.0,
+ scale=0.01,
+ png_width=1024,
+ png_height=1024,
+ output_path="",
+ )
+ assert list(tmp_path.iterdir()) == []
+
+ def test_png_output_path_writes_png(self, tool_fns, tmp_path):
+ out = str(tmp_path / "plot.png")
+ with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
+ tool_fns["ifc_plot"](
+ selector="",
+ element_ids=None,
+ view="floorplan",
+ width_mm=297.0,
+ height_mm=420.0,
+ scale=0.01,
+ png_width=1024,
+ png_height=1024,
+ output_path=out,
+ )
+ assert open(out, "rb").read() == PNG_FAKE
+
+ def test_svg_output_path_writes_svg(self, tool_fns, tmp_path):
+ out = str(tmp_path / "plot.svg")
+ # ifc_plot is called twice: once with "png" for the inline image,
+ # once with "svg" for the file.
+ with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]):
+ tool_fns["ifc_plot"](
+ selector="",
+ element_ids=None,
+ view="floorplan",
+ width_mm=297.0,
+ height_mm=420.0,
+ scale=0.01,
+ png_width=1024,
+ png_height=1024,
+ output_path=out,
+ )
+ assert open(out, "rb").read() == SVG_FAKE
diff --git a/src/ifcmcp/tests/test_session.py b/src/ifcmcp/tests/test_session.py
new file mode 100644
index 0000000000..4fa59120c0
--- /dev/null
+++ b/src/ifcmcp/tests/test_session.py
@@ -0,0 +1,62 @@
+# This file was generated with the assistance of an AI coding tool.
+from unittest.mock import patch
+
+import ifcopenshell
+import pytest
+
+from ifcmcp.core import IfcSession, IfcSessionError
+
+
+class TestLoad:
+ def test_load_file(self, session, model_file):
+ result = session.ifc_load(model_file)
+ assert "IFC4" in result
+ assert session.model is not None
+ assert session.model_path == model_file
+
+ def test_load_sets_entity_count(self, session, model_file):
+ result = session.ifc_load(model_file)
+ assert "entities" in result
+
+ def test_load_nonexistent_file(self, session):
+ with pytest.raises(Exception):
+ session.ifc_load("/nonexistent/path/model.ifc")
+
+
+class TestSave:
+ def test_save_no_model(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_save()
+
+ def test_save_overwrites_original(self, session, model_file):
+ session.ifc_load(model_file)
+ result = session.ifc_save()
+ assert model_file in result
+
+ def test_save_to_new_path(self, session, model_file, tmp_path):
+ session.ifc_load(model_file)
+ new_path = str(tmp_path / "output.ifc")
+ result = session.ifc_save(new_path)
+ assert new_path in result
+ reloaded = ifcopenshell.open(new_path)
+ assert reloaded.schema == "IFC4"
+
+ def test_save_no_path_no_original(self, loaded_session):
+ with pytest.raises(IfcSessionError, match="No path specified"):
+ loaded_session.ifc_save()
+
+
+class TestIfcPlotOutputFormat:
+ """ifc_plot should pass output_format through to the underlying plot function."""
+
+ def test_default_output_format_is_png(self, loaded_session):
+ with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot:
+ loaded_session.ifc_plot()
+ mock_plot.assert_called_once()
+ assert mock_plot.call_args.kwargs["output_format"] == "png"
+
+ def test_svg_output_format(self, loaded_session):
+ with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot:
+ result = loaded_session.ifc_plot(output_format="svg")
+ assert result == b"SVG_FAKE"
+ assert mock_plot.call_args.kwargs["output_format"] == "svg"
diff --git a/src/ifcmcp/tests/test_shape.py b/src/ifcmcp/tests/test_shape.py
new file mode 100644
index 0000000000..1b657d5730
--- /dev/null
+++ b/src/ifcmcp/tests/test_shape.py
@@ -0,0 +1,141 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+
+import pytest
+
+from ifcmcp.core import IfcSessionError
+
+
+class TestShapeList:
+ def test_returns_list(self, loaded_session):
+ result = loaded_session.ifc_shape_list()
+ assert isinstance(result, list)
+ assert len(result) > 0
+
+ def test_has_expected_methods(self, loaded_session):
+ result = loaded_session.ifc_shape_list()
+ names = [m["method"] for m in result]
+ assert "polyline" in names
+ assert "rectangle" in names
+ assert "extrude" in names
+ assert "profile" in names
+ assert "get_representation" in names
+
+ def test_well_documented_methods_have_descriptions(self, loaded_session):
+ result = loaded_session.ifc_shape_list()
+ by_name = {m["method"]: m for m in result}
+ # These methods have detailed docstrings
+ for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"):
+ assert by_name[name]["description"], f"'{name}' has no description"
+
+ def test_no_private_methods(self, loaded_session):
+ result = loaded_session.ifc_shape_list()
+ assert not any(m["method"].startswith("_") for m in result)
+
+ def test_does_not_require_model(self, session):
+ # ifc_shape_list is pure introspection — no model needed
+ result = session.ifc_shape_list()
+ assert isinstance(result, list)
+
+
+class TestShapeDocs:
+ def test_extrude_docs(self, loaded_session):
+ result = loaded_session.ifc_shape_docs("extrude")
+ assert result["method"] == "extrude"
+ assert result["description"]
+ assert "params" in result
+ param_names = [p["name"] for p in result["params"]]
+ assert "profile_or_curve" in param_names
+ assert "magnitude" in param_names
+
+ def test_has_return_type(self, loaded_session):
+ result = loaded_session.ifc_shape_docs("rectangle")
+ assert "return_type" in result
+
+ def test_has_param_descriptions(self, loaded_session):
+ result = loaded_session.ifc_shape_docs("polyline")
+ params_with_desc = [p for p in result["params"] if "description" in p]
+ assert len(params_with_desc) > 0
+
+ def test_unknown_method(self, loaded_session):
+ with pytest.raises(ValueError, match="no method"):
+ loaded_session.ifc_shape_docs("nonexistent_method")
+
+ def test_private_method_rejected(self, loaded_session):
+ with pytest.raises(ValueError):
+ loaded_session.ifc_shape_docs("__init__")
+
+ def test_does_not_require_model(self, session):
+ result = session.ifc_shape_docs("circle")
+ assert result["method"] == "circle"
+
+
+class TestShapeExecute:
+ def test_rectangle(self, loaded_session):
+ result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcIndexedPolyCurve"
+
+ def test_circle(self, loaded_session):
+ result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5}))
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcCircle"
+
+ def test_extrude_chained_from_rectangle(self, loaded_session):
+ rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
+ rect_id = rect["result"]["id"]
+ result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0}))
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcExtrudedAreaSolid"
+
+ def test_entity_id_as_integer(self, loaded_session):
+ """Entity IDs should be accepted as plain integers (from JSON)."""
+ rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]}))
+ rect_id = rect["result"]["id"]
+ # Pass as int, not string
+ result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0}))
+ assert result["ok"] is True
+
+ def test_rotate_2d_point_returns_list(self, loaded_session):
+ """Methods returning numpy arrays should give back plain lists."""
+ result = loaded_session.ifc_shape(
+ "rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True})
+ )
+ assert result["ok"] is True
+ assert isinstance(result["result"], list)
+ assert len(result["result"]) == 2
+
+ def test_set_polyline_coords_returns_none(self, loaded_session):
+ """In-place methods that return None should give ok=True, result=None."""
+ rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]}))
+ rect_id = rect["result"]["id"]
+ result = loaded_session.ifc_shape(
+ "set_polyline_coords",
+ json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}),
+ )
+ assert result["ok"] is True
+ assert result["result"] is None
+
+ def test_unknown_method(self, loaded_session):
+ result = loaded_session.ifc_shape("nonexistent_method", "{}")
+ assert result["ok"] is False
+ assert "error" in result
+
+ def test_private_method_rejected(self, loaded_session):
+ with pytest.raises(IfcSessionError):
+ loaded_session.ifc_shape("__init__", "{}")
+
+ def test_no_model_raises(self, session):
+ with pytest.raises(IfcSessionError, match="No model loaded"):
+ session.ifc_shape("rectangle", "{}")
+
+ def test_params_as_dict(self, loaded_session):
+ """params can be passed as a dict (not just a JSON string)."""
+ result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]})
+ assert result["ok"] is True
+
+ def test_error_on_bad_params(self, loaded_session):
+ """Bad parameters should give ok=False with an error message."""
+ result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0}))
+ assert result["ok"] is False
+ assert "error" in result
diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst
index 817630ad7d..3aa34a3aa8 100644
--- a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst
+++ b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst
@@ -775,3 +775,52 @@ responsibility to make sure the geometry is correct.
# Assign our new body geometry back to our beam
ifcopenshell.api.geometry.assign_representation(model, product=beam, representation=representation)
+
+Moving assemblies
+-----------------
+
+When moving an assembly and you want all children to follow, pass
+``should_transform_children=True``. The default (``False``) rewrites each
+child's local placement to preserve its world position, so the parent moves
+but the children stay where they are.
+
+.. code-block:: python
+
+ matrix = numpy.eye(4)
+ matrix[:,3][0:3] = (0, 0, 6)
+
+ # Move the assembly; children travel with it.
+ ifcopenshell.api.geometry.edit_object_placement(model,
+ product=assembly, matrix=matrix, is_si=True,
+ should_transform_children=True)
+
+Clipping normals convention
+---------------------------
+
+The ``normal`` passed to :func:`geometry.clip_solid`,
+:func:`geometry.clip_solid_bounded`, and the ``clippings`` parameter of
+:func:`geometry.add_wall_representation` points toward the **removed**
+material (the discarded side), not toward the kept material.
+
+.. code-block:: python
+
+ # Clip the top of a wall to a lean-to slope.
+ # normal points upward into the wedge that will be removed.
+ bcr = ifcopenshell.api.geometry.clip_solid(model,
+ item=extrusion,
+ location=[0.0, 0.0, 3.26],
+ normal=[0.419, 0.0, 0.908])
+ shape_representation.RepresentationType = "Clipping"
+
+Opening lifecycle
+-----------------
+
+``feature.remove_feature`` permanently deletes the feature entity from the
+model. Any fillings (windows, doors) that occupied the opening become
+orphaned and must be separately removed via ``root.remove_product``.
+
+.. code-block:: python
+
+ # Remove a window and its opening from a wall.
+ ifcopenshell.api.root.remove_product(model, product=window)
+ ifcopenshell.api.feature.remove_feature(model, feature=opening)
diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py
index f974ea2cd2..9d988a4e82 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py
@@ -108,7 +108,7 @@ class Usecase:
self.rel_space_boundary.ConnectionGeometry = connection_geometry
def create_point(self, point: npt.NDArray) -> ifcopenshell.entity_instance:
- return self.file.create_enitty("IfcCartesianPoint", ifc_safe_vector_type(point / self.unit_scale))
+ return self.file.create_entity("IfcCartesianPoint", ifc_safe_vector_type(point / self.unit_scale))
def close_polyline(
self, points: tuple[ifcopenshell.entity_instance, ...]
@@ -127,7 +127,7 @@ class Usecase:
return self.file.createIfcPlane(
self.file.createIfcAxis2Placement3D(
self.create_point(location),
- self.file.createIfcDirection(axis),
- self.file.createIfcDirection(ref_direction),
+ self.file.createIfcDirection(ifc_safe_vector_type(axis)),
+ self.file.createIfcDirection(ifc_safe_vector_type(ref_direction)),
)
)
diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py
index ee11094d13..4fa5516bc6 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py
@@ -27,12 +27,11 @@ def edit_attributes(
related_building_element: ifcopenshell.entity_instance,
parent_boundary: Optional[ifcopenshell.entity_instance] = None,
corresponding_boundary: Optional[ifcopenshell.entity_instance] = None,
+ physical_or_virtual: str = "NOTDEFINED",
+ internal_or_external: str = "NOTDEFINED",
) -> None:
"""Modify the relationships of a space boundary relationship
- Currently this function is quite minimal and offers no advantage to
- manual assignment of the space boundary attributes.
-
:param entity: The IfcRelSpaceBoundary to modify
:param relating_space: The IfcSpace or IfcExternalSpatialElement that
the space boundary is related to.
@@ -44,17 +43,18 @@ def edit_attributes(
:param corresponding_boundary: The other IfcRelSpaceBoundary on the
other side of the related element. The pair together represents a
thermal boundary. This only applies to 2nd level boundaries.
+ :param physical_or_virtual: IfcPhysicalOrVirtualEnum value: "PHYSICAL",
+ "VIRTUAL", or "NOTDEFINED".
+ :param internal_or_external: IfcInternalOrExternalEnum value:
+ "INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER",
+ "EXTERNAL_FIRE", or "NOTDEFINED".
:return: None
"""
- entity = entity
- relating_space = relating_space
- related_building_element = related_building_element
- parent_boundary = parent_boundary
- corresponding_boundary = corresponding_boundary
-
entity.RelatingSpace = relating_space
entity.RelatedBuildingElement = related_building_element
if hasattr(entity, "ParentBoundary"):
entity.ParentBoundary = parent_boundary
if hasattr(entity, "CorrespondingBoundary"):
entity.CorrespondingBoundary = corresponding_boundary
+ entity.PhysicalOrVirtualBoundary = physical_or_virtual
+ entity.InternalOrExternalBoundary = internal_or_external
diff --git a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py
index ff847b39eb..100e886214 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py
@@ -22,11 +22,13 @@ import ifcopenshell.util.element
def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instance) -> None:
- """Remove a feature
+ """Permanently delete a feature element and its void or projection relationship.
- Fillings are retained as orphans. Featured elements remain. Features
- cannot exist by themselves, so not only is the relationship removed, the
- feature is also removed.
+ The feature entity (e.g. IfcOpeningElement) is removed from the model
+ along with its IfcRelVoidsElement or IfcRelProjectsElement relationship.
+ The host element (wall, slab, etc.) is unaffected. Any fillings (windows,
+ doors) that occupied the opening become orphaned and must be separately
+ deleted via root.remove_product.
:param feature: The IfcFeatureElement to remove.
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py
index 44728c2902..d845f4dc83 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py
@@ -25,7 +25,10 @@ geometry extrusions).
from .. import wrap_usecases
from .add_axis_representation import add_axis_representation
+from .add_topology_representation import add_topology_representation
from .add_boolean import add_boolean
+from .clip_solid import clip_solid
+from .clip_solid_bounded import clip_solid_bounded
from .add_door_representation import add_door_representation
from .add_footprint_representation import add_footprint_representation
from .add_mesh_representation import add_mesh_representation
@@ -50,6 +53,7 @@ from .disconnect_element import disconnect_element
from .disconnect_path import disconnect_path
from .edit_object_placement import edit_object_placement
from .map_representation import map_representation
+from .copy_representation import copy_representation
from .regenerate_wall_representation import regenerate_wall_representation
from .remove_boolean import remove_boolean
from .remove_representation import remove_representation
@@ -60,7 +64,11 @@ wrap_usecases(__path__, __name__)
__all__ = [
"add_axis_representation",
+ "add_topology_representation",
"add_boolean",
+ "clip_solid",
+ "clip_solid_bounded",
+ "copy_representation",
"add_door_representation",
"add_footprint_representation",
"add_mesh_representation",
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py
new file mode 100644
index 0000000000..14c4fa9dcc
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py
@@ -0,0 +1,98 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+from typing import Optional
+
+import ifcopenshell
+
+
+_ITEM_TYPE_TO_REP_TYPE = {
+ "IfcVertex": "Vertex",
+ "IfcVertexPoint": "Vertex",
+ "IfcEdge": "Edge",
+ "IfcOrientedEdge": "Edge",
+ "IfcEdgeCurve": "Edge",
+ "IfcEdgeLoop": "Edge",
+ "IfcPath": "Edge",
+ "IfcFace": "Face",
+ "IfcFaceSurface": "Face",
+ "IfcAdvancedFace": "Face",
+ "IfcClosedShell": "Face",
+ "IfcOpenShell": "Face",
+ "IfcConnectedFaceSet": "Face",
+}
+
+
+def add_topology_representation(
+ file: ifcopenshell.file,
+ context: ifcopenshell.entity_instance,
+ item: ifcopenshell.entity_instance,
+ representation_identifier: Optional[str] = None,
+ representation_type: Optional[str] = None,
+) -> ifcopenshell.entity_instance:
+ """Adds an IfcTopologyRepresentation for a structural element
+
+ Structural analysis elements (IfcStructuralSurfaceMember,
+ IfcStructuralCurveMember) use topology representations rather than solid
+ geometry. This is analogous to :func:`add_axis_representation` and
+ :func:`add_profile_representation` but produces an
+ IfcTopologyRepresentation instead of an IfcShapeRepresentation.
+
+ The representation type ("Face", "Edge", "Vertex") is inferred from the
+ item's IFC class if not provided explicitly.
+
+ :param context: The IfcGeometricRepresentationContext for the
+ representation, typically a Reference context.
+ :param item: The IfcTopologicalRepresentationItem (e.g. IfcFaceSurface,
+ IfcEdge) to include in the representation.
+ :param representation_identifier: The RepresentationIdentifier string.
+ Defaults to the context's ContextIdentifier.
+ :param representation_type: The RepresentationType string ("Face",
+ "Edge", "Vertex"). Inferred from item class if not given.
+ :return: The newly created IfcTopologyRepresentation entity.
+
+ Example:
+
+ .. code:: python
+
+ context = ifcopenshell.util.representation.get_context(
+ model, "Model", "Reference", "GRAPH_VIEW")
+ face = model.createIfcFaceSurface(bounds, surface, True)
+ rep = ifcopenshell.api.geometry.add_topology_representation(
+ model, context=context, item=face)
+ ifcopenshell.api.geometry.assign_representation(
+ model, product=member, representation=rep)
+ """
+ if representation_identifier is None:
+ representation_identifier = context.ContextIdentifier
+
+ if representation_type is None:
+ for ifc_class, rep_type in _ITEM_TYPE_TO_REP_TYPE.items():
+ if item.is_a(ifc_class):
+ representation_type = rep_type
+ break
+ else:
+ representation_type = "Undefined"
+
+ return file.createIfcTopologyRepresentation(
+ context,
+ representation_identifier,
+ representation_type,
+ [item],
+ )
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py
index ff62bae474..c542471896 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py
@@ -47,7 +47,9 @@ def add_wall_representation(
:param thickness: The thickness of the wall in meters.
:param x_angle: The slope angle along the wall's X-axis, in radians.
:param clippings: List of clipping definitions. Clippings can be `Clipping` objects
- or dictionaries of arguments for `Clipping.parse`.
+ or dictionaries of arguments for `Clipping.parse`. Each clipping has a
+ ``normal`` that points toward the removed material (the discarded side),
+ not toward the kept material; see :func:`clip_solid` for details.
:param booleans: List of any existing IfcBooleanResults.
:return: IfcShapeRepresentation.
"""
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py
new file mode 100644
index 0000000000..a83fd4385f
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py
@@ -0,0 +1,86 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+
+from __future__ import annotations
+
+import json
+from typing import Optional, Sequence
+
+import ifcopenshell.api.pset
+import ifcopenshell.util.element
+import ifcopenshell.util.unit
+from ifcopenshell.util.data import Clipping
+
+
+def clip_solid(
+ file: ifcopenshell.file,
+ item: ifcopenshell.entity_instance,
+ location: Sequence[float],
+ normal: Sequence[float],
+ element: Optional[ifcopenshell.entity_instance] = None,
+) -> ifcopenshell.entity_instance:
+ """Clip a solid with a half-space plane, returning an IfcBooleanClippingResult.
+
+ Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for
+ use with any solid. This is the same convention used by the ``clippings``
+ parameter of :func:`add_wall_representation`.
+
+ .. warning::
+
+ The ``normal`` points toward the **removed** material (the discarded
+ side), not toward the kept material. For a slope clip the normal
+ points upward into the removed wedge above the slope line. For a
+ side mitre the normal points outward away from the wall body.
+
+ After clipping, set the parent ``IfcShapeRepresentation``
+ ``RepresentationType`` to ``"Clipping"``.
+
+ Example — trim an extruded solid to a lean-to slope (removed material is
+ above the slope)::
+
+ bcr = ifcopenshell.api.run(
+ "geometry.clip_solid", model,
+ item=extrusion,
+ location=[0.0, 0.0, 3.26],
+ normal=[0.419, 0.0, 0.908], # points UP toward removed material
+ )
+
+ :param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
+ or ``IfcBooleanClippingResult``).
+ :param location: A point on the clipping plane in the representation's
+ local coordinate system.
+ :param normal: Plane normal pointing toward the material to be removed
+ (see warning above).
+ :param element: If provided, the resulting ``IfcBooleanClippingResult`` is
+ registered in the element's ``BBIM_Boolean`` property set so that
+ :func:`regenerate_wall_representation` preserves it during regeneration.
+ :return: The resulting ``IfcBooleanClippingResult``.
+ """
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
+ clipping = Clipping(location=tuple(location), normal=tuple(normal))
+ result = clipping.apply(file, item, unit_scale)
+ if element is not None:
+ pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
+ if pset_data:
+ pset = file.by_id(pset_data["id"])
+ data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
+ else:
+ pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
+ data = [result.id()]
+ ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
+ return result
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py
new file mode 100644
index 0000000000..ac2e39c741
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py
@@ -0,0 +1,116 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+
+from __future__ import annotations
+
+import json
+from typing import Optional, Sequence
+
+import numpy as np
+
+import ifcopenshell.api.pset
+import ifcopenshell.util.element
+import ifcopenshell.util.unit
+from ifcopenshell.util.shape_builder import ShapeBuilder
+
+
+def clip_solid_bounded(
+ file: ifcopenshell.file,
+ item: ifcopenshell.entity_instance,
+ location: Sequence[float],
+ normal: Sequence[float],
+ boundary_points: Sequence[Sequence[float]],
+ boundary_position: Sequence[float] = (0.0, 0.0, 0.0),
+ element: Optional[ifcopenshell.entity_instance] = None,
+) -> ifcopenshell.entity_instance:
+ """Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult.
+
+ Like :func:`clip_solid`, but the boolean subtraction is restricted to the
+ region enclosed by ``boundary_points`` rather than extending across the
+ entire half-space. The clipping plane is still infinite, but material is
+ only removed within the extruded footprint of the polygon.
+
+ The ``normal`` convention is the same as :func:`clip_solid`: it points
+ toward the **removed** material.
+
+ After clipping, set the parent ``IfcShapeRepresentation``
+ ``RepresentationType`` to ``"Clipping"``.
+
+ Example::
+
+ bcr = ifcopenshell.api.run(
+ "geometry.clip_solid_bounded", model,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+
+ :param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
+ or ``IfcBooleanClippingResult``).
+ :param location: A point on the clipping plane in the representation's
+ local coordinate system.
+ :param normal: Plane normal pointing toward the material to be removed.
+ :param boundary_points: 2D ``[x, y]`` points defining the closed polygonal
+ boundary in the coordinate system of ``boundary_position``. The polygon
+ is automatically closed — do not repeat the first point.
+ :param boundary_position: 3D origin of the boundary coordinate system
+ (axes default to the global X/Y/Z directions). Defaults to the origin.
+ :param element: If provided, the resulting ``IfcBooleanClippingResult`` is
+ registered in the element's ``BBIM_Boolean`` property set so that
+ :func:`regenerate_wall_representation` preserves it during regeneration.
+ :return: The resulting ``IfcBooleanClippingResult``.
+ """
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
+ builder = ShapeBuilder(file)
+
+ normal_arr = np.array(normal)
+ if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2):
+ arbitrary_vector = np.array([0.0, 1.0, 0.0])
+ else:
+ arbitrary_vector = np.array([0.0, 0.0, 1.0])
+ x_axis = np.cross(normal_arr, arbitrary_vector)
+ x_axis /= np.linalg.norm(x_axis)
+
+ scaled_location = [i / unit_scale for i in location]
+ plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis)
+ plane = file.create_entity("IfcPlane", plane_placement)
+
+ scaled_boundary_position = [i / unit_scale for i in boundary_position]
+ boundary_pos_entity = file.create_entity(
+ "IfcAxis2Placement3D",
+ file.create_entity("IfcCartesianPoint", scaled_boundary_position),
+ )
+
+ scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points]
+ scaled_pts.append(scaled_pts[0]) # close the polygon
+ ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts]
+ boundary = file.createIfcPolyline(ifc_pts)
+
+ half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary)
+ result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space)
+ if element is not None:
+ pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
+ if pset_data:
+ pset = file.by_id(pset_data["id"])
+ data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
+ else:
+ pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
+ data = [result.id()]
+ ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
+ return result
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py
index 3135bef116..64c9fddb62 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py
@@ -31,6 +31,7 @@ def connect_path(
relating_connection: str = "NOTDEFINED",
related_connection: str = "NOTDEFINED",
description: Optional[str] = None,
+ connection_geometry: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
incompatible_connections: list[ifcopenshell.entity_instance] = []
for rel in relating_element.ConnectedTo:
@@ -73,6 +74,7 @@ def connect_path(
ifcopenshell.guid.new(),
OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
Description=description,
+ ConnectionGeometry=connection_geometry,
RelatingElement=relating_element,
RelatedElement=related_element,
RelatingConnectionType=relating_connection,
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py
new file mode 100644
index 0000000000..c2b4c485a9
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py
@@ -0,0 +1,76 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+
+from __future__ import annotations
+
+from typing import Optional
+
+import ifcopenshell.api.geometry
+import ifcopenshell.util.element
+import ifcopenshell.util.representation
+
+
+def copy_representation(
+ file: ifcopenshell.file,
+ source: ifcopenshell.entity_instance,
+ target: ifcopenshell.entity_instance,
+ context_identifier: str = "Body",
+) -> Optional[ifcopenshell.entity_instance]:
+ """Copy a geometric representation from one element to another.
+
+ Finds the named representation on ``source``, deep-copies its entity
+ graph (geometry items, profiles, placements, etc.), and assigns the copy
+ to ``target``. Representation contexts are shared rather than copied.
+ If ``target`` already has a matching representation it is removed and
+ replaced.
+
+ If no matching representation is found on ``source``, returns ``None``
+ and leaves ``target`` unchanged.
+
+ :param source: The element to copy the representation from.
+ :param target: The element to assign the copied representation to.
+ :param context_identifier: The RepresentationIdentifier to look up on
+ ``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``).
+ Defaults to ``"Body"``.
+ :return: The newly created IfcShapeRepresentation, or None if no
+ matching representation was found on ``source``.
+
+ Example:
+
+ .. code:: python
+
+ wall_a = model.by_id(1)
+ wall_b = model.by_id(2)
+
+ # Give wall_b the same body geometry as wall_a.
+ ifcopenshell.api.geometry.copy_representation(model,
+ source=wall_a, target=wall_b)
+ """
+ source_rep = ifcopenshell.util.representation.get_representation(source, "Model", context_identifier)
+ if source_rep is None:
+ return None
+
+ new_rep = ifcopenshell.util.element.copy_deep(file, source_rep, exclude=["IfcGeometricRepresentationContext"])
+
+ existing_rep = ifcopenshell.util.representation.get_representation(target, "Model", context_identifier)
+ if existing_rep:
+ ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep)
+ ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep)
+
+ ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep)
+ return new_rep
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py
index 7fbe44ce12..0d86b997f5 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py
@@ -52,10 +52,11 @@ def edit_object_placement(
:param is_si: If True, the matrix is given in SI units. If false, in
project units.
:param should_transform_children: A child element is a nested element,
- opening, filling, etc. If true, child elements will move along with the
- parent. If false, child elements will stay where they are. Because most
- placements in IFC are relative, this means that if a child moves, we
- actually don't change their placement.
+ opening, filling, etc. If True, child elements move along with the
+ parent; pass True when moving an assembly (roof, furniture group, etc.)
+ and you want all children to follow. If False (default), child elements
+ keep their current world positions; their local placements are rewritten
+ to compensate for the parent move.
:return: The new or updated IfcLocalPlacement entity
"""
usecase = Usecase()
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py
index 9143577438..e59c6e1efa 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py
@@ -69,6 +69,12 @@ def regenerate_wall_representation(
additional extrusions are generated for each connection that boolean
difference the base extrusion.
+ Clippings applied via :func:`geometry.clip_solid` or
+ :func:`geometry.clip_solid_bounded` are preserved only if the ``element``
+ parameter was passed when creating them, which registers the result in the
+ ``BBIM_Boolean`` property set. Clippings created without that parameter
+ are silently discarded during regeneration.
+
This will also update the axis line representation (e.g. trim the axis line
to any connections).
diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py
index 43ccaea2bc..c7e6dfba80 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py
@@ -17,6 +17,7 @@
# along with IfcOpenShell. If not, see .
import ifcopenshell
+import ifcopenshell.api.georeference
import ifcopenshell.api.pset
import ifcopenshell.util.element
@@ -63,8 +64,13 @@ def add_georeferencing(file: ifcopenshell.file, ifc_class: str = "IfcMapConversi
},
)
return
- if file.by_type("IfcProjectedCRS"):
+ has_crs = bool(file.by_type("IfcProjectedCRS"))
+ has_conversion = bool(file.by_type("IfcCoordinateOperation"))
+ if has_crs and has_conversion:
return
+ if has_crs or has_conversion:
+ # This is technically invalid, but we shall forgive the industry here if they are wrong ...
+ ifcopenshell.api.georeference.remove_georeferencing(file)
source_crs = None
for context in file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if context.ContextType == "Model":
diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py
index 302445483b..e7cca1dafa 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py
@@ -26,7 +26,7 @@ def assign_process(
relating_process: ifcopenshell.entity_instance,
related_object: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
- """Assigns an object to be related to a process, typically a construction task
+ """Assigns an object as an input, control, or resource of a process
Processes work using the ICOM (Input, Controls, Outputs, Mechanisms)
paradigm in IFC. This process model is commonly used in modeling
@@ -63,6 +63,17 @@ def assign_process(
For resources, any construction resource may be assigned to a task.
+ .. warning::
+
+ This function creates an **Input** relationship
+ (``IfcRelAssignsToProcess``), meaning the product is *consumed* or
+ *operated on* by the task — the typical case is demolition or
+ maintenance.
+
+ If the task *constructs or installs* a product (e.g. erecting a wall
+ or fitting a window), use :func:`assign_product` instead, which
+ creates an **Output** relationship (``IfcRelAssignsToProduct``).
+
:param relating_process: The IfcProcess (typically IfcTask) that the
input, control, or resource is related to.
:param related_object: The IfcProduct (for input), IfcCostItem (for
@@ -77,7 +88,7 @@ def assign_process(
# need to be part of a work schedule.
schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Construction Schedule A")
- # Let's create a construction task. Note that the predefined type is
+ # Let's create a demolition task. Note that the predefined type is
# important to distinguish types of tasks.
task = ifcopenshell.api.sequence.add_task(model,
work_schedule=schedule, name="Demolish existing", identification="A", predefined_type="DEMOLITION")
@@ -85,8 +96,12 @@ def assign_process(
# Let's say we have a wall somewhere.
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
- # Let's demolish that wall!
+ # The wall is an INPUT to the demolition task (it will be consumed).
ifcopenshell.api.sequence.assign_process(model, relating_process=task, related_object=wall)
+
+ # For a construction task that BUILDS a wall, use assign_product instead:
+ # build_task = ifcopenshell.api.sequence.add_task(model, ..., predefined_type="CONSTRUCTION")
+ # ifcopenshell.api.sequence.assign_product(model, relating_product=wall, related_object=build_task)
"""
if related_object.HasAssignments:
for assignment in related_object.HasAssignments:
diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py
index 2baa262432..8a21e73602 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py
@@ -30,7 +30,9 @@ from .add_structural_load import add_structural_load
from .add_structural_load_case import add_structural_load_case
from .add_structural_load_group import add_structural_load_group
from .add_structural_member_connection import add_structural_member_connection
+from .assign_product import assign_product
from .assign_structural_analysis_model import assign_structural_analysis_model
+from .assign_to_building import assign_to_building
from .edit_structural_analysis_model import edit_structural_analysis_model
from .edit_structural_boundary_condition import edit_structural_boundary_condition
from .edit_structural_connection_cs import edit_structural_connection_cs
@@ -57,7 +59,9 @@ __all__ = [
"add_structural_load_case",
"add_structural_load_group",
"add_structural_member_connection",
+ "assign_product",
"assign_structural_analysis_model",
+ "assign_to_building",
"edit_structural_analysis_model",
"edit_structural_boundary_condition",
"edit_structural_connection_cs",
diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py
new file mode 100644
index 0000000000..a30672facd
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py
@@ -0,0 +1,64 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import ifcopenshell
+import ifcopenshell.api.root
+
+
+def assign_product(
+ file: ifcopenshell.file,
+ relating_product: ifcopenshell.entity_instance,
+ related_object: ifcopenshell.entity_instance,
+) -> ifcopenshell.entity_instance:
+ """Links an object to a product via IfcRelAssignsToProduct
+
+ Typically used to associate a physical building element with a structural
+ analysis member (IfcStructuralSurfaceMember, IfcStructuralCurveMember) so
+ that analysis results can be traced back to the physical model.
+
+ :param relating_product: The IfcProduct that the object is assigned to,
+ typically an IfcStructuralMember.
+ :param related_object: The IfcObjectDefinition being assigned, typically
+ a physical building element such as an IfcWall or IfcSlab.
+ :return: The IfcRelAssignsToProduct relationship.
+
+ Example:
+
+ .. code:: python
+
+ wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
+ member = ifcopenshell.api.root.create_entity(
+ model, ifc_class="IfcStructuralSurfaceMember")
+ ifcopenshell.api.structural.assign_product(model,
+ relating_product=member, related_object=wall)
+ """
+ for rel in relating_product.ReferencedBy or []:
+ if not rel.is_a("IfcRelAssignsToProduct"):
+ continue
+ if related_object in rel.RelatedObjects:
+ return rel
+ related_objects = list(rel.RelatedObjects)
+ related_objects.append(related_object)
+ rel.RelatedObjects = related_objects
+ return rel
+
+ rel = ifcopenshell.api.root.create_entity(file, ifc_class="IfcRelAssignsToProduct")
+ rel.RelatingProduct = relating_product
+ rel.RelatedObjects = [related_object]
+ return rel
diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py
new file mode 100644
index 0000000000..7e70f20272
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py
@@ -0,0 +1,64 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import ifcopenshell
+import ifcopenshell.api.owner
+import ifcopenshell.guid
+
+
+def assign_to_building(
+ file: ifcopenshell.file,
+ structural_analysis_model: ifcopenshell.entity_instance,
+ building: ifcopenshell.entity_instance,
+) -> ifcopenshell.entity_instance:
+ """Associates a structural analysis model with a building via IfcRelServicesBuildings
+
+ The existing :func:`assign_structural_analysis_model` handles
+ IfcRelAssignsToGroup (linking structural members to the analysis model).
+ This function handles the separate model-to-building relationship, which
+ records which building the structural analysis model serves.
+
+ :param structural_analysis_model: The IfcStructuralAnalysisModel to
+ associate with the building.
+ :param building: The IfcBuilding (or other IfcSpatialStructureElement)
+ that the structural analysis model serves.
+ :return: The IfcRelServicesBuildings relationship.
+
+ Example:
+
+ .. code:: python
+
+ building = ifcopenshell.util.selector.filter_elements(model, "IfcBuilding")[0]
+ model_ = ifcopenshell.api.structural.add_structural_analysis_model(model)
+ ifcopenshell.api.structural.assign_to_building(model,
+ structural_analysis_model=model_, building=building)
+ """
+ for rel in structural_analysis_model.ServicesBuildings or []:
+ if building in rel.RelatedBuildings:
+ return rel
+ rel.RelatedBuildings = list(rel.RelatedBuildings) + [building]
+ return rel
+
+ return file.create_entity(
+ "IfcRelServicesBuildings",
+ ifcopenshell.guid.new(),
+ OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
+ RelatingSystem=structural_analysis_model,
+ RelatedBuildings=[building],
+ )
diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py
index 774c0c898e..e53d069a76 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py
@@ -517,11 +517,18 @@ class ShapeBuilder:
trim_points_mask: Sequence[int],
position_offset: Optional[VectorType] = None,
) -> np.ndarray:
- """Handy way to get edge points of the ellipse like shape of a given radiuses.
+ """Get cardinal-point coordinates of an ellipse by index mask.
- Mask points are numerated from 0 to 3 ccw starting from (x_axis_radius/2; 0).
+ The four cardinal points are numbered 0–3 counter-clockwise starting from the
+ positive X axis: 0 → ``(x, 0)``, 1 → ``(0, y)``, 2 → ``(-x, 0)``, 3 → ``(0, -y)``.
- Example: mask (0, 1, 2, 3) will return points (x, 0), (0, y), (-x, 0), (0, -y)
+ Example: mask ``(0, 1, 2, 3)`` returns all four points in order.
+
+ :param x_axis_radius: Radius (semi-axis length) along the X axis.
+ :param y_axis_radius: Radius (semi-axis length) along the Y axis.
+ :param trim_points_mask: Sequence of cardinal-point indices (0–3) to select.
+ :param position_offset: Optional 2D offset added to all returned points.
+ :return: Numpy array of the selected 2D points.
"""
points = np.array(
(
@@ -546,15 +553,23 @@ class ShapeBuilder:
ref_x_direction: VectorType = (1.0, 0.0),
trim_points_mask: Sequence[int] = (),
) -> ifcopenshell.entity_instance:
- """
- Ellipse trimming points should be specified in counter clockwise order.
+ """Create an IfcEllipse, optionally trimmed to an arc.
- For example, if you need to get the part of the ellipse ABOVE y-axis, you need to use mask (0,2). Below y-axis - (2,0)
+ If neither ``trim_points`` nor ``trim_points_mask`` is provided, a full IfcEllipse is returned.
+ Trimming points must be given in counter-clockwise order. For example, to get the arc
+ above the Y-axis use mask ``(0, 2)``; below the Y-axis use ``(2, 0)``.
- For more information about trim_points_mask check builder.get_trim_points_from_mask
+ A trimmed result (IfcTrimmedCurve) includes a closing segment between the trim points,
+ making it suitable for use as a profile in :meth:`extrude`.
- Notion: trimmed ellipse also contains polyline between trim points, meaning IfcTrimmedCurve could be used
- for further extrusion.
+ :param x_axis_radius: Semi-axis length along the local X axis.
+ :param y_axis_radius: Semi-axis length along the local Y axis.
+ :param position: 2D centre of the ellipse.
+ :param trim_points: Explicit pair of 2D trim points. Takes precedence over ``trim_points_mask``.
+ :param ref_x_direction: Direction of the local X axis.
+ :param trim_points_mask: Pair of cardinal-point indices (0–3) used when ``trim_points`` is empty.
+ See :meth:`get_trim_points_from_mask` for index definitions.
+ :return: IfcEllipse (untrimmed) or IfcTrimmedCurve (trimmed).
"""
ifc_position = self.create_axis2_placement_2d(position, ref_x_direction)
ifc_ellipse = self.file.createIfcEllipse(
@@ -685,6 +700,14 @@ class ShapeBuilder:
pivot_point: VectorType = (0.0, 0.0),
counter_clockwise: bool = False,
) -> np.ndarray:
+ """Rotate a single 2D point around a pivot.
+
+ :param point_2d: The 2D point to rotate.
+ :param angle: Rotation angle, in degrees. Defaults to 90.
+ :param pivot_point: The point to rotate around.
+ :param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise.
+ :return: Rotated 2D point as a numpy array.
+ """
angle_rad = radians(angle) * (1 if counter_clockwise else -1)
relative_point = np.array(point_2d) - pivot_point
relative_point = np_rotation_matrix(angle_rad, 2) @ relative_point
@@ -752,7 +775,16 @@ class ShapeBuilder:
mirror_axes: VectorType = (1.0, 1.0),
mirror_point: VectorType = (0.0, 0.0),
) -> np.ndarray:
- """mirror_axes - along which axes mirror will be applied"""
+ """Mirror a single 2D point across the specified axes.
+
+ :param point_2d: The 2D point to mirror.
+ :param mirror_axes: Indicates which axes to mirror across. A positive value in a
+ component means that axis is mirrored (negated relative to ``mirror_point``).
+ Example: ``(1, 0)`` mirrors across the Y-axis (negates X only),
+ ``(1, 1)`` mirrors across both axes.
+ :param mirror_point: Origin of the mirror operation.
+ :return: Mirrored 2D point as a numpy array.
+ """
mirror_axes: np.ndarray = np.where(np.array(mirror_axes) > 0, -1, 1)
mirror_point: np.ndarray = np.array(mirror_point)
relative_point = point_2d - mirror_point
@@ -798,7 +830,13 @@ class ShapeBuilder:
def create_axis2_placement_2d(
self, position: VectorType = (0.0, 0.0), x_direction: Optional[VectorType] = None
) -> ifcopenshell.entity_instance:
- """Create IfcAxis2Placement2D."""
+ """Create IfcAxis2Placement2D.
+
+ :param position: 2D origin of the placement.
+ :param x_direction: Direction of the local X axis. If not provided, defaults to
+ the global X axis ``(1, 0)``.
+ :return: IfcAxis2Placement2D
+ """
ref_direction = (
self.file.create_entity("IfcDirection", ifc_safe_vector_type(x_direction)) if x_direction else None
)
@@ -1000,7 +1038,7 @@ class ShapeBuilder:
) -> ifcopenshell.entity_instance:
"""
:param plane: The IfcPlane representing the half space.
- :param agreement_flag: False if +Z represents the void
+ :param agreement_flag: If False (default), the plane normal points toward the **removed** material (the void). The kept region is on the opposite side from the normal.
:return: IfcHalfSpaceSolid
"""
return self.file.createIfcHalfSpaceSolid(plane, AgreementFlag=agreement_flag)
@@ -1053,7 +1091,14 @@ class ShapeBuilder:
def create_swept_disk_solid(
self, path_curve: ifcopenshell.entity_instance, radius: float
) -> ifcopenshell.entity_instance:
- """Create IfcSweptDiskSolid from `path_curve` (must be 3D) and `radius`"""
+ """Create an IfcSweptDiskSolid — a circular cross-section swept along a 3D path.
+
+ Useful for modelling round pipes, conduits, and cables.
+
+ :param path_curve: A 3D curve entity defining the centreline path. Must have ``Dim == 3``.
+ :param radius: Radius of the circular disk cross-section.
+ :return: IfcSweptDiskSolid
+ """
if path_curve.Dim != 3:
raise Exception(
f"Path curve for IfcSweptDiskSolid should be 3D to be valid, currently it has {path_curve.Dim} dimensions.\n"
@@ -1071,10 +1116,22 @@ class ShapeBuilder:
) -> ifcopenshell.entity_instance:
"""Create IFC representation for the specified context and items.
+ **All items must belong to the same geometry category.** IFC prohibits
+ mixing incompatible item types in one representation (e.g.
+ ``IfcExtrudedAreaSolid`` with ``IfcBlock``, or solids with curves).
+ When ``representation_type`` is omitted the type is inferred via
+ :func:`ifcopenshell.util.representation.guess_type`; if the items are
+ heterogeneous ``guess_type`` returns ``None`` and the representation is
+ written with no ``RepresentationType``, which fails IFC validation.
+ Avoid mixing swept-solid primitives (``IfcExtrudedAreaSolid``,
+ ``IfcRevolvedAreaSolid``) with CSG primitives (``IfcBlock``,
+ ``IfcSphere``, etc.) or any other category in a single call.
+
:param context: IfcGeometricRepresentationSubContext
- :param items: could be a list or single curve/IfcExtrudedAreaSolid
+ :param items: A single item or list of items, all of the same geometry
+ category (e.g. all ``IfcExtrudedAreaSolid``, all ``IfcIndexedPolyCurve``)
:param representation_type: Explicitly specified RepresentationType.
- If not provided it will be guessed from the items types
+ If not provided it will be guessed from the items types.
:return: IfcShapeRepresentation
"""
if not isinstance(items, collections.abc.Iterable):
@@ -1096,18 +1153,26 @@ class ShapeBuilder:
)
def deep_copy(self, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
+ """Create a deep copy of an IFC element and all its referenced entities.
+
+ :param element: The IFC entity to copy.
+ :return: A new independent copy of the element.
+ """
return ifcopenshell.util.element.copy_deep(self.file, element)
# UTILITIES
def extrude_kwargs(self, axis: Literal["Y", "X", "Z"]) -> dict[str, tuple[float, float, float]]:
- """Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by some axis.
+ """Shortcut to get kwargs for :meth:`extrude` to extrude along a principal axis.
- It assumes you have 2D profile in:
- XZ plane for Y axis extrusion, \n
- YZ plane for X axis extrusion, \n
- XY plane for Z axis extrusion, \n
+ Assumes the 2D profile lies in the plane perpendicular to the extrusion axis:
+ XZ plane for Y-axis extrusion, YZ plane for X-axis extrusion, XY plane for Z-axis extrusion.
- Extruding by X/Y using other kwargs might break ValidExtrusionDirection."""
+ Extruding along X or Y with other kwargs may violate the IFC ValidExtrusionDirection constraint.
+
+ :param axis: The extrusion axis: ``'X'``, ``'Y'``, or ``'Z'``.
+ :return: A dict with keys ``position_x_axis``, ``position_z_axis``, and ``extrusion_vector``
+ suitable for passing as ``**kwargs`` to :meth:`extrude`.
+ """
if axis == "Y":
return {
@@ -1131,13 +1196,16 @@ class ShapeBuilder:
def rotate_extrusion_kwargs_by_z(
self, kwargs: dict[str, Any], angle: float, counter_clockwise: bool = False
) -> dict[str, VectorType]:
- """shortcut to rotate extrusion kwargs by z axis
+ """Rotate extrusion kwargs around the Z axis.
- `kwargs` expected to have `position_x_axis` and `position_z_axis` keys
+ A shortcut to rotate the ``position_x_axis`` and ``position_z_axis`` values returned by
+ :meth:`extrude_kwargs` around the Z axis before passing them to :meth:`extrude`.
- `angle` is a rotation value in radians
-
- by default rotation is clockwise, to make it counter clockwise use `counter_clockwise` flag
+ :param kwargs: A dict with ``position_x_axis`` and ``position_z_axis`` keys,
+ as returned by :meth:`extrude_kwargs`. The original dict is not mutated.
+ :param angle: Rotation angle, in radians.
+ :param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise.
+ :return: A new dict with ``position_x_axis`` and ``position_z_axis`` rotated around Z.
"""
rot = np_rotation_matrix(-angle, 3, "Z")
kwargs = kwargs.copy() # prevent mutation of original kwargs
@@ -1146,7 +1214,11 @@ class ShapeBuilder:
return kwargs
def get_polyline_coords(self, polyline: ifcopenshell.entity_instance) -> np.ndarray:
- """polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`"""
+ """Extract the coordinate array from a polyline entity.
+
+ :param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity.
+ :return: Numpy array of the polyline's point coordinates.
+ """
coords = None
if polyline.is_a("IfcIndexedPolyCurve"):
coords = np.array(polyline.Points.CoordList)
@@ -1157,7 +1229,12 @@ class ShapeBuilder:
return coords
def set_polyline_coords(self, polyline: ifcopenshell.entity_instance, coords: SequenceOfVectors) -> None:
- """polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`"""
+ """Update the coordinates of a polyline entity in-place.
+
+ :param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity.
+ :param coords: New sequence of point coordinates. Must contain the same number of
+ points as the original polyline.
+ """
if polyline.is_a("IfcIndexedPolyCurve"):
polyline.Points.CoordList = ifc_safe_vector_type(coords)
elif polyline.is_a("IfcPolyline"):
@@ -1296,6 +1373,18 @@ class ShapeBuilder:
WallThickness: float,
FilletRadius: float,
) -> ifcopenshell.entity_instance:
+ """Create a Z-profile (cold-formed steel section) outline curve with lips and fillets.
+
+ All dimensions are in the IFC project's length units.
+
+ :param FirstFlangeWidth: Width of the first (top) flange, measured from the web centreline.
+ :param SecondFlangeWidth: Width of the second (bottom) flange, measured from the web centreline.
+ :param Depth: Total depth of the section (web height).
+ :param Girth: Length of the return lips on each flange.
+ :param WallThickness: Uniform material thickness.
+ :param FilletRadius: Inner bend radius at each corner.
+ :return: IfcIndexedPolyCurve representing the closed Z-profile outline.
+ """
x1 = FirstFlangeWidth
x2 = SecondFlangeWidth
y = Depth / 2
@@ -1337,10 +1426,17 @@ class ShapeBuilder:
def create_transition_arc_ifc(
self, width: float, height: float, create_ifc_curve: bool = False
) -> tuple[SequenceOfVectors, list[list[int]], Union[ifcopenshell.entity_instance, None]]:
- """Create an arc in the rectangle with specified width and height.
+ """Create an arc fitting inside a rectangle of the given width and height.
- If it's not possible to make a complete arc, create an arc with longest radius possible
- and straight segment in the middle.
+ If a single arc cannot span the full width, the longest possible radius is used and
+ a straight segment is inserted in the middle.
+
+ :param width: Width of the bounding rectangle.
+ :param height: Height of the bounding rectangle (also the maximum arc radius).
+ :param create_ifc_curve: If True, also create and return an ``IfcIndexedPolyCurve``.
+ If False, only return the raw point and segment data.
+ :return: A tuple ``(points, segments, ifc_curve)`` where ``ifc_curve`` is an
+ ``IfcIndexedPolyCurve`` when ``create_ifc_curve=True``, otherwise ``None``.
"""
fillet_size = (width / 2) / height
if fillet_size <= 1:
@@ -1370,6 +1466,14 @@ class ShapeBuilder:
return points, segments, transition_arc
def mesh(self, points: SequenceOfVectors, faces: Sequence[Sequence[int]]) -> ifcopenshell.entity_instance:
+ """Create a tessellated mesh from points and face indices.
+
+ Delegates to :meth:`faceted_brep` for IFC2X3, or :meth:`polygonal_face_set` for IFC4 and later.
+
+ :param points: List of 3D coordinates.
+ :param faces: List of faces, each face a sequence of zero-based point indices.
+ :return: IfcFacetedBrep (IFC2X3) or IfcPolygonalFaceSet (IFC4+).
+ """
if self.file.schema == "IFC2X3":
return self.faceted_brep(points, faces)
return self.polygonal_face_set(points, faces)
@@ -1723,11 +1827,20 @@ class ShapeBuilder:
angle: float,
profile_offset: VectorType = (0.0, 0.0),
verbose: bool = True,
- ):
- """get the final transition length for two profiles dimensions, angle and XY offset between them,
+ ) -> Optional[float]:
+ """Get the transition length for two profile half-dimensions, an angle, and an XY offset.
- the difference from `calculate_transition` - `get_transition_length` is making sure
- that length will fit both sides of the transition
+ Unlike :meth:`mep_transition_calculate`, this method checks that the resulting length
+ satisfies the angle constraint from both the start and end profile perspectives.
+
+ :param start_half_dim: Half-dimensions of the start profile as a 3-element array
+ ``[half_x, half_y, depth]``. For circular profiles ``half_x == half_y == radius``.
+ :param end_half_dim: Half-dimensions of the end profile in the same format.
+ :param angle: Maximum allowed transition angle, in degrees.
+ :param profile_offset: 2D XY offset between the centrelines of the start and end profiles.
+ :param verbose: If True, print diagnostic values during calculation.
+ :return: Transition length in project length units, or ``None`` if no valid length exists
+ for the given angle and offset.
"""
print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None
np_X, np_Y = 0, 1
@@ -1788,9 +1901,23 @@ class ShapeBuilder:
angle: Optional[float] = None,
verbose: bool = True,
) -> Union[float, None]:
- """will return transition length based on the profile dimension differences and offset.
+ """Calculate MEP transition length from angle, or transition angle from length.
- If `length` is provided will return transition angle"""
+ Low-level calculation kernel used by :meth:`mep_transition_length`. Provide either
+ ``angle`` or ``length`` (not both); the other value is computed and returned.
+
+ :param start_half_dim: Half-dimensions of the start profile ``[half_x, half_y, depth]``.
+ :param end_half_dim: Half-dimensions of the end profile ``[half_x, half_y, depth]``.
+ :param offset: 2D XY offset between profile centrelines.
+ :param diff: Pre-computed absolute difference of start and end half-dimensions (XY only).
+ Computed from ``start_half_dim`` and ``end_half_dim`` if not provided.
+ :param end_profile: If True, swap X and Y axes to compute from the end-profile perspective.
+ :param length: Known transition length. If provided, the corresponding angle is returned.
+ :param angle: Known transition angle, in degrees. If provided, the corresponding length is returned.
+ :param verbose: If True, print diagnostic values during calculation.
+ :return: Transition length (if ``angle`` was given) or transition angle in degrees
+ (if ``length`` was given), or ``None`` if the geometry is not feasible.
+ """
print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None
diff --git a/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py
new file mode 100644
index 0000000000..ed79d7e8bf
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py
@@ -0,0 +1,115 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import ifcopenshell.api.boundary
+import ifcopenshell.api.root
+import test.bootstrap
+
+
+class TestEditAttributes(test.bootstrap.IFC4):
+ def setup_boundary(self):
+ space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
+ wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ boundary = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcRelSpaceBoundary")
+ return boundary, space, wall
+
+ def test_sets_relating_space_and_building_element(self):
+ boundary, space, wall = self.setup_boundary()
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file, entity=boundary, relating_space=space, related_building_element=wall
+ )
+ assert boundary.RelatingSpace == space
+ assert boundary.RelatedBuildingElement == wall
+
+ def test_defaults_enums_to_notdefined(self):
+ boundary, space, wall = self.setup_boundary()
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file, entity=boundary, relating_space=space, related_building_element=wall
+ )
+ assert boundary.PhysicalOrVirtualBoundary == "NOTDEFINED"
+ assert boundary.InternalOrExternalBoundary == "NOTDEFINED"
+
+ def test_sets_physical_or_virtual(self):
+ boundary, space, wall = self.setup_boundary()
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file,
+ entity=boundary,
+ relating_space=space,
+ related_building_element=wall,
+ physical_or_virtual="PHYSICAL",
+ )
+ assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL"
+
+ def test_sets_internal_or_external(self):
+ boundary, space, wall = self.setup_boundary()
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file,
+ entity=boundary,
+ relating_space=space,
+ related_building_element=wall,
+ internal_or_external="EXTERNAL",
+ )
+ assert boundary.InternalOrExternalBoundary == "EXTERNAL"
+
+ def test_sets_all_enum_variants(self):
+ boundary, space, wall = self.setup_boundary()
+ for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"):
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file,
+ entity=boundary,
+ relating_space=space,
+ related_building_element=wall,
+ physical_or_virtual=value,
+ )
+ assert boundary.PhysicalOrVirtualBoundary == value
+
+ for value in ("INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", "EXTERNAL_FIRE", "NOTDEFINED"):
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file,
+ entity=boundary,
+ relating_space=space,
+ related_building_element=wall,
+ internal_or_external=value,
+ )
+ assert boundary.InternalOrExternalBoundary == value
+
+
+class TestEditAttributesIFC2X3(test.bootstrap.IFC2X3, TestEditAttributes):
+ def test_sets_all_enum_variants(self):
+ boundary, space, wall = self.setup_boundary()
+ for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"):
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file,
+ entity=boundary,
+ relating_space=space,
+ related_building_element=wall,
+ physical_or_virtual=value,
+ )
+ assert boundary.PhysicalOrVirtualBoundary == value
+
+ # IFC2X3 only has INTERNAL, EXTERNAL, NOTDEFINED
+ for value in ("INTERNAL", "EXTERNAL", "NOTDEFINED"):
+ ifcopenshell.api.boundary.edit_attributes(
+ self.file,
+ entity=boundary,
+ relating_space=space,
+ related_building_element=wall,
+ internal_or_external=value,
+ )
+ assert boundary.InternalOrExternalBoundary == value
diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py b/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py
new file mode 100644
index 0000000000..5b89f50338
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py
@@ -0,0 +1,82 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import ifcopenshell.api.context
+import ifcopenshell.api.geometry
+import ifcopenshell.api.root
+import test.bootstrap
+
+
+class TestAddTopologyRepresentation(test.bootstrap.IFC4):
+ def setup_context(self):
+ ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
+ model = ifcopenshell.api.context.add_context(self.file, context_type="Model")
+ return ifcopenshell.api.context.add_context(
+ self.file,
+ context_type="Model",
+ context_identifier="Reference",
+ target_view="GRAPH_VIEW",
+ parent=model,
+ )
+
+ def test_creates_topology_representation(self):
+ context = self.setup_context()
+ face = self.file.create_entity("IfcFaceSurface")
+ rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face)
+ assert rep.is_a("IfcTopologyRepresentation")
+ assert rep.ContextOfItems == context
+ assert face in rep.Items
+
+ def test_infers_face_representation_type(self):
+ context = self.setup_context()
+ face = self.file.create_entity("IfcFaceSurface")
+ rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face)
+ assert rep.RepresentationType == "Face"
+
+ def test_infers_edge_representation_type(self):
+ context = self.setup_context()
+ edge = self.file.create_entity("IfcEdge")
+ rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=edge)
+ assert rep.RepresentationType == "Edge"
+
+ def test_defaults_representation_identifier_to_context_identifier(self):
+ context = self.setup_context()
+ face = self.file.create_entity("IfcFaceSurface")
+ rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face)
+ assert rep.RepresentationIdentifier == context.ContextIdentifier
+
+ def test_custom_representation_identifier(self):
+ context = self.setup_context()
+ face = self.file.create_entity("IfcFaceSurface")
+ rep = ifcopenshell.api.geometry.add_topology_representation(
+ self.file, context=context, item=face, representation_identifier="Body"
+ )
+ assert rep.RepresentationIdentifier == "Body"
+
+ def test_custom_representation_type_overrides_inferred(self):
+ context = self.setup_context()
+ face = self.file.create_entity("IfcFaceSurface")
+ rep = ifcopenshell.api.geometry.add_topology_representation(
+ self.file, context=context, item=face, representation_type="Undefined"
+ )
+ assert rep.RepresentationType == "Undefined"
+
+
+class TestAddTopologyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestAddTopologyRepresentation):
+ pass
diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py
new file mode 100644
index 0000000000..512bfcc92e
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py
@@ -0,0 +1,154 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+
+import json
+
+import ifcopenshell.api.geometry
+import ifcopenshell.util.element
+import ifcopenshell.util.shape_builder
+import test.bootstrap
+
+
+class TestClipSolid(test.bootstrap.IFC4):
+ def make_extrusion(self):
+ builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
+ rect = builder.rectangle(size=(1.0, 1.0))
+ return builder.extrude(rect, magnitude=4.0)
+
+ def test_returns_boolean_clipping_result(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ )
+ assert result.is_a("IfcBooleanClippingResult")
+ assert result.Operator == "DIFFERENCE"
+
+ def test_first_operand_is_the_item(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ )
+ assert result.FirstOperand == extrusion
+
+ def test_second_operand_is_half_space_solid(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ )
+ assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
+
+ def test_clip_plane_location_matches(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ )
+ plane = result.SecondOperand.BaseSurface
+ coords = plane.Position.Location.Coordinates
+ assert list(coords) == [0.0, 0.0, 3.0]
+
+ def test_chaining_two_clips(self):
+ extrusion = self.make_extrusion()
+ first_clip = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ )
+ second_clip = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=first_clip,
+ location=[0.0, 0.0, 1.0],
+ normal=[0.0, 0.0, -1.0],
+ )
+ assert second_clip.is_a("IfcBooleanClippingResult")
+ assert second_clip.FirstOperand == first_clip
+ assert first_clip.FirstOperand == extrusion
+
+ def test_angled_clip_plane(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.26],
+ normal=[0.419, 0.0, 0.908],
+ )
+ assert result.is_a("IfcBooleanClippingResult")
+ assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
+
+ def test_element_registers_result_in_bbim_boolean(self):
+ extrusion = self.make_extrusion()
+ wall = self.file.createIfcWall()
+ result = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ element=wall,
+ )
+ pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
+ assert pset is not None
+ assert result.id() in json.loads(pset["Data"])
+
+ def test_element_appends_to_existing_bbim_boolean(self):
+ extrusion = self.make_extrusion()
+ wall = self.file.createIfcWall()
+ first = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ element=wall,
+ )
+ second = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=first,
+ location=[0.0, 0.0, 1.0],
+ normal=[0.0, 0.0, -1.0],
+ element=wall,
+ )
+ pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
+ ids = json.loads(pset["Data"])
+ assert first.id() in ids
+ assert second.id() in ids
+
+ def test_no_element_does_not_create_pset(self):
+ extrusion = self.make_extrusion()
+ wall = self.file.createIfcWall()
+ ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ )
+ assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
+
+
+class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid):
+ pass
diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py
new file mode 100644
index 0000000000..3dae335cad
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py
@@ -0,0 +1,179 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+
+import json
+
+import ifcopenshell.api.geometry
+import ifcopenshell.util.element
+import ifcopenshell.util.shape_builder
+import test.bootstrap
+
+
+class TestClipSolidBounded(test.bootstrap.IFC4):
+ def make_extrusion(self):
+ builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
+ rect = builder.rectangle(size=(4.0, 1.0))
+ return builder.extrude(rect, magnitude=3.0)
+
+ def test_returns_boolean_clipping_result(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ assert result.is_a("IfcBooleanClippingResult")
+ assert result.Operator == "DIFFERENCE"
+
+ def test_first_operand_is_the_item(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ assert result.FirstOperand == extrusion
+
+ def test_second_operand_is_polygonal_bounded_half_space(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ assert result.SecondOperand.is_a("IfcPolygonalBoundedHalfSpace")
+
+ def test_agreement_flag_is_false(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ assert result.SecondOperand.AgreementFlag is False
+
+ def test_clip_plane_location_matches(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ plane = result.SecondOperand.BaseSurface
+ coords = plane.Position.Location.Coordinates
+ assert list(coords) == [2.5, 0.0, 2.0]
+
+ def test_boundary_is_closed_polyline(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ boundary = result.SecondOperand.PolygonalBoundary
+ assert boundary.is_a("IfcPolyline")
+ pts = [list(p.Coordinates) for p in boundary.Points]
+ assert pts[0] == pts[-1], "polygon should be closed"
+ assert len(pts) == 5 # 4 unique + closing repeat
+
+ def test_boundary_position_defaults_to_origin(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ pos = result.SecondOperand.Position
+ assert list(pos.Location.Coordinates) == [0.0, 0.0, 0.0]
+
+ def test_custom_boundary_position(self):
+ extrusion = self.make_extrusion()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ boundary_position=[1.0, 2.0, 3.0],
+ )
+ pos = result.SecondOperand.Position
+ assert list(pos.Location.Coordinates) == [1.0, 2.0, 3.0]
+
+ def test_chaining_with_clip_solid(self):
+ extrusion = self.make_extrusion()
+ first_clip = ifcopenshell.api.geometry.clip_solid(
+ self.file,
+ item=extrusion,
+ location=[0.0, 0.0, 3.0],
+ normal=[0.0, 0.0, 1.0],
+ )
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=first_clip,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ assert result.is_a("IfcBooleanClippingResult")
+ assert result.FirstOperand == first_clip
+ assert first_clip.FirstOperand == extrusion
+
+ def test_element_registers_result_in_bbim_boolean(self):
+ extrusion = self.make_extrusion()
+ wall = self.file.createIfcWall()
+ result = ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ element=wall,
+ )
+ pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
+ assert pset is not None
+ assert result.id() in json.loads(pset["Data"])
+
+ def test_no_element_does_not_create_pset(self):
+ extrusion = self.make_extrusion()
+ wall = self.file.createIfcWall()
+ ifcopenshell.api.geometry.clip_solid_bounded(
+ self.file,
+ item=extrusion,
+ location=[2.5, 0.0, 2.0],
+ normal=[0.6, 0.0, 0.8],
+ boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
+ )
+ assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
+
+
+class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded):
+ pass
diff --git a/src/ifcopenshell-python/test/api/geometry/test_connect_path.py b/src/ifcopenshell-python/test/api/geometry/test_connect_path.py
index bda341d2c6..0927e474ae 100644
--- a/src/ifcopenshell-python/test/api/geometry/test_connect_path.py
+++ b/src/ifcopenshell-python/test/api/geometry/test_connect_path.py
@@ -33,6 +33,18 @@ class TestConnectPath(test.bootstrap.IFC4):
assert rel.RelatedConnectionType == "ATEND"
assert rel.Description == "MITRE"
+ def test_storing_connection_geometry(self):
+ wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ geometry = self.file.create_entity("IfcConnectionPointGeometry")
+ rel = ifcopenshell.api.geometry.connect_path(
+ self.file,
+ relating_element=wall1,
+ related_element=wall2,
+ connection_geometry=geometry,
+ )
+ assert rel.ConnectionGeometry == geometry
+
def test_doing_nothing_if_the_element_is_already_connected(self):
wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
diff --git a/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py
new file mode 100644
index 0000000000..8faf07baa9
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py
@@ -0,0 +1,134 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+
+import ifcopenshell.api.geometry
+import ifcopenshell.api.root
+import ifcopenshell.util.representation
+import ifcopenshell.util.shape_builder
+import test.bootstrap
+
+
+class TestCopyRepresentation(test.bootstrap.IFC4):
+ def _body_context(self):
+ body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW")
+ if body is None:
+ model = self.file.createIfcGeometricRepresentationContext(
+ ContextType="Model",
+ CoordinateSpaceDimension=3,
+ Precision=1e-5,
+ WorldCoordinateSystem=self.file.createIfcAxis2Placement3D(
+ self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
+ ),
+ )
+ body = self.file.createIfcGeometricRepresentationSubContext(
+ ContextIdentifier="Body",
+ ContextType="Model",
+ TargetView="MODEL_VIEW",
+ ParentContext=model,
+ )
+ return body
+
+ def _add_body_rep(self, element):
+ body = self._body_context()
+ rep = ifcopenshell.api.geometry.add_wall_representation(
+ self.file, context=body, length=5.0, height=3.0, thickness=0.2
+ )
+ ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep)
+ return rep
+
+ def test_copy_to_empty_target(self):
+ wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ self._add_body_rep(wall_a)
+
+ result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
+
+ assert result is not None
+ assert result.is_a("IfcShapeRepresentation")
+ target_rep = ifcopenshell.util.representation.get_representation(wall_b, "Model", "Body")
+ assert target_rep is not None
+ assert target_rep == result
+
+ def test_source_rep_entities_are_distinct(self):
+ wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ source_rep = self._add_body_rep(wall_a)
+
+ new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
+
+ assert new_rep.id() != source_rep.id()
+ assert new_rep.Items[0].id() != source_rep.Items[0].id()
+
+ def test_context_is_shared_not_copied(self):
+ wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ source_rep = self._add_body_rep(wall_a)
+
+ new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
+
+ assert new_rep.ContextOfItems.id() == source_rep.ContextOfItems.id()
+
+ def test_replaces_existing_target_rep(self):
+ wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ self._add_body_rep(wall_a)
+ old_rep = self._add_body_rep(wall_b)
+ old_rep_id = old_rep.id()
+
+ ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
+
+ try:
+ self.file.by_id(old_rep_id)
+ assert False, "old representation still exists"
+ except RuntimeError:
+ pass # entity was removed, as expected
+
+ def test_source_unchanged_after_copy(self):
+ wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ source_rep = self._add_body_rep(wall_a)
+ source_rep_id = source_rep.id()
+
+ ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
+
+ assert self.file.by_id(source_rep_id) is not None # source must still exist
+ assert ifcopenshell.util.representation.get_representation(wall_a, "Model", "Body") is not None
+
+ def test_returns_none_when_no_matching_rep(self):
+ wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+
+ result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
+
+ assert result is None
+
+ def test_custom_context_identifier(self):
+ wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ self._add_body_rep(wall_a)
+
+ # "Axis" doesn't exist on wall_a, so should return None
+ result = ifcopenshell.api.geometry.copy_representation(
+ self.file, source=wall_a, target=wall_b, context_identifier="Axis"
+ )
+
+ assert result is None
+
+
+class TestCopyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestCopyRepresentation):
+ pass
diff --git a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py
index 68f1ac2597..44e45dec28 100644
--- a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py
+++ b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py
@@ -51,6 +51,30 @@ class TestAddGeoreferencing(test.bootstrap.IFC4):
assert len(self.file.by_type("IfcMapConversion")) == 1
assert len(self.file.by_type("IfcProjectedCRS")) == 1
+ def test_recovering_from_orphan_projected_crs(self):
+ ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
+ ifcopenshell.api.context.add_context(self.file, "Model")
+ self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234")
+ assert len(self.file.by_type("IfcProjectedCRS")) == 1
+ assert len(self.file.by_type("IfcCoordinateOperation")) == 0
+ ifcopenshell.api.georeference.add_georeferencing(self.file)
+ assert len(self.file.by_type("IfcMapConversion")) == 1
+ assert len(self.file.by_type("IfcProjectedCRS")) == 1
+
+ def test_recovering_from_orphan_coordinate_operation(self):
+ ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
+ context = ifcopenshell.api.context.add_context(self.file, "Model")
+ self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
+ ifcopenshell.api.georeference.remove_georeferencing(self.file)
+ # Simulate orphan by re-adding just a conversion without CRS
+ self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
+ self.file.remove(self.file.by_type("IfcProjectedCRS")[0])
+ assert len(self.file.by_type("IfcProjectedCRS")) == 0
+ assert len(self.file.by_type("IfcCoordinateOperation")) == 1
+ ifcopenshell.api.georeference.add_georeferencing(self.file)
+ assert len(self.file.by_type("IfcMapConversion")) == 1
+ assert len(self.file.by_type("IfcProjectedCRS")) == 1
+
class TestAddGeoreferencingIFC2X3(test.bootstrap.IFC2X3):
def test_adding_georeferencing(self):
diff --git a/src/ifcopenshell-python/test/api/structural/test_assign_product.py b/src/ifcopenshell-python/test/api/structural/test_assign_product.py
new file mode 100644
index 0000000000..3e2f1c2b4a
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/structural/test_assign_product.py
@@ -0,0 +1,56 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import ifcopenshell.api.root
+import ifcopenshell.api.structural
+import test.bootstrap
+
+
+class TestAssignProduct(test.bootstrap.IFC4):
+ def test_creating_a_new_relationship(self):
+ member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember")
+ wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ rel = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall)
+ assert rel.is_a("IfcRelAssignsToProduct")
+ assert rel.RelatingProduct == member
+ assert wall in rel.RelatedObjects
+
+ def test_adding_a_second_object_to_an_existing_relationship(self):
+ member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember")
+ wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ rel1 = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall1)
+ rel2 = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall2)
+ assert rel1 == rel2
+ assert len(self.file.by_type("IfcRelAssignsToProduct")) == 1
+ assert wall1 in rel1.RelatedObjects
+ assert wall2 in rel1.RelatedObjects
+
+ def test_does_not_duplicate_an_existing_assignment(self):
+ member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember")
+ wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+ ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall)
+ ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall)
+ assert len(self.file.by_type("IfcRelAssignsToProduct")) == 1
+ rels = self.file.by_type("IfcRelAssignsToProduct")
+ assert len(rels[0].RelatedObjects) == 1
+
+
+class TestAssignProductIFC2X3(test.bootstrap.IFC2X3, TestAssignProduct):
+ pass
diff --git a/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py b/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py
new file mode 100644
index 0000000000..671ca1b476
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py
@@ -0,0 +1,62 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell 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.
+#
+# IfcOpenShell 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 IfcOpenShell. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import ifcopenshell.api.root
+import ifcopenshell.api.structural
+import test.bootstrap
+
+
+class TestAssignToBuilding(test.bootstrap.IFC4):
+ def test_creating_a_new_relationship(self):
+ model = ifcopenshell.api.structural.add_structural_analysis_model(self.file)
+ building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding")
+ rel = ifcopenshell.api.structural.assign_to_building(
+ self.file, structural_analysis_model=model, building=building
+ )
+ assert rel.is_a("IfcRelServicesBuildings")
+ assert rel.RelatingSystem == model
+ assert building in rel.RelatedBuildings
+
+ def test_adding_a_second_building_to_an_existing_relationship(self):
+ model = ifcopenshell.api.structural.add_structural_analysis_model(self.file)
+ building1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding")
+ building2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding")
+ rel1 = ifcopenshell.api.structural.assign_to_building(
+ self.file, structural_analysis_model=model, building=building1
+ )
+ rel2 = ifcopenshell.api.structural.assign_to_building(
+ self.file, structural_analysis_model=model, building=building2
+ )
+ assert rel1 == rel2
+ assert len(self.file.by_type("IfcRelServicesBuildings")) == 1
+ assert building1 in rel1.RelatedBuildings
+ assert building2 in rel1.RelatedBuildings
+
+ def test_does_not_duplicate_an_existing_assignment(self):
+ model = ifcopenshell.api.structural.add_structural_analysis_model(self.file)
+ building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding")
+ ifcopenshell.api.structural.assign_to_building(self.file, structural_analysis_model=model, building=building)
+ ifcopenshell.api.structural.assign_to_building(self.file, structural_analysis_model=model, building=building)
+ assert len(self.file.by_type("IfcRelServicesBuildings")) == 1
+ rels = self.file.by_type("IfcRelServicesBuildings")
+ assert len(rels[0].RelatedBuildings) == 1
+
+
+class TestAssignToBuildingIFC2X3(test.bootstrap.IFC2X3, TestAssignToBuilding):
+ pass
diff --git a/src/ifcquery/README.md b/src/ifcquery/README.md
new file mode 100644
index 0000000000..0c90ecf596
--- /dev/null
+++ b/src/ifcquery/README.md
@@ -0,0 +1,499 @@
+
+# ifcquery
+
+A CLI tool for querying and inspecting IFC building models. All output is
+structured JSON (or human-readable text), making it easy to pipe into other
+tools or scripts.
+
+## Installation
+
+```bash
+pip install ifcquery
+```
+
+Requires `ifcopenshell`. The `clash` subcommand additionally requires the
+IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`).
+
+## Usage
+
+```
+ifcquery [options] [--format json|text|ids]
+```
+
+The `--format` flag controls output:
+
+- `json` (default) -- structured JSON, suitable for piping to `jq` or `ifcedit foreach`
+- `text` -- indented human-readable output
+- `ids` -- comma-separated step IDs extracted from list results, suitable for piping directly into `ifcedit run` parameters
+
+## Subcommands
+
+### summary
+
+Get a model overview: schema version, entity counts, and project info.
+
+```bash
+ifcquery model.ifc summary
+```
+
+```json
+{
+ "schema": "IFC4",
+ "total_entities": 1847,
+ "project": {
+ "id": 1,
+ "name": "Office Building",
+ "description": null
+ },
+ "types": {
+ "IfcWall": 42,
+ "IfcSlab": 12,
+ "IfcWindow": 36
+ }
+}
+```
+
+### tree
+
+Display the spatial hierarchy from IfcProject down through sites, buildings,
+storeys, and their contained elements.
+
+```bash
+ifcquery model.ifc tree
+```
+
+```json
+{
+ "id": 1,
+ "type": "IfcProject",
+ "name": "Office Building",
+ "children": [
+ {
+ "id": 2,
+ "type": "IfcSite",
+ "name": "Default Site",
+ "children": [
+ {
+ "id": 3,
+ "type": "IfcBuilding",
+ "name": "Main Building",
+ "children": [
+ {
+ "id": 4,
+ "type": "IfcBuildingStorey",
+ "name": "Ground Floor",
+ "elements": [
+ {"id": 10, "type": "IfcWall", "name": "Wall001"},
+ {"id": 11, "type": "IfcSlab", "name": "Floor001"}
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
+```
+
+### info
+
+Get detailed information about a specific element by step ID.
+
+```bash
+ifcquery model.ifc info 10
+ifcquery model.ifc info '#10'
+```
+
+Returns attributes, property sets, type relationship, material assignment,
+spatial container, and placement matrix.
+
+```json
+{
+ "id": 10,
+ "type": "IfcWall",
+ "attributes": {
+ "Name": "Wall001",
+ "Description": null,
+ "ObjectType": "LOADBEARING"
+ },
+ "property_sets": {
+ "Pset_WallCommon": {
+ "IsExternal": true,
+ "FireRating": "2HR"
+ }
+ },
+ "element_type": {"id": 50, "type": "IfcWallType", "name": "Standard"},
+ "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"},
+ "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
+ "placement": [
+ [1.0, 0.0, 0.0, 5.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0]
+ ]
+}
+```
+
+### select
+
+Filter elements using the ifcopenshell selector syntax.
+
+```bash
+ifcquery model.ifc select 'IfcWall'
+ifcquery model.ifc select 'IfcWall, IfcSlab'
+```
+
+```json
+[
+ {"id": 10, "type": "IfcWall", "name": "Wall001"},
+ {"id": 11, "type": "IfcWall", "name": "Wall002"},
+ {"id": 20, "type": "IfcSlab", "name": "Floor001"}
+]
+```
+
+Results are sorted by ID.
+
+Use `--format ids` to get a comma-separated list of step IDs for direct use
+in `ifcedit run` parameters:
+
+```bash
+ifcedit run model.ifc type.assign_type \
+ --related_objects "$(ifcquery model.ifc --format ids select 'IfcWall')" \
+ --relating_type 456
+```
+
+### relations
+
+Show all relationships for an element, organized by category: hierarchy,
+children, type relationships, groups, systems, material, and connections.
+
+```bash
+ifcquery model.ifc relations 10
+```
+
+```json
+{
+ "id": 10,
+ "type": "IfcWall",
+ "name": "Wall001",
+ "hierarchy": {
+ "parent": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
+ "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}
+ },
+ "children": {
+ "openings": [{"id": 30, "type": "IfcOpeningElement", "name": "Opening01"}]
+ },
+ "type_relationship": {
+ "type_of": {"id": 50, "type": "IfcWallType", "name": "Standard"}
+ },
+ "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"}
+}
+```
+
+Empty categories are omitted from output.
+
+Use `--traverse up` to walk the spatial hierarchy from the element up to
+IfcProject:
+
+```bash
+ifcquery model.ifc relations 10 --traverse up
+```
+
+```json
+[
+ {"id": 10, "type": "IfcWall", "name": "Wall001"},
+ {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
+ {"id": 3, "type": "IfcBuilding", "name": "Main Building"},
+ {"id": 2, "type": "IfcSite", "name": "Default Site"},
+ {"id": 1, "type": "IfcProject", "name": "Office Building"}
+]
+```
+
+### validate
+
+Check the model for schema and constraint violations.
+
+```bash
+ifcquery model.ifc validate
+ifcquery model.ifc validate --rules
+```
+
+Options:
+
+- `--rules` -- also run the slower EXPRESS rules check (default: off)
+
+```json
+{
+ "valid": true,
+ "issues": []
+}
+```
+
+On an invalid model:
+
+```json
+{
+ "valid": false,
+ "issues": [
+ {"level": "ERROR", "message": "Entity #42 IfcWall.GlobalId is not a valid IfcGloballyUniqueId"}
+ ]
+}
+```
+
+### schedule
+
+List all work schedules and their task trees from the model.
+
+```bash
+ifcquery model.ifc schedule
+ifcquery model.ifc schedule --depth 1
+```
+
+Options:
+
+- `--depth N` -- expand at most N levels of subtasks (default: unlimited). At the
+ cutoff, `subtasks` is replaced with `{"truncated": true, "count": N}`.
+
+```json
+[
+ {
+ "id": 42,
+ "name": "Construction Schedule",
+ "predefined_type": "BASELINE",
+ "tasks": [
+ {
+ "id": 55,
+ "name": "Phase 1",
+ "start": "2024-01-01T09:00:00",
+ "finish": "2024-06-30T17:00:00",
+ "is_milestone": false,
+ "outputs": [{"id": 10, "type": "IfcWall", "name": "Wall A"}],
+ "subtasks": [
+ {"id": 56, "name": "Foundations", "start": null, "finish": null,
+ "is_milestone": false, "outputs": [], "subtasks": []}
+ ]
+ }
+ ]
+ }
+]
+```
+
+### cost
+
+List all cost schedules and their cost item trees from the model.
+
+```bash
+ifcquery model.ifc cost
+ifcquery model.ifc cost --depth 2
+```
+
+Options:
+
+- `--depth N` -- expand at most N levels of subitems (default: unlimited). At the
+ cutoff, `subitems` is replaced with `{"truncated": true, "count": N}`.
+
+```json
+[
+ {
+ "id": 100,
+ "name": "Bill of Quantities",
+ "predefined_type": "COSTPLAN",
+ "items": [
+ {
+ "id": 110,
+ "name": "Concrete Works",
+ "values": [{"formula": "1200.00 = material(1200.0)", "category": "material"}],
+ "subitems": [
+ {"id": 111, "name": "Formwork", "values": [], "subitems": []}
+ ]
+ }
+ ]
+ }
+]
+```
+
+### schema
+
+Show IFC class documentation for any entity type, using the schema version of
+the loaded model.
+
+```bash
+ifcquery model.ifc schema IfcWall
+ifcquery model.ifc schema IfcBuildingStorey
+```
+
+```json
+{
+ "description": "The wall represents a vertical construction ...",
+ "predefined_types": {"STANDARD": "A standard wall, extruded vertically ..."},
+ "spec_url": "https://standards.buildingsmart.org/...",
+ "attributes": {
+ "Name": "Optional name for use by the participating software systems",
+ "ObjectPlacement": "Placement of the product in space ..."
+ }
+}
+```
+
+Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
+
+### contexts
+
+List all geometric representation contexts and subcontexts in the model.
+
+```bash
+ifcquery model.ifc contexts
+```
+
+```json
+[
+ {
+ "id": 5,
+ "type": "IfcGeometricRepresentationContext",
+ "context_type": "Model",
+ "subcontexts": [
+ {"id": 6, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Body", "target_view": "MODEL_VIEW"},
+ {"id": 7, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Axis", "target_view": "GRAPH_VIEW"}
+ ]
+ }
+]
+```
+
+### materials
+
+List all materials and material sets used in the model, with their assigned elements.
+
+```bash
+ifcquery model.ifc materials
+```
+
+```json
+[
+ {
+ "id": 60,
+ "type": "IfcMaterial",
+ "name": "Concrete",
+ "elements": [{"id": 10, "type": "IfcWall", "name": "Wall001"}]
+ }
+]
+```
+
+### plot
+
+Generate a 2D technical drawing (floor plan, elevation, or section) of the model and write it to a file.
+
+```bash
+ifcquery model.ifc plot output.svg
+ifcquery model.ifc plot output.png --view floorplan --scale 0.01
+```
+
+Options:
+
+- `--view {floorplan,elevation,section,auto}` -- drawing view (default: `floorplan`)
+- `--scale ` -- model-to-paper scale ratio (default: 0.01 = 1:100)
+- `--width-mm ` -- paper width in mm (default: 297)
+- `--height-mm ` -- paper height in mm (default: 420)
+- `--png-width ` -- raster output width in pixels (default: 1024)
+- `--png-height ` -- raster output height in pixels (default: 1024)
+
+Writes SVG when the output path ends in `.svg`, otherwise PNG.
+Requires the IfcOpenShell drawing module (`ifcopenshell.draw`).
+
+### render
+
+Render a 3D view of the model geometry to a PNG file.
+
+```bash
+ifcquery model.ifc render output.png
+ifcquery model.ifc render output.png --view iso --selector IfcWall
+```
+
+Options:
+
+- `--view {iso,top,south,north,east,west}` -- camera angle (default: `iso`)
+- `--selector ` -- ifcopenshell selector to restrict rendered elements
+
+Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
+
+### clash
+
+Check a single element for geometric intersections and clearance violations
+against other elements.
+
+```bash
+ifcquery model.ifc clash 10
+ifcquery model.ifc clash 10 --clearance 0.5
+ifcquery model.ifc clash 10 --scope all --tolerance 0.001
+```
+
+Options:
+
+- `--clearance ` -- minimum clearance distance to check
+- `--tolerance ` -- intersection tolerance (default: 0.002)
+- `--scope {storey,all}` -- check against same-storey elements or all elements (default: storey)
+
+```json
+{
+ "element": {"id": 10, "type": "IfcWall", "name": "Wall001"},
+ "scope": "storey",
+ "pass": false,
+ "checks": {
+ "intersection": {
+ "pass": false,
+ "tolerance": 0.002,
+ "clashes": [
+ {
+ "element": {"id": 11, "type": "IfcWall", "name": "Wall002"},
+ "type": "intersection",
+ "distance": 0.0,
+ "p1": [2.5, 2.5, 1.5],
+ "p2": [2.5, 2.5, 1.5]
+ }
+ ]
+ },
+ "clearance": {
+ "pass": true,
+ "clearance": 0.5,
+ "clashes": []
+ }
+ }
+}
+```
+
+Requires the IfcOpenShell C++ geometry bindings.
+
+## Scripting with ifcedit
+
+`ifcquery` and `ifcedit` are designed to compose. Use `--format ids` to pass
+query results directly into `ifcedit run` parameters, or pipe JSON into
+`ifcedit foreach` to apply an operation to every matching element.
+
+```bash
+# Remove all walls from their spatial container
+ifcedit run model.ifc spatial.unassign_container \
+ --products "$(ifcquery model.ifc --format ids select 'IfcWall')"
+
+# Delete every window (model opened and saved once)
+ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
+
+# Bulk rename all doors
+ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
+ --product {id} --attributes '{"Name": "Door"}'
+
+# Render an element highlighted against everything related to it
+ifcquery model.ifc render relations.png \
+ --element "$(ifcquery model.ifc --format ids relations 42)"
+
+# Render a clash — subject and clashing elements highlighted together
+ifcquery model.ifc render clash.png \
+ --element "$(ifcquery model.ifc --format ids clash 42)"
+```
+
+See the `ifcedit` documentation for the full `foreach` reference.
+
+## Error handling
+
+Errors are written to stderr. Exit code is 0 on success, 1 on error.
+
+## License
+
+LGPLv3+ -- see the IfcOpenShell project license.
diff --git a/src/ifcquery/ifcquery/__init__.py b/src/ifcquery/ifcquery/__init__.py
new file mode 100644
index 0000000000..be5327b20d
--- /dev/null
+++ b/src/ifcquery/ifcquery/__init__.py
@@ -0,0 +1,20 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+__version__ = version = "0.0.0"
diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py
new file mode 100644
index 0000000000..d4b6141fd9
--- /dev/null
+++ b/src/ifcquery/ifcquery/__main__.py
@@ -0,0 +1,397 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+
+import ifcopenshell
+
+from ifcquery import clash as clash_mod
+from ifcquery import contexts as contexts_mod
+from ifcquery import cost as cost_mod
+from ifcquery import (
+ info,
+ plot,
+ relations,
+ schedule,
+ schema,
+ select,
+ summary,
+ tree,
+)
+from ifcquery import (
+ materials as materials_mod,
+)
+from ifcquery import (
+ render as render_mod,
+)
+from ifcquery import validate as validate_mod
+
+
+def parse_element_id(raw: str) -> int:
+ """Parse an element ID from '#123' or '123' format."""
+ raw = raw.strip().lstrip("#")
+ return int(raw)
+
+
+def format_output(data, fmt: str) -> str:
+ if fmt == "json":
+ return json.dumps(data, indent=2, ensure_ascii=False)
+ elif fmt == "text":
+ return _format_text(data)
+ elif fmt == "ids":
+ return _format_ids(data)
+ return json.dumps(data, indent=2, ensure_ascii=False)
+
+
+def _format_ids(data) -> str:
+ """Extract 'id' fields from a list of dicts and return as comma-separated string.
+
+ For dicts with a top-level 'elements' key (e.g. clash, relations output),
+ extracts from that flat summary list rather than the nested structure.
+ """
+ if isinstance(data, list):
+ ids = [str(item["id"]) for item in data if isinstance(item, dict) and "id" in item]
+ return ",".join(ids)
+ if isinstance(data, dict):
+ if "elements" in data and isinstance(data["elements"], list):
+ return _format_ids(data["elements"])
+ if "id" in data:
+ return str(data["id"])
+ return ""
+
+
+def _format_text(data, indent: int = 0) -> str:
+ prefix = " " * indent
+ lines = []
+ if isinstance(data, dict):
+ for key, value in data.items():
+ if isinstance(value, (dict, list)):
+ lines.append(f"{prefix}{key}:")
+ lines.append(_format_text(value, indent + 1))
+ else:
+ lines.append(f"{prefix}{key}: {value}")
+ elif isinstance(data, list):
+ for item in data:
+ if isinstance(item, dict):
+ lines.append(_format_text(item, indent))
+ lines.append("")
+ else:
+ lines.append(f"{prefix}- {item}")
+ else:
+ lines.append(f"{prefix}{data}")
+ return "\n".join(lines)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ prog="ifcquery",
+ description="Query and inspect IFC building models",
+ )
+ parser.add_argument("ifc_file", help="Path to the IFC file")
+ parser.add_argument(
+ "--format",
+ choices=["json", "text", "ids"],
+ default="json",
+ dest="output_format",
+ help="Output format: json (default), text (human-readable), ids (comma-separated step IDs)",
+ )
+
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ subparsers.add_parser("summary", help="Model overview: schema, element counts, project info")
+
+ subparsers.add_parser("tree", help="Spatial hierarchy tree")
+
+ info_parser = subparsers.add_parser("info", help="Deep inspection of a specific element")
+ info_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
+
+ select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax")
+ select_parser.add_argument("query", help="Selector query string")
+
+ relations_parser = subparsers.add_parser("relations", help="Show relationships for an element")
+ 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)"
+ )
+
+ validate_parser = subparsers.add_parser("validate", help="Schema/constraint validation")
+ validate_parser.add_argument(
+ "--rules", action="store_true", help="Also check EXPRESS rules (slower, default: false)"
+ )
+
+ schedule_parser = subparsers.add_parser("schedule", help="List work plans and tasks from the model")
+ schedule_parser.add_argument(
+ "--depth", type=int, default=None, metavar="N", help="Limit subtask expansion to N levels (default: unlimited)"
+ )
+
+ cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model")
+ cost_parser.add_argument(
+ "--depth",
+ type=int,
+ default=None,
+ metavar="N",
+ help="Limit cost item expansion to N levels (default: unlimited)",
+ )
+
+ subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts")
+
+ subparsers.add_parser("materials", help="List materials and material sets")
+
+ schema_parser = subparsers.add_parser("schema", help="IFC class documentation")
+ schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
+
+ render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image")
+ render_parser.add_argument(
+ "-o", "--output", default="", metavar="FILE", help="Output PNG path (default: .png)"
+ )
+ render_parser.add_argument(
+ "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements"
+ )
+ render_parser.add_argument(
+ "--element",
+ default="",
+ metavar="ID[,ID...]",
+ help="Comma-separated step IDs of elements to highlight (rest rendered in grey)",
+ )
+ render_parser.add_argument(
+ "--view",
+ choices=render_mod.VIEWS,
+ default="iso",
+ help="Camera angle (default: iso)",
+ )
+
+ plot_parser = subparsers.add_parser(
+ "plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)"
+ )
+ plot_parser.add_argument(
+ "-o",
+ "--output",
+ default="",
+ metavar="FILE",
+ help="Output file path. Default depends on --out-format: .svg/.png",
+ )
+ plot_parser.add_argument(
+ "--out-format",
+ choices=["svg", "png", "base64"],
+ default="png",
+ help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png",
+ )
+ plot_parser.add_argument(
+ "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict plotted elements"
+ )
+ plot_parser.add_argument(
+ "--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight"
+ )
+ plot_parser.add_argument(
+ "--view",
+ choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")),
+ default="floorplan",
+ help="Drawing view (default: floorplan)",
+ )
+ plot_parser.add_argument(
+ "--width-mm",
+ type=float,
+ default=297.0,
+ metavar="MM",
+ help="Paper width in mm (default: 297)",
+ )
+ plot_parser.add_argument(
+ "--height-mm",
+ type=float,
+ default=420.0,
+ metavar="MM",
+ help="Paper height in mm (default: 420)",
+ )
+ plot_parser.add_argument(
+ "--scale",
+ type=float,
+ default=1.0 / 100.0,
+ metavar="S",
+ help="Model-to-paper scale (default: 0.01 = 1:100)",
+ )
+ plot_parser.add_argument(
+ "--png-width",
+ type=int,
+ default=1024,
+ metavar="PX",
+ help="PNG width in pixels (default: 1024)",
+ )
+ plot_parser.add_argument(
+ "--png-height",
+ type=int,
+ default=1024,
+ metavar="PX",
+ help="PNG height in pixels (default: 1024)",
+ )
+
+ args = parser.parse_args()
+
+ try:
+ model = ifcopenshell.open(args.ifc_file)
+ except Exception as e:
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if args.command == "summary":
+ result = summary.summary(model)
+ elif args.command == "tree":
+ result = tree.tree(model)
+ elif args.command == "info":
+ 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)
+ result = info.info(model, element)
+ elif args.command == "select":
+ result = select.select(model, args.query)
+ elif args.command == "relations":
+ 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)
+ 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)
+ elif args.command == "validate":
+ result = validate_mod.validate(model, express_rules=args.rules)
+ elif args.command == "schedule":
+ result = schedule.schedule(model, max_depth=args.depth)
+ elif args.command == "cost":
+ result = cost_mod.cost(model, max_depth=args.depth)
+ elif args.command == "contexts":
+ result = contexts_mod.contexts(model)
+ elif args.command == "materials":
+ result = materials_mod.materials(model)
+ elif args.command == "schema":
+ result = schema.schema(model, args.entity_type)
+ elif args.command == "render":
+ element_ids = None
+ if args.element:
+ try:
+ element_ids = [parse_element_id(part) for part in args.element.split(",")]
+ except ValueError:
+ print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
+ sys.exit(1)
+ out_path = args.output or (os.path.splitext(args.ifc_file)[0] + ".png")
+ try:
+ png_bytes = render_mod.render(
+ model,
+ selector=args.selector or None,
+ element_ids=element_ids,
+ view=args.view,
+ )
+ except ImportError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ except ValueError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ with open(out_path, "wb") as f:
+ f.write(png_bytes)
+ print(f"Saved render to {out_path}", file=sys.stderr)
+ return
+ elif args.command == "plot":
+ element_ids = None
+ if args.element:
+ try:
+ element_ids = [parse_element_id(part) for part in args.element.split(",")]
+ except ValueError:
+ print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
+ sys.exit(1)
+
+ try:
+ result = plot.plot(
+ model,
+ selector=args.selector or None,
+ element_ids=element_ids,
+ view=args.view,
+ width_mm=args.width_mm,
+ height_mm=args.height_mm,
+ scale=args.scale,
+ output_format=args.out_format,
+ )
+ except ImportError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ except ValueError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if args.out_format == "base64":
+ # result is a dict; serialise to stdout so callers can consume it
+ print(format_output(result, args.output_format))
+ return
+
+ # svg or png: write to a file
+ base = os.path.splitext(args.ifc_file)[0]
+ if args.out_format == "svg":
+ out_path = args.output or (base + ".svg")
+ else:
+ out_path = args.output or (base + ".png")
+
+ with open(out_path, "wb") as f:
+ f.write(result)
+
+ print(f"Saved drawing to {out_path}", file=sys.stderr)
+ return
+
+ print(format_output(result, args.output_format))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ifcquery/ifcquery/clash.py b/src/ifcquery/ifcquery/clash.py
new file mode 100644
index 0000000000..52c8d53e3b
--- /dev/null
+++ b/src/ifcquery/ifcquery/clash.py
@@ -0,0 +1,180 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+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
+
+ # Flat list of subject + all clashing elements across all checks, deduplicated.
+ # Allows --format ids to extract all involved IDs without jq.
+ seen: set[int] = {element.id()}
+ involved = [_ref(element)]
+ for check in checks.values():
+ for clash_item in check.get("clashes", []):
+ eid = clash_item["element"]["id"]
+ if eid not in seen:
+ seen.add(eid)
+ involved.append(clash_item["element"])
+ result["elements"] = involved
+
+ return result
diff --git a/src/ifcquery/ifcquery/contexts.py b/src/ifcquery/ifcquery/contexts.py
new file mode 100644
index 0000000000..912df173d7
--- /dev/null
+++ b/src/ifcquery/ifcquery/contexts.py
@@ -0,0 +1,44 @@
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+import ifcopenshell
+
+
+def contexts(model: ifcopenshell.file) -> list[dict]:
+ """Return all geometric representation contexts and subcontexts.
+
+ :param model: The in-memory IFC model.
+ :return: List of dicts with id, type, context_type, context_identifier,
+ and (for subcontexts) target_view and parent_context_id.
+ """
+ results = []
+ for ctx in model.by_type("IfcGeometricRepresentationContext"):
+ entry = {
+ "id": ctx.id(),
+ "type": ctx.is_a(),
+ "context_type": getattr(ctx, "ContextType", None),
+ "context_identifier": getattr(ctx, "ContextIdentifier", None),
+ }
+ if ctx.is_a("IfcGeometricRepresentationSubContext"):
+ entry["target_view"] = ctx.TargetView
+ parent = ctx.ParentContext
+ entry["parent_context_id"] = parent.id() if parent else None
+ results.append(entry)
+ return results
diff --git a/src/ifcquery/ifcquery/cost.py b/src/ifcquery/ifcquery/cost.py
new file mode 100644
index 0000000000..ef9559d597
--- /dev/null
+++ b/src/ifcquery/ifcquery/cost.py
@@ -0,0 +1,45 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.util.cost as cost_util
+
+
+def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
+ raw_values = cost_util.get_cost_values(item)
+ values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values]
+
+ if max_depth is not None and depth >= max_depth:
+ child_count = len(cost_util.get_nested_cost_items(item))
+ subitems = {"truncated": True, "count": child_count} if child_count else []
+ else:
+ subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)]
+
+ return {
+ "id": item.id(),
+ "name": getattr(item, "Name", None),
+ "values": values,
+ "subitems": subitems,
+ }
+
+
+def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
+ """Return a list of IfcCostSchedule entries with nested cost item trees.
+
+ max_depth limits how many levels of subitems are expanded (None = unlimited).
+ At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
+ """
+ result = []
+ for cost_schedule in model.by_type("IfcCostSchedule"):
+ items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)]
+ result.append(
+ {
+ "id": cost_schedule.id(),
+ "name": getattr(cost_schedule, "Name", None),
+ "predefined_type": getattr(cost_schedule, "PredefinedType", None),
+ "items": items,
+ }
+ )
+ return result
diff --git a/src/ifcquery/ifcquery/info.py b/src/ifcquery/ifcquery/info.py
new file mode 100644
index 0000000000..ad9a9daa4f
--- /dev/null
+++ b/src/ifcquery/ifcquery/info.py
@@ -0,0 +1,262 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.util.element
+import ifcopenshell.util.placement
+
+# ---------------------------------------------------------------------------
+# Geometry summary helpers
+# ---------------------------------------------------------------------------
+
+_MAX_PROFILE_POINTS = 20
+
+
+def _rc(coords) -> list[float]:
+ """Round a coordinate sequence to 6 decimal places."""
+ return [round(float(c), 6) for c in coords]
+
+
+def _curve_points(curve) -> list | None:
+ if curve.is_a("IfcPolyline"):
+ return [_rc(p.Coordinates) for p in curve.Points]
+ if curve.is_a("IfcIndexedPolyCurve"):
+ return [_rc(c) for c in curve.Points.CoordList]
+ return None
+
+
+def _profile_summary(profile) -> dict:
+ t = profile.is_a()
+ result: dict[str, Any] = {"type": t}
+ if t == "IfcRectangleProfileDef":
+ result["x_dim"] = profile.XDim
+ result["y_dim"] = profile.YDim
+ elif t in ("IfcCircleProfileDef", "IfcCircleHollowProfileDef"):
+ result["radius"] = profile.Radius
+ if t == "IfcCircleHollowProfileDef":
+ result["wall_thickness"] = profile.WallThickness
+ elif t in ("IfcArbitraryClosedProfileDef", "IfcArbitraryProfileDefWithVoids"):
+ pts = _curve_points(profile.OuterCurve)
+ if pts is not None:
+ if len(pts) <= _MAX_PROFILE_POINTS:
+ result["points"] = pts
+ else:
+ result["point_count"] = len(pts)
+ elif t == "IfcCompositeProfileDef":
+ result["profiles"] = [_profile_summary(p) for p in profile.Profiles]
+ return result
+
+
+def _half_space_plane(half_space) -> dict | None:
+ if not half_space.is_a("IfcHalfSpaceSolid"):
+ return None
+ surface = half_space.BaseSurface
+ if not surface or not surface.is_a("IfcPlane"):
+ return None
+ pos = surface.Position
+ loc = _rc(pos.Location.Coordinates)
+ normal = _rc(pos.Axis.DirectionRatios) if pos.Axis else [0.0, 0.0, 1.0]
+ return {"location": loc, "normal": normal}
+
+
+def _walk_clipping(item) -> tuple:
+ """Return (base_solid, [clipping_plane_dicts]) from a BooleanClippingResult chain."""
+ planes = []
+ current = item
+ while current.is_a("IfcBooleanClippingResult"):
+ plane = _half_space_plane(current.SecondOperand)
+ if plane:
+ planes.append(plane)
+ current = current.FirstOperand
+ return current, planes
+
+
+def _swept_solid_dict(item) -> dict:
+ result: dict[str, Any] = {"solid_type": item.is_a()}
+ if item.is_a("IfcExtrudedAreaSolid"):
+ result["depth"] = item.Depth
+ if item.ExtrudedDirection:
+ result["direction"] = _rc(item.ExtrudedDirection.DirectionRatios)
+ if item.SweptArea:
+ result["profile"] = _profile_summary(item.SweptArea)
+ return result
+
+
+def _summarize_rep(rep) -> dict:
+ rep_type = rep.RepresentationType or ""
+ result: dict[str, Any] = {"representation_type": rep_type}
+ items = list(rep.Items)
+
+ if rep_type == "MappedRepresentation":
+ for item in items:
+ if item.is_a("IfcMappedItem"):
+ return _summarize_rep(item.MappingSource.MappedRepresentation)
+
+ elif rep_type == "SweptSolid":
+ result["solids"] = [_swept_solid_dict(item) for item in items]
+
+ elif rep_type == "Clipping":
+ solids = []
+ for item in items:
+ base, planes = _walk_clipping(item)
+ solid = _swept_solid_dict(base)
+ if planes:
+ solid["clipping_planes"] = planes
+ solids.append(solid)
+ result["solids"] = solids
+
+ elif rep_type == "CSG":
+ ops = []
+ for item in items:
+ if hasattr(item, "Operator"):
+ ops.append({"operator": str(item.Operator), "type": item.is_a()})
+ if ops:
+ result["operations"] = ops
+
+ elif rep_type in ("Brep", "Tessellation", "SolidModel"):
+ face_count = 0
+ vertex_count = 0
+ for item in items:
+ if item.is_a("IfcPolygonalFaceSet"):
+ face_count += len(item.Faces)
+ vertex_count += len(item.Coordinates.CoordList)
+ elif item.is_a("IfcFacetedBrep"):
+ face_count += len(item.Outer.CfsFaces)
+ if face_count:
+ result["face_count"] = face_count
+ if vertex_count:
+ result["vertex_count"] = vertex_count
+
+ return result
+
+
+def _geometry_summary(element) -> dict | None:
+ if not hasattr(element, "Representation") or not element.Representation:
+ return None
+ body_rep = next(
+ (r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"),
+ None,
+ )
+ if body_rep is None:
+ return None
+ try:
+ return _summarize_rep(body_rep)
+ except Exception:
+ return None
+
+
+def _serialize_attribute(value: Any) -> Any:
+ """Convert an IFC attribute value to a JSON-serializable form."""
+ if isinstance(value, ifcopenshell.entity_instance):
+ return {"id": value.id(), "type": value.is_a()}
+ if isinstance(value, tuple):
+ return [_serialize_attribute(v) for v in value]
+ return value
+
+
+def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
+ """Convert a material entity to a summary dict."""
+ if material is None:
+ return None
+ result: dict[str, Any] = {
+ "id": material.id(),
+ "type": material.is_a(),
+ }
+ if hasattr(material, "Name"):
+ result["name"] = material.Name
+ return result
+
+
+def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
+ """Return deep inspection data for an element."""
+ result: dict[str, Any] = {
+ "id": element.id(),
+ "type": element.is_a(),
+ }
+
+ # Direct attributes via get_info() which returns a dict of all attributes
+ element_info = element.get_info()
+ attrs = {}
+ for key, value in element_info.items():
+ if key in ("id", "type"):
+ continue
+ attrs[key] = _serialize_attribute(value)
+ result["attributes"] = attrs
+
+ # Property sets and quantity sets
+ try:
+ psets = ifcopenshell.util.element.get_psets(element)
+ if psets:
+ result["property_sets"] = psets
+ except Exception:
+ pass
+
+ # Element type
+ try:
+ element_type = ifcopenshell.util.element.get_type(element)
+ if element_type:
+ type_info: dict[str, Any] = {
+ "id": element_type.id(),
+ "type": element_type.is_a(),
+ }
+ if hasattr(element_type, "Name"):
+ type_info["name"] = element_type.Name
+ result["element_type"] = type_info
+ except Exception:
+ pass
+
+ # Material
+ try:
+ material = ifcopenshell.util.element.get_material(element)
+ mat_dict = _material_to_dict(material)
+ if mat_dict:
+ result["material"] = mat_dict
+ except Exception:
+ pass
+
+ # Spatial container
+ try:
+ container = ifcopenshell.util.element.get_container(element)
+ if container:
+ result["container"] = {
+ "id": container.id(),
+ "type": container.is_a(),
+ "name": container.Name if hasattr(container, "Name") else None,
+ }
+ except Exception:
+ pass
+
+ # Placement (as 4x4 matrix)
+ try:
+ if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
+ matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
+ result["placement"] = matrix.tolist()
+ except Exception:
+ pass
+
+ # Geometry summary
+ geom = _geometry_summary(element)
+ if geom:
+ result["geometry_summary"] = geom
+
+ return result
diff --git a/src/ifcquery/ifcquery/materials.py b/src/ifcquery/ifcquery/materials.py
new file mode 100644
index 0000000000..c7402d51e5
--- /dev/null
+++ b/src/ifcquery/ifcquery/materials.py
@@ -0,0 +1,100 @@
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+import ifcopenshell
+
+
+def materials(model: ifcopenshell.file) -> list[dict]:
+ """Return all materials and material sets from the model.
+
+ :param model: The in-memory IFC model.
+ :return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
+ IfcMaterialConstituentSet, and IfcMaterialProfileSet entities.
+ """
+ results = []
+
+ for m in model.by_type("IfcMaterial"):
+ results.append(
+ {
+ "id": m.id(),
+ "type": "IfcMaterial",
+ "name": m.Name,
+ "category": getattr(m, "Category", None),
+ }
+ )
+
+ for ls in model.by_type("IfcMaterialLayerSet"):
+ layers = []
+ for layer in ls.MaterialLayers or []:
+ layers.append(
+ {
+ "name": layer.Name,
+ "thickness": layer.LayerThickness,
+ "material": layer.Material.Name if layer.Material else None,
+ "is_ventilated": layer.IsVentilated,
+ }
+ )
+ results.append(
+ {
+ "id": ls.id(),
+ "type": "IfcMaterialLayerSet",
+ "name": ls.LayerSetName,
+ "layers": layers,
+ }
+ )
+
+ for cs in model.by_type("IfcMaterialConstituentSet"):
+ constituents = []
+ for c in cs.MaterialConstituents or []:
+ constituents.append(
+ {
+ "name": c.Name,
+ "material": c.Material.Name if c.Material else None,
+ "fraction": c.Fraction,
+ }
+ )
+ results.append(
+ {
+ "id": cs.id(),
+ "type": "IfcMaterialConstituentSet",
+ "name": cs.Name,
+ "constituents": constituents,
+ }
+ )
+
+ for ps in model.by_type("IfcMaterialProfileSet"):
+ profiles = []
+ for p in ps.MaterialProfiles or []:
+ profiles.append(
+ {
+ "name": p.Name,
+ "material": p.Material.Name if p.Material else None,
+ }
+ )
+ results.append(
+ {
+ "id": ps.id(),
+ "type": "IfcMaterialProfileSet",
+ "name": ps.Name,
+ "profiles": profiles,
+ }
+ )
+
+ return results
diff --git a/src/ifcquery/ifcquery/plot.py b/src/ifcquery/ifcquery/plot.py
new file mode 100644
index 0000000000..3393a87fc8
--- /dev/null
+++ b/src/ifcquery/ifcquery/plot.py
@@ -0,0 +1,284 @@
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+import base64
+import os
+from io import BytesIO
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.geom
+import ifcopenshell.util.selector
+
+try:
+ import ifcopenshell.draw
+
+ _HAS_DRAW = True
+except ImportError:
+ _HAS_DRAW = False
+
+from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace
+
+try:
+ import cairosvg # type: ignore
+
+ _HAS_CAIROSVG = True
+except Exception:
+ _HAS_CAIROSVG = False
+
+
+try:
+ from PIL import Image # type: ignore
+
+ _HAS_PIL = True
+except Exception:
+ _HAS_PIL = False
+
+VIEWS = ("floorplan", "elevation", "section", "auto")
+OUTPUT_FORMATS = ("svg", "png", "base64")
+
+
+def _escape_css_attr(name: str) -> str:
+ # CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid)
+ return name.replace(":", "\\:")
+
+
+def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str:
+ guids: list[str] = []
+ for sid in element_ids:
+ try:
+ e = model.by_id(int(sid))
+ except RuntimeError:
+ continue
+ if e is None:
+ continue
+ gid = getattr(e, "GlobalId", None)
+ if isinstance(gid, str) and gid:
+ guids.append(gid)
+
+ if not guids:
+ return ""
+
+ attr = _escape_css_attr("ifc:guid")
+
+ css = [
+ "/* Auto-highlight injected by ifcquery.plot */",
+ f"[{attr}] path {{ opacity: 0.10; }}",
+ f"[{attr}] text {{ opacity: 0.25; }}",
+ ]
+ for gid in guids:
+ css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}')
+ css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}')
+ return "\n".join(css) + "\n"
+
+
+def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator:
+ # Avoid multiprocessing in WASM; os.cpu_count is good enough.
+ n_threads = os.cpu_count() or 1
+
+ # These flags mirror the defaults used by ifcopenshell.draw in v0.8.x.
+ geom_settings = ifcopenshell.geom.settings(
+ REORIENT_SHELLS=False,
+ ELEMENT_HIERARCHY=True,
+ )
+
+ # IfcOpenShell wrapper constants may live in different places across builds.
+ wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None)
+ if wrapper is not None:
+ try:
+ geom_settings.set("iterator-output", wrapper.NATIVE)
+ except Exception:
+ pass
+ try:
+ geom_settings.set("apply-default-materials", True)
+ except Exception:
+ pass
+ try:
+ geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS)
+ except Exception:
+ pass
+
+ return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements)
+
+
+def _diagnose_empty_drawing(model: ifcopenshell.file, view: str) -> str:
+ """Return a helpful error message when ifcopenshell.draw produces no geometry groups."""
+ hints = []
+
+ if view in ("floorplan", "auto"):
+ storeys = model.by_type("IfcBuildingStorey")
+ if not storeys:
+ hints.append("the model has no IfcBuildingStorey entities (required for auto_floorplan)")
+ else:
+ null_elevation = [s for s in storeys if getattr(s, "Elevation", None) is None]
+ if null_elevation:
+ names = ", ".join(f'"{s.Name or s.GlobalId}"' for s in null_elevation)
+ hints.append(
+ f"storey Elevation is None for: {names} — "
+ "set IfcBuildingStorey.Elevation (e.g. 0.0) so the section cut height can be determined"
+ )
+
+ has_geom = any(getattr(e, "Representation", None) is not None for e in model.by_type("IfcProduct"))
+ if not has_geom:
+ hints.append("no IfcProduct entities have geometric representations")
+
+ base = f"No plan geometry found for view={view!r}."
+ if hints:
+ return base + " Possible causes: " + "; ".join(hints) + "."
+ return base + " The model may lack geometry visible in this view."
+
+
+def plot(
+ model: ifcopenshell.file,
+ *,
+ output_format: str = "png",
+ selector: str | None = None,
+ element_ids: list[int] | None = None,
+ view: str = "floorplan",
+ # SVG / page sizing (draw works in mm coordinates)
+ width_mm: float = 297.0,
+ height_mm: float = 420.0,
+ scale: float = 1.0 / 100.0,
+ merge_projection: bool = True,
+ # PNG sizing (only for output_format png/base64)
+ png_width: int = 1024,
+ png_height: int = 1024,
+) -> bytes | dict[str, Any]:
+ """
+ Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG).
+
+ Args:
+ model: In-memory IFC model.
+ output_format: 'svg' | 'png' | 'base64'
+ - 'svg' -> returns SVG bytes
+ - 'png' -> returns PNG bytes
+ - 'base64'-> returns dict: {mime, png_b64, width, height, view}
+ selector: ifcopenshell selector query to restrict plotted elements.
+ element_ids: STEP ids to highlight; non-highlighted geometry is faded.
+ view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto').
+ width_mm, height_mm: Page size in mm.
+ scale: Model-to-paper scale (0.01 means 1:100).
+ merge_projection: Passed through to ifcopenshell.draw.main.
+ png_width, png_height: Raster size in pixels for png/base64 outputs.
+
+ Raises:
+ ImportError: if ifcopenshell.draw or CairoSVG is not available (as required).
+ ValueError: invalid args or selector matches nothing.
+ """
+ if output_format not in OUTPUT_FORMATS:
+ raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}")
+ if view not in VIEWS:
+ raise ValueError(f"view must be one of {VIEWS}, got {view!r}")
+ if not _HAS_DRAW:
+ raise ImportError("ifcopenshell.draw is not available in this environment.")
+
+ # Configure draw settings
+ settings = ifcopenshell.draw.draw_settings(
+ auto_floorplan=(view in ("floorplan", "auto")),
+ auto_elevation=(view in ("elevation", "auto")),
+ auto_section=(view in ("section", "auto")),
+ width=width_mm,
+ height=height_mm,
+ scale=scale,
+ css="",
+ )
+
+ # Optional highlight CSS overlay
+ if element_ids:
+ settings.css = _highlight_css_from_ids(model, element_ids)
+
+ # Optional element restriction via selector -> custom iterator
+ iterators: tuple[Any, ...] = ()
+ if selector:
+ include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector))
+ if not include_elements:
+ raise ValueError(f"Selector {selector!r} matched no elements")
+ it = _make_filtered_iterator(model, include_elements)
+ iterators = (it,)
+ # If we explicitly include elements, don't rely on exclude_entities (best-effort).
+ settings.exclude_entities = ""
+
+ # Generate SVG
+ svg_bytes = ifcopenshell.draw.main(
+ settings,
+ files=[model],
+ iterators=iterators,
+ merge_projection=merge_projection,
+ )
+
+ register_namespace("", "http://www.w3.org/2000/svg")
+
+ def svg_split(f):
+ x = ElementTree(file=f)
+ svg = x.getroot()
+ resources = []
+ for child in svg:
+ if child.tag == "{http://www.w3.org/2000/svg}g":
+ root = Element(svg.tag, svg.attrib)
+ n = ElementTree(root)
+ for r in resources + [child]:
+ root.append(r)
+ b = BytesIO()
+ n.write(b, xml_declaration=True, encoding="utf-8", method="xml")
+ yield b.getvalue()
+ else:
+ resources.append(child)
+
+ if output_format == "svg":
+ return svg_bytes
+
+ # Need CairoSVG for png/base64
+ if not _HAS_CAIROSVG:
+ raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg")
+
+ svgs = list(svg_split(BytesIO(svg_bytes)))
+ if not svgs:
+ raise ValueError(_diagnose_empty_drawing(model, view))
+
+ composite = None
+ png_bytes = None
+ for i, svgb in enumerate(svgs):
+ png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height)
+ if len(svgs) == 1:
+ break
+
+ # Need Pillow for concatenating images
+ if not _HAS_PIL:
+ raise ImportError("Pillow is not installed. Install with: pip install Pillow")
+
+ if composite is None:
+ composite = Image.new("RGBA", (png_width, png_height * len(svgs)))
+ img = Image.open(BytesIO(png_bytes))
+ composite.paste(img, (0, png_height * i))
+ if composite is not None:
+ b = BytesIO()
+ composite.save(b, "png")
+ png_bytes = b.getvalue()
+
+ if output_format == "base64":
+ return {
+ "mime": "image/png",
+ "png_b64": base64.b64encode(png_bytes).decode(),
+ "width": png_width,
+ "height": png_height,
+ "view": view,
+ }
+
+ return png_bytes
diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py
new file mode 100644
index 0000000000..334a3241a3
--- /dev/null
+++ b/src/ifcquery/ifcquery/relations.py
@@ -0,0 +1,193 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.util.element
+import ifcopenshell.util.system
+
+
+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 _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
+ return _ref(element) if element is not None else None
+
+
+def _ref_list(elements) -> list[dict[str, Any]]:
+ return [_ref(e) for e in elements]
+
+
+def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]:
+ """Walk the hierarchy from element up to IfcProject."""
+ chain = [_ref(element)]
+ current = element
+ while True:
+ parent = ifcopenshell.util.element.get_parent(current)
+ if parent is None:
+ break
+ chain.append(_ref(parent))
+ current = parent
+ return chain
+
+
+def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
+ """Collect all relationships for an element."""
+ result: dict[str, Any] = {
+ "id": element.id(),
+ "type": element.is_a(),
+ }
+ if hasattr(element, "Name") and element.Name:
+ result["name"] = element.Name
+
+ # Hierarchy (upward)
+ hierarchy: dict[str, Any] = {}
+ parent = ifcopenshell.util.element.get_parent(element)
+ if parent is not None:
+ hierarchy["parent"] = _ref(parent)
+ container = ifcopenshell.util.element.get_container(element)
+ if container is not None:
+ hierarchy["container"] = _ref(container)
+ aggregate = ifcopenshell.util.element.get_aggregate(element)
+ if aggregate is not None:
+ hierarchy["aggregate"] = _ref(aggregate)
+ nest = ifcopenshell.util.element.get_nest(element)
+ if nest is not None:
+ hierarchy["nest"] = _ref(nest)
+ filled_void = ifcopenshell.util.element.get_filled_void(element)
+ if filled_void is not None:
+ hierarchy["filled_void"] = _ref(filled_void)
+ voided_element = ifcopenshell.util.element.get_voided_element(element)
+ if voided_element is not None:
+ hierarchy["voided_element"] = _ref(voided_element)
+ if hierarchy:
+ result["hierarchy"] = hierarchy
+
+ # Children (downward)
+ children: dict[str, Any] = {}
+ contained = ifcopenshell.util.element.get_contained(element)
+ if contained:
+ children["contained"] = _ref_list(contained)
+ parts = ifcopenshell.util.element.get_parts(element)
+ if parts:
+ children["parts"] = _ref_list(parts)
+ components = ifcopenshell.util.element.get_components(element)
+ if components:
+ children["components"] = _ref_list(components)
+ openings = list(ifcopenshell.util.element.get_openings(element))
+ if openings:
+ children["openings"] = _ref_list(openings)
+ if children:
+ result["children"] = children
+
+ # Type relationship
+ type_relationship: dict[str, Any] = {}
+ element_type = ifcopenshell.util.element.get_type(element)
+ if element_type is not None:
+ type_relationship["type_of"] = _ref(element_type)
+ try:
+ occurrences = ifcopenshell.util.element.get_types(element)
+ if occurrences:
+ type_relationship["occurrences"] = _ref_list(occurrences)
+ except Exception:
+ pass
+ if type_relationship:
+ result["type_relationship"] = type_relationship
+
+ # Groups
+ groups = ifcopenshell.util.element.get_groups(element)
+ if groups:
+ result["groups"] = _ref_list(groups)
+
+ # Systems
+ systems = ifcopenshell.util.system.get_element_systems(element)
+ if systems:
+ result["systems"] = _ref_list(systems)
+
+ # Zones
+ zones = ifcopenshell.util.system.get_element_zones(element)
+ if zones:
+ result["zones"] = _ref_list(zones)
+
+ # Material
+ material = ifcopenshell.util.element.get_material(element)
+ if material is not None:
+ result["material"] = _ref(material)
+
+ # Referenced structures
+ referenced = ifcopenshell.util.element.get_referenced_structures(element)
+ if referenced:
+ result["referenced_structures"] = _ref_list(referenced)
+
+ # Connections
+ connections: dict[str, Any] = {}
+ connected_to = ifcopenshell.util.system.get_connected_to(element)
+ if connected_to:
+ connections["connected_to"] = _ref_list(connected_to)
+ connected_from = ifcopenshell.util.system.get_connected_from(element)
+ if connected_from:
+ connections["connected_from"] = _ref_list(connected_from)
+ ports = ifcopenshell.util.system.get_ports(element)
+ if ports:
+ connections["ports"] = _ref_list(ports)
+ if connections:
+ result["connections"] = connections
+
+ return result
+
+
+def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -> None:
+ """Recursively collect all element refs (dicts with 'id') from a nested structure."""
+ if isinstance(data, dict):
+ if "id" in data and isinstance(data["id"], int):
+ eid = data["id"]
+ if eid not in seen:
+ seen.add(eid)
+ result.append({"id": data["id"], "type": data.get("type"), "name": data.get("name")} if "name" in data else {"id": data["id"], "type": data.get("type")})
+ for v in data.values():
+ _collect_elements(v, seen, result)
+ elif isinstance(data, list):
+ for item in data:
+ _collect_elements(item, seen, result)
+
+
+def relations(
+ model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
+) -> dict[str, Any] | list[dict[str, Any]]:
+ """Return relationships for an element, or hierarchy chain if traverse='up'."""
+ if traverse == "up":
+ return _traverse_up(element)
+ result = _all_relations(model, element)
+
+ # Flat list of subject + all referenced elements, deduplicated.
+ # Allows --format ids to extract all involved IDs without jq.
+ seen: set[int] = set()
+ elements: list[dict[str, Any]] = []
+ _collect_elements(result, seen, elements)
+ result["elements"] = elements
+
+ return result
diff --git a/src/ifcquery/ifcquery/render.py b/src/ifcquery/ifcquery/render.py
new file mode 100644
index 0000000000..44105e0ee2
--- /dev/null
+++ b/src/ifcquery/ifcquery/render.py
@@ -0,0 +1,465 @@
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+import multiprocessing
+import os
+import tempfile
+
+import ifcopenshell
+import ifcopenshell.geom
+import ifcopenshell.guid
+import ifcopenshell.util.selector
+
+try:
+ import numpy as np
+ import pyvista as pv
+
+ _HAS_PYVISTA = True
+except ImportError:
+ _HAS_PYVISTA = False
+
+VIEWS = ("iso", "top", "south", "north", "east", "west")
+
+
+def _apply_view(plotter: pv.Plotter, view: str) -> None:
+ """Set the camera to the requested named view. Z is up (IFC convention)."""
+ if view == "top":
+ plotter.view_xy()
+ elif view == "south":
+ # Camera at -Y looking toward +Y (south face of building)
+ plotter.view_xz(negative=True)
+ elif view == "north":
+ plotter.view_xz(negative=False)
+ elif view == "east":
+ plotter.view_yz(negative=False)
+ elif view == "west":
+ plotter.view_yz(negative=True)
+ else:
+ plotter.view_isometric()
+ # Ensure Z is world up for elevation views
+ if view not in ("top",):
+ plotter.camera.up = (0, 0, 1)
+
+
+def _add_shape(
+ shape: object,
+ plotter: pv.Plotter,
+ highlight_ids: frozenset[int] | None,
+) -> None:
+ """Triangulate and add a geometry shape to the plotter."""
+ geom = shape.geometry
+ verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
+ if verts.size == 0:
+ return
+
+ raw_faces = np.array(geom.faces, dtype=int)
+ if raw_faces.size == 0 or raw_faces.size % 3 != 0:
+ return # degenerate geometry from kernel — skip silently
+ faces = raw_faces.reshape(-1, 3)
+ material_ids = np.array(geom.material_ids, dtype=int)
+
+ is_subject = highlight_ids is not None and shape.product.id() in highlight_ids
+
+ for midx, mat in enumerate(geom.materials):
+ tri_mask = material_ids == midx
+ if not np.any(tri_mask):
+ continue
+
+ sub_faces = faces[tri_mask]
+ faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel()
+ mesh = pv.PolyData(verts, faces_pv)
+
+ if highlight_ids is not None and not is_subject:
+ color = (180, 180, 180)
+ opacity = 0.10
+ else:
+ diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0)
+ color = tuple((diffuse * 255).astype(np.uint8))
+ transparency = mat.transparency if mat.transparency == mat.transparency else 0.0
+ opacity = float(np.clip(1.0 - transparency, 0.0, 1.0))
+
+ plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
+
+
+def _render_iterator(
+ iterator: object,
+ highlight_ids: list[int] | None,
+ view: str,
+) -> bytes:
+ """Drive a geometry iterator into a pyvista plotter and return PNG bytes."""
+ plotter = pv.Plotter(off_screen=True, window_size=(1280, 960))
+ plotter.background_color = "white"
+
+ while True:
+ try:
+ _add_shape(iterator.get(), plotter, highlight_ids=frozenset(highlight_ids) if highlight_ids else None)
+ except Exception:
+ pass # skip broken shapes, keep rendering the rest
+ if not iterator.next():
+ break
+
+ plotter.reset_camera()
+ _apply_view(plotter, view)
+
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png")
+ os.close(tmp_fd)
+ try:
+ plotter.show(screenshot=tmp_path, auto_close=True)
+ with open(tmp_path, "rb") as f:
+ return f.read()
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings:
+ """Build geometry settings, excluding Clearance subcontexts."""
+ settings = ifcopenshell.geom.settings()
+ settings.set("use-world-coords", True)
+
+ clearance_ids = {
+ c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance"
+ }
+ if clearance_ids:
+ ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids]
+ if ctx_ids:
+ settings.set("context-ids", ctx_ids)
+
+ return settings
+
+
+def _get_occurrence_class(type_entity) -> str:
+ """Derive the occurrence IFC class from a type entity class name."""
+ type_class = type_entity.is_a()
+ if type_class.endswith("Type"):
+ return type_class[:-4]
+ return "IfcBuildingElementProxy"
+
+
+def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
+ """Create a temporary occurrence for *type_entity* using its RepresentationMaps.
+
+ The occurrence is added to *model* and references the type's existing
+ RepresentationMap entities via IfcMappedItem. Returns the occurrence entity,
+ or ``None`` when the type has no usable RepresentationMaps.
+
+ .. note::
+ This function is intended for use on a temporary model copy. The
+ caller is responsible for discarding that copy after rendering.
+ """
+ rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
+ if not rep_maps:
+ return None
+
+ # One IfcMappedItem per RepresentationMap.
+ mapped_items = []
+ for rep_map in rep_maps:
+ origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
+ transform = model.create_entity(
+ "IfcCartesianTransformationOperator3D",
+ LocalOrigin=origin,
+ )
+ mapped_item = model.create_entity(
+ "IfcMappedItem",
+ MappingSource=rep_map,
+ MappingTarget=transform,
+ )
+ mapped_items.append(mapped_item)
+
+ context = rep_maps[0].MappedRepresentation.ContextOfItems
+ shape_rep = model.create_entity(
+ "IfcShapeRepresentation",
+ ContextOfItems=context,
+ RepresentationIdentifier="Body",
+ RepresentationType="MappedRepresentation",
+ Items=mapped_items,
+ )
+ prod_def_shape = model.create_entity(
+ "IfcProductDefinitionShape",
+ Representations=[shape_rep],
+ )
+
+ # Identity placement.
+ pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
+ z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
+ x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
+ axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir)
+ placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
+
+ occ_class = _get_occurrence_class(type_entity)
+ try:
+ occurrence = model.create_entity(
+ occ_class,
+ GlobalId=ifcopenshell.guid.new(),
+ Name=f"_type_preview_{type_entity.id()}",
+ ObjectPlacement=placement,
+ Representation=prod_def_shape,
+ )
+ except Exception:
+ occurrence = model.create_entity(
+ "IfcBuildingElementProxy",
+ GlobalId=ifcopenshell.guid.new(),
+ Name=f"_type_preview_{type_entity.id()}",
+ ObjectPlacement=placement,
+ Representation=prod_def_shape,
+ )
+ return occurrence
+
+
+def _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
+ """Create a temporary occurrence for a type that has a material profile set.
+
+ Finds the first profile in the type's IfcMaterialProfileSet and creates a
+ 1-metre IfcExtrudedAreaSolid body representation from it. Returns the
+ occurrence, or ``None`` when no usable profile is found.
+
+ .. note::
+ Intended for use on a temporary model copy; caller discards it after
+ rendering.
+ """
+ # Locate the first profile from the type's material profile set.
+ profile = None
+ for rel in getattr(type_entity, "HasAssociations", []):
+ if not rel.is_a("IfcRelAssociatesMaterial"):
+ continue
+ mat = rel.RelatingMaterial
+ if mat.is_a("IfcMaterialProfileSetUsage"):
+ mat = mat.ForProfileSet
+ if mat.is_a("IfcMaterialProfileSet"):
+ mat_profiles = list(getattr(mat, "MaterialProfiles", None) or [])
+ if mat_profiles:
+ profile = getattr(mat_profiles[0], "Profile", None)
+ if profile is not None:
+ break
+ if profile is None:
+ return None
+
+ # Find a Body subcontext, or fall back to any Model context.
+ body_ctx = None
+ for ctx in model.by_type("IfcGeometricRepresentationSubContext"):
+ if ctx.ContextIdentifier == "Body":
+ body_ctx = ctx
+ break
+ if body_ctx is None:
+ for ctx in model.by_type("IfcGeometricRepresentationContext"):
+ if ctx.ContextType == "Model":
+ body_ctx = ctx
+ break
+ if body_ctx is None:
+ return None
+
+ # Extrude 1 metre along Z (profile lies in XY plane).
+ origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
+ z_axis = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
+ x_axis = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
+ position = model.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis)
+ extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
+ extrusion = model.create_entity(
+ "IfcExtrudedAreaSolid",
+ SweptArea=profile,
+ Position=position,
+ ExtrudedDirection=extrude_dir,
+ Depth=1.0,
+ )
+ shape_rep = model.create_entity(
+ "IfcShapeRepresentation",
+ ContextOfItems=body_ctx,
+ RepresentationIdentifier="Body",
+ RepresentationType="SweptSolid",
+ Items=[extrusion],
+ )
+ prod_def_shape = model.create_entity(
+ "IfcProductDefinitionShape",
+ Representations=[shape_rep],
+ )
+
+ # Identity placement.
+ pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
+ z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
+ x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
+ axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir)
+ placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
+
+ occ_class = _get_occurrence_class(type_entity)
+ try:
+ occurrence = model.create_entity(
+ occ_class,
+ GlobalId=ifcopenshell.guid.new(),
+ Name=f"_profile_preview_{type_entity.id()}",
+ ObjectPlacement=placement,
+ Representation=prod_def_shape,
+ )
+ except Exception:
+ occurrence = model.create_entity(
+ "IfcBuildingElementProxy",
+ GlobalId=ifcopenshell.guid.new(),
+ Name=f"_profile_preview_{type_entity.id()}",
+ ObjectPlacement=placement,
+ Representation=prod_def_shape,
+ )
+ return occurrence
+
+
+def _render_with_types(
+ model: ifcopenshell.file,
+ types: list,
+ selector_elements: list | None,
+ element_ids: list[int] | None,
+ type_highlight_ids: set[int],
+ view: str,
+) -> bytes:
+ """Render type entities by creating occurrences in a temporary model copy.
+
+ *types* — list of IfcTypeProduct entities to render.
+ *selector_elements* — non-type elements from the selector (or ``None``).
+ *element_ids* — original highlight IDs (may contain type IDs).
+ *type_highlight_ids* — subset of *element_ids* that are type IDs.
+ """
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc")
+ os.close(tmp_fd)
+ try:
+ model.write(tmp_path)
+ tmp = ifcopenshell.open(tmp_path)
+
+ # Map original type step-ID → new occurrence step-ID in the tmp model.
+ type_id_to_occ_id: dict[int, int] = {}
+ for t in types:
+ tmp_type = tmp.by_id(t.id())
+ occ = _make_type_occurrence(tmp, tmp_type) or _make_profile_occurrence(tmp, tmp_type)
+ if occ:
+ type_id_to_occ_id[t.id()] = occ.id()
+
+ if not type_id_to_occ_id:
+ raise ValueError("Type entities have no RepresentationMaps or material profile sets to render")
+
+ include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()]
+ if selector_elements:
+ include.extend(tmp.by_id(e.id()) for e in selector_elements)
+
+ settings = _build_geom_settings(tmp)
+ iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include)
+ if not iterator.initialize():
+ raise ValueError("Type entities have no renderable geometry")
+
+ # Remap type IDs → occurrence IDs in the highlight list.
+ new_highlight = None
+ if element_ids:
+ new_highlight = []
+ for hid in element_ids:
+ if hid in type_highlight_ids:
+ mapped = type_id_to_occ_id.get(hid)
+ if mapped:
+ new_highlight.append(mapped)
+ else:
+ new_highlight.append(hid)
+
+ return _render_iterator(iterator, new_highlight, view)
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def render(
+ model: ifcopenshell.file,
+ selector: str | None = None,
+ element_ids: list[int] | None = None,
+ view: str = "iso",
+) -> bytes:
+ """Render IFC model geometry to a PNG image.
+
+ Supports both element instances and element types (e.g. ``IfcWallType``).
+ When type entities are targeted — via *selector* or *element_ids* — a
+ temporary copy of the model is used to create proxy occurrences that
+ reference the type's RepresentationMaps; the original model is not
+ modified.
+
+ :param model: The in-memory IFC model.
+ :param selector: ifcopenshell selector to restrict rendered elements
+ (e.g. ``'IfcWall'``, ``'IfcWallType'``, or
+ ``'IfcBuildingStorey[Name="Ground Floor"]'``).
+ When omitted the whole model is rendered.
+ :param element_ids: Step IDs of elements (or types) to highlight. The
+ rest of the model is rendered in translucent grey so the highlighted
+ items stand out.
+ :param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
+ ``east``, or ``west``. Defaults to ``iso``.
+ :return: PNG image as raw bytes.
+ :raises ImportError: If pyvista is not installed.
+ :raises ValueError: If the selector matches nothing or the model has no
+ renderable geometry.
+ """
+ if not _HAS_PYVISTA:
+ raise ImportError("pyvista is not installed. Install with: pip install pyvista")
+
+ # --- Partition selector results into types and elements ---
+ if selector:
+ matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
+ if not matched:
+ raise ValueError(f"Selector {selector!r} matched no elements")
+ types = [e for e in matched if e.is_a("IfcTypeProduct")]
+ selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")]
+ else:
+ types = []
+ selector_elements = None # no restriction — render all elements
+
+ # --- Collect any type entities from element_ids ---
+ type_highlight_ids: set[int] = set()
+ if element_ids:
+ for eid in element_ids:
+ entity = model.by_id(eid)
+ if entity.is_a("IfcTypeProduct"):
+ type_highlight_ids.add(eid)
+ seen = {t.id() for t in types}
+ if eid not in seen:
+ types.append(entity)
+
+ # --- Delegate to temp-copy path when any type entities are involved ---
+ if types:
+ return _render_with_types(model, types, selector_elements, element_ids, type_highlight_ids, view)
+
+ # --- Regular element rendering ---
+ settings = _build_geom_settings(model)
+
+ if selector_elements is not None:
+ if not selector_elements:
+ raise ValueError(f"Selector {selector!r} matched only type entities (use a type selector or IfcElement)")
+ iterator = ifcopenshell.geom.iterator(
+ settings,
+ model,
+ multiprocessing.cpu_count(),
+ include=selector_elements,
+ )
+ else:
+ exclude = list(model.by_type("IfcOpeningElement"))
+ iterator = ifcopenshell.geom.iterator(
+ settings,
+ model,
+ multiprocessing.cpu_count(),
+ exclude=exclude if exclude else None,
+ )
+
+ if not iterator.initialize():
+ raise ValueError("No renderable geometry found in model (or selector matched nothing)")
+
+ return _render_iterator(iterator, element_ids, view)
diff --git a/src/ifcquery/ifcquery/schedule.py b/src/ifcquery/ifcquery/schedule.py
new file mode 100644
index 0000000000..b3e3ae45d6
--- /dev/null
+++ b/src/ifcquery/ifcquery/schedule.py
@@ -0,0 +1,56 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.util.sequence as seq
+
+
+def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
+ task_time = task.TaskTime
+ start = None
+ finish = None
+ if task_time:
+ start = task_time.ScheduleStart
+ finish = task_time.ScheduleFinish
+
+ outputs = []
+ for product in seq.get_task_outputs(task):
+ outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)})
+
+ if max_depth is not None and depth >= max_depth:
+ child_count = len(seq.get_nested_tasks(task))
+ subtasks = {"truncated": True, "count": child_count} if child_count else []
+ else:
+ subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)]
+
+ return {
+ "id": task.id(),
+ "name": getattr(task, "Name", None),
+ "start": start,
+ "finish": finish,
+ "is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False,
+ "outputs": outputs,
+ "subtasks": subtasks,
+ }
+
+
+def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
+ """Return a list of IfcWorkSchedule entries with nested task trees.
+
+ max_depth limits how many levels of subtasks are expanded (None = unlimited).
+ At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
+ """
+ result = []
+ for work_schedule in model.by_type("IfcWorkSchedule"):
+ tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)]
+ result.append(
+ {
+ "id": work_schedule.id(),
+ "name": getattr(work_schedule, "Name", None),
+ "predefined_type": getattr(work_schedule, "PredefinedType", None),
+ "tasks": tasks,
+ }
+ )
+ return result
diff --git a/src/ifcquery/ifcquery/schema.py b/src/ifcquery/ifcquery/schema.py
new file mode 100644
index 0000000000..ad47486db2
--- /dev/null
+++ b/src/ifcquery/ifcquery/schema.py
@@ -0,0 +1,19 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.util.doc
+
+
+def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
+ """Return IFC class documentation for entity_type from model's schema version."""
+ schema_name = model.schema
+ try:
+ doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
+ except Exception:
+ return {"error": f"Unknown entity: {entity_type}"}
+ if not doc:
+ return {"error": f"Unknown entity: {entity_type}"}
+ return dict(doc)
diff --git a/src/ifcquery/ifcquery/select.py b/src/ifcquery/ifcquery/select.py
new file mode 100644
index 0000000000..c28a159b90
--- /dev/null
+++ b/src/ifcquery/ifcquery/select.py
@@ -0,0 +1,41 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.util.selector
+
+
+def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
+ """Filter elements using selector syntax and return matching element summaries."""
+ elements = ifcopenshell.util.selector.filter_elements(model, query)
+ results = []
+ for element in sorted(elements, key=lambda e: e.id()):
+ entry: dict[str, Any] = {
+ "id": element.id(),
+ "type": element.is_a(),
+ "repr": str(element),
+ }
+ if hasattr(element, "Name"):
+ entry["name"] = element.Name
+ results.append(entry)
+ return results
diff --git a/src/ifcquery/ifcquery/summary.py b/src/ifcquery/ifcquery/summary.py
new file mode 100644
index 0000000000..d13f070472
--- /dev/null
+++ b/src/ifcquery/ifcquery/summary.py
@@ -0,0 +1,52 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+from collections import Counter
+from typing import Any
+
+import ifcopenshell
+
+
+def summary(model: ifcopenshell.file) -> dict[str, Any]:
+ """Return a model overview with schema, element counts, and project info."""
+ # Count elements by IFC type, sorted by count descending
+ type_counter: Counter[str] = Counter()
+ total = 0
+ for entity in model:
+ type_counter[entity.is_a()] += 1
+ total += 1
+
+ result: dict[str, Any] = {
+ "schema": model.schema,
+ "total_entities": total,
+ }
+
+ projects = model.by_type("IfcProject")
+ if projects:
+ project = projects[0]
+ result["project"] = {
+ "id": project.id(),
+ "name": project.Name,
+ "description": project.Description,
+ }
+
+ result["types"] = dict(type_counter.most_common())
+ return result
diff --git a/src/ifcquery/ifcquery/tree.py b/src/ifcquery/ifcquery/tree.py
new file mode 100644
index 0000000000..1cbbaeb986
--- /dev/null
+++ b/src/ifcquery/ifcquery/tree.py
@@ -0,0 +1,68 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcQuery - IFC model interrogation CLI
+# Copyright (C) 2026 Bruno Postle
+#
+# 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 .
+
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.util.element
+
+
+def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]:
+ """Return a minimal summary dict for an element."""
+ return {
+ "id": element.id(),
+ "type": element.is_a(),
+ "name": element.Name if hasattr(element, "Name") else None,
+ }
+
+
+def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]:
+ """Recursively build a spatial tree node."""
+ node = _element_summary(element)
+
+ # Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.)
+ aggregates = []
+ for rel in getattr(element, "IsDecomposedBy", []):
+ for child in rel.RelatedObjects:
+ aggregates.append(_build_spatial_node(child))
+
+ # Get contained elements (walls, slabs, etc. in a storey/space)
+ contained = []
+ for rel in getattr(element, "ContainsElements", []):
+ for child in rel.RelatedElements:
+ contained.append(_element_summary(child))
+
+ if aggregates:
+ node["children"] = aggregates
+ if contained:
+ node["elements"] = contained
+
+ return node
+
+
+def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
+ """Return the spatial hierarchy tree starting from IfcProject."""
+ projects = model.by_type("IfcProject")
+ if not projects:
+ return {"error": "No IfcProject found in model"}
+ if len(projects) == 1:
+ return _build_spatial_node(projects[0])
+ return [_build_spatial_node(p) for p in projects]
diff --git a/src/ifcquery/ifcquery/validate.py b/src/ifcquery/ifcquery/validate.py
new file mode 100644
index 0000000000..d35d9150af
--- /dev/null
+++ b/src/ifcquery/ifcquery/validate.py
@@ -0,0 +1,15 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+import ifcopenshell.validate
+
+
+def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
+ """Validate the model and return a dict with 'valid' bool and 'issues' list."""
+ logger = ifcopenshell.validate.json_logger()
+ ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
+ issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]
+ return {"valid": len(issues) == 0, "issues": issues}
diff --git a/src/ifcquery/pyproject.toml b/src/ifcquery/pyproject.toml
new file mode 100644
index 0000000000..3c8e734474
--- /dev/null
+++ b/src/ifcquery/pyproject.toml
@@ -0,0 +1,33 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ifcquery"
+version = "0.0.0"
+authors = [
+ { name="Bruno Postle", email="bruno@postle.net" },
+]
+description = "CLI tool for querying and inspecting IFC building models"
+readme = "README.md"
+keywords = ["IFC", "BIM", "Query"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
+]
+dependencies = ["ifcopenshell"]
+
+[project.scripts]
+ifcquery = "ifcquery.__main__:main"
+
+[project.urls]
+Homepage = "http://ifcopenshell.org"
+Documentation = "https://docs.ifcopenshell.org"
+Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
+
+[tool.setuptools.packages.find]
+include = ["ifcquery*"]
+exclude = ["test*"]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
diff --git a/src/ifcquery/tests/__init__.py b/src/ifcquery/tests/__init__.py
new file mode 100644
index 0000000000..0a3bc271a0
--- /dev/null
+++ b/src/ifcquery/tests/__init__.py
@@ -0,0 +1 @@
+# This file was generated with the assistance of an AI coding tool.
diff --git a/src/ifcquery/tests/conftest.py b/src/ifcquery/tests/conftest.py
new file mode 100644
index 0000000000..496e74cd6e
--- /dev/null
+++ b/src/ifcquery/tests/conftest.py
@@ -0,0 +1,36 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.aggregate
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+
+@pytest.fixture
+def model():
+ """Create an IFC4 model with a spatial hierarchy and a 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="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)
+
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
+ ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
+
+ slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
+ ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
+
+ return f
diff --git a/src/ifcquery/tests/test_clash.py b/src/ifcquery/tests/test_clash.py
new file mode 100644
index 0000000000..150200f95c
--- /dev/null
+++ b/src/ifcquery/tests/test_clash.py
@@ -0,0 +1,330 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+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
+import numpy as np
+import pytest
+
+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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002")
+ assert wall2.id() in clash_ids
+
+ def test_clash_has_points(self, model_with_geometry):
+ wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall")
+ 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 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall")
+ 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 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
+ 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)
diff --git a/src/ifcquery/tests/test_contexts.py b/src/ifcquery/tests/test_contexts.py
new file mode 100644
index 0000000000..c7a21acb96
--- /dev/null
+++ b/src/ifcquery/tests/test_contexts.py
@@ -0,0 +1,52 @@
+import ifcopenshell.api.context
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.unit
+
+from ifcquery.contexts import contexts
+
+
+class TestContexts:
+ def test_empty_model(self):
+ f = ifcopenshell.api.project.create_file()
+ result = contexts(f)
+ assert isinstance(result, list)
+ assert len(result) == 0
+
+ def test_model_context(self, model):
+ import ifcopenshell.api.context
+
+ ifcopenshell.api.context.add_context(model, context_type="Model")
+ result = contexts(model)
+ assert len(result) == 1
+ entry = result[0]
+ assert entry["type"] == "IfcGeometricRepresentationContext"
+ assert entry["context_type"] == "Model"
+ assert "id" in entry
+ assert "context_identifier" in entry
+
+ def test_subcontext(self, model):
+ import ifcopenshell.api.context
+
+ model_ctx = ifcopenshell.api.context.add_context(model, context_type="Model")
+ ifcopenshell.api.context.add_context(
+ model,
+ context_type="Model",
+ context_identifier="Body",
+ target_view="MODEL_VIEW",
+ parent=model_ctx,
+ )
+ result = contexts(model)
+ assert len(result) == 2
+ subctx = next(e for e in result if e["type"] == "IfcGeometricRepresentationSubContext")
+ assert subctx["context_identifier"] == "Body"
+ assert subctx["target_view"] == "MODEL_VIEW"
+ assert subctx["parent_context_id"] == model_ctx.id()
+
+ def test_ids_are_integers(self, model):
+ import ifcopenshell.api.context
+
+ ifcopenshell.api.context.add_context(model, context_type="Model")
+ result = contexts(model)
+ for entry in result:
+ assert isinstance(entry["id"], int)
diff --git a/src/ifcquery/tests/test_cost.py b/src/ifcquery/tests/test_cost.py
new file mode 100644
index 0000000000..1011beca0a
--- /dev/null
+++ b/src/ifcquery/tests/test_cost.py
@@ -0,0 +1,108 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+import ifcopenshell
+import ifcopenshell.api.cost
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.unit
+import pytest
+
+from ifcquery.cost import cost
+
+
+@pytest.fixture
+def cost_model():
+ """Create an IFC4 model with a cost schedule, a top-level item, and one nested subitem."""
+ 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)
+
+ cs = ifcopenshell.api.cost.add_cost_schedule(f, name="Bill of Quantities")
+ item = ifcopenshell.api.cost.add_cost_item(f, cost_schedule=cs)
+ ifcopenshell.api.cost.edit_cost_item(f, cost_item=item, attributes={"Name": "Concrete Works"})
+ cv = ifcopenshell.api.cost.add_cost_value(f, parent=item)
+ ifcopenshell.api.cost.edit_cost_value(f, cost_value=cv, attributes={"AppliedValue": 1200.0, "Category": "material"})
+
+ # Add a nested subitem
+ subitem = ifcopenshell.api.cost.add_cost_item(f, cost_item=item)
+ ifcopenshell.api.cost.edit_cost_item(f, cost_item=subitem, attributes={"Name": "Formwork"})
+
+ return f
+
+
+class TestCost:
+ def test_returns_list(self, cost_model):
+ result = cost(cost_model)
+ assert isinstance(result, list)
+
+ def test_finds_cost_schedule(self, cost_model):
+ result = cost(cost_model)
+ assert len(result) == 1
+
+ def test_schedule_has_name(self, cost_model):
+ result = cost(cost_model)
+ assert result[0]["name"] == "Bill of Quantities"
+
+ def test_schedule_has_id(self, cost_model):
+ result = cost(cost_model)
+ assert isinstance(result[0]["id"], int)
+ assert result[0]["id"] > 0
+
+ def test_schedule_has_items(self, cost_model):
+ result = cost(cost_model)
+ assert len(result[0]["items"]) == 1
+
+ def test_item_has_required_fields(self, cost_model):
+ result = cost(cost_model)
+ item = result[0]["items"][0]
+ assert "id" in item
+ assert "name" in item
+ assert "values" in item
+ assert "subitems" in item
+
+ def test_item_name(self, cost_model):
+ result = cost(cost_model)
+ assert result[0]["items"][0]["name"] == "Concrete Works"
+
+ def test_item_has_values(self, cost_model):
+ result = cost(cost_model)
+ values = result[0]["items"][0]["values"]
+ assert len(values) == 1
+ assert "formula" in values[0]
+ assert "category" in values[0]
+
+ def test_item_value_category(self, cost_model):
+ result = cost(cost_model)
+ values = result[0]["items"][0]["values"]
+ assert values[0]["category"] == "material"
+
+ def test_empty_model_returns_empty_list(self, model):
+ result = cost(model)
+ assert result == []
+
+ def test_max_depth_none_returns_full_tree(self, cost_model):
+ result = cost(cost_model, max_depth=None)
+ item = result[0]["items"][0]
+ assert isinstance(item["subitems"], list)
+ assert len(item["subitems"]) == 1
+ assert item["subitems"][0]["name"] == "Formwork"
+
+ def test_max_depth_1_truncates_subitems(self, cost_model):
+ result = cost(cost_model, max_depth=1)
+ item = result[0]["items"][0]
+ assert isinstance(item["subitems"], dict)
+ assert item["subitems"]["truncated"] is True
+ assert item["subitems"]["count"] == 1
+
+ def test_max_depth_2_expands_to_depth_2(self, cost_model):
+ result = cost(cost_model, max_depth=2)
+ item = result[0]["items"][0]
+ assert isinstance(item["subitems"], list)
+ assert item["subitems"][0]["name"] == "Formwork"
+ # subitem has no children, so subitems should be empty list
+ assert item["subitems"][0]["subitems"] == []
diff --git a/src/ifcquery/tests/test_info.py b/src/ifcquery/tests/test_info.py
new file mode 100644
index 0000000000..c17331a24d
--- /dev/null
+++ b/src/ifcquery/tests/test_info.py
@@ -0,0 +1,112 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.context
+import ifcopenshell.api.geometry
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.unit
+import ifcopenshell.util.representation
+import ifcopenshell.util.shape_builder
+
+from ifcquery.info import info
+
+
+class TestInfo:
+ def test_basic_attributes(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = info(model, wall)
+ assert result["id"] == wall.id()
+ assert result["type"] == "IfcWall"
+ assert result["attributes"]["Name"] == "Wall001"
+
+ def test_container(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = info(model, wall)
+ assert result["container"]["type"] == "IfcBuildingStorey"
+ assert result["container"]["name"] == "Ground Floor"
+
+ def test_project_info(self, model):
+ project = model.by_type("IfcProject")[0]
+ result = info(model, project)
+ assert result["type"] == "IfcProject"
+ assert result["attributes"]["Name"] == "TestProject"
+
+ def test_all_attributes_serializable(self, model):
+ """All attribute values should be JSON-serializable (no entity instances)."""
+ import json
+
+ wall = model.by_type("IfcWall")[0]
+ result = info(model, wall)
+ # Should not raise
+ json.dumps(result)
+
+ def test_no_geometry_summary_without_representation(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = info(model, wall)
+ assert "geometry_summary" not in result
+
+
+class TestGeometrySummary:
+ def _make_model_with_wall(self):
+ f = ifcopenshell.api.project.create_file()
+ ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject")
+ ifcopenshell.api.unit.assign_unit(f)
+ model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
+ 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="W1")
+ ifcopenshell.api.geometry.edit_object_placement(f, product=wall)
+ return f, wall
+
+ def _body_context(self, f):
+ return ifcopenshell.util.representation.get_context(f, "Model", "Body", "MODEL_VIEW")
+
+ def test_swept_solid_summary(self):
+ f, wall = self._make_model_with_wall()
+ body = self._body_context(f)
+ rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2)
+ ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
+ result = info(f, wall)
+ gs = result["geometry_summary"]
+ assert gs["representation_type"] == "SweptSolid"
+ assert len(gs["solids"]) == 1
+ solid = gs["solids"][0]
+ assert solid["depth"] == 3000.0 # stored in project units (mm)
+ assert solid["profile"]["type"] == "IfcArbitraryClosedProfileDef"
+ assert len(solid["profile"]["points"]) == 5 # closed polyline
+
+ def test_clipping_summary(self):
+ f, wall = self._make_model_with_wall()
+ body = self._body_context(f)
+ rep = ifcopenshell.api.geometry.add_wall_representation(
+ f,
+ context=body,
+ length=5.0,
+ height=4.0,
+ thickness=0.2,
+ clippings=[{"location": (0.0, 0.0, 3.0), "normal": (0.0, 0.0, 1.0)}],
+ )
+ ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
+ result = info(f, wall)
+ gs = result["geometry_summary"]
+ assert gs["representation_type"] == "Clipping"
+ solid = gs["solids"][0]
+ assert len(solid["clipping_planes"]) == 1
+ plane = solid["clipping_planes"][0]
+ assert plane["location"][2] == 3000.0 # stored in project units (mm)
+ assert plane["normal"] == [0.0, 0.0, 1.0]
+
+ def test_geometry_summary_json_serializable(self):
+ import json
+
+ f, wall = self._make_model_with_wall()
+ body = self._body_context(f)
+ rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2)
+ ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
+ result = info(f, wall)
+ json.dumps(result)
diff --git a/src/ifcquery/tests/test_main.py b/src/ifcquery/tests/test_main.py
new file mode 100644
index 0000000000..164b93152d
--- /dev/null
+++ b/src/ifcquery/tests/test_main.py
@@ -0,0 +1,121 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+import ifcopenshell
+import ifcopenshell.api.project
+import pytest
+
+
+@pytest.fixture
+def ifc_path(model):
+ """Write the model fixture to a temp file and return its path."""
+ with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) as f:
+ model.write(f.name)
+ yield f.name
+ os.unlink(f.name)
+
+
+def run_ifcquery(*args):
+ """Run ifcquery as a subprocess and return (returncode, stdout, stderr)."""
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", *args],
+ capture_output=True,
+ text=True,
+ )
+ return result.returncode, result.stdout, result.stderr
+
+
+class TestCLI:
+ def test_summary_json(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "summary")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["schema"] == "IFC4"
+ assert "types" in data
+
+ def test_tree_json(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "tree")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["type"] == "IfcProject"
+
+ def test_info_json(self, ifc_path, model):
+ wall = model.by_type("IfcWall")[0]
+ rc, stdout, stderr = run_ifcquery(ifc_path, "info", str(wall.id()))
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["type"] == "IfcWall"
+
+ def test_info_hash_id(self, ifc_path, model):
+ wall = model.by_type("IfcWall")[0]
+ rc, stdout, stderr = run_ifcquery(ifc_path, "info", f"#{wall.id()}")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["type"] == "IfcWall"
+
+ def test_select_json(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "select", "IfcWall")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert len(data) == 1
+ assert data[0]["type"] == "IfcWall"
+
+ def test_text_format(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "text", "summary")
+ assert rc == 0
+ assert "schema:" in stdout
+
+ def test_bad_file(self):
+ rc, stdout, stderr = run_ifcquery("/nonexistent.ifc", "summary")
+ assert rc != 0
+ assert "Error" in stderr
+
+ def test_bad_element_id(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "info", "999999")
+ assert rc != 0
+ assert "Error" in stderr
+
+ def test_no_command(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path)
+ assert rc != 0
+
+ def test_select_ids_format(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcWall")
+ assert rc == 0
+ # Should be a comma-separated string of integers with no surrounding whitespace
+ ids = stdout.strip()
+ assert ids != ""
+ for part in ids.split(","):
+ assert part.isdigit()
+
+ def test_select_ids_format_multiple(self, ifc_path, model):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcElement")
+ assert rc == 0
+ ids = stdout.strip().split(",")
+ assert len(ids) >= 2
+
+ def test_ids_format_empty_result(self, ifc_path):
+ rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcDoor")
+ assert rc == 0
+ assert stdout.strip() == ""
+
+ def test_relations_ids_format(self, ifc_path, model):
+ storey = model.by_type("IfcBuildingStorey")[0]
+ rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "relations", str(storey.id()))
+ assert rc == 0
+ ids = stdout.strip().split(",")
+ assert all(i.isdigit() for i in ids)
+ # should include the storey itself and its contained elements
+ assert str(storey.id()) in ids
+ wall_id = str(model.by_type("IfcWall")[0].id())
+ assert wall_id in ids
+
+ def test_info_ids_format(self, ifc_path, model):
+ wall = model.by_type("IfcWall")[0]
+ rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "info", str(wall.id()))
+ assert rc == 0
+ assert stdout.strip() == str(wall.id())
diff --git a/src/ifcquery/tests/test_materials.py b/src/ifcquery/tests/test_materials.py
new file mode 100644
index 0000000000..aa6886156f
--- /dev/null
+++ b/src/ifcquery/tests/test_materials.py
@@ -0,0 +1,52 @@
+import ifcopenshell.api.material
+import ifcopenshell.api.project
+
+from ifcquery.materials import materials
+
+
+class TestMaterials:
+ def test_empty_model(self, model):
+ result = materials(model)
+ assert isinstance(result, list)
+ assert len(result) == 0
+
+ def test_single_material(self, model):
+ ifcopenshell.api.material.add_material(model, name="Concrete", category="concrete")
+ result = materials(model)
+ assert len(result) == 1
+ m = result[0]
+ assert m["type"] == "IfcMaterial"
+ assert m["name"] == "Concrete"
+ assert m["category"] == "concrete"
+ assert isinstance(m["id"], int)
+
+ def test_material_layer_set(self, model):
+ mat = ifcopenshell.api.material.add_material(model, name="Brick")
+ layer_set = ifcopenshell.api.material.add_material_set(model, name="BrickSet", set_type="IfcMaterialLayerSet")
+ ifcopenshell.api.material.add_layer(model, layer_set=layer_set, material=mat)
+ result = materials(model)
+ layer_sets = [e for e in result if e["type"] == "IfcMaterialLayerSet"]
+ assert len(layer_sets) == 1
+ ls = layer_sets[0]
+ assert ls["name"] == "BrickSet"
+ assert isinstance(ls["layers"], list)
+ assert len(ls["layers"]) == 1
+ layer = ls["layers"][0]
+ assert layer["material"] == "Brick"
+
+ def test_material_constituent_set(self, model):
+ mat = ifcopenshell.api.material.add_material(model, name="Steel")
+ cs = ifcopenshell.api.material.add_material_set(model, name="CompSet", set_type="IfcMaterialConstituentSet")
+ ifcopenshell.api.material.add_constituent(model, constituent_set=cs, material=mat)
+ result = materials(model)
+ constituent_sets = [e for e in result if e["type"] == "IfcMaterialConstituentSet"]
+ assert len(constituent_sets) == 1
+ entry = constituent_sets[0]
+ assert entry["name"] == "CompSet"
+ assert isinstance(entry["constituents"], list)
+
+ def test_ids_are_integers(self, model):
+ ifcopenshell.api.material.add_material(model, name="Wood")
+ result = materials(model)
+ for entry in result:
+ assert isinstance(entry["id"], int)
diff --git a/src/ifcquery/tests/test_plot.py b/src/ifcquery/tests/test_plot.py
new file mode 100644
index 0000000000..4c003ab946
--- /dev/null
+++ b/src/ifcquery/tests/test_plot.py
@@ -0,0 +1,265 @@
+from __future__ import annotations
+
+import base64
+import os
+import subprocess
+import sys
+import tempfile
+
+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
+import pytest
+
+from ifcquery.plot import _highlight_css_from_ids, plot
+
+try:
+ import ifcopenshell.draw # noqa: F401
+
+ HAS_DRAW = True
+except ImportError:
+ HAS_DRAW = False
+
+try:
+ import cairosvg # noqa: F401
+
+ HAS_CAIROSVG = True
+except ImportError:
+ HAS_CAIROSVG = False
+
+pytestmark = pytest.mark.skipif(not HAS_DRAW, reason="ifcopenshell.draw not available")
+
+SVG_MAGIC = b" elements, PNG/base64 should raise a clear error."""
+
+ def test_empty_drawing_png_raises(self, model_no_plan):
+ """PNG format raises ValueError (not silently returns None) for empty drawings."""
+ model, _ = model_no_plan
+ svg = plot(model, output_format="svg")
+ has_groups = b"" in svg
+ if not has_groups:
+ pytest.raises(ValueError, plot, model, output_format="png")
+ else:
+ pytest.skip("Model produced non-empty SVG — empty path not triggered")
+
+ def test_empty_drawing_base64_raises(self, model_no_plan):
+ """base64 format raises ValueError (not silently returns None) for empty drawings."""
+ model, _ = model_no_plan
+ svg = plot(model, output_format="svg")
+ has_groups = b"" in svg
+ if not has_groups:
+ pytest.raises(ValueError, plot, model, output_format="base64")
+ else:
+ pytest.skip("Model produced non-empty SVG — empty path not triggered")
+
+
+@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed")
+class TestPlotPNG:
+ """PNG and base64 require cairosvg."""
+
+ def test_png_returns_bytes_or_raises_on_empty(self, model_with_annotations):
+ model, _ = model_with_annotations
+ svg = plot(model, output_format="svg")
+ has_groups = b"" in svg
+ if has_groups:
+ result = plot(model, output_format="png")
+ assert isinstance(result, bytes)
+ assert result[:4] == PNG_MAGIC
+ else:
+ with pytest.raises(ValueError, match="No plan geometry"):
+ plot(model, output_format="png")
+
+ def test_base64_returns_dict(self, model_with_annotations):
+ model, _ = model_with_annotations
+ svg = plot(model, output_format="svg")
+ has_groups = b"" in svg
+ if has_groups:
+ result = plot(model, output_format="base64")
+ assert isinstance(result, dict)
+ assert result["mime"] == "image/png"
+ assert "png_b64" in result
+ assert "width" in result
+ assert "height" in result
+ assert "view" in result
+ # Verify the base64 is valid PNG
+ decoded = base64.b64decode(result["png_b64"])
+ assert decoded[:4] == PNG_MAGIC
+ else:
+ with pytest.raises(ValueError, match="No plan geometry"):
+ plot(model, output_format="base64")
+
+ def test_base64_view_field_matches_requested(self, model_with_annotations):
+ model, _ = model_with_annotations
+ svg = plot(model, output_format="svg")
+ has_groups = b"" in svg
+ if not has_groups:
+ pytest.skip("Model produces empty SVG")
+ result = plot(model, output_format="base64", view="floorplan")
+ assert result["view"] == "floorplan"
+
+ def test_png_custom_size(self, model_with_annotations):
+ model, _ = model_with_annotations
+ svg = plot(model, output_format="svg")
+ has_groups = b"" in svg
+ if not has_groups:
+ pytest.skip("Model produces empty SVG")
+ result = plot(model, output_format="png", png_width=512, png_height=512)
+ assert isinstance(result, bytes)
+ assert result[:4] == PNG_MAGIC
+
+
+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_plot_svg_writes_file(self, model_with_annotations):
+ model, _ = model_with_annotations
+ ifc_path = self._ifc_path(model)
+ out_path = ifc_path.replace(".ifc", "_out.svg")
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "svg", "-o", out_path],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ assert os.path.exists(out_path)
+ with open(out_path, "rb") as f:
+ assert f.read(5) == SVG_MAGIC
+ finally:
+ for path in (ifc_path, out_path):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+ @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed")
+ def test_plot_base64_prints_json(self, model_with_annotations):
+ """base64 format prints JSON to stdout instead of writing a file."""
+ model, _ = model_with_annotations
+ ifc_path = self._ifc_path(model)
+ try:
+ # First check if the model would produce geometry
+ svg = plot(model, output_format="svg")
+ has_groups = b"" in svg
+ if not has_groups:
+ pytest.skip("Model produces empty SVG — base64 would raise ValueError")
+
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "base64"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ # Output should be JSON (not an error) and contain base64 key
+ assert "png_b64" in result.stdout
+ finally:
+ try:
+ os.unlink(ifc_path)
+ except OSError:
+ pass
diff --git a/src/ifcquery/tests/test_relations.py b/src/ifcquery/tests/test_relations.py
new file mode 100644
index 0000000000..32bc8bf3cb
--- /dev/null
+++ b/src/ifcquery/tests/test_relations.py
@@ -0,0 +1,199 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+from ifcquery.relations import relations
+
+
+class TestWallRelations:
+ def test_wall_has_container(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ assert result["id"] == wall.id()
+ assert result["type"] == "IfcWall"
+ assert result["hierarchy"]["container"]["type"] == "IfcBuildingStorey"
+ assert result["hierarchy"]["container"]["name"] == "Ground Floor"
+
+ def test_wall_has_parent(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ assert result["hierarchy"]["parent"]["type"] == "IfcBuildingStorey"
+
+ def test_wall_no_children(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ assert "children" not in result
+
+ def test_wall_empty_categories_omitted(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ assert "groups" not in result
+ assert "systems" not in result
+ assert "zones" not in result
+ assert "connections" not in result
+ assert "referenced_structures" not in result
+
+
+class TestStoreyRelations:
+ def test_storey_has_contained(self, model):
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = relations(model, storey)
+ contained_types = {e["type"] for e in result["children"]["contained"]}
+ assert "IfcWall" in contained_types
+ assert "IfcSlab" in contained_types
+
+ def test_storey_has_aggregate_parent(self, model):
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = relations(model, storey)
+ assert result["hierarchy"]["aggregate"]["type"] == "IfcBuilding"
+ assert result["hierarchy"]["aggregate"]["name"] == "TestBuilding"
+
+
+class TestProjectRelations:
+ def test_project_has_parts(self, model):
+ project = model.by_type("IfcProject")[0]
+ result = relations(model, project)
+ parts = result["children"]["parts"]
+ assert any(p["type"] == "IfcSite" for p in parts)
+
+ def test_project_no_hierarchy(self, model):
+ project = model.by_type("IfcProject")[0]
+ result = relations(model, project)
+ assert "hierarchy" not in result
+
+
+class TestTraverseUp:
+ def test_wall_to_project(self, model):
+ wall = model.by_type("IfcWall")[0]
+ chain = relations(model, wall, traverse="up")
+ assert isinstance(chain, list)
+ assert chain[0]["type"] == "IfcWall"
+ assert chain[-1]["type"] == "IfcProject"
+ types = [e["type"] for e in chain]
+ assert "IfcBuildingStorey" in types
+ assert "IfcBuilding" in types
+ assert "IfcSite" in types
+
+ def test_project_traverse(self, model):
+ project = model.by_type("IfcProject")[0]
+ chain = relations(model, project, traverse="up")
+ assert len(chain) == 1
+ assert chain[0]["type"] == "IfcProject"
+
+ def test_storey_to_project(self, model):
+ storey = model.by_type("IfcBuildingStorey")[0]
+ chain = relations(model, storey, traverse="up")
+ assert chain[0]["type"] == "IfcBuildingStorey"
+ assert chain[-1]["type"] == "IfcProject"
+ assert len(chain) == 4 # storey -> building -> site -> project
+
+
+class TestElementsSummary:
+ def test_wall_elements_includes_self(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ ids = [e["id"] for e in result["elements"]]
+ assert wall.id() in ids
+
+ def test_wall_elements_includes_container(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ ids = [e["id"] for e in result["elements"]]
+ storey = model.by_type("IfcBuildingStorey")[0]
+ assert storey.id() in ids
+
+ def test_storey_elements_includes_contained(self, model):
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = relations(model, storey)
+ ids = [e["id"] for e in result["elements"]]
+ wall = model.by_type("IfcWall")[0]
+ assert wall.id() in ids
+
+ def test_elements_no_duplicates(self, model):
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = relations(model, storey)
+ ids = [e["id"] for e in result["elements"]]
+ assert len(ids) == len(set(ids))
+
+ def test_elements_all_have_id_and_type(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ for e in result["elements"]:
+ assert "id" in e
+ assert "type" in e
+
+ def test_traverse_up_has_no_elements_field(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall, traverse="up")
+ assert isinstance(result, list)
+ assert not any("elements" in item for item in result)
+
+
+class TestJsonSerializable:
+ def test_relations_serializable(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall)
+ json.dumps(result)
+
+ def test_traverse_serializable(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = relations(model, wall, traverse="up")
+ json.dumps(result)
+
+
+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_relations_json(self, model):
+ path = self._ifc_path(model)
+ try:
+ wall = model.by_type("IfcWall")[0]
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id())],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert data["type"] == "IfcWall"
+ assert "hierarchy" in data
+ finally:
+ os.unlink(path)
+
+ def test_relations_traverse_up(self, model):
+ path = self._ifc_path(model)
+ try:
+ wall = model.by_type("IfcWall")[0]
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id()), "--traverse", "up"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert isinstance(data, list)
+ assert data[0]["type"] == "IfcWall"
+ assert data[-1]["type"] == "IfcProject"
+ finally:
+ os.unlink(path)
+
+ def test_relations_bad_id(self, model):
+ path = self._ifc_path(model)
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", path, "relations", "999999"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode != 0
+ assert "Error" in result.stderr
+ finally:
+ os.unlink(path)
diff --git a/src/ifcquery/tests/test_render.py b/src/ifcquery/tests/test_render.py
new file mode 100644
index 0000000000..28d83a6b68
--- /dev/null
+++ b/src/ifcquery/tests/test_render.py
@@ -0,0 +1,355 @@
+# This file was generated with the assistance of an AI coding tool.
+import os
+import subprocess
+import sys
+import tempfile
+
+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
+import ifcopenshell.guid
+import numpy as np
+import pytest
+
+from ifcquery.render import _make_profile_occurrence, _make_type_occurrence, render
+
+try:
+ import pyvista # noqa: F401
+
+ HAS_PYVISTA = True
+except ImportError:
+ HAS_PYVISTA = False
+
+pytestmark = pytest.mark.skipif(not HAS_PYVISTA, reason="pyvista not installed")
+
+PNG_MAGIC = b"\x89PNG"
+
+
+@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)
+
+ 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
+ )
+
+ 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)
+
+ wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
+ rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, 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.eye(4)
+ matrix2[1, 3] = 3.0
+ ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2)
+
+ return f
+
+
+@pytest.fixture
+def library_with_type():
+ """IFC4 library file: a WallType with a RepresentationMap but no instances."""
+ 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="LibProject")
+ ifcopenshell.api.unit.assign_unit(f)
+
+ 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
+ )
+
+ # Build the shape representation and wrap it in an IfcRepresentationMap.
+ shape_rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=3, height=2.5, thickness=0.2)
+ origin = f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
+ z_dir = f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
+ x_dir = f.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
+ map_origin = f.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_dir, RefDirection=x_dir)
+ rep_map = f.create_entity("IfcRepresentationMap", MappingOrigin=map_origin, MappedRepresentation=shape_rep)
+
+ wall_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="LibWallType")
+ wall_type.RepresentationMaps = [rep_map]
+
+ return f, wall_type
+
+
+@pytest.fixture
+def library_with_profile_type():
+ """IFC4 library: a BeamType with an IfcMaterialProfileSet but no RepresentationMaps."""
+ 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="ProfileLibProject")
+ ifcopenshell.api.unit.assign_unit(f)
+
+ model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
+ ifcopenshell.api.context.add_context(
+ f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
+ )
+
+ # Rectangular profile 0.2m x 0.3m
+ profile = f.create_entity(
+ "IfcRectangleProfileDef",
+ ProfileType="AREA",
+ ProfileName="200x300",
+ XDim=0.2,
+ YDim=0.3,
+ )
+ material = f.create_entity("IfcMaterial", Name="Steel")
+ mat_profile = f.create_entity("IfcMaterialProfile", Material=material, Profile=profile)
+ profile_set = f.create_entity("IfcMaterialProfileSet", MaterialProfiles=[mat_profile])
+
+ beam_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBeamType", name="200x300 Steel Beam")
+ rel = f.create_entity(
+ "IfcRelAssociatesMaterial",
+ GlobalId=ifcopenshell.guid.new(),
+ RelatedObjects=[beam_type],
+ RelatingMaterial=profile_set,
+ )
+
+ return f, beam_type
+
+
+class TestRenderBasic:
+ def test_returns_png_bytes(self, model_with_geometry):
+ result = render(model_with_geometry)
+ assert isinstance(result, bytes)
+ assert result[:4] == PNG_MAGIC
+
+ def test_iso_view(self, model_with_geometry):
+ result = render(model_with_geometry, view="iso")
+ assert result[:4] == PNG_MAGIC
+
+ def test_top_view(self, model_with_geometry):
+ result = render(model_with_geometry, view="top")
+ assert result[:4] == PNG_MAGIC
+
+ def test_south_view(self, model_with_geometry):
+ result = render(model_with_geometry, view="south")
+ assert result[:4] == PNG_MAGIC
+
+ def test_unknown_view_falls_back_to_iso(self, model_with_geometry):
+ # Unknown view strings fall through to isometric
+ result = render(model_with_geometry, view="diagonal")
+ assert result[:4] == PNG_MAGIC
+
+
+class TestRenderSelector:
+ def test_selector_restricts_elements(self, model_with_geometry):
+ result = render(model_with_geometry, selector="IfcWall")
+ assert result[:4] == PNG_MAGIC
+
+ def test_selector_no_match_raises(self, model_with_geometry):
+ with pytest.raises(ValueError, match="matched no elements"):
+ render(model_with_geometry, selector="IfcDoor")
+
+
+class TestRenderHighlight:
+ def test_highlight_single_element(self, model_with_geometry):
+ wall = model_with_geometry.by_type("IfcWall")[0]
+ result = render(model_with_geometry, element_ids=[wall.id()])
+ assert result[:4] == PNG_MAGIC
+
+ def test_highlight_multiple_elements(self, model_with_geometry):
+ walls = model_with_geometry.by_type("IfcWall")
+ result = render(model_with_geometry, element_ids=[w.id() for w in walls])
+ assert result[:4] == PNG_MAGIC
+
+
+class TestRenderTypes:
+ def test_render_type_by_selector(self, library_with_type):
+ """Selecting a type class renders its RepresentationMap geometry."""
+ model, wall_type = library_with_type
+ result = render(model, selector="IfcWallType")
+ assert result[:4] == PNG_MAGIC
+
+ def test_render_type_by_element_id(self, library_with_type):
+ """Passing a type step-ID via element_ids renders it highlighted."""
+ model, wall_type = library_with_type
+ result = render(model, element_ids=[wall_type.id()])
+ assert result[:4] == PNG_MAGIC
+
+ def test_original_model_unmodified(self, library_with_type):
+ """Rendering a type must not add entities to the original model."""
+ model, wall_type = library_with_type
+ entity_count_before = len(list(model))
+ render(model, selector="IfcWallType")
+ assert len(list(model)) == entity_count_before
+
+ def test_make_type_occurrence_no_rep_maps(self, library_with_type):
+ """_make_type_occurrence returns None for a type with no RepresentationMaps."""
+ model, _ = library_with_type
+ bare_type = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWallType", name="Bare")
+ assert _make_type_occurrence(model, bare_type) is None
+
+ def test_type_without_rep_maps_raises(self):
+ """Selecting a type that has no RepresentationMaps raises ValueError."""
+ 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]
+ ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
+ ifcopenshell.api.unit.assign_unit(f)
+ ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="Bare")
+ with pytest.raises(ValueError):
+ render(f, selector="IfcWallType")
+
+
+class TestRenderProfileTypes:
+ def test_render_profile_type_by_element_id(self, library_with_profile_type):
+ """A type with only a material profile set renders via temporary extrusion."""
+ model, beam_type = library_with_profile_type
+ result = render(model, element_ids=[beam_type.id()])
+ assert result[:4] == PNG_MAGIC
+
+ def test_make_profile_occurrence_creates_occurrence(self, library_with_profile_type):
+ """_make_profile_occurrence returns an occurrence entity for a profile-set type."""
+ model, beam_type = library_with_profile_type
+ occ = _make_profile_occurrence(model, beam_type)
+ assert occ is not None
+
+ def test_make_profile_occurrence_no_profile_returns_none(self, library_with_type):
+ """_make_profile_occurrence returns None when type has no material profile set."""
+ model, wall_type = library_with_type
+ # wall_type has RepresentationMaps but no material profile set
+ occ = _make_profile_occurrence(model, wall_type)
+ assert occ is None
+
+ def test_original_model_unmodified_for_profile_type(self, library_with_profile_type):
+ """Rendering a profile-based type does not modify the original model."""
+ model, beam_type = library_with_profile_type
+ entity_count_before = len(list(model))
+ render(model, element_ids=[beam_type.id()])
+ assert len(list(model)) == entity_count_before
+
+
+class TestRenderNoGeometry:
+ def test_no_geometry_raises(self):
+ """A model without geometry representations raises ValueError."""
+ 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)
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wallless")
+ ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
+
+ with pytest.raises(ValueError, match="No renderable geometry"):
+ render(f)
+
+
+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_render_writes_png(self, model_with_geometry):
+ ifc_path = self._ifc_path(model_with_geometry)
+ out_path = ifc_path.replace(".ifc", "_out.png")
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ assert os.path.exists(out_path)
+ with open(out_path, "rb") as f:
+ assert f.read(4) == PNG_MAGIC
+ finally:
+ for path in (ifc_path, out_path):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+ def test_render_default_output_path(self, model_with_geometry):
+ ifc_path = self._ifc_path(model_with_geometry)
+ expected_png = ifc_path.replace(".ifc", ".png")
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", ifc_path, "render"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ assert os.path.exists(expected_png)
+ finally:
+ for path in (ifc_path, expected_png):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+ def test_render_with_selector(self, model_with_geometry):
+ ifc_path = self._ifc_path(model_with_geometry)
+ out_path = ifc_path.replace(".ifc", "_sel.png")
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--selector", "IfcWall"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ with open(out_path, "rb") as f:
+ assert f.read(4) == PNG_MAGIC
+ finally:
+ for path in (ifc_path, out_path):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+ def test_render_with_view(self, model_with_geometry):
+ ifc_path = self._ifc_path(model_with_geometry)
+ out_path = ifc_path.replace(".ifc", "_top.png")
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--view", "top"],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ with open(out_path, "rb") as f:
+ assert f.read(4) == PNG_MAGIC
+ finally:
+ for path in (ifc_path, out_path):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
diff --git a/src/ifcquery/tests/test_schedule.py b/src/ifcquery/tests/test_schedule.py
new file mode 100644
index 0000000000..0d3a3e934f
--- /dev/null
+++ b/src/ifcquery/tests/test_schedule.py
@@ -0,0 +1,121 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+import ifcopenshell
+import ifcopenshell.api.aggregate
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.sequence
+import ifcopenshell.api.unit
+import pytest
+
+from ifcquery.schedule import schedule
+
+
+@pytest.fixture
+def schedule_model():
+ """Create an IFC4 model with a work schedule and nested tasks."""
+ 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)
+
+ ws = ifcopenshell.api.sequence.add_work_schedule(f, name="Construction Schedule")
+
+ task1 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 1", identification="P1")
+ tt1 = ifcopenshell.api.sequence.add_task_time(f, task=task1)
+ ifcopenshell.api.sequence.edit_task_time(
+ f, task_time=tt1, attributes={"ScheduleStart": "2024-01-01", "ScheduleFinish": "2024-06-30"}
+ )
+
+ task2 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 2", identification="P2")
+ subtask = ifcopenshell.api.sequence.add_task(f, parent_task=task1, name="Sub Task", identification="S1")
+
+ return f
+
+
+class TestSchedule:
+ def test_returns_list(self, schedule_model):
+ result = schedule(schedule_model)
+ assert isinstance(result, list)
+
+ def test_finds_work_schedule(self, schedule_model):
+ result = schedule(schedule_model)
+ assert len(result) == 1
+
+ def test_work_schedule_has_name(self, schedule_model):
+ result = schedule(schedule_model)
+ assert result[0]["name"] == "Construction Schedule"
+
+ def test_work_schedule_has_id(self, schedule_model):
+ result = schedule(schedule_model)
+ assert isinstance(result[0]["id"], int)
+ assert result[0]["id"] > 0
+
+ def test_work_schedule_has_tasks(self, schedule_model):
+ result = schedule(schedule_model)
+ tasks = result[0]["tasks"]
+ assert isinstance(tasks, list)
+ assert len(tasks) >= 1
+
+ def test_task_has_required_fields(self, schedule_model):
+ result = schedule(schedule_model)
+ task = result[0]["tasks"][0]
+ assert "id" in task
+ assert "name" in task
+ assert "start" in task
+ assert "finish" in task
+ assert "is_milestone" in task
+ assert "outputs" in task
+ assert "subtasks" in task
+
+ def test_task_name(self, schedule_model):
+ result = schedule(schedule_model)
+ task_names = [t["name"] for t in result[0]["tasks"]]
+ assert "Phase 1" in task_names
+
+ def test_task_start_finish(self, schedule_model):
+ result = schedule(schedule_model)
+ phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
+ assert phase1["start"] is not None
+ assert phase1["finish"] is not None
+
+ def test_subtasks(self, schedule_model):
+ result = schedule(schedule_model)
+ phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
+ assert len(phase1["subtasks"]) == 1
+ assert phase1["subtasks"][0]["name"] == "Sub Task"
+
+ def test_empty_model_returns_empty_list(self, model):
+ result = schedule(model)
+ assert result == []
+
+ def test_max_depth_none_returns_full_tree(self, schedule_model):
+ result = schedule(schedule_model, max_depth=None)
+ phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
+ assert isinstance(phase1["subtasks"], list)
+ assert len(phase1["subtasks"]) == 1
+
+ def test_max_depth_1_truncates_subtasks(self, schedule_model):
+ result = schedule(schedule_model, max_depth=1)
+ phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
+ assert isinstance(phase1["subtasks"], dict)
+ assert phase1["subtasks"]["truncated"] is True
+ assert phase1["subtasks"]["count"] == 1
+
+ def test_max_depth_truncation_shows_count(self, schedule_model):
+ result = schedule(schedule_model, max_depth=1)
+ # Phase 2 has no subtasks — should return empty list, not truncation dict
+ phase2 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 2")
+ assert phase2["subtasks"] == []
+
+ def test_max_depth_2_expands_to_depth_2(self, schedule_model):
+ result = schedule(schedule_model, max_depth=2)
+ phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
+ # subtask at depth 2 should be fully expanded (it has no children)
+ assert isinstance(phase1["subtasks"], list)
+ assert phase1["subtasks"][0]["name"] == "Sub Task"
+ assert phase1["subtasks"][0]["subtasks"] == []
diff --git a/src/ifcquery/tests/test_schema.py b/src/ifcquery/tests/test_schema.py
new file mode 100644
index 0000000000..c4f63344ee
--- /dev/null
+++ b/src/ifcquery/tests/test_schema.py
@@ -0,0 +1,32 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+import pytest
+
+from ifcquery.schema import schema
+
+
+class TestSchema:
+ def test_ifc_wall_has_description(self, model):
+ result = schema(model, "IfcWall")
+ assert "description" in result
+ assert isinstance(result["description"], str)
+ assert len(result["description"]) > 0
+
+ def test_ifc_wall_has_attributes(self, model):
+ result = schema(model, "IfcWall")
+ assert "attributes" in result
+
+ def test_ifc_wall_has_spec_url(self, model):
+ result = schema(model, "IfcWall")
+ assert "spec_url" in result
+
+ def test_unknown_entity_returns_error(self, model):
+ result = schema(model, "IfcNonExistentFooBar")
+ assert "error" in result
+ assert "IfcNonExistentFooBar" in result["error"]
+
+ def test_ifc_window_has_description(self, model):
+ result = schema(model, "IfcWindow")
+ assert "description" in result
+ assert len(result["description"]) > 0
diff --git a/src/ifcquery/tests/test_select.py b/src/ifcquery/tests/test_select.py
new file mode 100644
index 0000000000..d5700a38e6
--- /dev/null
+++ b/src/ifcquery/tests/test_select.py
@@ -0,0 +1,32 @@
+# This file was generated with the assistance of an AI coding tool.
+from ifcquery.select import select
+
+
+class TestSelect:
+ def test_select_by_type(self, model):
+ result = select(model, "IfcWall")
+ assert len(result) == 1
+ assert result[0]["type"] == "IfcWall"
+ assert result[0]["name"] == "Wall001"
+
+ def test_select_multiple_types(self, model):
+ result = select(model, "IfcWall, IfcSlab")
+ assert len(result) == 2
+ types = {r["type"] for r in result}
+ assert types == {"IfcWall", "IfcSlab"}
+
+ def test_select_no_match(self, model):
+ result = select(model, "IfcDoor")
+ assert result == []
+
+ def test_results_sorted_by_id(self, model):
+ result = select(model, "IfcWall, IfcSlab")
+ ids = [r["id"] for r in result]
+ assert ids == sorted(ids)
+
+ def test_result_has_id_type_name(self, model):
+ result = select(model, "IfcWall")
+ entry = result[0]
+ assert "id" in entry
+ assert "type" in entry
+ assert "name" in entry
diff --git a/src/ifcquery/tests/test_summary.py b/src/ifcquery/tests/test_summary.py
new file mode 100644
index 0000000000..c230b48ab7
--- /dev/null
+++ b/src/ifcquery/tests/test_summary.py
@@ -0,0 +1,34 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.project
+
+from ifcquery.summary import summary
+
+
+class TestSummary:
+ def test_schema(self, model):
+ result = summary(model)
+ assert result["schema"] == "IFC4"
+
+ def test_total_entities(self, model):
+ result = summary(model)
+ assert result["total_entities"] == len(list(model))
+ assert result["total_entities"] > 0
+
+ def test_project_info(self, model):
+ result = summary(model)
+ assert result["project"]["name"] == "TestProject"
+
+ def test_type_counts(self, model):
+ result = summary(model)
+ types = result["types"]
+ assert "IfcWall" in types
+ assert types["IfcWall"] == 1
+ assert "IfcSlab" in types
+ assert types["IfcSlab"] == 1
+
+ def test_empty_model(self):
+ f = ifcopenshell.api.project.create_file()
+ result = summary(f)
+ assert result["schema"] == "IFC4"
+ assert "project" not in result
diff --git a/src/ifcquery/tests/test_tree.py b/src/ifcquery/tests/test_tree.py
new file mode 100644
index 0000000000..2110be3188
--- /dev/null
+++ b/src/ifcquery/tests/test_tree.py
@@ -0,0 +1,37 @@
+# This file was generated with the assistance of an AI coding tool.
+from ifcquery.tree import tree
+
+
+class TestTree:
+ def test_root_is_project(self, model):
+ result = tree(model)
+ assert result["type"] == "IfcProject"
+ assert result["name"] == "TestProject"
+
+ def test_spatial_hierarchy(self, model):
+ result = tree(model)
+ # Project > Site > Building > Storey
+ site = result["children"][0]
+ assert site["type"] == "IfcSite"
+ assert site["name"] == "TestSite"
+
+ building = site["children"][0]
+ assert building["type"] == "IfcBuilding"
+ assert building["name"] == "TestBuilding"
+
+ storey = building["children"][0]
+ assert storey["type"] == "IfcBuildingStorey"
+ assert storey["name"] == "Ground Floor"
+
+ def test_contained_elements(self, model):
+ result = tree(model)
+ storey = result["children"][0]["children"][0]["children"][0]
+ elements = storey["elements"]
+ element_types = {e["type"] for e in elements}
+ assert "IfcWall" in element_types
+ assert "IfcSlab" in element_types
+
+ def test_element_ids_present(self, model):
+ result = tree(model)
+ assert "id" in result
+ assert isinstance(result["id"], int)
diff --git a/src/ifcquery/tests/test_validate.py b/src/ifcquery/tests/test_validate.py
new file mode 100644
index 0000000000..44734ce4b0
--- /dev/null
+++ b/src/ifcquery/tests/test_validate.py
@@ -0,0 +1,47 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+import ifcopenshell
+import ifcopenshell.api.project
+import pytest
+
+from ifcquery.validate import validate
+
+
+class TestValidate:
+ def test_valid_model_returns_valid_true(self, model):
+ result = validate(model)
+ assert result["valid"] is True
+ assert isinstance(result["issues"], list)
+
+ def test_valid_model_has_no_issues(self, model):
+ result = validate(model)
+ assert result["issues"] == []
+
+ def test_empty_model_is_valid(self):
+ f = ifcopenshell.api.project.create_file()
+ result = validate(f)
+ assert result["valid"] is True
+ assert result["issues"] == []
+
+ def test_result_has_expected_keys(self, model):
+ result = validate(model)
+ assert "valid" in result
+ assert "issues" in result
+
+ def test_express_rules_flag_accepted(self, model):
+ # Just verify it runs without error; express rules may add/not add issues
+ result = validate(model, express_rules=True)
+ assert "valid" in result
+ assert isinstance(result["issues"], list)
+
+ def test_issue_has_level_and_message(self, model):
+ # Force an issue by manually breaking the model (invalid IfcWall attribute)
+ f = ifcopenshell.file()
+ # Create a raw IfcWall with deliberately wrong type for GlobalId (use int)
+ # We just check structure if any issues appear; on well-formed models there are none.
+ result = validate(model)
+ # Even if no issues, the structure contract must hold for any issues present
+ for issue in result["issues"]:
+ assert "level" in issue
+ assert "message" in issue
diff --git a/src/ifctester/Makefile b/src/ifctester/Makefile
index 63ce7bc311..71ecbd48d7 100644
--- a/src/ifctester/Makefile
+++ b/src/ifctester/Makefile
@@ -25,9 +25,19 @@ WEBAPP_BUILD_DIR := $(WEBAPP_DIR)/dist
PYODIDE_DIR := $(WEBAPP_DIR)/public/pyodide
PYODIDE_VERSION := 0.28.0
PYODIDE_URL := https://github.com/pyodide/pyodide/releases/download/$(PYODIDE_VERSION)/pyodide-$(PYODIDE_VERSION).tar.bz2
+WORKER_BIN_DIR := $(WEBAPP_DIR)/public/worker/bin
+IFCOPENSHELL_WASM_WHEEL := ifcopenshell-0.8.5+a51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl
+IFCOPENSHELL_WASM_WHEEL_URL := https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-0.8.5%2Ba51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl
+IFCOPENSHELL_WASM_WHEEL_PATH := $(WORKER_BIN_DIR)/$(IFCOPENSHELL_WASM_WHEEL)
+WEBAPP_GENERATED_DIR := $(WEBAPP_DIR)/public/worker/generated
+WEBAPP_IFCTESTER_MANIFEST := $(WEBAPP_GENERATED_DIR)/ifctester.json
+WEBAPP_IFCTESTER_BUILD_DIR := build-webapp-wheel
+PACKAGE_WEBAPP_DIR := $(PACKAGE_NAME)/webapp
+PACKAGE_WEBAPP_WWW_DIR := $(PACKAGE_WEBAPP_DIR)/www
.PHONY: webapp-dev
-webapp-dev:
+webapp-dev: pyodide-download ifcopenshell-wasm-download webapp-stage-ifctester-wheel
+ cd $(WEBAPP_DIR) && npm install
cd $(WEBAPP_DIR) && npm run dev
.PHONY: pyodide-download
@@ -62,8 +72,43 @@ pyodide-download:
echo "Pyodide $(PYODIDE_VERSION) prepared in $(PYODIDE_DIR)"; \
fi
+.PHONY: ifcopenshell-wasm-download
+ifcopenshell-wasm-download:
+ @if [ -f "$(IFCOPENSHELL_WASM_WHEEL_PATH)" ]; then \
+ echo "IfcOpenShell wasm wheel already exists at $(IFCOPENSHELL_WASM_WHEEL_PATH), skipping download"; \
+ else \
+ echo "Downloading IfcOpenShell wasm wheel..."; \
+ mkdir -p $(WORKER_BIN_DIR); \
+ rm -f $(WORKER_BIN_DIR)/ifcopenshell-*.whl; \
+ curl -fL -o "$(IFCOPENSHELL_WASM_WHEEL_PATH)" "$(IFCOPENSHELL_WASM_WHEEL_URL)"; \
+ echo "IfcOpenShell wasm wheel prepared at $(IFCOPENSHELL_WASM_WHEEL_PATH)"; \
+ fi
+
+.PHONY: webapp-stage-ifctester-wheel
+webapp-stage-ifctester-wheel:
+ rm -rf $(WEBAPP_GENERATED_DIR)
+ rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR)
+ mkdir -p $(WEBAPP_IFCTESTER_BUILD_DIR)
+ cp -r $(PACKAGE_NAME) $(WEBAPP_IFCTESTER_BUILD_DIR)/
+ rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/webapp
+ cp pyproject.toml $(WEBAPP_IFCTESTER_BUILD_DIR)/
+ cp README.md $(WEBAPP_IFCTESTER_BUILD_DIR)/
+ifeq ($(IS_STABLE), TRUE)
+ $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/pyproject.toml
+ $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/__init__.py
+else
+ $(SED) 's/version = "0.0.0"/version = "$(VERSION)a$(VERSION_DATE)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/pyproject.toml
+ $(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/__init__.py
+endif
+ cd $(WEBAPP_IFCTESTER_BUILD_DIR) && $(PYTHON) -m venv env --system-site-packages && . env/$(VENV_ACTIVATE) && python -m pip install build && python -m build --wheel --no-isolation
+ mkdir -p $(WEBAPP_GENERATED_DIR)
+ wheel=$$(basename $(WEBAPP_IFCTESTER_BUILD_DIR)/dist/$(PACKAGE_NAME)-*.whl); \
+ cp "$(WEBAPP_IFCTESTER_BUILD_DIR)/dist/$$wheel" "$(WEBAPP_GENERATED_DIR)/$$wheel"; \
+ printf '{\n "wheel_url": "/worker/generated/%s"\n}\n' "$$wheel" > "$(WEBAPP_IFCTESTER_MANIFEST)"
+ rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR)
+
.PHONY: webapp-build
-webapp-build: pyodide-download
+webapp-build: pyodide-download ifcopenshell-wasm-download webapp-stage-ifctester-wheel
cd $(WEBAPP_DIR) && npm install
cd $(WEBAPP_DIR) && npm run build
@@ -76,22 +121,30 @@ clean:
rm -rf $(WEBAPP_BUILD_DIR)
rm -rf $(WEBAPP_DIR)/node_modules
rm -rf $(PYODIDE_DIR)
+ rm -f $(WORKER_BIN_DIR)/ifcopenshell-*.whl
+ rm -rf $(WEBAPP_GENERATED_DIR)
+ rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR)
rm -rf $(PACKAGE_NAME)/webapp
rm -rf dist
-.PHONY: dist
-dist: webapp-prepare
+.PHONY: python-dist
+python-dist:
+ rm -rf dist
# For some reason OS is not initalized when we call common.mk dist, which matters on Windows.
# So we pass it explicitly.
- $(MAKE) -f ../common.mk dist PACKAGE_NAME=$(PACKAGE_NAME) OS=$(OS)
+ $(MAKE) -f ../common.mk dist PACKAGE_NAME=$(PACKAGE_NAME) OS=$(OS) IS_STABLE=$(IS_STABLE)
+
+.PHONY: dist
+dist: webapp-prepare
+ $(MAKE) python-dist OS=$(OS) IS_STABLE=$(IS_STABLE)
.PHONY: webapp-prepare
webapp-prepare: webapp-build
- rm -rf $(PACKAGE_NAME)/webapp/www/*
- mkdir -p $(PACKAGE_NAME)/webapp/www
- cp -r $(WEBAPP_BUILD_DIR)/* $(PACKAGE_NAME)/webapp/www/
- cp $(WEBAPP_DIR)/__init__.py $(PACKAGE_NAME)/webapp/__init__.py
- cp $(WEBAPP_DIR)/serve.py $(PACKAGE_NAME)/webapp/serve.py
+ rm -rf $(PACKAGE_WEBAPP_WWW_DIR)
+ mkdir -p $(PACKAGE_WEBAPP_WWW_DIR)
+ cp -r $(WEBAPP_BUILD_DIR)/* $(PACKAGE_WEBAPP_WWW_DIR)/
+ cp $(WEBAPP_DIR)/__init__.py $(PACKAGE_WEBAPP_DIR)/__init__.py
+ cp $(WEBAPP_DIR)/serve.py $(PACKAGE_WEBAPP_DIR)/serve.py
.PHONY: test
test:
diff --git a/src/ifctester/webapp/.gitignore b/src/ifctester/webapp/.gitignore
index 4205867691..f7daf321ef 100644
--- a/src/ifctester/webapp/.gitignore
+++ b/src/ifctester/webapp/.gitignore
@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
+public/worker/bin/ifcopenshell-*.whl
+public/worker/generated
# Editor directories and files
.vscode/*
@@ -24,4 +26,4 @@ dist-ssr
*.sw?
.claude
-experiment/*
\ No newline at end of file
+experiment/*
diff --git a/src/ifctester/webapp/biome.json b/src/ifctester/webapp/biome.json
new file mode 100644
index 0000000000..ce01c80c3f
--- /dev/null
+++ b/src/ifctester/webapp/biome.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
+ "files": {
+ "ignore": [
+ "dist",
+ "node_modules",
+ "public/pyodide",
+ "**/*.svelte"
+ ]
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true
+ }
+ },
+ "overrides": [
+ {
+ "include": [
+ "src/modules/wasm/worker/**"
+ ],
+ "linter": {
+ "rules": {
+ "suspicious": {
+ "noExplicitAny": "off"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/src/ifctester/webapp/index.html b/src/ifctester/webapp/index.html
index 27c32440dc..48ac4cc1c8 100644
--- a/src/ifctester/webapp/index.html
+++ b/src/ifctester/webapp/index.html
@@ -9,6 +9,6 @@
-
+