Compare commits

..

13 Commits

Author SHA1 Message Date
Andrej730 10781398d4 Script for making pyodide wheel 2026-04-08 16:27:04 +05:00
Andrej730 da470c5135 Fix missing but used initial_t var 2026-04-01 10:37:23 +05:00
Andrej730 214cd44f8e Fix missing view3d_utils import 2026-04-01 10:37:07 +05:00
Andrej730 4bff2fa554 Fix ruff 2026-04-01 10:37:07 +05:00
Andrej730 9d78df392d black . 2026-04-01 10:37:07 +05:00
Andrej730 86bef0a254 typing 2026-04-01 10:37:06 +05:00
Andrej730 05bf59d360 ci-bonsai-daily - bump Blender version to 5.1 2026-04-01 10:37:06 +05:00
Bruno Postle 17eaef778a api.geometry.connect_path: add connection_geometry parameter
IfcRelConnectsPathElements has an optional ConnectionGeometry attribute for
recording the geometric cut-plane between adjacent elements, but there was
no way to set it via the API.

Generated with the assistance of an AI coding tool.
2026-03-30 07:30:38 +01:00
Bruno Postle f46be80193 Add api.structural.assign_product, assign_to_building, and api.geometry.add_topology_representation
assign_product creates IfcRelAssignsToProduct linking a structural member to
a physical building element. assign_to_building creates IfcRelServicesBuildings
linking a structural analysis model to a building. add_topology_representation
creates IfcTopologyRepresentation for structural elements, inferring the
representation type from the item class.

Generated with the assistance of an AI coding tool.
2026-03-30 07:28:01 +01:00
Bruno Postle be05d771a2 api.boundary.edit_attributes: add PhysicalOrVirtualBoundary and InternalOrExternalBoundary params
Both attributes are required by the IFC schema but were not settable via
the API function. Add physical_or_virtual and internal_or_external parameters
with "NOTDEFINED" defaults for backward compatibility. Update Bonsai boundary
panel to expose both fields in the editor.

Generated with the assistance of an AI coding tool.
2026-03-30 07:25:22 +01:00
Bruno Postle c214d255c9 Fix api.boundary.assign_connection_geometry TypeError
TypeError: attribute 'DirectionRatios' for entity 'IFC4.IfcDirection' is
    expecting value of type 'AGGREGATE OF DOUBLE', got 'ndarray'
2026-03-29 22:04:59 +01:00
Bruno Postle 0d8ba71384 Fix typo in api.boundary.assign_connection_geometry 2026-03-29 21:46:29 +01:00
Bruno Postle 1c26ee86c9 ifcquery/ifcedit: enable shell scripting by composing query and edit commands
Add --format ids to ifcquery to output step IDs suitable for piping into
ifcedit parameters. Add ifcedit foreach to apply an operation to every
element in a query result. Extend clash and relations output so --format ids
extracts all involved element IDs, enabling one-liners like clash detection
piped directly into render.

