mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-08 17:01:40 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10781398d4 | |||
| da470c5135 | |||
| 214cd44f8e | |||
| 4bff2fa554 | |||
| 9d78df392d | |||
| 86bef0a254 | |||
| 05bf59d360 | |||
| 17eaef778a | |||
| f46be80193 | |||
| be05d771a2 | |||
| c214d255c9 | |||
| 0d8ba71384 | |||
| 1c26ee86c9 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user