Generated with the assistance of an AI coding tool.
2026-03-29 15:17:22 +01:00
42 changed files with 1526 additions and 84 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
# Download Blender.
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.1.0-linux-x64.tar.xz
tar -xf blender.tar.xz
# Setup Blender.
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Build an ifcopenshell WASM wheel using Pyodide build system.
Usage:
python make_wheel.py # Show this help
python make_wheel.py --build # Build wheel
python make_wheel.py --clean # Clean build artifacts and exit
"""
import argparse
import platform
import re
import shutil
import subprocess
import time
import zipfile
from pathlib import Path
from urllib.parse import quote
import requests
# Get repo root (parent of this script's parent directory)
REPO_ROOT = Path(__file__).parent.parent
PYODIDE_DIR = REPO_ROOT / "pyodide"
BUILD_DIR = PYODIDE_DIR / "build"
# Hardcoded path (Windows packing workaround with --dev flag)
PYODIDE_BUILD = Path(r"L:\Projects\Github\pyodide-build")
# Wheel platform tag (from PYODIDE_EMSCRIPTEN_VERSION in pyodide-build/Makefile.envs)
WHEEL_PLATFORM_TAG = "emscripten_4_0_9_wasm32"
# Location where ifcopenshell will be extracted
IFCOPENSHELL_DIR = PYODIDE_DIR / "ifcopenshell"
class WheelBuilder:
@staticmethod
def extract_ifcopenshell_from_git(dst: Path) -> None:
"""Extract ifcopenshell directory from git repo into destination."""
Tools.rmrf(dst)
print(f"Extracting ifcopenshell from git to {dst}...")
# Use git ls-files piped to git checkout-index to avoid copying
# untracked or ignored files from the actual repo.
ls_proc = subprocess.Popen(
["git", "ls-files", "-z", "src/ifcopenshell-python/ifcopenshell"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
checkout_proc = subprocess.Popen(
["git", "checkout-index", "-z", "--prefix", "pyodide/", "--stdin"],
cwd=REPO_ROOT,
stdin=ls_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
assert ls_proc.stdout is not None
ls_proc.stdout.close()
checkout_proc.communicate()
if checkout_proc.returncode != 0:
assert checkout_proc.stderr is not None
raise RuntimeError(f"Failed to extract: {checkout_proc.stderr.decode()}")
# Move src/ifcopenshell-python/ifcopenshell to ifcopenshell.
temp_src = PYODIDE_DIR / "src" / "ifcopenshell-python" / "ifcopenshell"
shutil.move(temp_src, dst)
# Clean up temporary src directory.
Tools.rmrf(PYODIDE_DIR / "src")
print("✓ Extracted ifcopenshell from git")
@staticmethod
def get_wheel_url(makefile_path: Path) -> str:
"""Get S3 wheel URL based on BINARY_VERSION and BUILD_COMMIT from Makefile."""
def parse_makefile_vars() -> dict[str, str]:
content = makefile_path.read_text()
vars: dict[str, str] = {}
for match in re.finditer(r"^(BINARY_VERSION|BUILD_COMMIT):=(.+)$", content, re.MULTILINE):
vars[match.group(1)] = match.group(2).strip()
return vars
vars: dict[str, str] = parse_makefile_vars()
binary_version = vars["BINARY_VERSION"]
build_commit = vars["BUILD_COMMIT"]
filename = f"ifcopenshell-{binary_version}+{build_commit}-cp313-cp313-pyodide_2025_0_wasm32.whl"
encoded_filename = quote(filename, safe="")
return f"https://s3.amazonaws.com/ifcopenshell-builds/{encoded_filename}"
@staticmethod
def download_and_extract_so(url: str, build_dir: Path) -> tuple[Path, Path]:
"""Download wheel from URL and extract .so and .py files."""
py_wrapper_filename = "ifcopenshell_wrapper.py"
build_dir.mkdir(parents=True, exist_ok=True)
wheel_path = build_dir / url.rsplit("/", 1)[-1]
if wheel_path.exists():
print(f"Using cached wheel: {wheel_path}")
else:
print(f"Downloading {url}...")
response = requests.get(url)
response.raise_for_status()
wheel_path.write_bytes(response.content)
print("Extracting _ifcopenshell_wrapper files...")
with zipfile.ZipFile(wheel_path) as zf:
so_files = [f for f in zf.namelist() if f.endswith(".so")]
py_files = [f for f in zf.namelist() if f.endswith(py_wrapper_filename)]
assert so_files, "No .so file found in wheel"
assert py_files, f"No {py_wrapper_filename} file found in wheel"
so_file = so_files[0]
so_dst = build_dir / Path(so_file).name
so_dst.write_bytes(zf.read(so_file))
py_file = py_files[0]
py_dst = build_dir / Path(py_file).name
py_dst.write_bytes(zf.read(py_file))
return so_dst, py_dst
class Tools:
@staticmethod
def run(
cmd: list[str],
cwd: Path | None = None,
venv: Path | None = None,
) -> None:
if not venv:
print(f"$ {' '.join(cmd)}")
subprocess.check_call(cmd, cwd=cwd)
return
if platform.system() == "Windows":
activate = venv / ".venv" / "Scripts" / "activate.bat"
cmd_str = f'"{activate}" && {" ".join(cmd)}'
else:
activate = venv / ".venv" / "bin" / "activate"
cmd_str = f'source "{activate}" && {" ".join(cmd)}'
print(f"$ {cmd_str}")
subprocess.check_call(cmd_str, shell=True, cwd=cwd)
@staticmethod
def create_symlink(dst: Path, src: Path) -> None:
Tools.rmrf(dst)
dst.symlink_to(src)
@staticmethod
def rmrf(path: Path) -> None:
if path.exists() or path.is_symlink():
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def clean() -> None:
"""Remove build artifacts."""
paths_to_remove = (
BUILD_DIR,
PYODIDE_DIR / ".venv",
PYODIDE_DIR / ".pyodide_build",
PYODIDE_DIR / "dist",
PYODIDE_DIR / "ifcopenshell.egg-info",
PYODIDE_DIR / "src",
IFCOPENSHELL_DIR,
)
for path in paths_to_remove:
if path.exists() or path.is_symlink():
print(f"Removing {path}...")
Tools.rmrf(path)
print("✓ Clean complete")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, add_help=False)
parser.add_argument("--build", action="store_true", help="Build the wheel")
parser.add_argument("--clean", action="store_true", help="Clean build folder")
parser.add_argument(
"--dev",
action="store_true",
help="Use editable pyodide-build from hardcoded path (Windows packing workaround)",
)
args = parser.parse_args()
if not args.build and not args.clean:
print(__doc__)
return
if args.clean:
clean()
return
start_time = time.time()
WheelBuilder.extract_ifcopenshell_from_git(IFCOPENSHELL_DIR)
print("Downloading and extracting _ifcopenshell_wrapper files...")
makefile = REPO_ROOT / "src" / "ifcopenshell-python" / "Makefile"
wheel_url = WheelBuilder.get_wheel_url(makefile)
so_file, py_file = WheelBuilder.download_and_extract_so(wheel_url, BUILD_DIR)
Tools.create_symlink(IFCOPENSHELL_DIR / Path(so_file).name, so_file)
Tools.create_symlink(IFCOPENSHELL_DIR / Path(py_file).name, py_file)
print("Creating venv...")
Tools.run(["uv", "venv", "--clear", "--python", "3.13"], cwd=PYODIDE_DIR)
print("Installing pyodide-build...")
if args.dev:
Tools.run(["uv", "pip", "install", "-e", str(PYODIDE_BUILD)], cwd=PYODIDE_DIR)
else:
Tools.run(["uv", "pip", "install", "pyodide-build"], cwd=PYODIDE_DIR)
print("Installing setuptools...")
Tools.run(["uv", "pip", "install", "setuptools"], cwd=PYODIDE_DIR)
print("Building with pyodide...")
# Use --no-isolation due to pyodide-build Windows support issues:
# symlink_unisolated_packages fails with missing `_sysconfigdata_$(CPYTHON_ABI_FLAGS)_emscripten_wasm32-emscripten.py`.
# Hardcode platform name since pyodide doesn't yet support overriding wheel tags on Windows.
Tools.run(
["pyodide", "build", "--no-isolation", f"-C--build-option=--plat-name={WHEEL_PLATFORM_TAG}"],
cwd=PYODIDE_DIR,
venv=PYODIDE_DIR,
)
elapsed = time.time() - start_time
print(f"\n✓ Done! ({elapsed:.1f}s)")
if __name__ == "__main__":
main()
+37 -1
View File
@@ -2,12 +2,16 @@
# because `tool.setuptools.ext-modules` is still experimental in pyproject.toml
# and we need it to get the wheel suffix right.
import os
import sys
from pathlib import Path
import tomllib
from setuptools import Extension, find_packages, setup
from setuptools.command.build_ext import build_ext
REPO_FOLDER = Path(__file__).parent
# Detect repo folder: if setup.py is in pyodide folder, go to parent
SETUP_DIR = Path(__file__).parent
REPO_FOLDER = SETUP_DIR.parent if SETUP_DIR.name == "pyodide" else SETUP_DIR
def get_version() -> str:
@@ -24,6 +28,37 @@ def get_dependencies() -> list[str]:
dependencies = pyproject_data["project"]["dependencies"]
return dependencies
class UnixBuildExt(build_ext):
"""Customize ``build_ext`` to support packing on Windows."""
def finalize_options(self):
from distutils import sysconfig
super().finalize_options()
if sys.platform == 'win32':
self.compiler = 'unix'
# Configure sysconfig for Windows builds
# CCSHARED is the only variable that's not customizable with env vars.
# Basically avoiding this:
# File ".venv\Lib\site-packages\setuptools\_distutils\sysconfig.py", line 366, in customize_compiler
# compiler_so=cc_cmd + ' ' + ccshared,
# ~~~~~~~~~~~~~^~~~~~~~~~
# TypeError: can only concatenate str (not "NoneType") to str
sysconfig.get_config_vars() # Initialize config cache
if sysconfig._config_vars.get('CCSHARED') is None:
sysconfig._config_vars['CCSHARED'] = '-fPIC'
# Override compiler type before it's instantiated
# Set Emscripten compiler environment variables
os.environ['CC'] = 'emcc'
os.environ['CXX'] = 'em++'
os.environ['CFLAGS'] = ''
os.environ['CXXFLAGS'] = ''
os.environ['LDSHARED'] = 'emcc -shared'
os.environ['AR'] = 'emar'
os.environ['ARFLAGS'] = 'rcs'
os.environ['SETUPTOOLS_EXT_SUFFIX'] = '.cpython-313-wasm32-emscripten.so'
setup(
name="ifcopenshell",
@@ -44,4 +79,5 @@ setup(
},
# Has to provide extension to get the correct wheel suffix.
ext_modules=[Extension("ifcopenshell._ifcopenshell_wrapper", sources=[])],
cmdclass={'build_ext': UnixBuildExt},
)
+2 -2
View File
@@ -64,8 +64,8 @@ class MaterialCreator:
mesh: Union[OBJECT_DATA_TYPE, None],
shape_has_openings: bool,
) -> None:
if (((rep := getattr(element, "Representation", ...)) is not ... and not rep) or
((rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep)
if ((rep := getattr(element, "Representation", ...)) is not ... and not rep) or (
(rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep
):
return
@@ -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"}
@@ -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):
@@ -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)
@@ -1102,7 +1102,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
else:
return self.finish_loading_project(context)
def finish_loading_project(self, context):
def finish_loading_project(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
try:
filepath = self.get_filepath()
if not self.is_existing_ifc_file():
+12 -24
View File
@@ -27,11 +27,9 @@ import mathutils
import numpy as np
from mathutils import Vector
from bpy_extras import view3d_utils
import bonsai.core.tool
import bonsai.tool as tool
from bpy_extras import view3d_utils
class Raycast(bonsai.core.tool.Raycast):
offset = 10
@@ -78,11 +76,7 @@ class Raycast(bonsai.core.tool.Raycast):
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)
]
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] = []
@@ -118,7 +112,9 @@ class Raycast(bonsai.core.tool.Raycast):
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]])
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:
@@ -151,7 +147,7 @@ class Raycast(bonsai.core.tool.Raycast):
z_near = -clip_start
za = a_view.z
zb = b_view.z
denom = (zb - za)
denom = zb - za
if denom == 0.0:
return None, None
t = (z_near - za) / denom
@@ -211,13 +207,10 @@ class Raycast(bonsai.core.tool.Raycast):
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
final_t = t_on_ab
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
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:
@@ -407,7 +400,7 @@ class Raycast(bonsai.core.tool.Raycast):
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):
@@ -733,8 +726,7 @@ class Raycast(bonsai.core.tool.Raycast):
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
snap_obj = cls.create_snap_obj(obj)
if snap_obj is not None:
objs_to_raycast.append(snap_obj)
objs_to_raycast.append(snap_obj)
return objs_to_raycast
@@ -826,8 +818,8 @@ class Raycast(bonsai.core.tool.Raycast):
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)
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)
@@ -849,7 +841,6 @@ class Raycast(bonsai.core.tool.Raycast):
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)
@@ -864,7 +855,6 @@ class Raycast(bonsai.core.tool.Raycast):
"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:
@@ -897,8 +887,6 @@ class Raycast(bonsai.core.tool.Raycast):
@classmethod
def create_snap_obj(cls, obj):
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
return None
for snap_obj in cls.snap_objs:
if obj.name == snap_obj.obj.name:
return snap_obj
+10 -5
View File
@@ -360,7 +360,6 @@ 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:
@@ -392,7 +391,9 @@ class Snap(bonsai.core.tool.Snap):
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)
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:
for snap in closest_snaps:
@@ -405,15 +406,19 @@ class Snap(bonsai.core.tool.Snap):
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 (
"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 = []
+1 -5
View File
@@ -997,9 +997,7 @@ class Spatial(bonsai.core.tool.Spatial):
return obj
@classmethod
def set_obj_origin_to_polygon_center(
cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True
) -> None:
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:
@@ -1007,7 +1005,6 @@ class Spatial(bonsai.core.tool.Spatial):
else:
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
@classmethod
def get_2d_vertices_from_polygon(
cls,
@@ -1195,7 +1192,6 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
if tool.Ifc.get().schema == "IFC2X3":
+57
View File
@@ -181,6 +181,50 @@ 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 <path>` -- 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
@@ -243,6 +287,19 @@ Exit code is 0 on success, 1 on error.
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.
+48
View File
@@ -26,6 +26,7 @@ import sys
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.foreach import run_foreach
from ifcedit.quantify import list_rules, run_quantify
from ifcedit.run import run_api
@@ -138,6 +139,42 @@ def _parse_extra_args(extra: list[str]) -> dict[str, str]:
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()
@@ -191,6 +228,15 @@ def main():
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")
@@ -209,6 +255,8 @@ def main():
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)
+76
View File
@@ -0,0 +1,76 @@
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
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,
}
+85
View File
@@ -0,0 +1,85 @@
# 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
+112 -1
View File
@@ -3,15 +3,17 @@ import json
import subprocess
import sys
import ifcopenshell
import pytest
def run_ifcedit(*args):
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
@@ -98,3 +100,112 @@ class TestRunCommand:
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"
+2 -2
View File
@@ -31,9 +31,9 @@ def main():
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",
"error: the 'mcp' package is required to run the server.\n" "Install it with: pip install mcp",
file=sys.stderr,
)
sys.exit(1)
@@ -131,17 +131,21 @@ class SchemaError(Error):
@overload
def open(
path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: Literal[False] = False
path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[False] = False
) -> Union[_file, sqlite]: ...
@overload
def open(path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: Literal[True]) -> _stream: ...
def open(path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[True]) -> _stream: ...
@overload
def open(
path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: bool = False, readonly: bool = False
path: Union[os.PathLike, str],
format: SupportedFormat = None,
*,
should_stream: bool = False,
readonly: bool = False,
) -> Union[_file, sqlite, _stream]: ...
def open(
path: Union[os.PathLike, str],
format: Optional[str] = None,
format: SupportedFormat = None,
should_stream: bool = False,
readonly: bool = False,
mmap: bool = False,
@@ -153,8 +157,7 @@ def open(
for reading large files.
You can specify a file format. If no format is given, it is guessed from
its extension. Currently supported specified format: .ifc | .ifcZIP |
.ifcXML.
its extension.
You can then filter by element ID, class, etc, and subscript by id or guid.
@@ -200,12 +203,12 @@ def open(
f.bypass_type(ty)
if mmap:
# mmap parameter is only available for builds with USE_MMAP, not used in our main builds
f.initialize(str(path.absolute()), mmap=mmap) # type: ignore[unknown-argument]
f.initialize(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument]
else:
f.initialize(str(path.absolute()))
elif mmap:
# mmap parameter is only available for builds with USE_MMAP, not used in our main builds
f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # type: ignore[unknown-argument]
f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument]
else:
f = ifcopenshell_wrapper.open(str(path.absolute()))
return file(f)
@@ -288,7 +291,10 @@ def schema_by_name(
return ifcopenshell_wrapper.schema_by_name(schema)
def guess_format(path: Path) -> Literal[".ifc", ".ifcZIP", ".ifcXML", ".ifcJSON", ".ifcSQLite", None]:
SupportedFormat = Literal[".ifc", ".ifcZIP", ".ifcXML", ".ifcJSON", ".ifcSQLite", "rocksdb", None]
def guess_format(path: Path) -> SupportedFormat:
"""Guesses the IFC format using file extension
IFCs may be serialised as different formats. The most common is a ``.ifc``
@@ -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)),
)
)
@@ -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
@@ -25,6 +25,7 @@ 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
@@ -63,6 +64,7 @@ wrap_usecases(__path__, __name__)
__all__ = [
"add_axis_representation",
"add_topology_representation",
"add_boolean",
"clip_solid",
"clip_solid_bounded",
@@ -0,0 +1,97 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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],
)
@@ -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,
@@ -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",
@@ -0,0 +1,64 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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
@@ -0,0 +1,64 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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],
)
@@ -434,10 +434,10 @@ class RocksDBPrefixIterator:
class RocksDbSerializer:
def __init__(self, *args): ...
def finalize(self): ...
def ready(self): ...
def setFile(self, arg2): ...
def writeHeader(self): ...
def finalize(self) -> None: ...
def ready(self) -> bool: ...
def setFile(self, arg2) -> None: ...
def writeHeader(self) -> None: ...
class Serialization(Representation):
def __init__(self, brep): ...
@@ -1008,7 +1008,7 @@ class file:
def by_id(self, id: int) -> entity_instance: ...
def by_type(self, *args): ...
def by_type_excl_subtypes(self, *args): ...
def bypass_type(self, type_name): ...
def bypass_type(self, type_name: str) -> None: ...
calculate_unit_factors: bool
check_existance_before_adding: bool
def create(self, decl): ...
@@ -1717,7 +1717,7 @@ def line_segments_to_polygons(s, eps, segments): ...
def map_shape(settings, instance): ...
def nary_union(sequence): ...
def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ...
def open(fn, readonly=False): ...
def open(fn: str, readonly: bool = False) -> file: ...
def parse_ifcxml(filename): ...
def polygons_to_svg(*args): ...
def read(data): ...
@@ -0,0 +1,115 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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
@@ -62,9 +62,7 @@ class TestEditCostValue(test.bootstrap.IFC4):
attributes={"UnitBasis": {"ValueComponent": 1.0, "UnitComponent": unit}},
)
assert value.UnitBasis is not None
ifcopenshell.api.cost.edit_cost_value(
self.file, cost_value=value, attributes={"UnitBasis": None}
)
ifcopenshell.api.cost.edit_cost_value(self.file, cost_value=value, attributes={"UnitBasis": None})
assert value.UnitBasis is None
@@ -0,0 +1,82 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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
@@ -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")
@@ -19,7 +19,6 @@
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
import test.bootstrap
@@ -64,10 +64,18 @@ class TestAddGeoreferencing(test.bootstrap.IFC4):
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"))
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.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
@@ -24,9 +24,7 @@ class TestRemoveResourceQuantity(test.bootstrap.IFC4):
def test_removing_a_resource_quantity(self):
self.file.create_entity("IfcProject")
resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcLaborResource")
ifcopenshell.api.resource.add_resource_quantity(
self.file, resource=resource, ifc_class="IfcQuantityTime"
)
ifcopenshell.api.resource.add_resource_quantity(self.file, resource=resource, ifc_class="IfcQuantityTime")
assert resource.BaseQuantity is not None
ifcopenshell.api.resource.remove_resource_quantity(self.file, resource=resource)
assert resource.BaseQuantity is None
@@ -0,0 +1,56 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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
@@ -0,0 +1,62 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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
+44 -3
View File
@@ -17,11 +17,14 @@ IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`).
## Usage
```
ifcquery <ifc_file> <command> [options] [--format json|text]
ifcquery <ifc_file> <command> [options] [--format json|text|ids]
```
The `--format` flag controls output. Default is `json`; use `text` for
indented human-readable output.
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
@@ -150,6 +153,15 @@ ifcquery model.ifc select 'IfcWall, IfcSlab'
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,
@@ -449,6 +461,35 @@ Options:
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.
+21 -2
View File
@@ -59,9 +59,28 @@ def format_output(data, fmt: str) -> str:
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 = []
@@ -92,10 +111,10 @@ def main():
parser.add_argument("ifc_file", help="Path to the IFC file")
parser.add_argument(
"--format",
choices=["json", "text"],
choices=["json", "text", "ids"],
default="json",
dest="output_format",
help="Output format (default: json)",
help="Output format: json (default), text (human-readable), ids (comma-separated step IDs)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
+13
View File
@@ -164,4 +164,17 @@ def clash(
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
+29 -1
View File
@@ -160,10 +160,38 @@ def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instan
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)
return _all_relations(model, 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
+37
View File
@@ -82,3 +82,40 @@ class TestCLI:
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())
+41
View File
@@ -91,6 +91,47 @@ class TestTraverseUp:
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]