mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10781398d4 | |||
| da470c5135 | |||
| 214cd44f8e | |||
| 4bff2fa554 | |||
| 9d78df392d | |||
| 86bef0a254 | |||
| 05bf59d360 | |||
| 17eaef778a | |||
| f46be80193 | |||
| be05d771a2 | |||
| c214d255c9 | |||
| 0d8ba71384 | |||
| 1c26ee86c9 | |||
| 0ed96d32dd | |||
| f96526195d | |||
| fd29481d65 | |||
| 24b48497f0 | |||
| 2b9822f141 | |||
| 3d4db13fc1 | |||
| 31b571322b | |||
| cef5d41b54 | |||
| d7b2358d58 | |||
| cafe5aa7f7 | |||
| de34e73451 | |||
| 5721a8b602 | |||
| f820214500 | |||
| dae913e06a | |||
| 1a849395c2 | |||
| 611273a20a | |||
| db68195310 | |||
| 29079e8cba | |||
| 6bf4259298 | |||
| 7cd40bf8cb | |||
| 8b8f78095d | |||
| 23ba9e4db0 | |||
| 1aec991f08 | |||
| bddf9b85f8 |
@@ -53,7 +53,7 @@ jobs:
|
||||
python ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.21
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: mac-${{ matrix.arch }}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python ../IfcOpenShell/nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.21
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.21
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.21
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.21
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: win-${{ matrix.arch }}
|
||||
# Windows ccache needs ~1GB
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: |
|
||||
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||
steps:
|
||||
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
|
||||
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
|
||||
with:
|
||||
environment-name: test-env
|
||||
create-args: >-
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
run: |
|
||||
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
|
||||
|
||||
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
|
||||
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
|
||||
with:
|
||||
environment-name: test-env
|
||||
create-args: >-
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
-
|
||||
name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.21
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
|
||||
-
|
||||
name: Build ifcopenshell
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
libhdf5-dev libcgal-dev libeigen3-dev
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.21
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload static files as artifact
|
||||
id: deployment
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.7",
|
||||
"ruff==0.15.8",
|
||||
"poethepoet",
|
||||
"gersemi==0.26.1",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -421,7 +421,7 @@ class PolylineOperator:
|
||||
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
return {"RUNNING_MODAL"}
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
|
||||
props = tool.Model.get_model_props()
|
||||
@@ -461,6 +461,7 @@ class PolylineOperator:
|
||||
self.tool_state.axis_method = None
|
||||
self.tool_state.plane_method = None
|
||||
self.tool_state.mode = "Mouse"
|
||||
tool.Raycast.clear_snap_objs()
|
||||
self.visible_objs = tool.Raycast.get_visible_objects(context)
|
||||
for obj in self.visible_objs:
|
||||
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
|
||||
|
||||
@@ -943,7 +943,7 @@ class EditObjectUI:
|
||||
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(
|
||||
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
|
||||
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
|
||||
)
|
||||
|
||||
if AuthoringData.data["is_flippable_element"]:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Union
|
||||
|
||||
import bmesh
|
||||
@@ -26,7 +29,7 @@ from mathutils import Vector
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
class Raycast(bonsai.core.tool.Raycast):
|
||||
offset = 10
|
||||
@@ -41,6 +44,7 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
(0, -offset),
|
||||
(offset, -offset),
|
||||
)
|
||||
snap_objs = []
|
||||
|
||||
@classmethod
|
||||
def get_visible_objects(cls, context: bpy.types.Context):
|
||||
@@ -69,8 +73,10 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
rv3d = context.region_data
|
||||
assert rv3d
|
||||
view_location = rv3d.view_matrix.inverted().translation
|
||||
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
|
||||
obj_matrix = obj.matrix_world.copy()
|
||||
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
|
||||
bbox_edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
|
||||
|
||||
transposed_bbox: list[Vector] = []
|
||||
bbox_2d: list[float] = []
|
||||
@@ -94,8 +100,25 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
for v in bbox:
|
||||
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
|
||||
if coord_2d is not None:
|
||||
transposed_bbox.append(coord_2d)
|
||||
transposed_bbox.append(coord_2d)
|
||||
|
||||
if not any(transposed_bbox):
|
||||
transposed_bbox = []
|
||||
# If there are None values in transposed_bbox it means that there are vertices behind the camera
|
||||
# so we get the intersection of the edge with the region border
|
||||
# new_bbox = []
|
||||
if any(transposed_bbox) and not all(transposed_bbox):
|
||||
new_bbox = transposed_bbox.copy()
|
||||
new_bbox = [x for x in new_bbox if x is not None]
|
||||
for edge in bbox_edges:
|
||||
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
|
||||
point, _ = cls.intersect_edge_region_border(
|
||||
context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]]
|
||||
)
|
||||
if point:
|
||||
new_bbox.append(point)
|
||||
if new_bbox:
|
||||
transposed_bbox = new_bbox
|
||||
|
||||
region = context.region
|
||||
borders = (0, region.width, 0, region.height)
|
||||
@@ -117,6 +140,96 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
return (obj, bbox_2d)
|
||||
return None
|
||||
|
||||
def intersect_edge_region_border(region, space, rv3d, v1, v2):
|
||||
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
|
||||
a_view = view_matrix @ p_world_a
|
||||
b_view = view_matrix @ p_world_b
|
||||
z_near = -clip_start
|
||||
za = a_view.z
|
||||
zb = b_view.z
|
||||
denom = zb - za
|
||||
if denom == 0.0:
|
||||
return None, None
|
||||
t = (z_near - za) / denom
|
||||
if t < 0.0 or t > 1.0:
|
||||
return None, None
|
||||
p_view = a_view.lerp(b_view, t)
|
||||
cam_world = view_matrix.inverted()
|
||||
p_world = cam_world @ p_view
|
||||
return p_world, t
|
||||
|
||||
def is_inside_region(pt2d, region):
|
||||
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
|
||||
|
||||
def clamp_to_region_border(point2d, region):
|
||||
x, y = point2d
|
||||
x_clamped = max(0.0, min(region.width, x))
|
||||
y_clamped = max(0.0, min(region.height, y))
|
||||
return Vector((x_clamped, y_clamped))
|
||||
|
||||
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
|
||||
"""
|
||||
Use iterative approach: move t toward 0. Returns the first point that is inside region border
|
||||
"""
|
||||
t = initial_t_on_segment
|
||||
for i in range(max_iters):
|
||||
test_3d = p1.lerp(p2, t)
|
||||
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
|
||||
if test_2d is not None and is_inside_region(test_2d, region):
|
||||
return test_3d, test_2d, t
|
||||
# move t toward 0 by reducing it by a fraction of its current value
|
||||
t -= step
|
||||
# if t is already very small, break
|
||||
if t <= 1e-6:
|
||||
break
|
||||
|
||||
return None, None, None
|
||||
|
||||
# Ensures that all the calculation uses the same direction based on which point is on the screen
|
||||
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
|
||||
onscreen_vert = v1
|
||||
offscreen_vert = v2
|
||||
else:
|
||||
onscreen_vert = v2
|
||||
offscreen_vert = v1
|
||||
# v2, v1 = v1, v2
|
||||
|
||||
clip_start = space.clip_start
|
||||
view_mat = rv3d.view_matrix
|
||||
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
|
||||
|
||||
if inter_world is None:
|
||||
print("No intersection with viewport near plane found for the segment.")
|
||||
return
|
||||
|
||||
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
|
||||
|
||||
if init_2d is not None and is_inside_region(init_2d, region):
|
||||
final_world = inter_world
|
||||
final_2d = init_2d
|
||||
final_t = 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
|
||||
)
|
||||
if found_world is None:
|
||||
if init_2d is None:
|
||||
print("Initial projection invalid and iterative search failed.")
|
||||
return
|
||||
# fallback: clamp projected point to border via manual mapping
|
||||
final_2d = clamp_to_region_border(init_2d, region)
|
||||
final_world = None
|
||||
final_t = None
|
||||
# print("Iterative search failed; using clamped 2D:", final_2d)
|
||||
else:
|
||||
final_world = found_world
|
||||
final_2d = found_2d
|
||||
final_t = found_t
|
||||
# print(f"Found onscreen point at t={final_t:.4f}")
|
||||
|
||||
# print("Final 2D:", final_2d)
|
||||
return final_2d, v2
|
||||
|
||||
@classmethod
|
||||
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
|
||||
x, y = mouse_pos
|
||||
@@ -232,6 +345,161 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def ray_cast_by_proximity_2d(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
snap_obj: SnapObj,
|
||||
):
|
||||
|
||||
def divide_vector(start, end, n):
|
||||
points = []
|
||||
delta = (end - start) / n
|
||||
for i in range(1, n):
|
||||
point = start + i * delta
|
||||
points.append(point)
|
||||
return points
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
mouse_pos = event.mouse_region_x, event.mouse_region_y
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
points = []
|
||||
|
||||
try:
|
||||
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
verts_2d = [
|
||||
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
|
||||
] # Numpy version is worst in performance
|
||||
|
||||
intersected = snap_obj.raycast_boxes(
|
||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||
)
|
||||
edges = []
|
||||
for it in intersected:
|
||||
edges.extend(it.edges)
|
||||
edges = set(edges)
|
||||
|
||||
edge_verts = {}
|
||||
for e in edges:
|
||||
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
|
||||
verts = snap_obj.obj.data.vertices
|
||||
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
|
||||
v1_2d = verts_2d[verts_idx[0]]
|
||||
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
|
||||
v2_2d = verts_2d[verts_idx[1]]
|
||||
if (v1_2d is None) ^ (v2_2d is None):
|
||||
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
|
||||
if v1_2d is None:
|
||||
edge_verts[e] = (point, v2_2d)
|
||||
else:
|
||||
edge_verts[e] = (v1_2d, point)
|
||||
else:
|
||||
edge_verts[e] = (v1_2d, v2_2d)
|
||||
|
||||
snap_threshold = 10.0
|
||||
|
||||
for i, point in enumerate(verts_2d):
|
||||
if not point:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - point).length
|
||||
if distance <= snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Vertex",
|
||||
"point": snap_obj.verts_3d[i],
|
||||
"distance": distance / 10,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
count = 0
|
||||
selected_edges = {}
|
||||
for e in edges:
|
||||
p0, p1 = edge_verts[e]
|
||||
p0x, p0y = p0
|
||||
p1x, p1y = p1
|
||||
px, py = mouse_pos
|
||||
|
||||
# segment vector = p1 - p0
|
||||
sx = p1x - p0x
|
||||
sy = p1y - p0y
|
||||
|
||||
# seg length squared
|
||||
seg_len_sq = sx * sx + sy * sy
|
||||
|
||||
if seg_len_sq == 0.0:
|
||||
# degenerate segment: return distance to p0
|
||||
dx = px - p0x
|
||||
dy = py - p0y
|
||||
dist = math.hypot(dx, dy)
|
||||
return dist, (p0x, p0y), 0.0
|
||||
|
||||
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
|
||||
apx = px - p0x
|
||||
apy = py - p0y
|
||||
t = (apx * sx + apy * sy) / seg_len_sq
|
||||
|
||||
# clamp to segment
|
||||
if t <= 0.0:
|
||||
t_clamped = 0.0
|
||||
cx, cy = p0x, p0y
|
||||
elif t >= 1.0:
|
||||
t_clamped = 1.0
|
||||
cx, cy = p1x, p1y
|
||||
else:
|
||||
t_clamped = t
|
||||
cx = p0x + sx * t_clamped
|
||||
cy = p0y + sy * t_clamped
|
||||
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
dist = math.hypot(dx, dy)
|
||||
if dist <= snap_threshold:
|
||||
selected_edges[dist] = e
|
||||
|
||||
if selected_edges:
|
||||
min_dist = float("inf")
|
||||
for key in selected_edges:
|
||||
if key < min_dist:
|
||||
min_dist = key
|
||||
|
||||
idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
|
||||
edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
|
||||
division_points = divide_vector(
|
||||
edge_verts[0], edge_verts[1], 2
|
||||
) # TODO Make it work for different divisions
|
||||
for division_point in division_points:
|
||||
intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
|
||||
distance = (division_point - intersection).length
|
||||
if distance < snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Edge Center",
|
||||
"point": division_point.copy(),
|
||||
"distance": distance,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
|
||||
if intersection[0]:
|
||||
if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
|
||||
distance = (intersection[1] - intersection[0]).length
|
||||
if distance < snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Edge",
|
||||
"point": intersection[1].copy(),
|
||||
"edge_verts": edge_verts,
|
||||
"distance": distance,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
return points
|
||||
|
||||
@classmethod
|
||||
def ray_cast_by_proximity(
|
||||
cls,
|
||||
@@ -457,7 +725,8 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if bbox_2d:
|
||||
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
|
||||
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
|
||||
objs_to_raycast.append(obj)
|
||||
snap_obj = cls.create_snap_obj(obj)
|
||||
objs_to_raycast.append(snap_obj)
|
||||
|
||||
return objs_to_raycast
|
||||
|
||||
@@ -474,12 +743,6 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
face_index = None
|
||||
# Wireframes
|
||||
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
|
||||
if snap_points:
|
||||
hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
|
||||
if hit:
|
||||
hit_world = obj.original.matrix_world @ hit
|
||||
return obj, hit_world, face_index
|
||||
return None, None, None
|
||||
# Meshes
|
||||
else:
|
||||
@@ -514,19 +777,20 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
for obj in objs_to_raycast:
|
||||
for snap_obj in objs_to_raycast:
|
||||
if not include_wireframes and (
|
||||
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
|
||||
snap_obj.obj.type in {"EMPTY", "CURVE"}
|
||||
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
|
||||
):
|
||||
continue
|
||||
|
||||
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if best_obj is None or length_squared < best_length_squared:
|
||||
best_length_squared = length_squared
|
||||
best_obj = snap_obj
|
||||
best_obj = hit_obj
|
||||
best_hit = hit
|
||||
best_face_index = face_index
|
||||
|
||||
@@ -536,6 +800,79 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def ray_cast_and_get_closest_to_camera_snaps(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
objs_to_raycast: list[bpy.types.Object],
|
||||
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
|
||||
closest_length_squared = 1.0
|
||||
closest_obj = None
|
||||
closest_hit = None
|
||||
closest_face_index = None
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
closest_snaps = []
|
||||
hit = None
|
||||
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
# For wireframe objects we have to test all the snaps to see which is closer
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
closest_wf_hit = None
|
||||
closest_wf_length_squared = 1.0
|
||||
closest_wf_point = None
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
closest_snaps.append(point)
|
||||
length = (point["point"] - ray_origin).length_squared
|
||||
if closest_wf_hit is None or length < closest_wf_length_squared:
|
||||
closest_wf_length_squared = length
|
||||
closest_wf_hit = point["point"]
|
||||
closest_wf_point = point
|
||||
|
||||
if closest_wf_point:
|
||||
hit_obj = closest_wf_point["object"]
|
||||
hit = closest_wf_point["point"]
|
||||
face_index = None
|
||||
|
||||
else:
|
||||
# Solid objects
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": hit_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
# Here we test which is closer, including wireframe and solid objects
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
|
||||
# Label snaps from the closest object
|
||||
if closest_obj is not None:
|
||||
for snap in closest_snaps:
|
||||
if snap["object"] == closest_obj:
|
||||
snap["is_closest_to_camera"] = True
|
||||
|
||||
return closest_snaps
|
||||
|
||||
@classmethod
|
||||
def calculate_snap_threshold(cls, view_distance):
|
||||
snap_threshold = view_distance / 100
|
||||
@@ -547,3 +884,260 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if lens < 50:
|
||||
snap_threshold *= value
|
||||
return snap_threshold
|
||||
|
||||
@classmethod
|
||||
def create_snap_obj(cls, obj):
|
||||
for snap_obj in cls.snap_objs:
|
||||
if obj.name == snap_obj.obj.name:
|
||||
return snap_obj
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
return snap_obj
|
||||
|
||||
@classmethod
|
||||
def clear_snap_objs(cls):
|
||||
TreeNode.__clear_all__()
|
||||
SnapObj.__clear_all__()
|
||||
cls.snap_objs.clear()
|
||||
|
||||
|
||||
class TreeNode:
|
||||
all = []
|
||||
|
||||
def __init__(self, box: tuple):
|
||||
self.__class__.all.append(self)
|
||||
self.box = box
|
||||
self.child_a = None
|
||||
self.child_b = None
|
||||
self.edges = []
|
||||
|
||||
def __clear_all__():
|
||||
for instance in TreeNode.all:
|
||||
del instance
|
||||
TreeNode.all.clear()
|
||||
|
||||
|
||||
class SnapObj:
|
||||
max_depth = 9
|
||||
all = []
|
||||
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
self.__class__.all.append(self)
|
||||
self.obj = obj
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
||||
self.snap_points = []
|
||||
|
||||
def __clear_all__():
|
||||
for instance in SnapObj.all:
|
||||
del instance
|
||||
SnapObj.all.clear()
|
||||
|
||||
def _create_root_node(self) -> TreeNode:
|
||||
bbox = tool.Blender.get_object_bounding_box(self.obj)
|
||||
min_point = self.obj.matrix_world @ bbox["min_point"]
|
||||
max_point = self.obj.matrix_world @ bbox["max_point"]
|
||||
new_bbox = self.expand_bounding_box((min_point, max_point))
|
||||
return TreeNode(new_bbox)
|
||||
|
||||
def divide_bounding_box_along_longest_axis(
|
||||
self, min_pt: Vector, max_pt: Vector
|
||||
) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
|
||||
"""
|
||||
Divide a bounding box into two equal parts along the axis with the longest dimension.
|
||||
|
||||
Args:
|
||||
min_pt: The minimum point of the bounding box.
|
||||
max_pt: The maximum point of the bounding box.
|
||||
|
||||
Returns:
|
||||
list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
|
||||
"""
|
||||
|
||||
# Calculate the dimensions of the box
|
||||
dx = max_pt.x - min_pt.x
|
||||
dy = max_pt.y - min_pt.y
|
||||
dz = max_pt.z - min_pt.z
|
||||
|
||||
# Determine the axis with the longest dimension
|
||||
if dx >= dy and dx >= dz:
|
||||
# Divide along the x-axis
|
||||
mid_x = min_pt.x + dx / 2
|
||||
box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
|
||||
box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
|
||||
elif dy >= dx and dy >= dz:
|
||||
# Divide along the y-axis
|
||||
mid_y = min_pt.y + dy / 2
|
||||
box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
|
||||
box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
|
||||
else:
|
||||
# Divide along the z-axis
|
||||
mid_z = min_pt.z + dz / 2
|
||||
box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
|
||||
box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
|
||||
|
||||
return [box1, box2]
|
||||
|
||||
def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
|
||||
"""
|
||||
Expand a 3D bounding box by a given offset.
|
||||
|
||||
Args:
|
||||
min_pt: The minimum point of the bounding box.
|
||||
max_pt: The maximum point of the bounding box.
|
||||
offset: The offset to expand the bounding box by.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
|
||||
"""
|
||||
|
||||
min_pt, max_pt = box
|
||||
# Calculate the new minimum and maximum points
|
||||
new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
|
||||
new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
|
||||
|
||||
return new_min_pt, new_max_pt
|
||||
|
||||
def split_box(self, parent: TreeNode, depth: int):
|
||||
"""
|
||||
Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
|
||||
|
||||
Args:
|
||||
parent: the TreeNode instance that represents the parent node of a BVH Tree.
|
||||
depth: the depth of the BVH Tree no be used in recursion.
|
||||
"""
|
||||
if depth > self.max_depth:
|
||||
return
|
||||
box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
|
||||
parent.child_a = TreeNode(box_a)
|
||||
parent.child_b = TreeNode(box_b)
|
||||
edges_a = []
|
||||
edges_b = []
|
||||
for e in parent.edges:
|
||||
verts_idx = [v for v in self.obj.data.edges[e].vertices]
|
||||
verts_coords = []
|
||||
for idx in verts_idx:
|
||||
if idx < len(self.obj.data.vertices):
|
||||
verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
|
||||
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
|
||||
edges_a.append(e)
|
||||
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
|
||||
edges_b.append(e)
|
||||
parent.child_a.edges = edges_a
|
||||
parent.child_b.edges = edges_b
|
||||
self.split_box(parent.child_a, depth + 1)
|
||||
self.split_box(parent.child_b, depth + 1)
|
||||
|
||||
def raycast_box(
|
||||
self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
|
||||
) -> bool:
|
||||
"""
|
||||
Raycast bounding box.
|
||||
|
||||
Args:
|
||||
context: Blender context.
|
||||
event: Blender event.
|
||||
node: a TreeNode instance.
|
||||
rays: tuple containing ray origin and ray direction
|
||||
|
||||
Returns:
|
||||
True if hits the box or False otherwise.
|
||||
"""
|
||||
box = node.box
|
||||
min_v = box[0]
|
||||
max_v = box[1]
|
||||
t_min = 0.0
|
||||
t_max = float("inf")
|
||||
ray_origin, ray_dir = rays
|
||||
inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
|
||||
# X
|
||||
tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
|
||||
tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
|
||||
tmin = min(tx1, tx2)
|
||||
tmax = max(tx1, tx2)
|
||||
# Y
|
||||
ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
|
||||
ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
|
||||
tmin = max(tmin, min(ty1, ty2))
|
||||
tmax = min(tmax, max(ty1, ty2))
|
||||
# Z
|
||||
tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
|
||||
tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
|
||||
tmin = max(tmin, min(tz1, tz2))
|
||||
tmax = min(tmax, max(tz1, tz2))
|
||||
return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
|
||||
|
||||
def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
|
||||
"""
|
||||
Check if a line segment intersects an axis-aligned bounding box (AABB).
|
||||
|
||||
Args:
|
||||
v1: The first endpoint of the line segment as a mathutils.Vector.
|
||||
v2: The second endpoint of the line segment as a mathutils.Vector.
|
||||
box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
|
||||
|
||||
Returns:
|
||||
bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
|
||||
"""
|
||||
bmin, bmax = box
|
||||
dir = v2 - v1
|
||||
tmin = 0.0
|
||||
tmax = 1.0
|
||||
|
||||
for i in range(3):
|
||||
if abs(dir[i]) < 1e-12:
|
||||
# Line is parallel to slab. If origin not within slab -> no hit.
|
||||
if v1[i] < bmin[i] or v1[i] > bmax[i]:
|
||||
return False
|
||||
else:
|
||||
ood = 1.0 / dir[i]
|
||||
t1 = (bmin[i] - v1[i]) * ood
|
||||
t2 = (bmax[i] - v1[i]) * ood
|
||||
if t1 > t2:
|
||||
t1, t2 = t2, t1
|
||||
if t1 > tmin:
|
||||
tmin = t1
|
||||
if t2 < tmax:
|
||||
tmax = t2
|
||||
if tmin > tmax:
|
||||
return False
|
||||
|
||||
# If any overlap in [0,1] exists, there's intersection
|
||||
return (tmax >= 0.0) and (tmin <= 1.0)
|
||||
|
||||
def raycast_boxes(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.Types.Event,
|
||||
node: TreeNode,
|
||||
intersected: Union[TreeNode] = [],
|
||||
rays: tuple[Vector, Vector] = (),
|
||||
) -> Union[TreeNode]:
|
||||
"""
|
||||
Raycast bounding box subdivisions recursively.
|
||||
|
||||
Args:
|
||||
context: Blender context.
|
||||
event: Blender event.
|
||||
node: a TreeNode instance.
|
||||
intersected: list of intersected boxes to use in recursion.
|
||||
rays: tuple containing ray origin and ray direction
|
||||
|
||||
Returns:
|
||||
tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
|
||||
"""
|
||||
if not node.child_a:
|
||||
intersected.append(node)
|
||||
return intersected
|
||||
|
||||
intersects_a = self.raycast_box(context, event, node.child_a, rays)
|
||||
intersects_b = self.raycast_box(context, event, node.child_b, rays)
|
||||
if intersects_a:
|
||||
intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
|
||||
|
||||
if intersects_b:
|
||||
intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
|
||||
|
||||
return intersected
|
||||
|
||||
@@ -388,57 +388,36 @@ class Snap(bonsai.core.tool.Snap):
|
||||
|
||||
# Objects
|
||||
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
|
||||
# Wireframes
|
||||
# For wireframe we have to get all the objects so we can further calculate edge intersection
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
detected_snaps.append(point)
|
||||
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
|
||||
detected_snaps.extend(closest_snaps)
|
||||
|
||||
if (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
|
||||
):
|
||||
results = []
|
||||
for obj in objs_to_raycast:
|
||||
results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
|
||||
else:
|
||||
results = []
|
||||
results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
|
||||
)
|
||||
|
||||
for result in results:
|
||||
snap_obj = result[0]
|
||||
hit = result[1]
|
||||
face_index = result[2]
|
||||
if hit is not None:
|
||||
# Wireframes
|
||||
if snap_obj.type in {"EMPTY", "CURVE"} or (
|
||||
snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
|
||||
):
|
||||
continue
|
||||
# Meshes
|
||||
else:
|
||||
# Add face snap
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": snap_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
detected_snaps.append(snap_point)
|
||||
|
||||
# Add vertex and edge snap
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(
|
||||
context, event, snap_obj, snap_obj.data.polygons[face_index]
|
||||
)
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
detected_snaps.append(point)
|
||||
for snap_obj in objs_to_raycast:
|
||||
for snap in closest_snaps:
|
||||
if snap_obj.obj == snap["object"]:
|
||||
if xray_mode:
|
||||
if "face_index" in snap and snap["face_index"] is not None:
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
detected_snaps.append(point)
|
||||
else:
|
||||
# If it is a solid object that is closest to camera it ignores all the rest
|
||||
if (
|
||||
"is_closest_to_camera" in snap
|
||||
and snap["is_closest_to_camera"]
|
||||
and snap["group"] == "Object"
|
||||
):
|
||||
closest_snap = [snap] # discards objects that aren't the closest
|
||||
if "face_index" in snap and snap["face_index"] is not None:
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
closest_snap.append(point)
|
||||
detected_snaps = closest_snap
|
||||
|
||||
# snap to cut geometry (e.g. in plan view)
|
||||
if CutDecorator.installed:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -396,6 +396,44 @@ Scenario: Add a slab
|
||||
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
|
||||
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
|
||||
|
||||
Scenario: Extend walls to underside
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcSlab/Slab" is moved to "0,0,2.5"
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcSlab/Slab" is selected
|
||||
And I look at the tool header
|
||||
And I click "Extend To Underside"
|
||||
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
|
||||
|
||||
Scenario: Extend walls to underside - extending to a tessellated gable roof
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
# Create gable roof: a cube turned into a prism with a ridge.
|
||||
And I add a cube of size "1" at "0.5,0.05,3"
|
||||
And the object "Cube" is selected
|
||||
And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
|
||||
And I press "bim.assign_class"
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcRoof/Cube" is selected
|
||||
And I look at the tool header
|
||||
And I click "Extend To Underside"
|
||||
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
|
||||
|
||||
Scenario: Enable editing a slab profile
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
|
||||
@@ -133,7 +133,11 @@ class PanelSpy:
|
||||
self.spied_labels.append(kwargs["text"])
|
||||
return self
|
||||
elif self.spied_attr == "prop":
|
||||
props, name = args
|
||||
if args:
|
||||
props, name = args
|
||||
else:
|
||||
props = kwargs.get("data")
|
||||
name = kwargs.get("property")
|
||||
props: bpy.types.bpy_struct
|
||||
text = kwargs.get("text", props.bl_rna.properties[name].name)
|
||||
icon = kwargs.get("icon", None)
|
||||
@@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None:
|
||||
panel_spy.refresh_spy()
|
||||
|
||||
|
||||
@given(parsers.parse("I look at the tool header"))
|
||||
@when(parsers.parse("I look at the tool header"))
|
||||
@then(parsers.parse("I look at the tool header"))
|
||||
def i_look_at_the_tool_header() -> None:
|
||||
from bonsai.bim.module.model.workspace import EditObjectUI
|
||||
|
||||
class MockRegion:
|
||||
type = "UI"
|
||||
|
||||
class MockContext:
|
||||
def __getattr__(self, name):
|
||||
if name == "region":
|
||||
return MockRegion()
|
||||
return getattr(bpy.context, name)
|
||||
|
||||
global panel_spy
|
||||
panel_spy = PanelSpy(EditObjectUI)
|
||||
panel_spy.is_spy_dirty = False
|
||||
panel_spy.spied_attr = None
|
||||
panel_spy.spied_labels = []
|
||||
panel_spy.spied_props = []
|
||||
panel_spy.spied_operators = []
|
||||
panel_spy.spied_lists = []
|
||||
EditObjectUI.draw(MockContext(), panel_spy)
|
||||
|
||||
|
||||
@given(parsers.parse('I open the "{name}" menu'))
|
||||
@when(parsers.parse('I open the "{name}" menu'))
|
||||
@then(parsers.parse('I open the "{name}" menu'))
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# ifcedit
|
||||
|
||||
A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
|
||||
shell commands. Functions are auto-discovered at runtime via introspection --
|
||||
no hardcoded list to maintain.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcedit
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
ifcedit <command> [options] [--format json|text]
|
||||
```
|
||||
|
||||
Three subcommands: `list` to discover functions, `docs` to read their
|
||||
documentation, and `run` to execute them.
|
||||
|
||||
## Subcommands
|
||||
|
||||
### list
|
||||
|
||||
Discover available API modules and their functions.
|
||||
|
||||
**List all modules:**
|
||||
|
||||
```bash
|
||||
ifcedit list
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"module": "root",
|
||||
"description": "Functions for creating project-level entities",
|
||||
"functions": ["create_entity", "remove_product", "copy_class"],
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"module": "spatial",
|
||||
"description": "Functions for managing spatial relationships",
|
||||
"functions": ["assign_container", "unassign_container"],
|
||||
"count": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**List functions in a module:**
|
||||
|
||||
```bash
|
||||
ifcedit list root
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "create_entity",
|
||||
"description": "Create an IFC entity with optional initial attributes",
|
||||
"params": [
|
||||
{"name": "ifc_class", "type": "str", "required": true},
|
||||
{"name": "name", "type": "Optional[str]"}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### docs
|
||||
|
||||
Show full documentation for a specific function, including parameter
|
||||
descriptions from docstrings and return type.
|
||||
|
||||
```bash
|
||||
ifcedit docs root.create_entity
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "root",
|
||||
"function": "create_entity",
|
||||
"description": "Create an IFC entity with optional initial attributes",
|
||||
"long_description": "This function creates a new entity instance...",
|
||||
"params": [
|
||||
{
|
||||
"name": "ifc_class",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "Optional[str]",
|
||||
"description": "Optional name attribute"
|
||||
}
|
||||
],
|
||||
"return_type": "ifcopenshell.entity_instance",
|
||||
"return_description": "The newly created entity instance"
|
||||
}
|
||||
```
|
||||
|
||||
### run
|
||||
|
||||
Execute an API function against an IFC file. Parameters are passed as
|
||||
`--key value` pairs after the function name.
|
||||
|
||||
```bash
|
||||
ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `-o, --output <path>` -- write to a different file instead of overwriting the input
|
||||
- `--dry-run` -- validate parameters without executing or saving
|
||||
|
||||
```bash
|
||||
# Save to a new file
|
||||
ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
|
||||
|
||||
# Validate without executing
|
||||
ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
|
||||
```
|
||||
|
||||
Dry-run output shows the resolved parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"dry_run": true,
|
||||
"module": "root",
|
||||
"function": "create_entity",
|
||||
"args": {"ifc_class": "IfcWall", "name": "My Wall"}
|
||||
}
|
||||
```
|
||||
|
||||
## Parameter type coercion
|
||||
|
||||
CLI strings are automatically converted to the types expected by each API
|
||||
function, using the function's type annotations:
|
||||
|
||||
| Type | CLI input | Python value |
|
||||
|------|-----------|--------------|
|
||||
| `str` | `"hello"` | `"hello"` |
|
||||
| `int` | `"42"` or `"#42"` | `42` |
|
||||
| `float` | `"3.14"` | `3.14` |
|
||||
| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
|
||||
| `Optional[X]` | `"none"` | `None` |
|
||||
| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
|
||||
| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
|
||||
| `dict` | `'{"key": "val"}'` | parsed JSON object |
|
||||
| `Literal["A", "B"]` | `"A"` | validated against allowed values |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Create a project
|
||||
ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
|
||||
|
||||
# Assign an element to a storey
|
||||
ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
|
||||
|
||||
# Assign multiple elements at once
|
||||
ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
|
||||
|
||||
# Add a property set
|
||||
ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
|
||||
|
||||
# Edit properties
|
||||
ifcedit run model.ifc pset.edit_pset --pset 15 \
|
||||
--properties '{"IsExternal": true, "FireRating": "2HR"}'
|
||||
```
|
||||
|
||||
### foreach
|
||||
|
||||
Apply an API function to each element in a JSON array read from stdin.
|
||||
`{field}` placeholders in argument values are substituted with fields from
|
||||
each JSON object. The model is opened once and saved once regardless of how
|
||||
many elements are processed.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
```
|
||||
|
||||
```json
|
||||
{"ok": true, "count": 36, "errors": []}
|
||||
```
|
||||
|
||||
Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`,
|
||||
`{type}`, and `{name}`:
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
|
||||
--product {id} --attributes '{"Name": "Door"}'
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `-o, --output <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
|
||||
(volume, area, length, count, weight) and writing them back as
|
||||
`IfcElementQuantity` property sets. Uses `ifc5d` rules.
|
||||
|
||||
**List available rules:**
|
||||
|
||||
```bash
|
||||
ifcedit quantify list
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{"name": "IFC4QtoBaseQuantities"},
|
||||
{"name": "IFC4X3QtoBaseQuantities"}
|
||||
]
|
||||
```
|
||||
|
||||
**Run QTO on a file:**
|
||||
|
||||
```bash
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
|
||||
```
|
||||
|
||||
```json
|
||||
{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
|
||||
- `-o, --output <path>` -- write to a different file instead of overwriting the input
|
||||
|
||||
Note: `quantify run` writes geometry-based measurements and requires the
|
||||
IfcOpenShell C++ geometry bindings for elements with computed quantities.
|
||||
|
||||
## Error handling
|
||||
|
||||
Errors are reported in the JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Entity #999 not found in model"
|
||||
}
|
||||
```
|
||||
|
||||
Exit code is 0 on success, 1 on error.
|
||||
|
||||
## Relationship to ifcquery
|
||||
|
||||
`ifcedit` and `ifcquery` are complementary tools:
|
||||
|
||||
- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
|
||||
- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
|
||||
|
||||
A typical workflow: inspect with `ifcquery`, look up the right API function
|
||||
with `ifcedit docs`, then apply changes with `ifcedit run`.
|
||||
|
||||
The two tools also compose directly in shell scripts. Use `ifcquery --format ids`
|
||||
to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON
|
||||
into `ifcedit foreach` to apply an operation to every matching element:
|
||||
|
||||
```bash
|
||||
# Aggregate — pass all IDs as a list parameter
|
||||
ifcedit run model.ifc spatial.unassign_container \
|
||||
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
|
||||
|
||||
# Fan-out — one operation per element, model opened and saved once
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <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/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,265 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <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 argparse
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
def format_output(data, fmt: str) -> str:
|
||||
if fmt == "json":
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
elif fmt == "text":
|
||||
return _format_text(data)
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _format_text(data, indent: int = 0) -> str:
|
||||
prefix = " " * indent
|
||||
lines = []
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
lines.append(f"{prefix}{key}:")
|
||||
lines.append(_format_text(value, indent + 1))
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {value}")
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
lines.append(_format_text(item, indent))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"{prefix}- {item}")
|
||||
else:
|
||||
lines.append(f"{prefix}{data}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
if args.module:
|
||||
try:
|
||||
functions = list_functions(args.module)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(format_output(functions, args.output_format))
|
||||
else:
|
||||
modules = list_modules()
|
||||
print(format_output(modules, args.output_format))
|
||||
|
||||
|
||||
def cmd_docs(args):
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
try:
|
||||
docs = function_docs(module, function)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(format_output(docs, args.output_format))
|
||||
|
||||
|
||||
def cmd_run(args, extra_args):
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
|
||||
# Parse extra --key value arguments into a dict
|
||||
raw_kwargs = _parse_extra_args(extra_args)
|
||||
|
||||
if args.dry_run:
|
||||
result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
|
||||
else:
|
||||
result = run_api(model, module, function, raw_kwargs)
|
||||
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _parse_extra_args(extra: list[str]) -> dict[str, str]:
|
||||
"""Parse a list of ['--key', 'value', ...] into a dict."""
|
||||
kwargs = {}
|
||||
i = 0
|
||||
while i < len(extra):
|
||||
arg = extra[i]
|
||||
if arg.startswith("--"):
|
||||
key = arg[2:]
|
||||
if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
|
||||
kwargs[key] = extra[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
# Flag without value — treat as "true"
|
||||
kwargs[key] = "true"
|
||||
i += 1
|
||||
else:
|
||||
print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return kwargs
|
||||
|
||||
|
||||
def cmd_foreach(args, extra_args):
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
|
||||
raw_kwargs_template = _parse_extra_args(extra_args)
|
||||
|
||||
try:
|
||||
stdin_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(stdin_data, list):
|
||||
print("Error: stdin must be a JSON array", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = run_foreach(model, module, function, raw_kwargs_template, stdin_data)
|
||||
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_quantify(args, extra_args):
|
||||
if args.quantify_command == "list":
|
||||
result = list_rules()
|
||||
print(format_output(result, args.output_format))
|
||||
elif args.quantify_command == "run":
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
selector = args.selector or None
|
||||
result = run_quantify(model, args.rule_name, selector=selector)
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ifcedit",
|
||||
description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# list
|
||||
list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
|
||||
list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
|
||||
|
||||
# docs
|
||||
docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
|
||||
docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
|
||||
|
||||
# run
|
||||
run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
|
||||
run_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
|
||||
run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
|
||||
|
||||
# foreach
|
||||
foreach_parser = subparsers.add_parser(
|
||||
"foreach",
|
||||
help="Apply an API function to each element in a JSON array read from stdin",
|
||||
)
|
||||
foreach_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)")
|
||||
foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
|
||||
# quantify
|
||||
quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
|
||||
quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
|
||||
quantify_sub.add_parser("list", help="List available QTO rule names")
|
||||
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
|
||||
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
|
||||
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
|
||||
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
|
||||
args, extra = parser.parse_known_args()
|
||||
|
||||
if args.command == "list":
|
||||
cmd_list(args)
|
||||
elif args.command == "docs":
|
||||
cmd_docs(args)
|
||||
elif args.command == "run":
|
||||
cmd_run(args, extra)
|
||||
elif args.command == "foreach":
|
||||
cmd_foreach(args, extra)
|
||||
elif args.command == "quantify":
|
||||
cmd_quantify(args, extra)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <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 json
|
||||
import typing
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def coerce_value(
|
||||
value_str: str,
|
||||
type_hint,
|
||||
model: ifcopenshell.file | None = None,
|
||||
lookup_file: ifcopenshell.file | None = None,
|
||||
):
|
||||
"""Convert a CLI string argument to the proper Python type based on a type hint.
|
||||
|
||||
Args:
|
||||
value_str: The raw string from the CLI.
|
||||
type_hint: The type annotation from the function signature.
|
||||
model: The main open IFC model, needed to resolve entity instance references by ID.
|
||||
lookup_file: Override file for entity resolution (e.g. a library file for
|
||||
project.append_asset). When provided, entity IDs are looked up here instead
|
||||
of in model.
|
||||
|
||||
Returns:
|
||||
The converted Python value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted.
|
||||
TypeError: If the type hint is not supported.
|
||||
"""
|
||||
# When a library file has been opened, entity IDs are resolved from it, not the main model.
|
||||
effective_lookup = lookup_file if lookup_file is not None else model
|
||||
|
||||
if type_hint is None:
|
||||
return value_str
|
||||
|
||||
origin = typing.get_origin(type_hint)
|
||||
args = typing.get_args(type_hint)
|
||||
|
||||
# Union / Optional
|
||||
if origin is typing.Union:
|
||||
non_none_types = [a for a in args if a is not type(None)]
|
||||
if value_str.lower() == "none":
|
||||
if type(None) in args:
|
||||
return None
|
||||
# Try each non-None type in order
|
||||
for t in non_none_types:
|
||||
try:
|
||||
return coerce_value(value_str, t, model, lookup_file)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
|
||||
|
||||
# Literal
|
||||
if origin is typing.Literal:
|
||||
allowed = args
|
||||
if value_str in [str(a) for a in allowed]:
|
||||
# return the actual literal value with proper type
|
||||
for a in allowed:
|
||||
if str(a) == value_str:
|
||||
return a
|
||||
raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
|
||||
|
||||
# list types
|
||||
if origin is list:
|
||||
if args and _is_entity_type(args[0]):
|
||||
return _coerce_entity_list(value_str, effective_lookup)
|
||||
if args:
|
||||
items = _split_list(value_str)
|
||||
return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
|
||||
return _split_list(value_str)
|
||||
|
||||
# dict types
|
||||
if origin is dict:
|
||||
return _floatify_numeric_lists(json.loads(value_str))
|
||||
|
||||
# Simple types
|
||||
if type_hint is str:
|
||||
return value_str
|
||||
if type_hint is int:
|
||||
return int(value_str.lstrip("#"))
|
||||
if type_hint is float:
|
||||
return float(value_str)
|
||||
if type_hint is bool:
|
||||
return value_str.lower() in ("true", "1", "yes")
|
||||
|
||||
# ifcopenshell.file — open from path string
|
||||
if type_hint is ifcopenshell.file:
|
||||
return ifcopenshell.open(value_str)
|
||||
|
||||
# entity_instance
|
||||
if _is_entity_type(type_hint):
|
||||
return _coerce_entity(value_str, effective_lookup)
|
||||
|
||||
# Fallback: try json.loads for complex types, then plain string
|
||||
try:
|
||||
return json.loads(value_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value_str
|
||||
|
||||
|
||||
def _is_entity_type(hint) -> bool:
|
||||
"""Check if a type hint refers to ifcopenshell.entity_instance."""
|
||||
if hint is ifcopenshell.entity_instance:
|
||||
return True
|
||||
if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
|
||||
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
|
||||
if lookup_file is None:
|
||||
raise ValueError("Cannot resolve entity reference without an IFC model")
|
||||
if isinstance(value_str, int):
|
||||
entity_id = value_str
|
||||
else:
|
||||
entity_id = int(value_str.strip().lstrip("#"))
|
||||
try:
|
||||
return lookup_file.by_id(entity_id)
|
||||
except RuntimeError:
|
||||
raise ValueError(f"Entity #{entity_id} not found in model")
|
||||
|
||||
|
||||
def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]:
|
||||
"""Resolve a comma-separated list of step IDs to entity instances."""
|
||||
items = _split_list(value_str)
|
||||
return [_coerce_entity(item.strip(), lookup_file) for item in items]
|
||||
|
||||
|
||||
def _floatify_numeric_lists(obj):
|
||||
"""Recursively convert lists of numbers to lists of floats.
|
||||
|
||||
IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
|
||||
attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
|
||||
for whole numbers like 0, which causes a TypeError at the binding level.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
|
||||
if (
|
||||
isinstance(obj, list)
|
||||
and obj
|
||||
and all(isinstance(v, (int, float)) for v in obj)
|
||||
and any(isinstance(v, float) for v in obj)
|
||||
):
|
||||
return [float(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _split_list(value_str: str) -> list[str]:
|
||||
"""Split a comma-separated string, handling JSON arrays too."""
|
||||
value_str = value_str.strip()
|
||||
if value_str.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value_str)
|
||||
if isinstance(parsed, list):
|
||||
return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [item.strip() for item in value_str.split(",") if item.strip()]
|
||||
@@ -0,0 +1,282 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <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 importlib
|
||||
import inspect
|
||||
import re
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _api_package_path() -> Path:
|
||||
"""Return the filesystem path to the ifcopenshell.api package."""
|
||||
import ifcopenshell.api
|
||||
|
||||
return Path(ifcopenshell.api.__file__).parent
|
||||
|
||||
|
||||
def list_modules() -> list[dict]:
|
||||
"""List all API modules with their function counts and descriptions.
|
||||
|
||||
Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
|
||||
"""
|
||||
api_path = _api_package_path()
|
||||
modules = []
|
||||
for child in sorted(api_path.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith("_"):
|
||||
continue
|
||||
init_file = child / "__init__.py"
|
||||
if not init_file.exists():
|
||||
continue
|
||||
try:
|
||||
mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
|
||||
except Exception:
|
||||
continue
|
||||
all_names = getattr(mod, "__all__", [])
|
||||
if not all_names:
|
||||
continue
|
||||
description = ""
|
||||
if mod.__doc__:
|
||||
description = mod.__doc__.strip().split("\n")[0]
|
||||
modules.append(
|
||||
{
|
||||
"module": child.name,
|
||||
"description": description,
|
||||
"functions": list(all_names),
|
||||
"count": len(all_names),
|
||||
}
|
||||
)
|
||||
return modules
|
||||
|
||||
|
||||
def list_functions(module: str) -> list[dict]:
|
||||
"""List functions in an API module with one-line descriptions and parameter info.
|
||||
|
||||
Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
|
||||
"""
|
||||
mod = importlib.import_module(f"ifcopenshell.api.{module}")
|
||||
all_names = getattr(mod, "__all__", [])
|
||||
functions = []
|
||||
for name in all_names:
|
||||
fn = _get_underlying_function(module, name)
|
||||
if fn is None:
|
||||
continue
|
||||
description = ""
|
||||
if fn.__doc__:
|
||||
description = fn.__doc__.strip().split("\n")[0]
|
||||
params = _extract_params(fn)
|
||||
functions.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
return functions
|
||||
|
||||
|
||||
def function_docs(module: str, function: str) -> dict:
|
||||
"""Full documentation for a single API function.
|
||||
|
||||
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
|
||||
"""
|
||||
fn = _get_underlying_function(module, function)
|
||||
if fn is None:
|
||||
raise ValueError(f"Function '{module}.{function}' not found")
|
||||
|
||||
description = ""
|
||||
long_description = ""
|
||||
if fn.__doc__:
|
||||
description, long_description = _parse_docstring_body(fn.__doc__)
|
||||
|
||||
params = _extract_params(fn)
|
||||
param_descriptions = _parse_param_docs(fn.__doc__ or "")
|
||||
for param in params:
|
||||
if param["name"] in param_descriptions:
|
||||
param["description"] = param_descriptions[param["name"]]
|
||||
|
||||
return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
|
||||
return_description = _parse_return_doc(fn.__doc__ or "")
|
||||
|
||||
result = {
|
||||
"module": module,
|
||||
"function": function,
|
||||
"description": description,
|
||||
"long_description": long_description,
|
||||
"params": params,
|
||||
}
|
||||
if return_type:
|
||||
result["return_type"] = return_type
|
||||
if return_description:
|
||||
result["return_description"] = return_description
|
||||
return result
|
||||
|
||||
|
||||
def _get_underlying_function(module: str, function: str):
|
||||
"""Get the actual function object (unwrapping the listener wrapper if needed)."""
|
||||
try:
|
||||
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
|
||||
fn = getattr(fn_module, function, None)
|
||||
return fn
|
||||
except (ImportError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_params(fn) -> list[dict]:
|
||||
"""Extract parameter info from a function's signature and type hints."""
|
||||
sig = inspect.signature(fn)
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
params = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "file" or name == "self":
|
||||
continue
|
||||
info = {"name": name}
|
||||
if name in hints:
|
||||
info["type"] = _format_type_hint(hints[name])
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
info["default"] = _serialize_default(param.default)
|
||||
else:
|
||||
info["required"] = True
|
||||
params.append(info)
|
||||
return params
|
||||
|
||||
|
||||
def _format_type_hint(hint) -> str | None:
|
||||
"""Format a type hint to a readable string."""
|
||||
import ifcopenshell
|
||||
|
||||
if hint is None:
|
||||
return None
|
||||
if hint is type(None):
|
||||
return "None"
|
||||
# ifcopenshell.file params are passed as a file path string
|
||||
if hint is ifcopenshell.file:
|
||||
return "file_path"
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
|
||||
# Union (including Optional)
|
||||
if origin is typing.Union:
|
||||
formatted = [_format_type_hint(a) for a in args]
|
||||
# Optional[X] is Union[X, None] — render as "Optional[X]"
|
||||
if len(formatted) == 2 and "None" in formatted:
|
||||
inner = next(f for f in formatted if f != "None")
|
||||
return f"Optional[{inner}]"
|
||||
return " | ".join(formatted)
|
||||
|
||||
# Literal
|
||||
if origin is typing.Literal:
|
||||
values = ", ".join(repr(a) for a in args)
|
||||
return f"Literal[{values}]"
|
||||
|
||||
# Generic types (list, dict, etc.)
|
||||
if origin is not None:
|
||||
origin_name = getattr(origin, "__name__", str(origin))
|
||||
if args:
|
||||
inner = ", ".join(_format_type_hint(a) for a in args)
|
||||
return f"{origin_name}[{inner}]"
|
||||
return origin_name
|
||||
|
||||
# Simple types
|
||||
return getattr(hint, "__name__", str(hint))
|
||||
|
||||
|
||||
def _serialize_default(value):
|
||||
"""Serialize a default value to something JSON-friendly."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _parse_docstring_body(docstring: str) -> tuple[str, str]:
|
||||
"""Parse the summary and long description from a docstring."""
|
||||
lines = docstring.strip().split("\n")
|
||||
summary = lines[0].strip() if lines else ""
|
||||
body_lines = []
|
||||
in_body = False
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(":param") or stripped.startswith(":return"):
|
||||
break
|
||||
if stripped.startswith("Example"):
|
||||
break
|
||||
if not in_body and not stripped:
|
||||
in_body = True
|
||||
continue
|
||||
if in_body:
|
||||
body_lines.append(stripped)
|
||||
|
||||
long_description = " ".join(body_lines).strip()
|
||||
# collapse multiple spaces
|
||||
long_description = re.sub(r"\s+", " ", long_description)
|
||||
return summary, long_description
|
||||
|
||||
|
||||
_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
|
||||
|
||||
|
||||
def _parse_param_docs(docstring: str) -> dict[str, str]:
|
||||
"""Extract :param name: description lines from a docstring."""
|
||||
params = {}
|
||||
current_param = None
|
||||
current_lines = []
|
||||
for line in docstring.split("\n"):
|
||||
stripped = line.strip()
|
||||
match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
|
||||
if match:
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
current_param = match.group(1)
|
||||
current_lines = [match.group(2)]
|
||||
elif current_param and stripped and not _FIELD_MARKER.match(stripped):
|
||||
current_lines.append(stripped)
|
||||
elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param):
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
current_param = None
|
||||
current_lines = []
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
# collapse whitespace
|
||||
return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
|
||||
|
||||
|
||||
def _parse_return_doc(docstring: str) -> str:
|
||||
"""Extract :return: description from a docstring."""
|
||||
lines = []
|
||||
in_return = False
|
||||
for line in docstring.split("\n"):
|
||||
stripped = line.strip()
|
||||
match = re.match(r":return:\s*(.*)", stripped)
|
||||
if match:
|
||||
in_return = True
|
||||
lines = [match.group(1)]
|
||||
elif in_return:
|
||||
if _FIELD_MARKER.match(stripped) or stripped == "":
|
||||
break
|
||||
lines.append(stripped)
|
||||
return re.sub(r"\s+", " ", " ".join(lines).strip())
|
||||
@@ -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,37 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
|
||||
|
||||
|
||||
def list_rules() -> list[dict[str, str]]:
|
||||
"""Return a list of available quantification rule names."""
|
||||
return [{"name": name} for name in AVAILABLE_RULES]
|
||||
|
||||
|
||||
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place by adding/updating IfcElementQuantity psets.
|
||||
Returns a summary dict with ok, rule, and elements_quantified.
|
||||
"""
|
||||
from ifc5d.qto import edit_qtos, quantify
|
||||
from ifc5d.qto import rules as rule_sets
|
||||
|
||||
if rule not in rule_sets:
|
||||
return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
|
||||
|
||||
import ifcopenshell.util.selector
|
||||
|
||||
if selector:
|
||||
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||
else:
|
||||
elements = set(model.by_type("IfcElement"))
|
||||
|
||||
results = quantify(model, elements, rule_sets[rule])
|
||||
edit_qtos(model, results)
|
||||
return {"ok": True, "rule": rule, "elements_quantified": len(results)}
|
||||
@@ -0,0 +1,148 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <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 importlib
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcedit.coerce import coerce_value
|
||||
|
||||
|
||||
def _is_file_type(hint) -> bool:
|
||||
"""Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file])."""
|
||||
if hint is ifcopenshell.file:
|
||||
return True
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
if origin is typing.Union and ifcopenshell.file in args:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run_api(
|
||||
model: ifcopenshell.file,
|
||||
module: str,
|
||||
function: str,
|
||||
raw_kwargs: dict[str, str],
|
||||
) -> dict:
|
||||
"""Execute an ifcopenshell.api function with CLI-provided string arguments.
|
||||
|
||||
Args:
|
||||
model: The open IFC model.
|
||||
module: API module name (e.g. "root").
|
||||
function: Function name (e.g. "create_entity").
|
||||
raw_kwargs: String keyword arguments from the CLI.
|
||||
|
||||
Returns:
|
||||
A dict with {"ok": True, "result": ...} on success,
|
||||
or {"ok": False, "error": "..."} on failure.
|
||||
"""
|
||||
try:
|
||||
fn = _import_function(module, function)
|
||||
except (ImportError, AttributeError) as e:
|
||||
return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
|
||||
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
coerced_kwargs = {}
|
||||
|
||||
# Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset).
|
||||
# The opened file is then used as the lookup file for entity resolution in pass 2.
|
||||
opened_files: list[ifcopenshell.file] = []
|
||||
for name, value_str in raw_kwargs.items():
|
||||
if name not in sig.parameters:
|
||||
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
||||
hint = hints.get(name)
|
||||
if not _is_file_type(hint):
|
||||
continue
|
||||
try:
|
||||
coerced = coerce_value(value_str, hint, model)
|
||||
coerced_kwargs[name] = coerced
|
||||
if isinstance(coerced, ifcopenshell.file):
|
||||
opened_files.append(coerced)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
||||
|
||||
# Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened
|
||||
# library file (if any), since you are always appending from another file, never
|
||||
# from the current model.
|
||||
lookup_file = opened_files[0] if opened_files else None
|
||||
for name, value_str in raw_kwargs.items():
|
||||
if name in coerced_kwargs:
|
||||
continue
|
||||
if name not in sig.parameters:
|
||||
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
||||
hint = hints.get(name)
|
||||
try:
|
||||
coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
||||
|
||||
# Determine if the function takes 'file' as its first parameter
|
||||
first_param = next(iter(sig.parameters), None)
|
||||
try:
|
||||
if first_param == "file":
|
||||
result = fn(model, **coerced_kwargs)
|
||||
else:
|
||||
result = fn(**coerced_kwargs)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return {"ok": True, "result": serialize_result(result)}
|
||||
|
||||
|
||||
def _import_function(module: str, function: str):
|
||||
"""Import and return the underlying function from ifcopenshell.api."""
|
||||
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
|
||||
fn = getattr(fn_module, function)
|
||||
return fn
|
||||
|
||||
|
||||
def serialize_result(value) -> object:
|
||||
"""Serialize an API result to a JSON-friendly structure."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, ifcopenshell.entity_instance):
|
||||
return _serialize_entity(value)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [serialize_result(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): serialize_result(v) for k, v in value.items()}
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
|
||||
"""Serialize an entity instance to a summary dict."""
|
||||
result = {
|
||||
"id": entity.id(),
|
||||
"type": entity.is_a(),
|
||||
}
|
||||
if hasattr(entity, "Name") and entity.Name:
|
||||
result["name"] = entity.Name
|
||||
return result
|
||||
@@ -0,0 +1,33 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ifcedit"
|
||||
version = "0.0.0"
|
||||
authors = [
|
||||
{ name="Bruno Postle", email="bruno@postle.net" },
|
||||
]
|
||||
description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
|
||||
readme = "README.md"
|
||||
keywords = ["IFC", "BIM", "API"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell", "ifc5d"]
|
||||
|
||||
[project.scripts]
|
||||
ifcedit = "ifcedit.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://ifcopenshell.org"
|
||||
Documentation = "https://docs.ifcopenshell.org"
|
||||
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["ifcedit*"]
|
||||
exclude = ["test*"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -0,0 +1,63 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model():
|
||||
"""Create an IFC4 model with a spatial hierarchy and a wall."""
|
||||
f = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||
ifcopenshell.api.unit.assign_unit(f)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_file(model, tmp_path):
|
||||
"""Write the model fixture to a temp file and return the path."""
|
||||
path = tmp_path / "test.ifc"
|
||||
model.write(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library():
|
||||
"""Create an IFC4 library with a single IfcWallType asset."""
|
||||
lib = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary")
|
||||
ifcopenshell.api.unit.assign_unit(lib)
|
||||
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01")
|
||||
return lib
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_file(library, tmp_path):
|
||||
"""Write the library fixture to a temp file and return the path."""
|
||||
path = tmp_path / "library.ifc"
|
||||
library.write(str(path))
|
||||
return str(path)
|
||||
@@ -0,0 +1,158 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.project
|
||||
import pytest
|
||||
|
||||
from ifcedit.coerce import coerce_value
|
||||
|
||||
|
||||
class TestStringCoercion:
|
||||
def test_plain_string(self):
|
||||
assert coerce_value("hello", str) == "hello"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert coerce_value("", str) == ""
|
||||
|
||||
|
||||
class TestIntCoercion:
|
||||
def test_plain_int(self):
|
||||
assert coerce_value("42", int) == 42
|
||||
|
||||
def test_hash_prefix(self):
|
||||
assert coerce_value("#42", int) == 42
|
||||
|
||||
def test_negative(self):
|
||||
assert coerce_value("-5", int) == -5
|
||||
|
||||
|
||||
class TestFloatCoercion:
|
||||
def test_plain_float(self):
|
||||
assert coerce_value("3.14", float) == pytest.approx(3.14)
|
||||
|
||||
def test_integer_as_float(self):
|
||||
assert coerce_value("5", float) == 5.0
|
||||
|
||||
|
||||
class TestBoolCoercion:
|
||||
def test_true_values(self):
|
||||
for val in ("true", "True", "TRUE", "1", "yes"):
|
||||
assert coerce_value(val, bool) is True
|
||||
|
||||
def test_false_values(self):
|
||||
for val in ("false", "False", "0", "no"):
|
||||
assert coerce_value(val, bool) is False
|
||||
|
||||
|
||||
class TestOptionalCoercion:
|
||||
def test_optional_string(self):
|
||||
assert coerce_value("hello", Optional[str]) == "hello"
|
||||
|
||||
def test_optional_none(self):
|
||||
assert coerce_value("none", Optional[str]) is None
|
||||
assert coerce_value("None", Optional[str]) is None
|
||||
|
||||
def test_optional_int(self):
|
||||
assert coerce_value("42", Optional[int]) == 42
|
||||
|
||||
|
||||
class TestUnionCoercion:
|
||||
def test_union_str_int(self):
|
||||
# Tries str first (or int first depending on order), both work
|
||||
result = coerce_value("hello", Union[str, int])
|
||||
assert result == "hello"
|
||||
|
||||
def test_union_int_none(self):
|
||||
result = coerce_value("42", Union[int, None])
|
||||
assert result == 42
|
||||
|
||||
|
||||
class TestLiteralCoercion:
|
||||
def test_valid_literal(self):
|
||||
assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
|
||||
|
||||
def test_invalid_literal(self):
|
||||
with pytest.raises(ValueError, match="not one of"):
|
||||
coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
|
||||
|
||||
|
||||
class TestDictCoercion:
|
||||
def test_json_dict(self):
|
||||
result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
|
||||
assert result == {"IsExternal": True, "FireRating": "2HR"}
|
||||
|
||||
def test_mixed_float_int_list_coerced_to_float(self):
|
||||
# [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
|
||||
# so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
|
||||
result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
|
||||
assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
|
||||
assert all(isinstance(v, float) for v in result["DirectionRatios"])
|
||||
|
||||
def test_pure_int_list_not_coerced(self):
|
||||
# All-integer lists (e.g. face indices) must stay as ints
|
||||
result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
|
||||
assert result["CoordIndex"] == [0, 1, 2]
|
||||
assert all(isinstance(v, int) for v in result["CoordIndex"])
|
||||
|
||||
|
||||
class TestListCoercion:
|
||||
def test_comma_separated(self):
|
||||
result = coerce_value("a,b,c", list[str])
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_json_array(self):
|
||||
result = coerce_value("[1, 2, 3]", list[int])
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
|
||||
class TestEntityCoercion:
|
||||
def test_entity_by_id(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
|
||||
assert result == wall
|
||||
|
||||
def test_entity_with_hash(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
|
||||
assert result == wall
|
||||
|
||||
def test_entity_not_found(self, model):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
coerce_value("999999", ifcopenshell.entity_instance, model)
|
||||
|
||||
def test_entity_list(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
|
||||
assert len(result) == 1
|
||||
assert result[0] == wall
|
||||
|
||||
def test_entity_list_multiple(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
storey = model.by_type("IfcBuildingStorey")[0]
|
||||
result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_entity_no_model(self):
|
||||
with pytest.raises(ValueError, match="without an IFC model"):
|
||||
coerce_value("42", ifcopenshell.entity_instance, None)
|
||||
|
||||
|
||||
class TestFileCoercion:
|
||||
def test_opens_file_from_path(self, model_file):
|
||||
result = coerce_value(model_file, ifcopenshell.file)
|
||||
assert isinstance(result, ifcopenshell.file)
|
||||
|
||||
def test_entity_from_lookup_file(self, model_file):
|
||||
lib = ifcopenshell.open(model_file)
|
||||
wall = lib.by_type("IfcWall")[0]
|
||||
empty_model = ifcopenshell.api.project.create_file()
|
||||
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib)
|
||||
assert result.id() == wall.id()
|
||||
assert result.is_a("IfcWall")
|
||||
|
||||
|
||||
class TestFallback:
|
||||
def test_no_type_hint(self):
|
||||
assert coerce_value("hello", None) == "hello"
|
||||
@@ -0,0 +1,104 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
|
||||
|
||||
class TestListModules:
|
||||
def test_returns_list(self):
|
||||
result = list_modules()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_module_structure(self):
|
||||
result = list_modules()
|
||||
for entry in result:
|
||||
assert "module" in entry
|
||||
assert "description" in entry
|
||||
assert "functions" in entry
|
||||
assert "count" in entry
|
||||
assert isinstance(entry["functions"], list)
|
||||
assert entry["count"] == len(entry["functions"])
|
||||
|
||||
def test_known_modules_present(self):
|
||||
result = list_modules()
|
||||
module_names = [m["module"] for m in result]
|
||||
for expected in ("root", "spatial", "pset", "aggregate", "unit"):
|
||||
assert expected in module_names
|
||||
|
||||
def test_root_module_has_functions(self):
|
||||
result = list_modules()
|
||||
root = next(m for m in result if m["module"] == "root")
|
||||
assert "create_entity" in root["functions"]
|
||||
assert root["count"] >= 3
|
||||
|
||||
|
||||
class TestListFunctions:
|
||||
def test_root_functions(self):
|
||||
result = list_functions("root")
|
||||
assert isinstance(result, list)
|
||||
names = [f["name"] for f in result]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_function_structure(self):
|
||||
result = list_functions("root")
|
||||
for fn in result:
|
||||
assert "name" in fn
|
||||
assert "description" in fn
|
||||
assert "params" in fn
|
||||
|
||||
def test_create_entity_params(self):
|
||||
result = list_functions("root")
|
||||
create = next(f for f in result if f["name"] == "create_entity")
|
||||
param_names = [p["name"] for p in create["params"]]
|
||||
assert "ifc_class" in param_names
|
||||
assert "name" in param_names
|
||||
|
||||
def test_pset_functions(self):
|
||||
result = list_functions("pset")
|
||||
names = [f["name"] for f in result]
|
||||
assert "add_pset" in names
|
||||
assert "edit_pset" in names
|
||||
|
||||
|
||||
class TestFunctionDocs:
|
||||
def test_create_entity_docs(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
assert result["module"] == "root"
|
||||
assert result["function"] == "create_entity"
|
||||
assert result["description"]
|
||||
assert isinstance(result["params"], list)
|
||||
assert len(result["params"]) > 0
|
||||
|
||||
def test_params_have_types(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
for param in result["params"]:
|
||||
assert "name" in param
|
||||
assert "type" in param
|
||||
|
||||
def test_params_have_descriptions(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
|
||||
assert "description" in ifc_class
|
||||
assert len(ifc_class["description"]) > 0
|
||||
|
||||
def test_return_type(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
assert "return_type" in result
|
||||
|
||||
def test_assign_container_docs(self):
|
||||
result = function_docs("spatial", "assign_container")
|
||||
assert result["module"] == "spatial"
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "products" in param_names
|
||||
assert "relating_structure" in param_names
|
||||
|
||||
def test_unknown_function_raises(self):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
function_docs("root", "nonexistent_function")
|
||||
|
||||
def test_edit_pset_docs(self):
|
||||
result = function_docs("pset", "edit_pset")
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "pset" in param_names
|
||||
assert "properties" in param_names
|
||||
@@ -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
|
||||
@@ -0,0 +1,211 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
|
||||
def run_ifcedit(*args, stdin=None):
|
||||
"""Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "ifcedit", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
input=stdin,
|
||||
)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
|
||||
|
||||
class TestListCommand:
|
||||
def test_list_all_modules(self):
|
||||
stdout, stderr, rc = run_ifcedit("list")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert isinstance(data, list)
|
||||
module_names = [m["module"] for m in data]
|
||||
assert "root" in module_names
|
||||
assert "spatial" in module_names
|
||||
|
||||
def test_list_module_functions(self):
|
||||
stdout, stderr, rc = run_ifcedit("list", "root")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert isinstance(data, list)
|
||||
names = [f["name"] for f in data]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_list_text_format(self):
|
||||
stdout, stderr, rc = run_ifcedit("--format", "text", "list")
|
||||
assert rc == 0
|
||||
assert "root" in stdout
|
||||
|
||||
|
||||
class TestDocsCommand:
|
||||
def test_docs_create_entity(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["module"] == "root"
|
||||
assert data["function"] == "create_entity"
|
||||
assert "params" in data
|
||||
|
||||
def test_docs_invalid_path(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
|
||||
assert rc != 0
|
||||
assert "module.function" in stderr
|
||||
|
||||
def test_docs_unknown_function(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
|
||||
assert rc != 0
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
def test_create_entity(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["result"]["type"] == "IfcWall"
|
||||
assert data["result"]["name"] == "CLIWall"
|
||||
|
||||
def test_dry_run(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["dry_run"] is True
|
||||
|
||||
def test_output_to_different_file(self, model_file, tmp_path):
|
||||
output = str(tmp_path / "output.ifc")
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
|
||||
import os
|
||||
|
||||
assert os.path.exists(output)
|
||||
|
||||
def test_run_error_bad_function(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
|
||||
assert rc != 0
|
||||
|
||||
def test_run_invalid_function_path(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
|
||||
assert rc != 0
|
||||
assert "module.function" in stderr
|
||||
|
||||
|
||||
class TestForeachCommand:
|
||||
def _select_json(self, model, ifc_class):
|
||||
"""Build a JSON array like ifcquery select would produce."""
|
||||
elements = model.by_type(ifc_class)
|
||||
return json.dumps([{"id": e.id(), "type": e.is_a(), "name": getattr(e, "Name", None)} for e in elements])
|
||||
|
||||
def test_foreach_rename(self, model, model_file):
|
||||
walls_json = self._select_json(model, "IfcWall")
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "Renamed"}',
|
||||
stdin=walls_json,
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == 1
|
||||
assert data["errors"] == []
|
||||
updated = ifcopenshell.open(model_file)
|
||||
assert updated.by_type("IfcWall")[0].Name == "Renamed"
|
||||
|
||||
def test_foreach_multiple_elements(self, model, model_file):
|
||||
# Build a two-item list by selecting all IfcObject (includes spatial structure + elements)
|
||||
elements_json = self._select_json(model, "IfcObject")
|
||||
items = json.loads(elements_json)
|
||||
assert len(items) >= 2
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "Bulk"}',
|
||||
stdin=elements_json,
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == len(items)
|
||||
|
||||
def test_foreach_empty_list(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "X"}',
|
||||
stdin="[]",
|
||||
)
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == 0
|
||||
|
||||
def test_foreach_invalid_json_stdin(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"root.remove_product",
|
||||
"--product",
|
||||
"{id}",
|
||||
stdin="not json",
|
||||
)
|
||||
assert rc != 0
|
||||
assert "Error" in stderr
|
||||
|
||||
def test_foreach_not_array_stdin(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"root.remove_product",
|
||||
"--product",
|
||||
"{id}",
|
||||
stdin='{"id": 1}',
|
||||
)
|
||||
assert rc != 0
|
||||
assert "Error" in stderr
|
||||
|
||||
def test_foreach_output_to_different_file(self, model, model_file, tmp_path):
|
||||
import os
|
||||
|
||||
output = str(tmp_path / "out.ifc")
|
||||
walls_json = self._select_json(model, "IfcWall")
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"-o",
|
||||
output,
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "OutFile"}',
|
||||
stdin=walls_json,
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
assert os.path.exists(output)
|
||||
updated = ifcopenshell.open(output)
|
||||
assert updated.by_type("IfcWall")[0].Name == "OutFile"
|
||||
@@ -0,0 +1,87 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify
|
||||
|
||||
|
||||
class TestListRules:
|
||||
def test_returns_list(self):
|
||||
result = list_rules()
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_each_entry_has_name(self):
|
||||
result = list_rules()
|
||||
for entry in result:
|
||||
assert "name" in entry
|
||||
|
||||
def test_ifc4_rule_present(self):
|
||||
result = list_rules()
|
||||
names = [r["name"] for r in result]
|
||||
assert "IFC4QtoBaseQuantities" in names
|
||||
|
||||
def test_ifc4x3_rule_present(self):
|
||||
result = list_rules()
|
||||
names = [r["name"] for r in result]
|
||||
assert "IFC4X3QtoBaseQuantities" in names
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quantify_model():
|
||||
"""Create an IFC4 model with a wall element."""
|
||||
f = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||
ifcopenshell.api.unit.assign_unit(f)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
class TestRunQuantify:
|
||||
def test_returns_ok_true(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_returns_rule_name(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
|
||||
assert result["rule"] == "IFC4QtoBaseQuantities"
|
||||
|
||||
def test_returns_elements_quantified(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
|
||||
assert "elements_quantified" in result
|
||||
assert isinstance(result["elements_quantified"], int)
|
||||
|
||||
def test_unknown_rule_returns_error(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "NonExistentRule")
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_selector_restricts_elements(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall")
|
||||
assert result["ok"] is True
|
||||
assert result["rule"] == "IFC4QtoBaseQuantities"
|
||||
|
||||
def test_empty_selector_runs_on_all(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
|
||||
assert result["ok"] is True
|
||||
@@ -0,0 +1,97 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
|
||||
from ifcedit.run import run_api, serialize_result
|
||||
|
||||
|
||||
class TestRunApi:
|
||||
def test_create_entity(self, model):
|
||||
result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcWall"
|
||||
assert result["result"]["name"] == "NewWall"
|
||||
assert isinstance(result["result"]["id"], int)
|
||||
|
||||
def test_create_entity_default_class(self, model):
|
||||
result = run_api(model, "root", "create_entity", {})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcBuildingElementProxy"
|
||||
|
||||
def test_assign_container(self, model):
|
||||
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2")
|
||||
storey = model.by_type("IfcBuildingStorey")[0]
|
||||
result = run_api(
|
||||
model,
|
||||
"spatial",
|
||||
"assign_container",
|
||||
{"products": str(wall.id()), "relating_structure": str(storey.id())},
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcRelContainedInSpatialStructure"
|
||||
|
||||
def test_add_pset(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcPropertySet"
|
||||
|
||||
def test_unknown_function(self, model):
|
||||
result = run_api(model, "root", "nonexistent", {})
|
||||
assert result["ok"] is False
|
||||
assert "Cannot find" in result["error"]
|
||||
|
||||
def test_unknown_parameter(self, model):
|
||||
result = run_api(model, "root", "create_entity", {"bogus_param": "value"})
|
||||
assert result["ok"] is False
|
||||
assert "Unknown parameter" in result["error"]
|
||||
|
||||
def test_bad_entity_reference(self, model):
|
||||
result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"})
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["error"]
|
||||
|
||||
|
||||
class TestAppendAsset:
|
||||
def test_append_asset_from_library(self, model, library_file):
|
||||
lib = ifcopenshell.open(library_file)
|
||||
wall_type = lib.by_type("IfcWallType")[0]
|
||||
result = run_api(
|
||||
model,
|
||||
"project",
|
||||
"append_asset",
|
||||
{"library": library_file, "element": str(wall_type.id())},
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcWallType"
|
||||
assert model.by_type("IfcWallType"), "wall type should have been appended to the model"
|
||||
|
||||
|
||||
class TestSerializeResult:
|
||||
def test_none(self):
|
||||
assert serialize_result(None) is None
|
||||
|
||||
def test_string(self):
|
||||
assert serialize_result("hello") == "hello"
|
||||
|
||||
def test_int(self):
|
||||
assert serialize_result(42) == 42
|
||||
|
||||
def test_entity(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = serialize_result(wall)
|
||||
assert result["id"] == wall.id()
|
||||
assert result["type"] == "IfcWall"
|
||||
assert result["name"] == "Wall001"
|
||||
|
||||
def test_list(self, model):
|
||||
walls = model.by_type("IfcWall")
|
||||
result = serialize_result(walls)
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(r, dict) for r in result)
|
||||
|
||||
def test_dict(self):
|
||||
result = serialize_result({"key": "value"})
|
||||
assert result == {"key": "value"}
|
||||
@@ -0,0 +1,401 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# ifcmcp
|
||||
|
||||
An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`,
|
||||
holding the IFC model in memory across tool calls for fast interactive editing
|
||||
sessions.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcmcp
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
|
||||
|
||||
## Running the server
|
||||
|
||||
```bash
|
||||
python3 -m ifcmcp
|
||||
```
|
||||
|
||||
This starts the server on stdio transport, suitable for use with Claude Code
|
||||
or any MCP client.
|
||||
|
||||
### Claude Code configuration
|
||||
|
||||
Use the `claude mcp add` command:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport stdio ifc -- python3 -m ifcmcp
|
||||
```
|
||||
|
||||
Or create a `.mcp.json` file in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ifc": {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["-m", "ifcmcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After adding the server, restart Claude Code for the tools to become available.
|
||||
Then load a model by asking Claude to use `ifc_load`:
|
||||
|
||||
```
|
||||
load model.ifc using ifc_load
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Session
|
||||
|
||||
#### ifc_new
|
||||
|
||||
Create a new empty IFC model in memory, replacing any currently loaded model.
|
||||
|
||||
```
|
||||
ifc_new()
|
||||
ifc_new(schema="IFC4X3")
|
||||
```
|
||||
|
||||
Default schema is `IFC4`.
|
||||
|
||||
#### ifc_load
|
||||
|
||||
Open an IFC file into memory.
|
||||
|
||||
```
|
||||
ifc_load(path="/path/to/model.ifc")
|
||||
-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
|
||||
```
|
||||
|
||||
#### ifc_reset
|
||||
|
||||
Unload the current model from memory, freeing all session state.
|
||||
|
||||
```
|
||||
ifc_reset()
|
||||
```
|
||||
|
||||
#### ifc_save
|
||||
|
||||
Write the in-memory model to disk. Empty path overwrites the original file.
|
||||
|
||||
```
|
||||
ifc_save()
|
||||
ifc_save(path="/path/to/output.ifc")
|
||||
```
|
||||
|
||||
### Query tools
|
||||
|
||||
All query tools require a model to be loaded first via `ifc_load`.
|
||||
|
||||
#### ifc_summary
|
||||
|
||||
Model overview: schema, entity counts, project info.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "IFC4",
|
||||
"total_entities": 1847,
|
||||
"project": {"id": 1, "name": "Office Building"},
|
||||
"types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36}
|
||||
}
|
||||
```
|
||||
|
||||
#### ifc_tree
|
||||
|
||||
Full spatial hierarchy from IfcProject down through sites, buildings, storeys,
|
||||
and contained elements.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"type": "IfcProject",
|
||||
"name": "Office Building",
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "IfcSite",
|
||||
"children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### ifc_info
|
||||
|
||||
Deep inspection of an entity by step ID: attributes, property sets, type,
|
||||
material, container, and 4x4 placement matrix.
|
||||
|
||||
```
|
||||
ifc_info(element_id=10)
|
||||
```
|
||||
|
||||
#### ifc_select
|
||||
|
||||
Filter elements using ifcopenshell selector syntax.
|
||||
|
||||
```
|
||||
ifc_select(query="IfcWall")
|
||||
ifc_select(query="IfcWindow")
|
||||
```
|
||||
|
||||
Returns a sorted list of `{"id", "type", "name"}` references.
|
||||
|
||||
#### ifc_relations
|
||||
|
||||
Show all relationships for an element: hierarchy, children, type, groups,
|
||||
systems, material, connections.
|
||||
|
||||
```
|
||||
ifc_relations(element_id=10)
|
||||
ifc_relations(element_id=10, traverse="up")
|
||||
```
|
||||
|
||||
With `traverse="up"`, walks the hierarchy from element up to IfcProject.
|
||||
|
||||
#### ifc_contexts
|
||||
|
||||
List all geometric representation contexts and subcontexts in the loaded model.
|
||||
|
||||
```
|
||||
ifc_contexts()
|
||||
```
|
||||
|
||||
#### ifc_materials
|
||||
|
||||
List all materials and material sets in the loaded model, with their assigned elements.
|
||||
|
||||
```
|
||||
ifc_materials()
|
||||
```
|
||||
|
||||
#### ifc_clash
|
||||
|
||||
Check an element for geometric intersections and clearance violations.
|
||||
|
||||
```
|
||||
ifc_clash(element_id=10)
|
||||
ifc_clash(element_id=10, clearance=0.5, scope="all")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check)
|
||||
- `tolerance` -- intersection tolerance in meters (default: 0.002)
|
||||
- `scope` -- `"storey"` or `"all"` (default: `"storey"`)
|
||||
|
||||
#### ifc_validate
|
||||
|
||||
Check the model for schema and constraint violations.
|
||||
|
||||
```
|
||||
ifc_validate()
|
||||
ifc_validate(express_rules=True)
|
||||
```
|
||||
|
||||
Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`.
|
||||
|
||||
#### ifc_schedule
|
||||
|
||||
List all work schedules and their nested task trees.
|
||||
|
||||
```
|
||||
ifc_schedule()
|
||||
ifc_schedule(max_depth=1) # top-level phases only
|
||||
```
|
||||
|
||||
`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced
|
||||
with `{"truncated": true, "count": N}` so you know children exist without
|
||||
fetching them all. Omit for unlimited depth.
|
||||
|
||||
#### ifc_cost
|
||||
|
||||
List all cost schedules and their nested cost item trees.
|
||||
|
||||
```
|
||||
ifc_cost()
|
||||
ifc_cost(max_depth=2) # top two levels of the BoQ
|
||||
```
|
||||
|
||||
`max_depth` limits cost item expansion, same truncation convention as
|
||||
`ifc_schedule`.
|
||||
|
||||
#### ifc_schema
|
||||
|
||||
Return IFC class documentation for any entity type, using the loaded model's
|
||||
schema version.
|
||||
|
||||
```
|
||||
ifc_schema(entity_type="IfcWall")
|
||||
ifc_schema(entity_type="IfcBuildingStorey")
|
||||
```
|
||||
|
||||
Returns description, predefined types, spec URL, and attribute descriptions.
|
||||
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
|
||||
|
||||
#### ifc_quantify
|
||||
|
||||
Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule.
|
||||
Computes physical measurements (volume, area, length, count, weight) and
|
||||
writes them back as `IfcElementQuantity` property sets. Modifies the model
|
||||
in-place -- call `ifc_save()` when done.
|
||||
|
||||
```
|
||||
ifc_quantify(rule="IFC4QtoBaseQuantities")
|
||||
ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
|
||||
```
|
||||
|
||||
Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
|
||||
|
||||
`selector` is an optional ifcopenshell selector to restrict which elements
|
||||
are quantified (default: all `IfcElement`).
|
||||
|
||||
Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
|
||||
|
||||
### Drawing and rendering tools
|
||||
|
||||
#### ifc_plot
|
||||
|
||||
Generate a 2D technical drawing of the loaded model and return it as an inline image.
|
||||
|
||||
```
|
||||
ifc_plot()
|
||||
ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg")
|
||||
ifc_plot(element_ids=[10, 11], view="floorplan")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `selector` -- ifcopenshell selector to restrict plotted elements
|
||||
- `element_ids` -- step IDs of elements to highlight; others are faded
|
||||
- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"`
|
||||
- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420)
|
||||
- `scale` -- model-to-paper ratio (default: 0.01 = 1:100)
|
||||
- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024)
|
||||
- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG)
|
||||
|
||||
Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`.
|
||||
|
||||
#### ifc_render
|
||||
|
||||
Render the loaded model to a 3D PNG image.
|
||||
|
||||
```
|
||||
ifc_render()
|
||||
ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png")
|
||||
ifc_render(element_ids=[10, 11], view="south")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `selector` -- ifcopenshell selector to restrict rendered elements
|
||||
- `element_ids` -- step IDs of elements to highlight; others are shown translucent
|
||||
- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"`
|
||||
- `output_path` -- optional path to save the PNG to disk
|
||||
|
||||
Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
|
||||
|
||||
### Shape builder tools
|
||||
|
||||
#### ifc_shape_list
|
||||
|
||||
List all available `ShapeBuilder` methods with brief descriptions.
|
||||
|
||||
```
|
||||
ifc_shape_list()
|
||||
```
|
||||
|
||||
#### ifc_shape_docs
|
||||
|
||||
Show full documentation for a specific `ShapeBuilder` method.
|
||||
|
||||
```
|
||||
ifc_shape_docs(method="extrude")
|
||||
ifc_shape_docs(method="create_ellipse")
|
||||
```
|
||||
|
||||
#### ifc_shape
|
||||
|
||||
Execute a `ShapeBuilder` method on the loaded model.
|
||||
|
||||
```
|
||||
ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}')
|
||||
```
|
||||
|
||||
`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`).
|
||||
|
||||
### Edit discovery tools
|
||||
|
||||
#### ifc_list
|
||||
|
||||
List all API modules, or functions within a specific module.
|
||||
|
||||
```
|
||||
ifc_list() # all modules
|
||||
ifc_list(module="root") # functions in the root module
|
||||
```
|
||||
|
||||
#### ifc_docs
|
||||
|
||||
Show full documentation for an API function including parameters, types,
|
||||
defaults, and descriptions.
|
||||
|
||||
```
|
||||
ifc_docs(function_path="root.create_entity")
|
||||
```
|
||||
|
||||
### Edit execution
|
||||
|
||||
#### ifc_edit
|
||||
|
||||
Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
|
||||
JSON string with string values that get coerced by ifcedit's type system.
|
||||
|
||||
```
|
||||
ifc_edit(
|
||||
function_path="root.create_entity",
|
||||
params='{"ifc_class": "IfcWall", "name": "My Wall"}'
|
||||
)
|
||||
```
|
||||
|
||||
Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
|
||||
|
||||
Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
|
||||
|
||||
**Parameter coercion:**
|
||||
|
||||
| Type | JSON value | Python value |
|
||||
|------|------------|--------------|
|
||||
| `entity_instance` | `"42"` | resolved from model by step ID |
|
||||
| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
|
||||
| `dict` | `'{"key": "val"}'` | parsed JSON object |
|
||||
| `bool` | `"true"` | `True` |
|
||||
| `Optional[X]` | `"none"` | `None` |
|
||||
|
||||
## Typical workflow
|
||||
|
||||
1. **Load** a model: `ifc_load`
|
||||
2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
|
||||
3. **Validate** if needed: `ifc_validate`
|
||||
4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
|
||||
5. **Look up IFC classes**: `ifc_schema`
|
||||
6. **Find** the right API function: `ifc_list`, `ifc_docs`
|
||||
7. **Edit** the model: `ifc_edit`
|
||||
8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
|
||||
9. **Verify** changes with query tools
|
||||
10. **Save** when satisfied: `ifc_save`
|
||||
|
||||
The model stays in memory across all calls, so multi-step editing sessions
|
||||
are fast -- no file I/O between operations.
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcMCP - MCP server for IFC building models
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcMCP.
|
||||
#
|
||||
# IfcMCP is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcMCP is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcMCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,48 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import argparse
|
||||
|
||||
from ifcmcp import __version__
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python3 -m ifcmcp",
|
||||
description=(
|
||||
"ifcmcp — MCP server for IFC building models.\n\n"
|
||||
"Runs a Model Context Protocol server over stdio so that MCP clients\n"
|
||||
"can query and edit IFC files without writing them to disk between\n"
|
||||
"operations.\n\n"
|
||||
"Add to .mcp.json to configure:\n"
|
||||
' {"mcpServers": {"ifc": {"type": "stdio", "command": "python3", "args": ["-m", "ifcmcp"]}}}'
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"ifcmcp {__version__}")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["stdio", "sse", "streamable-http"],
|
||||
default="stdio",
|
||||
help="MCP transport to use (default: stdio)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP # noqa: F401
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
print(
|
||||
"error: the 'mcp' package is required to run the server.\n" "Install it with: pip install mcp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from ifcmcp.server import build_server
|
||||
|
||||
server = build_server()
|
||||
server.run(transport=args.transport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,731 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
# inside ifcmcp/core.py
|
||||
import json
|
||||
from collections.abc import Callable # noqa: F401 — Callable used in helpers below
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
from ifcedit.quantify import run_quantify
|
||||
from ifcedit.run import run_api
|
||||
from ifcquery import clash as clash_mod
|
||||
from ifcquery import contexts as contexts_mod
|
||||
from ifcquery import cost as cost_mod
|
||||
from ifcquery import (
|
||||
info,
|
||||
relations,
|
||||
schedule,
|
||||
schema,
|
||||
select,
|
||||
summary,
|
||||
tree,
|
||||
)
|
||||
from ifcquery import (
|
||||
materials as materials_mod,
|
||||
)
|
||||
from ifcquery import (
|
||||
plot as plot_mod,
|
||||
)
|
||||
from ifcquery import (
|
||||
render as render_mod,
|
||||
)
|
||||
from ifcquery import validate as validate_mod
|
||||
|
||||
|
||||
def _jsonify(x: Any) -> Any:
|
||||
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
|
||||
if x is None or isinstance(x, (str, int, float, bool)):
|
||||
return x
|
||||
|
||||
# numpy arrays (and any array-like with tolist)
|
||||
if hasattr(x, "tolist"):
|
||||
return x.tolist()
|
||||
|
||||
# IfcOpenShell entity instances: normalize
|
||||
if isinstance(x, ifcopenshell.entity_instance):
|
||||
return {
|
||||
"id": int(x.id()),
|
||||
"type": x.is_a(),
|
||||
"repr": str(x),
|
||||
"name": getattr(x, "Name", None),
|
||||
}
|
||||
|
||||
if isinstance(x, dict):
|
||||
return {str(k): _jsonify(v) for k, v in x.items()}
|
||||
|
||||
if isinstance(x, (list, tuple, set)):
|
||||
return [_jsonify(v) for v in x]
|
||||
|
||||
# Try JSON as-is, else fallback to string
|
||||
try:
|
||||
json.dumps(x)
|
||||
return x
|
||||
except Exception:
|
||||
return str(x)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape builder helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _list_shape_methods() -> list[dict]:
|
||||
"""Introspect ShapeBuilder and return a summary of all public methods."""
|
||||
import inspect
|
||||
|
||||
from ifcedit.discover import _extract_params
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
results = []
|
||||
for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
doc = fn.__doc__ or ""
|
||||
description = doc.strip().split("\n")[0] if doc.strip() else ""
|
||||
results.append({"method": name, "description": description, "params": _extract_params(fn)})
|
||||
return results
|
||||
|
||||
|
||||
def _shape_method_docs(method_name: str) -> dict:
|
||||
"""Return full documentation for a single ShapeBuilder method."""
|
||||
import typing
|
||||
|
||||
from ifcedit.discover import (
|
||||
_extract_params,
|
||||
_format_type_hint,
|
||||
_parse_docstring_body,
|
||||
_parse_param_docs,
|
||||
_parse_return_doc,
|
||||
)
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
if method_name.startswith("_"):
|
||||
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
|
||||
fn = getattr(ShapeBuilder, method_name, None)
|
||||
if fn is None:
|
||||
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
|
||||
|
||||
doc = fn.__doc__ or ""
|
||||
description, long_description = _parse_docstring_body(doc)
|
||||
params = _extract_params(fn)
|
||||
for param in params:
|
||||
param_desc = _parse_param_docs(doc)
|
||||
if param["name"] in param_desc:
|
||||
param["description"] = param_desc[param["name"]]
|
||||
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"method": method_name,
|
||||
"description": description,
|
||||
"long_description": long_description,
|
||||
"params": params,
|
||||
}
|
||||
return_type = _format_type_hint(hints.get("return"))
|
||||
if return_type:
|
||||
result["return_type"] = return_type
|
||||
return_description = _parse_return_doc(doc)
|
||||
if return_description:
|
||||
result["return_description"] = return_description
|
||||
return result
|
||||
|
||||
|
||||
def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict:
|
||||
"""Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method."""
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
return {
|
||||
key: _coerce_shape_value(value, hints.get(key), model)
|
||||
for key, value in raw_kwargs.items()
|
||||
if key in sig.parameters and key != "self"
|
||||
}
|
||||
|
||||
|
||||
def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any:
|
||||
"""Convert a single JSON-parsed value to the correct Python type."""
|
||||
import typing
|
||||
|
||||
if hint is None or value is None:
|
||||
return value
|
||||
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
|
||||
# Optional[X] / Union — try each non-None branch in order
|
||||
if origin is typing.Union:
|
||||
if value is None:
|
||||
return None
|
||||
for t in (a for a in args if a is not type(None)):
|
||||
try:
|
||||
return _coerce_shape_value(value, t, model)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return value
|
||||
|
||||
# entity_instance: resolve integer or "#N" string step ID
|
||||
if hint is ifcopenshell.entity_instance or (
|
||||
isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance)
|
||||
):
|
||||
entity_id = int(str(value).lstrip("#"))
|
||||
entity = model.by_id(entity_id)
|
||||
if entity is None:
|
||||
raise ValueError(f"Entity #{entity_id} not found in model")
|
||||
return entity
|
||||
|
||||
# Sequence[entity_instance]: resolve each element in the list
|
||||
import collections.abc
|
||||
|
||||
if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str):
|
||||
if args and (
|
||||
args[0] is ifcopenshell.entity_instance
|
||||
or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance))
|
||||
):
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_coerce_shape_value(v, args[0], model) for v in value]
|
||||
|
||||
# bool: JSON gives actual bools; also accept string representations
|
||||
if hint is bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in ("true", "1", "yes")
|
||||
|
||||
# Everything else (float, int, VectorType lists, dicts, Literals) passes through
|
||||
return value
|
||||
|
||||
|
||||
class IfcSessionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class IfcSession:
|
||||
"""In-memory IFC session (no FastMCP dependency).
|
||||
|
||||
Designed to work in:
|
||||
- FastMCP server (single global session)
|
||||
- Embedded runtimes like Pyodide (one session per browser tab/worker)
|
||||
"""
|
||||
|
||||
model: ifcopenshell.file | None = None
|
||||
model_path: str | None = None
|
||||
|
||||
# -----------------
|
||||
# Session lifecycle
|
||||
# -----------------
|
||||
def _require_model(self) -> ifcopenshell.file:
|
||||
if self.model is None:
|
||||
raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.")
|
||||
return self.model
|
||||
|
||||
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
|
||||
"""Create a new empty IFC model in memory."""
|
||||
self.model = ifcopenshell.file(schema=schema)
|
||||
self.model_path = None
|
||||
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
|
||||
|
||||
def ifc_load(self, path: str) -> str:
|
||||
"""Open an IFC file into memory. Returns confirmation string."""
|
||||
self.model = ifcopenshell.open(path)
|
||||
self.model_path = path
|
||||
count = sum(1 for _ in self.model)
|
||||
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
|
||||
|
||||
def ifc_save(self, path: str = "") -> str:
|
||||
"""Write the in-memory model to disk. Empty path overwrites the original file."""
|
||||
model = self._require_model()
|
||||
target = path if path else self.model_path
|
||||
if not target:
|
||||
raise IfcSessionError("No path specified and no original path available.")
|
||||
model.write(target)
|
||||
return f"Saved to {target}"
|
||||
|
||||
def ifc_reset(self) -> dict[str, Any]:
|
||||
"""Drop the in-memory model."""
|
||||
self.model = None
|
||||
self.model_path = None
|
||||
return {"ok": True}
|
||||
|
||||
# -------------
|
||||
# Query tools
|
||||
# -------------
|
||||
def ifc_summary(self) -> dict[str, Any]:
|
||||
"""Model overview: schema, entity counts, project info."""
|
||||
return summary.summary(self._require_model())
|
||||
|
||||
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
|
||||
return tree.tree(self._require_model())
|
||||
|
||||
def ifc_info(self, element_id: int) -> dict[str, Any]:
|
||||
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return info.info(model, element)
|
||||
|
||||
def ifc_select(self, query: str) -> list[dict[str, Any]]:
|
||||
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
|
||||
return select.select(self._require_model(), query)
|
||||
|
||||
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return relations.relations(model, element, traverse=traverse if traverse else None)
|
||||
|
||||
def ifc_clash(
|
||||
self,
|
||||
element_id: int,
|
||||
clearance: float = 0.0,
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return clash_mod.clash(
|
||||
model,
|
||||
element,
|
||||
clearance=clearance if clearance and clearance > 0.0 else None,
|
||||
tolerance=tolerance,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
def ifc_contexts(self) -> list[dict[str, Any]]:
|
||||
"""List all geometric representation contexts and subcontexts with their step IDs."""
|
||||
return contexts_mod.contexts(self._require_model())
|
||||
|
||||
def ifc_materials(self) -> list[dict[str, Any]]:
|
||||
"""List all materials and material sets (layers, constituents, profiles)."""
|
||||
return materials_mod.materials(self._require_model())
|
||||
|
||||
# ------------------------
|
||||
# Edit discovery + execute
|
||||
# ------------------------
|
||||
def ifc_list(self, module: str = "") -> list[dict]:
|
||||
"""List all API modules, or functions within a module. Empty module = all modules."""
|
||||
return list_functions(module) if module else list_modules()
|
||||
|
||||
def ifc_docs(self, function_path: str) -> dict:
|
||||
"""Show full documentation for an API function. Input format: 'module.function'."""
|
||||
module, function = function_path.split(".", 1)
|
||||
return function_docs(module, function)
|
||||
|
||||
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
|
||||
"""Execute an ifcopenshell.api mutation.
|
||||
|
||||
params may be:
|
||||
- JSON string
|
||||
- dict (from tool calling / JS)
|
||||
- JsProxy (handled upstream in embedded.py)
|
||||
"""
|
||||
model = self._require_model()
|
||||
module, function = function_path.split(".", 1)
|
||||
|
||||
if isinstance(params, str):
|
||||
raw_kwargs = json.loads(params) if params.strip() else {}
|
||||
elif isinstance(params, dict):
|
||||
raw_kwargs = params
|
||||
else:
|
||||
# e.g. list/None/etc
|
||||
raw_kwargs = dict(params) if params is not None else {}
|
||||
|
||||
res = run_api(model, module, function, raw_kwargs)
|
||||
return _jsonify(res)
|
||||
|
||||
# ------------------------
|
||||
# Extended query + edit tools
|
||||
# ------------------------
|
||||
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
|
||||
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
|
||||
return validate_mod.validate(self._require_model(), express_rules=express_rules)
|
||||
|
||||
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List work schedules and nested tasks from the model.
|
||||
|
||||
max_depth limits subtask expansion (None = unlimited). At the cutoff,
|
||||
subtasks is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return schedule.schedule(self._require_model(), max_depth=max_depth)
|
||||
|
||||
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List cost schedules and nested cost items from the model.
|
||||
|
||||
max_depth limits cost item expansion (None = unlimited). At the cutoff,
|
||||
subitems is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return cost_mod.cost(self._require_model(), max_depth=max_depth)
|
||||
|
||||
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
|
||||
"""Return IFC class documentation for entity_type using the model's schema version."""
|
||||
return schema.schema(self._require_model(), entity_type)
|
||||
|
||||
def ifc_plot(
|
||||
self,
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "floorplan",
|
||||
width_mm: float = 297.0,
|
||||
height_mm: float = 420.0,
|
||||
scale: float = 1.0 / 100.0,
|
||||
png_width: int = 1024,
|
||||
png_height: int = 1024,
|
||||
output_format: str = "png",
|
||||
) -> bytes:
|
||||
"""Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes.
|
||||
|
||||
Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG
|
||||
when output_format is 'png'.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict plotted elements
|
||||
(e.g. ``'IfcWall'``). Omit to plot the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are faded to 10% opacity so the subject stands out.
|
||||
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
|
||||
``section``, or ``auto``.
|
||||
:param width_mm: Paper width in mm (default 297 = A4).
|
||||
:param height_mm: Paper height in mm (default 420 = A4).
|
||||
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
|
||||
:param png_width: Raster output width in pixels (default 1024).
|
||||
:param png_height: Raster output height in pixels (default 1024).
|
||||
:param output_format: ``'svg'`` or ``'png'`` (default ``'png'``).
|
||||
:return: SVG or PNG bytes depending on output_format.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return plot_mod.plot(
|
||||
model,
|
||||
output_format=output_format,
|
||||
selector=selector if selector else None,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
scale=scale,
|
||||
png_width=png_width,
|
||||
png_height=png_height,
|
||||
)
|
||||
|
||||
def ifc_render(
|
||||
self,
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "iso",
|
||||
) -> bytes:
|
||||
"""Render the loaded model to a PNG image and return raw bytes.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict rendered elements
|
||||
(e.g. ``'IfcWall'``). Omit to render the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are rendered in translucent grey.
|
||||
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
|
||||
``east``, or ``west``.
|
||||
:return: PNG image as raw bytes.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return render_mod.render(
|
||||
model,
|
||||
selector=selector if selector else None,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
)
|
||||
|
||||
# ------------------------
|
||||
# Shape builder tools
|
||||
# ------------------------
|
||||
def ifc_shape_list(self) -> list[dict]:
|
||||
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
|
||||
return _list_shape_methods()
|
||||
|
||||
def ifc_shape_docs(self, method: str) -> dict:
|
||||
"""Full documentation for a ShapeBuilder method: params, types, return value."""
|
||||
return _shape_method_docs(method)
|
||||
|
||||
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
|
||||
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
|
||||
|
||||
params is a JSON string of keyword arguments. Pass entity references as integer
|
||||
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
|
||||
"""
|
||||
model = self._require_model()
|
||||
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
if method.startswith("_"):
|
||||
raise IfcSessionError(f"Private method '{method}' is not accessible")
|
||||
fn = getattr(ShapeBuilder, method, None)
|
||||
if fn is None:
|
||||
return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"}
|
||||
|
||||
if isinstance(params, str):
|
||||
raw_kwargs = json.loads(params) if params.strip() else {}
|
||||
elif isinstance(params, dict):
|
||||
raw_kwargs = params
|
||||
else:
|
||||
raw_kwargs = {}
|
||||
|
||||
try:
|
||||
coerced = _coerce_shape_params(fn, raw_kwargs, model)
|
||||
result = fn(ShapeBuilder(model), **coerced)
|
||||
return {"ok": True, "result": _jsonify(result)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place; call ifc_save() after.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return run_quantify(model, rule, selector=selector if selector else None)
|
||||
|
||||
# ------------------------
|
||||
# Generic dispatcher + tool specs for LLMs
|
||||
# ------------------------
|
||||
def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
|
||||
args = args or {}
|
||||
fn = getattr(self, name, None)
|
||||
if not callable(fn):
|
||||
raise IfcSessionError(f"Unknown tool: {name}")
|
||||
return _jsonify(fn(**args))
|
||||
|
||||
def openai_tools(self) -> list[dict[str, Any]]:
|
||||
"""Tool schemas in the OpenAI 'Responses API' format (type=function)."""
|
||||
# Keep schemas tight so the model calls tools correctly.
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_new",
|
||||
"description": "Create a new empty IFC model in memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_summary",
|
||||
"description": "Get a concise overview of the loaded IFC model.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_tree",
|
||||
"description": "Get the full spatial hierarchy tree.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_select",
|
||||
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_info",
|
||||
"description": "Inspect an entity by STEP id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"element_id": {"type": "integer"}},
|
||||
"required": ["element_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_relations",
|
||||
"description": "Get relationships for an element. traverse='up' walks to IfcProject.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
|
||||
"required": ["element_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_clash",
|
||||
"description": "Run clash/clearance checks for an element.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"element_id": {"type": "integer"},
|
||||
"clearance": {"type": "number"},
|
||||
"tolerance": {"type": "number"},
|
||||
"scope": {"type": "string", "description": "storey or all"},
|
||||
},
|
||||
"required": ["element_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_contexts",
|
||||
"description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_materials",
|
||||
"description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_list",
|
||||
"description": "List ifcopenshell.api modules or functions within a module.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"module": {"type": "string"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_docs",
|
||||
"description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"function_path": {"type": "string"}},
|
||||
"required": ["function_path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_edit",
|
||||
"description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
|
||||
"required": ["function_path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_validate",
|
||||
"description": "Validate the loaded model. Returns valid bool and list of issues.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_schedule",
|
||||
"description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Max levels of subtask expansion (omit for unlimited)",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_cost",
|
||||
"description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Max levels of cost item expansion (omit for unlimited)",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_schema",
|
||||
"description": "Return IFC class documentation for an entity type.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}},
|
||||
"required": ["entity_type"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_quantify",
|
||||
"description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "ifcopenshell selector to restrict elements (default: all IfcElement)",
|
||||
},
|
||||
},
|
||||
"required": ["rule"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_render",
|
||||
"description": (
|
||||
"Render the loaded IFC model to a PNG image for visual inspection. "
|
||||
"Use selector to restrict which elements are rendered (e.g. a single storey). "
|
||||
"Use element_ids to highlight elements against a greyed-out background. "
|
||||
"Returns base64-encoded PNG bytes."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
|
||||
"element_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "Step IDs of elements to highlight",
|
||||
},
|
||||
"view": {
|
||||
"type": "string",
|
||||
"enum": ["iso", "top", "south", "north", "east", "west"],
|
||||
"description": "Camera angle (default: iso)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
|
||||
session = IfcSession()
|
||||
|
||||
# Optional imports only available under Pyodide
|
||||
try:
|
||||
from pyodide.ffi import JsProxy, to_py # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
JsProxy = None # type: ignore
|
||||
to_py = None # type: ignore
|
||||
|
||||
|
||||
def _coerce_args(args: Any) -> dict[str, Any]:
|
||||
"""Convert JS objects / JsProxy / mappings into a real Python dict."""
|
||||
if args is None:
|
||||
return {}
|
||||
|
||||
# Pyodide: JS object arrives as JsProxy; convert recursively to Python.
|
||||
if JsProxy is not None and isinstance(args, JsProxy):
|
||||
# dict_converter=dict ensures JS object -> Python dict (not Map)
|
||||
return to_py(args, dict_converter=dict)
|
||||
|
||||
# Already a Python dict
|
||||
if isinstance(args, dict):
|
||||
return args
|
||||
|
||||
# Any Mapping-like object
|
||||
if isinstance(args, Mapping):
|
||||
return dict(args)
|
||||
|
||||
# Last resort: try dict() coercion
|
||||
try:
|
||||
return dict(args)
|
||||
except Exception as e:
|
||||
raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e
|
||||
|
||||
|
||||
def tools_openai() -> list[dict[str, Any]]:
|
||||
return session.openai_tools()
|
||||
|
||||
|
||||
def call_tool(name: str, args: Any = None) -> dict[str, Any]:
|
||||
"""
|
||||
Non-throwing tool dispatcher.
|
||||
Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...}
|
||||
"""
|
||||
try:
|
||||
py_args = _coerce_args(args)
|
||||
data = session.dispatch(name, py_args)
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
except Exception as e:
|
||||
# Keep it short; avoid full tracebacks in tool output unless debugging.
|
||||
return {"ok": False, "error_type": type(e).__name__, "error": str(e)}
|
||||
@@ -0,0 +1,231 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP # type: ignore
|
||||
from mcp.types import ImageContent # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
FastMCP = None # type: ignore
|
||||
ImageContent = None # type: ignore
|
||||
|
||||
|
||||
def build_server() -> Any:
|
||||
"""Create the FastMCP server if the dependency is available."""
|
||||
if FastMCP is None:
|
||||
raise ImportError(
|
||||
"FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)."
|
||||
)
|
||||
|
||||
session = IfcSession()
|
||||
|
||||
server = FastMCP(
|
||||
name="ifc-mcp",
|
||||
instructions=(
|
||||
"MCP server for querying and editing IFC building models. "
|
||||
"Load a file first with ifc_load, then use query/edit tools. "
|
||||
"Save changes with ifc_save."
|
||||
),
|
||||
)
|
||||
|
||||
# ---- Lifecycle ----
|
||||
@server.tool()
|
||||
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
|
||||
return session.ifc_new(schema=schema)
|
||||
|
||||
@server.tool()
|
||||
def ifc_load(path: str) -> str:
|
||||
return session.ifc_load(path)
|
||||
|
||||
@server.tool()
|
||||
def ifc_save(path: str = "") -> str:
|
||||
return session.ifc_save(path)
|
||||
|
||||
@server.tool()
|
||||
def ifc_reset() -> dict[str, Any]:
|
||||
return session.ifc_reset()
|
||||
|
||||
# ---- Query ----
|
||||
@server.tool()
|
||||
def ifc_summary() -> dict[str, Any]:
|
||||
return session.ifc_summary()
|
||||
|
||||
@server.tool()
|
||||
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_tree()
|
||||
|
||||
@server.tool()
|
||||
def ifc_info(element_id: int) -> dict[str, Any]:
|
||||
return session.ifc_info(element_id)
|
||||
|
||||
@server.tool()
|
||||
def ifc_select(query: str) -> list[dict[str, Any]]:
|
||||
return session.ifc_select(query)
|
||||
|
||||
@server.tool()
|
||||
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_relations(element_id, traverse=traverse)
|
||||
|
||||
@server.tool()
|
||||
def ifc_clash(
|
||||
element_id: int,
|
||||
clearance: float = 0.0,
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
return session.ifc_clash(
|
||||
element_id=element_id,
|
||||
clearance=clearance,
|
||||
tolerance=tolerance,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def ifc_contexts() -> list[dict[str, Any]]:
|
||||
return session.ifc_contexts()
|
||||
|
||||
@server.tool()
|
||||
def ifc_materials() -> list[dict[str, Any]]:
|
||||
return session.ifc_materials()
|
||||
|
||||
# ---- Edit ----
|
||||
@server.tool()
|
||||
def ifc_list(module: str = "") -> list[dict]:
|
||||
return session.ifc_list(module=module)
|
||||
|
||||
@server.tool()
|
||||
def ifc_docs(function_path: str) -> dict:
|
||||
return session.ifc_docs(function_path=function_path)
|
||||
|
||||
@server.tool()
|
||||
def ifc_edit(function_path: str, params: str = "{}") -> dict:
|
||||
return session.ifc_edit(function_path=function_path, params=params)
|
||||
|
||||
# ---- Extended query + edit ----
|
||||
@server.tool()
|
||||
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
|
||||
return session.ifc_validate(express_rules=express_rules)
|
||||
|
||||
@server.tool()
|
||||
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_schedule(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_cost(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
def ifc_schema(entity_type: str) -> dict[str, Any]:
|
||||
return session.ifc_schema(entity_type=entity_type)
|
||||
|
||||
@server.tool()
|
||||
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||
return session.ifc_quantify(rule=rule, selector=selector)
|
||||
|
||||
# ---- Shape builder ----
|
||||
@server.tool()
|
||||
def ifc_shape_list() -> list[dict]:
|
||||
return session.ifc_shape_list()
|
||||
|
||||
@server.tool()
|
||||
def ifc_shape_docs(method: str) -> dict:
|
||||
return session.ifc_shape_docs(method=method)
|
||||
|
||||
@server.tool()
|
||||
def ifc_shape(method: str, params: str = "{}") -> dict:
|
||||
return session.ifc_shape(method=method, params=params)
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
def ifc_plot(
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "floorplan",
|
||||
width_mm: float = 297.0,
|
||||
height_mm: float = 420.0,
|
||||
scale: float = 1.0 / 100.0,
|
||||
png_width: int = 1024,
|
||||
png_height: int = 1024,
|
||||
output_path: str = "",
|
||||
) -> list[ImageContent]:
|
||||
"""Generate a 2D technical drawing of the loaded IFC model.
|
||||
|
||||
Returns an inline PNG image (floor plan, elevation, or section) that the
|
||||
LLM can inspect to understand the 2D layout of the model. If
|
||||
``output_path`` is provided the drawing is also saved to disk — as SVG
|
||||
when the path ends in ``.svg``, otherwise as PNG.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict plotted elements
|
||||
(e.g. ``'IfcWall'``). Omit to plot the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are faded so the subject stands out.
|
||||
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
|
||||
``section``, or ``auto``.
|
||||
:param width_mm: Paper width in mm (default 297 = A4 landscape width).
|
||||
:param height_mm: Paper height in mm (default 420 = A4 landscape height).
|
||||
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
|
||||
:param png_width: Raster output width in pixels (default 1024).
|
||||
:param png_height: Raster output height in pixels (default 1024).
|
||||
:param output_path: Optional file path to save the drawing to disk.
|
||||
"""
|
||||
png_bytes = session.ifc_plot(
|
||||
selector=selector,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
scale=scale,
|
||||
png_width=png_width,
|
||||
png_height=png_height,
|
||||
output_format="png",
|
||||
)
|
||||
if output_path:
|
||||
if output_path.endswith(".svg"):
|
||||
svg_bytes = session.ifc_plot(
|
||||
selector=selector,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
scale=scale,
|
||||
output_format="svg",
|
||||
)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(svg_bytes)
|
||||
else:
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
def ifc_render(
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "iso",
|
||||
output_path: str = "",
|
||||
) -> list[ImageContent]:
|
||||
"""Render the loaded IFC model to a PNG image.
|
||||
|
||||
Returns an inline image the LLM can inspect to understand the spatial
|
||||
layout of the model or a specific element in context. If
|
||||
``output_path`` is provided the PNG is also saved to that file path.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict rendered elements
|
||||
(e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
|
||||
Omit to render the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are rendered in translucent grey so the subject stands out.
|
||||
:param view: Camera angle — ``iso`` (default), ``top``, ``south``,
|
||||
``north``, ``east``, or ``west``.
|
||||
:param output_path: Optional file path to save the PNG to disk.
|
||||
"""
|
||||
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
|
||||
if output_path:
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
|
||||
|
||||
return server
|
||||
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ifcmcp"
|
||||
version = "0.0.0"
|
||||
authors = [
|
||||
{ name="Bruno Postle", email="bruno@postle.net" },
|
||||
]
|
||||
description = "MCP server for querying and editing IFC building models"
|
||||
readme = "README.md"
|
||||
keywords = ["IFC", "BIM", "MCP"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
mcp = ["mcp"]
|
||||
|
||||
[project.scripts]
|
||||
ifcmcp = "ifcmcp.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://ifcopenshell.org"
|
||||
Documentation = "https://docs.ifcopenshell.org"
|
||||
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["ifcmcp*"]
|
||||
exclude = ["test*"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -0,0 +1,59 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
return IfcSession()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model():
|
||||
"""IFC4 model with a spatial hierarchy, a wall, and a slab."""
|
||||
f = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||
ifcopenshell.api.unit.assign_unit(f)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||
|
||||
slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_file(model, tmp_path):
|
||||
"""Write the model fixture to a temp file and return the path."""
|
||||
path = tmp_path / "test.ifc"
|
||||
model.write(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loaded_session(model):
|
||||
"""An IfcSession with an in-memory model already loaded (no file path)."""
|
||||
s = IfcSession()
|
||||
s.model = model
|
||||
return s
|
||||
@@ -0,0 +1,96 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSession, IfcSessionError
|
||||
|
||||
|
||||
class TestNoModel:
|
||||
def test_edit_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_edit("root.create_entity")
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_list_all_modules(self, loaded_session):
|
||||
result = loaded_session.ifc_list()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
modules = [m["module"] for m in result]
|
||||
assert "root" in modules
|
||||
assert "spatial" in modules
|
||||
|
||||
def test_list_module_functions(self, loaded_session):
|
||||
result = loaded_session.ifc_list(module="root")
|
||||
assert isinstance(result, list)
|
||||
names = [f["name"] for f in result]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_list_empty_string_returns_modules(self, loaded_session):
|
||||
result = loaded_session.ifc_list(module="")
|
||||
assert isinstance(result, list)
|
||||
assert any(m["module"] == "root" for m in result)
|
||||
|
||||
|
||||
class TestDocs:
|
||||
def test_docs_create_entity(self, loaded_session):
|
||||
result = loaded_session.ifc_docs("root.create_entity")
|
||||
assert result["module"] == "root"
|
||||
assert result["function"] == "create_entity"
|
||||
assert "params" in result
|
||||
|
||||
def test_docs_bad_format(self, loaded_session):
|
||||
with pytest.raises(ValueError):
|
||||
loaded_session.ifc_docs("no_dot_here")
|
||||
|
||||
|
||||
class TestEdit:
|
||||
def test_create_entity(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcWall"
|
||||
assert result["result"]["name"] == "NewWall"
|
||||
|
||||
def test_create_entity_default_params(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.create_entity", "{}")
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_unknown_function(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.nonexistent", "{}")
|
||||
assert result["ok"] is False
|
||||
assert "Cannot find" in result["error"]
|
||||
|
||||
def test_unknown_parameter(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"}))
|
||||
assert result["ok"] is False
|
||||
assert "Unknown parameter" in result["error"]
|
||||
|
||||
def test_bad_json(self, loaded_session):
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
loaded_session.ifc_edit("root.create_entity", "not json")
|
||||
|
||||
def test_edit_does_not_save(self, loaded_session, tmp_path):
|
||||
"""Verify that ifc_edit mutates the in-memory model but does not write to disk."""
|
||||
path = str(tmp_path / "test.ifc")
|
||||
loaded_session.model.write(path)
|
||||
loaded_session.model_path = path
|
||||
|
||||
before_count = sum(1 for _ in loaded_session.model)
|
||||
loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
|
||||
after_count = sum(1 for _ in loaded_session.model)
|
||||
assert after_count == before_count + 1
|
||||
|
||||
on_disk = ifcopenshell.open(path)
|
||||
disk_count = sum(1 for _ in on_disk)
|
||||
assert disk_count == before_count
|
||||
|
||||
def test_assign_container(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
storey = loaded_session.model.by_type("IfcBuildingStorey")[0]
|
||||
result = loaded_session.ifc_edit(
|
||||
"spatial.assign_container",
|
||||
json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
|
||||
)
|
||||
assert result["ok"] is True
|
||||
@@ -0,0 +1,113 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSessionError
|
||||
|
||||
|
||||
class TestNoModel:
|
||||
"""All query tools should fail when no model is loaded."""
|
||||
|
||||
def test_summary_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_summary()
|
||||
|
||||
def test_tree_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_tree()
|
||||
|
||||
def test_info_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_info(1)
|
||||
|
||||
def test_select_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_select("IfcWall")
|
||||
|
||||
def test_relations_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_relations(1)
|
||||
|
||||
|
||||
class TestSummary:
|
||||
def test_schema(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["schema"] == "IFC4"
|
||||
|
||||
def test_total_entities(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["total_entities"] > 0
|
||||
|
||||
def test_project_name(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["project"]["name"] == "TestProject"
|
||||
|
||||
def test_type_counts(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["types"]["IfcWall"] == 1
|
||||
assert result["types"]["IfcSlab"] == 1
|
||||
|
||||
|
||||
class TestTree:
|
||||
def test_root_is_project(self, loaded_session):
|
||||
result = loaded_session.ifc_tree()
|
||||
assert result["type"] == "IfcProject"
|
||||
assert result["name"] == "TestProject"
|
||||
|
||||
def test_hierarchy_depth(self, loaded_session):
|
||||
result = loaded_session.ifc_tree()
|
||||
site = result["children"][0]
|
||||
assert site["type"] == "IfcSite"
|
||||
building = site["children"][0]
|
||||
assert building["type"] == "IfcBuilding"
|
||||
storey = building["children"][0]
|
||||
assert storey["type"] == "IfcBuildingStorey"
|
||||
|
||||
|
||||
class TestInfo:
|
||||
def test_wall_info(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_info(wall.id())
|
||||
assert result["id"] == wall.id()
|
||||
assert result["type"] == "IfcWall"
|
||||
|
||||
def test_invalid_id(self, loaded_session):
|
||||
with pytest.raises(Exception):
|
||||
loaded_session.ifc_info(999999)
|
||||
|
||||
|
||||
class TestSelect:
|
||||
def test_select_walls(self, loaded_session):
|
||||
result = loaded_session.ifc_select("IfcWall")
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "IfcWall"
|
||||
assert result[0]["name"] == "Wall001"
|
||||
|
||||
def test_select_slabs(self, loaded_session):
|
||||
result = loaded_session.ifc_select("IfcSlab")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Slab001"
|
||||
|
||||
def test_select_no_match(self, loaded_session):
|
||||
result = loaded_session.ifc_select("IfcWindow")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestRelations:
|
||||
def test_wall_relations(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_relations(wall.id())
|
||||
assert result["id"] == wall.id()
|
||||
assert result["type"] == "IfcWall"
|
||||
assert "hierarchy" in result
|
||||
|
||||
def test_traverse_up(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_relations(wall.id(), traverse="up")
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["type"] == "IfcWall"
|
||||
assert result[-1]["type"] == "IfcProject"
|
||||
|
||||
def test_traverse_empty_string_means_no_traverse(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_relations(wall.id(), traverse="")
|
||||
assert isinstance(result, dict)
|
||||
@@ -0,0 +1,106 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ifcmcp.server import build_server
|
||||
|
||||
|
||||
class TestServerRegistration:
|
||||
def test_server_name(self):
|
||||
server = build_server()
|
||||
assert server.name == "ifc-mcp"
|
||||
|
||||
def test_all_tools_registered(self):
|
||||
server = build_server()
|
||||
tools = [t.name for t in server._tool_manager.list_tools()]
|
||||
expected = [
|
||||
"ifc_load",
|
||||
"ifc_save",
|
||||
"ifc_summary",
|
||||
"ifc_tree",
|
||||
"ifc_info",
|
||||
"ifc_select",
|
||||
"ifc_relations",
|
||||
"ifc_clash",
|
||||
"ifc_list",
|
||||
"ifc_docs",
|
||||
"ifc_edit",
|
||||
]
|
||||
for name in expected:
|
||||
assert name in tools, f"Tool {name} not registered"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_fns():
|
||||
"""Return a dict of tool name → raw function from a freshly built server."""
|
||||
server = build_server()
|
||||
return {t.name: t.fn for t in server._tool_manager.list_tools()}
|
||||
|
||||
|
||||
PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE"
|
||||
SVG_FAKE = b"<svg>FAKE</svg>"
|
||||
|
||||
|
||||
class TestRenderOutputPath:
|
||||
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
|
||||
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="")
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_png_output_path_writes_file(self, tool_fns, tmp_path):
|
||||
out = str(tmp_path / "render.png")
|
||||
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out)
|
||||
assert open(out, "rb").read() == PNG_FAKE
|
||||
|
||||
|
||||
class TestPlotOutputPath:
|
||||
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
|
||||
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_plot"](
|
||||
selector="",
|
||||
element_ids=None,
|
||||
view="floorplan",
|
||||
width_mm=297.0,
|
||||
height_mm=420.0,
|
||||
scale=0.01,
|
||||
png_width=1024,
|
||||
png_height=1024,
|
||||
output_path="",
|
||||
)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_png_output_path_writes_png(self, tool_fns, tmp_path):
|
||||
out = str(tmp_path / "plot.png")
|
||||
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_plot"](
|
||||
selector="",
|
||||
element_ids=None,
|
||||
view="floorplan",
|
||||
width_mm=297.0,
|
||||
height_mm=420.0,
|
||||
scale=0.01,
|
||||
png_width=1024,
|
||||
png_height=1024,
|
||||
output_path=out,
|
||||
)
|
||||
assert open(out, "rb").read() == PNG_FAKE
|
||||
|
||||
def test_svg_output_path_writes_svg(self, tool_fns, tmp_path):
|
||||
out = str(tmp_path / "plot.svg")
|
||||
# ifc_plot is called twice: once with "png" for the inline image,
|
||||
# once with "svg" for the file.
|
||||
with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]):
|
||||
tool_fns["ifc_plot"](
|
||||
selector="",
|
||||
element_ids=None,
|
||||
view="floorplan",
|
||||
width_mm=297.0,
|
||||
height_mm=420.0,
|
||||
scale=0.01,
|
||||
png_width=1024,
|
||||
png_height=1024,
|
||||
output_path=out,
|
||||
)
|
||||
assert open(out, "rb").read() == SVG_FAKE
|
||||
@@ -0,0 +1,62 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from unittest.mock import patch
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSession, IfcSessionError
|
||||
|
||||
|
||||
class TestLoad:
|
||||
def test_load_file(self, session, model_file):
|
||||
result = session.ifc_load(model_file)
|
||||
assert "IFC4" in result
|
||||
assert session.model is not None
|
||||
assert session.model_path == model_file
|
||||
|
||||
def test_load_sets_entity_count(self, session, model_file):
|
||||
result = session.ifc_load(model_file)
|
||||
assert "entities" in result
|
||||
|
||||
def test_load_nonexistent_file(self, session):
|
||||
with pytest.raises(Exception):
|
||||
session.ifc_load("/nonexistent/path/model.ifc")
|
||||
|
||||
|
||||
class TestSave:
|
||||
def test_save_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_save()
|
||||
|
||||
def test_save_overwrites_original(self, session, model_file):
|
||||
session.ifc_load(model_file)
|
||||
result = session.ifc_save()
|
||||
assert model_file in result
|
||||
|
||||
def test_save_to_new_path(self, session, model_file, tmp_path):
|
||||
session.ifc_load(model_file)
|
||||
new_path = str(tmp_path / "output.ifc")
|
||||
result = session.ifc_save(new_path)
|
||||
assert new_path in result
|
||||
reloaded = ifcopenshell.open(new_path)
|
||||
assert reloaded.schema == "IFC4"
|
||||
|
||||
def test_save_no_path_no_original(self, loaded_session):
|
||||
with pytest.raises(IfcSessionError, match="No path specified"):
|
||||
loaded_session.ifc_save()
|
||||
|
||||
|
||||
class TestIfcPlotOutputFormat:
|
||||
"""ifc_plot should pass output_format through to the underlying plot function."""
|
||||
|
||||
def test_default_output_format_is_png(self, loaded_session):
|
||||
with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot:
|
||||
loaded_session.ifc_plot()
|
||||
mock_plot.assert_called_once()
|
||||
assert mock_plot.call_args.kwargs["output_format"] == "png"
|
||||
|
||||
def test_svg_output_format(self, loaded_session):
|
||||
with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot:
|
||||
result = loaded_session.ifc_plot(output_format="svg")
|
||||
assert result == b"SVG_FAKE"
|
||||
assert mock_plot.call_args.kwargs["output_format"] == "svg"
|
||||
@@ -0,0 +1,141 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSessionError
|
||||
|
||||
|
||||
class TestShapeList:
|
||||
def test_returns_list(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_has_expected_methods(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
names = [m["method"] for m in result]
|
||||
assert "polyline" in names
|
||||
assert "rectangle" in names
|
||||
assert "extrude" in names
|
||||
assert "profile" in names
|
||||
assert "get_representation" in names
|
||||
|
||||
def test_well_documented_methods_have_descriptions(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
by_name = {m["method"]: m for m in result}
|
||||
# These methods have detailed docstrings
|
||||
for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"):
|
||||
assert by_name[name]["description"], f"'{name}' has no description"
|
||||
|
||||
def test_no_private_methods(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
assert not any(m["method"].startswith("_") for m in result)
|
||||
|
||||
def test_does_not_require_model(self, session):
|
||||
# ifc_shape_list is pure introspection — no model needed
|
||||
result = session.ifc_shape_list()
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestShapeDocs:
|
||||
def test_extrude_docs(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_docs("extrude")
|
||||
assert result["method"] == "extrude"
|
||||
assert result["description"]
|
||||
assert "params" in result
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "profile_or_curve" in param_names
|
||||
assert "magnitude" in param_names
|
||||
|
||||
def test_has_return_type(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_docs("rectangle")
|
||||
assert "return_type" in result
|
||||
|
||||
def test_has_param_descriptions(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_docs("polyline")
|
||||
params_with_desc = [p for p in result["params"] if "description" in p]
|
||||
assert len(params_with_desc) > 0
|
||||
|
||||
def test_unknown_method(self, loaded_session):
|
||||
with pytest.raises(ValueError, match="no method"):
|
||||
loaded_session.ifc_shape_docs("nonexistent_method")
|
||||
|
||||
def test_private_method_rejected(self, loaded_session):
|
||||
with pytest.raises(ValueError):
|
||||
loaded_session.ifc_shape_docs("__init__")
|
||||
|
||||
def test_does_not_require_model(self, session):
|
||||
result = session.ifc_shape_docs("circle")
|
||||
assert result["method"] == "circle"
|
||||
|
||||
|
||||
class TestShapeExecute:
|
||||
def test_rectangle(self, loaded_session):
|
||||
result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcIndexedPolyCurve"
|
||||
|
||||
def test_circle(self, loaded_session):
|
||||
result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcCircle"
|
||||
|
||||
def test_extrude_chained_from_rectangle(self, loaded_session):
|
||||
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
|
||||
rect_id = rect["result"]["id"]
|
||||
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcExtrudedAreaSolid"
|
||||
|
||||
def test_entity_id_as_integer(self, loaded_session):
|
||||
"""Entity IDs should be accepted as plain integers (from JSON)."""
|
||||
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]}))
|
||||
rect_id = rect["result"]["id"]
|
||||
# Pass as int, not string
|
||||
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0}))
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_rotate_2d_point_returns_list(self, loaded_session):
|
||||
"""Methods returning numpy arrays should give back plain lists."""
|
||||
result = loaded_session.ifc_shape(
|
||||
"rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True})
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert isinstance(result["result"], list)
|
||||
assert len(result["result"]) == 2
|
||||
|
||||
def test_set_polyline_coords_returns_none(self, loaded_session):
|
||||
"""In-place methods that return None should give ok=True, result=None."""
|
||||
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]}))
|
||||
rect_id = rect["result"]["id"]
|
||||
result = loaded_session.ifc_shape(
|
||||
"set_polyline_coords",
|
||||
json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}),
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["result"] is None
|
||||
|
||||
def test_unknown_method(self, loaded_session):
|
||||
result = loaded_session.ifc_shape("nonexistent_method", "{}")
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_private_method_rejected(self, loaded_session):
|
||||
with pytest.raises(IfcSessionError):
|
||||
loaded_session.ifc_shape("__init__", "{}")
|
||||
|
||||
def test_no_model_raises(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_shape("rectangle", "{}")
|
||||
|
||||
def test_params_as_dict(self, loaded_session):
|
||||
"""params can be passed as a dict (not just a JSON string)."""
|
||||
result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]})
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_error_on_bad_params(self, loaded_session):
|
||||
"""Bad parameters should give ok=False with an error message."""
|
||||
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0}))
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
@@ -775,3 +775,52 @@ responsibility to make sure the geometry is correct.
|
||||
|
||||
# Assign our new body geometry back to our beam
|
||||
ifcopenshell.api.geometry.assign_representation(model, product=beam, representation=representation)
|
||||
|
||||
Moving assemblies
|
||||
-----------------
|
||||
|
||||
When moving an assembly and you want all children to follow, pass
|
||||
``should_transform_children=True``. The default (``False``) rewrites each
|
||||
child's local placement to preserve its world position, so the parent moves
|
||||
but the children stay where they are.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
matrix = numpy.eye(4)
|
||||
matrix[:,3][0:3] = (0, 0, 6)
|
||||
|
||||
# Move the assembly; children travel with it.
|
||||
ifcopenshell.api.geometry.edit_object_placement(model,
|
||||
product=assembly, matrix=matrix, is_si=True,
|
||||
should_transform_children=True)
|
||||
|
||||
Clipping normals convention
|
||||
---------------------------
|
||||
|
||||
The ``normal`` passed to :func:`geometry.clip_solid`,
|
||||
:func:`geometry.clip_solid_bounded`, and the ``clippings`` parameter of
|
||||
:func:`geometry.add_wall_representation` points toward the **removed**
|
||||
material (the discarded side), not toward the kept material.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Clip the top of a wall to a lean-to slope.
|
||||
# normal points upward into the wedge that will be removed.
|
||||
bcr = ifcopenshell.api.geometry.clip_solid(model,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.26],
|
||||
normal=[0.419, 0.0, 0.908])
|
||||
shape_representation.RepresentationType = "Clipping"
|
||||
|
||||
Opening lifecycle
|
||||
-----------------
|
||||
|
||||
``feature.remove_feature`` permanently deletes the feature entity from the
|
||||
model. Any fillings (windows, doors) that occupied the opening become
|
||||
orphaned and must be separately removed via ``root.remove_product``.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Remove a window and its opening from a wall.
|
||||
ifcopenshell.api.root.remove_product(model, product=window)
|
||||
ifcopenshell.api.feature.remove_feature(model, feature=opening)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,11 +22,13 @@ import ifcopenshell.util.element
|
||||
|
||||
|
||||
def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instance) -> None:
|
||||
"""Remove a feature
|
||||
"""Permanently delete a feature element and its void or projection relationship.
|
||||
|
||||
Fillings are retained as orphans. Featured elements remain. Features
|
||||
cannot exist by themselves, so not only is the relationship removed, the
|
||||
feature is also removed.
|
||||
The feature entity (e.g. IfcOpeningElement) is removed from the model
|
||||
along with its IfcRelVoidsElement or IfcRelProjectsElement relationship.
|
||||
The host element (wall, slab, etc.) is unaffected. Any fillings (windows,
|
||||
doors) that occupied the opening become orphaned and must be separately
|
||||
deleted via root.remove_product.
|
||||
|
||||
:param feature: The IfcFeatureElement to remove.
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ geometry extrusions).
|
||||
|
||||
from .. import wrap_usecases
|
||||
from .add_axis_representation import add_axis_representation
|
||||
from .add_topology_representation import add_topology_representation
|
||||
from .add_boolean import add_boolean
|
||||
from .clip_solid import clip_solid
|
||||
from .clip_solid_bounded import clip_solid_bounded
|
||||
from .add_door_representation import add_door_representation
|
||||
from .add_footprint_representation import add_footprint_representation
|
||||
from .add_mesh_representation import add_mesh_representation
|
||||
@@ -50,6 +53,7 @@ from .disconnect_element import disconnect_element
|
||||
from .disconnect_path import disconnect_path
|
||||
from .edit_object_placement import edit_object_placement
|
||||
from .map_representation import map_representation
|
||||
from .copy_representation import copy_representation
|
||||
from .regenerate_wall_representation import regenerate_wall_representation
|
||||
from .remove_boolean import remove_boolean
|
||||
from .remove_representation import remove_representation
|
||||
@@ -60,7 +64,11 @@ wrap_usecases(__path__, __name__)
|
||||
|
||||
__all__ = [
|
||||
"add_axis_representation",
|
||||
"add_topology_representation",
|
||||
"add_boolean",
|
||||
"clip_solid",
|
||||
"clip_solid_bounded",
|
||||
"copy_representation",
|
||||
"add_door_representation",
|
||||
"add_footprint_representation",
|
||||
"add_mesh_representation",
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
@@ -47,7 +47,9 @@ def add_wall_representation(
|
||||
:param thickness: The thickness of the wall in meters.
|
||||
:param x_angle: The slope angle along the wall's X-axis, in radians.
|
||||
:param clippings: List of clipping definitions. Clippings can be `Clipping` objects
|
||||
or dictionaries of arguments for `Clipping.parse`.
|
||||
or dictionaries of arguments for `Clipping.parse`. Each clipping has a
|
||||
``normal`` that points toward the removed material (the discarded side),
|
||||
not toward the kept material; see :func:`clip_solid` for details.
|
||||
:param booleans: List of any existing IfcBooleanResults.
|
||||
:return: IfcShapeRepresentation.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell.util.data import Clipping
|
||||
|
||||
|
||||
def clip_solid(
|
||||
file: ifcopenshell.file,
|
||||
item: ifcopenshell.entity_instance,
|
||||
location: Sequence[float],
|
||||
normal: Sequence[float],
|
||||
element: Optional[ifcopenshell.entity_instance] = None,
|
||||
) -> ifcopenshell.entity_instance:
|
||||
"""Clip a solid with a half-space plane, returning an IfcBooleanClippingResult.
|
||||
|
||||
Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for
|
||||
use with any solid. This is the same convention used by the ``clippings``
|
||||
parameter of :func:`add_wall_representation`.
|
||||
|
||||
.. warning::
|
||||
|
||||
The ``normal`` points toward the **removed** material (the discarded
|
||||
side), not toward the kept material. For a slope clip the normal
|
||||
points upward into the removed wedge above the slope line. For a
|
||||
side mitre the normal points outward away from the wall body.
|
||||
|
||||
After clipping, set the parent ``IfcShapeRepresentation``
|
||||
``RepresentationType`` to ``"Clipping"``.
|
||||
|
||||
Example — trim an extruded solid to a lean-to slope (removed material is
|
||||
above the slope)::
|
||||
|
||||
bcr = ifcopenshell.api.run(
|
||||
"geometry.clip_solid", model,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.26],
|
||||
normal=[0.419, 0.0, 0.908], # points UP toward removed material
|
||||
)
|
||||
|
||||
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
|
||||
or ``IfcBooleanClippingResult``).
|
||||
:param location: A point on the clipping plane in the representation's
|
||||
local coordinate system.
|
||||
:param normal: Plane normal pointing toward the material to be removed
|
||||
(see warning above).
|
||||
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
|
||||
registered in the element's ``BBIM_Boolean`` property set so that
|
||||
:func:`regenerate_wall_representation` preserves it during regeneration.
|
||||
:return: The resulting ``IfcBooleanClippingResult``.
|
||||
"""
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
|
||||
clipping = Clipping(location=tuple(location), normal=tuple(normal))
|
||||
result = clipping.apply(file, item, unit_scale)
|
||||
if element is not None:
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
|
||||
if pset_data:
|
||||
pset = file.by_id(pset_data["id"])
|
||||
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
|
||||
else:
|
||||
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
|
||||
data = [result.id()]
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
|
||||
return result
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
|
||||
def clip_solid_bounded(
|
||||
file: ifcopenshell.file,
|
||||
item: ifcopenshell.entity_instance,
|
||||
location: Sequence[float],
|
||||
normal: Sequence[float],
|
||||
boundary_points: Sequence[Sequence[float]],
|
||||
boundary_position: Sequence[float] = (0.0, 0.0, 0.0),
|
||||
element: Optional[ifcopenshell.entity_instance] = None,
|
||||
) -> ifcopenshell.entity_instance:
|
||||
"""Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult.
|
||||
|
||||
Like :func:`clip_solid`, but the boolean subtraction is restricted to the
|
||||
region enclosed by ``boundary_points`` rather than extending across the
|
||||
entire half-space. The clipping plane is still infinite, but material is
|
||||
only removed within the extruded footprint of the polygon.
|
||||
|
||||
The ``normal`` convention is the same as :func:`clip_solid`: it points
|
||||
toward the **removed** material.
|
||||
|
||||
After clipping, set the parent ``IfcShapeRepresentation``
|
||||
``RepresentationType`` to ``"Clipping"``.
|
||||
|
||||
Example::
|
||||
|
||||
bcr = ifcopenshell.api.run(
|
||||
"geometry.clip_solid_bounded", model,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
|
||||
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
|
||||
or ``IfcBooleanClippingResult``).
|
||||
:param location: A point on the clipping plane in the representation's
|
||||
local coordinate system.
|
||||
:param normal: Plane normal pointing toward the material to be removed.
|
||||
:param boundary_points: 2D ``[x, y]`` points defining the closed polygonal
|
||||
boundary in the coordinate system of ``boundary_position``. The polygon
|
||||
is automatically closed — do not repeat the first point.
|
||||
:param boundary_position: 3D origin of the boundary coordinate system
|
||||
(axes default to the global X/Y/Z directions). Defaults to the origin.
|
||||
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
|
||||
registered in the element's ``BBIM_Boolean`` property set so that
|
||||
:func:`regenerate_wall_representation` preserves it during regeneration.
|
||||
:return: The resulting ``IfcBooleanClippingResult``.
|
||||
"""
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
|
||||
builder = ShapeBuilder(file)
|
||||
|
||||
normal_arr = np.array(normal)
|
||||
if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2):
|
||||
arbitrary_vector = np.array([0.0, 1.0, 0.0])
|
||||
else:
|
||||
arbitrary_vector = np.array([0.0, 0.0, 1.0])
|
||||
x_axis = np.cross(normal_arr, arbitrary_vector)
|
||||
x_axis /= np.linalg.norm(x_axis)
|
||||
|
||||
scaled_location = [i / unit_scale for i in location]
|
||||
plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis)
|
||||
plane = file.create_entity("IfcPlane", plane_placement)
|
||||
|
||||
scaled_boundary_position = [i / unit_scale for i in boundary_position]
|
||||
boundary_pos_entity = file.create_entity(
|
||||
"IfcAxis2Placement3D",
|
||||
file.create_entity("IfcCartesianPoint", scaled_boundary_position),
|
||||
)
|
||||
|
||||
scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points]
|
||||
scaled_pts.append(scaled_pts[0]) # close the polygon
|
||||
ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts]
|
||||
boundary = file.createIfcPolyline(ifc_pts)
|
||||
|
||||
half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary)
|
||||
result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space)
|
||||
if element is not None:
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
|
||||
if pset_data:
|
||||
pset = file.by_id(pset_data["id"])
|
||||
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
|
||||
else:
|
||||
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
|
||||
data = [result.id()]
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
|
||||
return result
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
|
||||
|
||||
def copy_representation(
|
||||
file: ifcopenshell.file,
|
||||
source: ifcopenshell.entity_instance,
|
||||
target: ifcopenshell.entity_instance,
|
||||
context_identifier: str = "Body",
|
||||
) -> Optional[ifcopenshell.entity_instance]:
|
||||
"""Copy a geometric representation from one element to another.
|
||||
|
||||
Finds the named representation on ``source``, deep-copies its entity
|
||||
graph (geometry items, profiles, placements, etc.), and assigns the copy
|
||||
to ``target``. Representation contexts are shared rather than copied.
|
||||
If ``target`` already has a matching representation it is removed and
|
||||
replaced.
|
||||
|
||||
If no matching representation is found on ``source``, returns ``None``
|
||||
and leaves ``target`` unchanged.
|
||||
|
||||
:param source: The element to copy the representation from.
|
||||
:param target: The element to assign the copied representation to.
|
||||
:param context_identifier: The RepresentationIdentifier to look up on
|
||||
``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``).
|
||||
Defaults to ``"Body"``.
|
||||
:return: The newly created IfcShapeRepresentation, or None if no
|
||||
matching representation was found on ``source``.
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
wall_a = model.by_id(1)
|
||||
wall_b = model.by_id(2)
|
||||
|
||||
# Give wall_b the same body geometry as wall_a.
|
||||
ifcopenshell.api.geometry.copy_representation(model,
|
||||
source=wall_a, target=wall_b)
|
||||
"""
|
||||
source_rep = ifcopenshell.util.representation.get_representation(source, "Model", context_identifier)
|
||||
if source_rep is None:
|
||||
return None
|
||||
|
||||
new_rep = ifcopenshell.util.element.copy_deep(file, source_rep, exclude=["IfcGeometricRepresentationContext"])
|
||||
|
||||
existing_rep = ifcopenshell.util.representation.get_representation(target, "Model", context_identifier)
|
||||
if existing_rep:
|
||||
ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep)
|
||||
ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep)
|
||||
|
||||
ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep)
|
||||
return new_rep
|
||||
@@ -52,10 +52,11 @@ def edit_object_placement(
|
||||
:param is_si: If True, the matrix is given in SI units. If false, in
|
||||
project units.
|
||||
:param should_transform_children: A child element is a nested element,
|
||||
opening, filling, etc. If true, child elements will move along with the
|
||||
parent. If false, child elements will stay where they are. Because most
|
||||
placements in IFC are relative, this means that if a child moves, we
|
||||
actually don't change their placement.
|
||||
opening, filling, etc. If True, child elements move along with the
|
||||
parent; pass True when moving an assembly (roof, furniture group, etc.)
|
||||
and you want all children to follow. If False (default), child elements
|
||||
keep their current world positions; their local placements are rewritten
|
||||
to compensate for the parent move.
|
||||
:return: The new or updated IfcLocalPlacement entity
|
||||
"""
|
||||
usecase = Usecase()
|
||||
|
||||
@@ -69,6 +69,12 @@ def regenerate_wall_representation(
|
||||
additional extrusions are generated for each connection that boolean
|
||||
difference the base extrusion.
|
||||
|
||||
Clippings applied via :func:`geometry.clip_solid` or
|
||||
:func:`geometry.clip_solid_bounded` are preserved only if the ``element``
|
||||
parameter was passed when creating them, which registers the result in the
|
||||
``BBIM_Boolean`` property set. Clippings created without that parameter
|
||||
are silently discarded during regeneration.
|
||||
|
||||
This will also update the axis line representation (e.g. trim the axis line
|
||||
to any connections).
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.georeference
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.element
|
||||
|
||||
@@ -63,8 +64,13 @@ def add_georeferencing(file: ifcopenshell.file, ifc_class: str = "IfcMapConversi
|
||||
},
|
||||
)
|
||||
return
|
||||
if file.by_type("IfcProjectedCRS"):
|
||||
has_crs = bool(file.by_type("IfcProjectedCRS"))
|
||||
has_conversion = bool(file.by_type("IfcCoordinateOperation"))
|
||||
if has_crs and has_conversion:
|
||||
return
|
||||
if has_crs or has_conversion:
|
||||
# This is technically invalid, but we shall forgive the industry here if they are wrong ...
|
||||
ifcopenshell.api.georeference.remove_georeferencing(file)
|
||||
source_crs = None
|
||||
for context in file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
|
||||
if context.ContextType == "Model":
|
||||
|
||||
@@ -26,7 +26,7 @@ def assign_process(
|
||||
relating_process: ifcopenshell.entity_instance,
|
||||
related_object: ifcopenshell.entity_instance,
|
||||
) -> ifcopenshell.entity_instance:
|
||||
"""Assigns an object to be related to a process, typically a construction task
|
||||
"""Assigns an object as an input, control, or resource of a process
|
||||
|
||||
Processes work using the ICOM (Input, Controls, Outputs, Mechanisms)
|
||||
paradigm in IFC. This process model is commonly used in modeling
|
||||
@@ -63,6 +63,17 @@ def assign_process(
|
||||
|
||||
For resources, any construction resource may be assigned to a task.
|
||||
|
||||
.. warning::
|
||||
|
||||
This function creates an **Input** relationship
|
||||
(``IfcRelAssignsToProcess``), meaning the product is *consumed* or
|
||||
*operated on* by the task — the typical case is demolition or
|
||||
maintenance.
|
||||
|
||||
If the task *constructs or installs* a product (e.g. erecting a wall
|
||||
or fitting a window), use :func:`assign_product` instead, which
|
||||
creates an **Output** relationship (``IfcRelAssignsToProduct``).
|
||||
|
||||
:param relating_process: The IfcProcess (typically IfcTask) that the
|
||||
input, control, or resource is related to.
|
||||
:param related_object: The IfcProduct (for input), IfcCostItem (for
|
||||
@@ -77,7 +88,7 @@ def assign_process(
|
||||
# need to be part of a work schedule.
|
||||
schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Construction Schedule A")
|
||||
|
||||
# Let's create a construction task. Note that the predefined type is
|
||||
# Let's create a demolition task. Note that the predefined type is
|
||||
# important to distinguish types of tasks.
|
||||
task = ifcopenshell.api.sequence.add_task(model,
|
||||
work_schedule=schedule, name="Demolish existing", identification="A", predefined_type="DEMOLITION")
|
||||
@@ -85,8 +96,12 @@ def assign_process(
|
||||
# Let's say we have a wall somewhere.
|
||||
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
|
||||
|
||||
# Let's demolish that wall!
|
||||
# The wall is an INPUT to the demolition task (it will be consumed).
|
||||
ifcopenshell.api.sequence.assign_process(model, relating_process=task, related_object=wall)
|
||||
|
||||
# For a construction task that BUILDS a wall, use assign_product instead:
|
||||
# build_task = ifcopenshell.api.sequence.add_task(model, ..., predefined_type="CONSTRUCTION")
|
||||
# ifcopenshell.api.sequence.assign_product(model, relating_product=wall, related_object=build_task)
|
||||
"""
|
||||
if related_object.HasAssignments:
|
||||
for assignment in related_object.HasAssignments:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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/>.
|
||||
|
||||
import json
|
||||
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.shape_builder
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestClipSolid(test.bootstrap.IFC4):
|
||||
def make_extrusion(self):
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
|
||||
rect = builder.rectangle(size=(1.0, 1.0))
|
||||
return builder.extrude(rect, magnitude=4.0)
|
||||
|
||||
def test_returns_boolean_clipping_result(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
)
|
||||
assert result.is_a("IfcBooleanClippingResult")
|
||||
assert result.Operator == "DIFFERENCE"
|
||||
|
||||
def test_first_operand_is_the_item(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
)
|
||||
assert result.FirstOperand == extrusion
|
||||
|
||||
def test_second_operand_is_half_space_solid(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
)
|
||||
assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
|
||||
|
||||
def test_clip_plane_location_matches(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
)
|
||||
plane = result.SecondOperand.BaseSurface
|
||||
coords = plane.Position.Location.Coordinates
|
||||
assert list(coords) == [0.0, 0.0, 3.0]
|
||||
|
||||
def test_chaining_two_clips(self):
|
||||
extrusion = self.make_extrusion()
|
||||
first_clip = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
)
|
||||
second_clip = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=first_clip,
|
||||
location=[0.0, 0.0, 1.0],
|
||||
normal=[0.0, 0.0, -1.0],
|
||||
)
|
||||
assert second_clip.is_a("IfcBooleanClippingResult")
|
||||
assert second_clip.FirstOperand == first_clip
|
||||
assert first_clip.FirstOperand == extrusion
|
||||
|
||||
def test_angled_clip_plane(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.26],
|
||||
normal=[0.419, 0.0, 0.908],
|
||||
)
|
||||
assert result.is_a("IfcBooleanClippingResult")
|
||||
assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
|
||||
|
||||
def test_element_registers_result_in_bbim_boolean(self):
|
||||
extrusion = self.make_extrusion()
|
||||
wall = self.file.createIfcWall()
|
||||
result = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
element=wall,
|
||||
)
|
||||
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
|
||||
assert pset is not None
|
||||
assert result.id() in json.loads(pset["Data"])
|
||||
|
||||
def test_element_appends_to_existing_bbim_boolean(self):
|
||||
extrusion = self.make_extrusion()
|
||||
wall = self.file.createIfcWall()
|
||||
first = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
element=wall,
|
||||
)
|
||||
second = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=first,
|
||||
location=[0.0, 0.0, 1.0],
|
||||
normal=[0.0, 0.0, -1.0],
|
||||
element=wall,
|
||||
)
|
||||
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
|
||||
ids = json.loads(pset["Data"])
|
||||
assert first.id() in ids
|
||||
assert second.id() in ids
|
||||
|
||||
def test_no_element_does_not_create_pset(self):
|
||||
extrusion = self.make_extrusion()
|
||||
wall = self.file.createIfcWall()
|
||||
ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
)
|
||||
assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
|
||||
|
||||
|
||||
class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid):
|
||||
pass
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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/>.
|
||||
|
||||
import json
|
||||
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.shape_builder
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestClipSolidBounded(test.bootstrap.IFC4):
|
||||
def make_extrusion(self):
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
|
||||
rect = builder.rectangle(size=(4.0, 1.0))
|
||||
return builder.extrude(rect, magnitude=3.0)
|
||||
|
||||
def test_returns_boolean_clipping_result(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
assert result.is_a("IfcBooleanClippingResult")
|
||||
assert result.Operator == "DIFFERENCE"
|
||||
|
||||
def test_first_operand_is_the_item(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
assert result.FirstOperand == extrusion
|
||||
|
||||
def test_second_operand_is_polygonal_bounded_half_space(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
assert result.SecondOperand.is_a("IfcPolygonalBoundedHalfSpace")
|
||||
|
||||
def test_agreement_flag_is_false(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
assert result.SecondOperand.AgreementFlag is False
|
||||
|
||||
def test_clip_plane_location_matches(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
plane = result.SecondOperand.BaseSurface
|
||||
coords = plane.Position.Location.Coordinates
|
||||
assert list(coords) == [2.5, 0.0, 2.0]
|
||||
|
||||
def test_boundary_is_closed_polyline(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
boundary = result.SecondOperand.PolygonalBoundary
|
||||
assert boundary.is_a("IfcPolyline")
|
||||
pts = [list(p.Coordinates) for p in boundary.Points]
|
||||
assert pts[0] == pts[-1], "polygon should be closed"
|
||||
assert len(pts) == 5 # 4 unique + closing repeat
|
||||
|
||||
def test_boundary_position_defaults_to_origin(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
pos = result.SecondOperand.Position
|
||||
assert list(pos.Location.Coordinates) == [0.0, 0.0, 0.0]
|
||||
|
||||
def test_custom_boundary_position(self):
|
||||
extrusion = self.make_extrusion()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
boundary_position=[1.0, 2.0, 3.0],
|
||||
)
|
||||
pos = result.SecondOperand.Position
|
||||
assert list(pos.Location.Coordinates) == [1.0, 2.0, 3.0]
|
||||
|
||||
def test_chaining_with_clip_solid(self):
|
||||
extrusion = self.make_extrusion()
|
||||
first_clip = ifcopenshell.api.geometry.clip_solid(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[0.0, 0.0, 3.0],
|
||||
normal=[0.0, 0.0, 1.0],
|
||||
)
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=first_clip,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
assert result.is_a("IfcBooleanClippingResult")
|
||||
assert result.FirstOperand == first_clip
|
||||
assert first_clip.FirstOperand == extrusion
|
||||
|
||||
def test_element_registers_result_in_bbim_boolean(self):
|
||||
extrusion = self.make_extrusion()
|
||||
wall = self.file.createIfcWall()
|
||||
result = ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
element=wall,
|
||||
)
|
||||
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
|
||||
assert pset is not None
|
||||
assert result.id() in json.loads(pset["Data"])
|
||||
|
||||
def test_no_element_does_not_create_pset(self):
|
||||
extrusion = self.make_extrusion()
|
||||
wall = self.file.createIfcWall()
|
||||
ifcopenshell.api.geometry.clip_solid_bounded(
|
||||
self.file,
|
||||
item=extrusion,
|
||||
location=[2.5, 0.0, 2.0],
|
||||
normal=[0.6, 0.0, 0.8],
|
||||
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
|
||||
)
|
||||
assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
|
||||
|
||||
|
||||
class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded):
|
||||
pass
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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/>.
|
||||
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.util.representation
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestCopyRepresentation(test.bootstrap.IFC4):
|
||||
def _body_context(self):
|
||||
body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW")
|
||||
if body is None:
|
||||
model = self.file.createIfcGeometricRepresentationContext(
|
||||
ContextType="Model",
|
||||
CoordinateSpaceDimension=3,
|
||||
Precision=1e-5,
|
||||
WorldCoordinateSystem=self.file.createIfcAxis2Placement3D(
|
||||
self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
|
||||
),
|
||||
)
|
||||
body = self.file.createIfcGeometricRepresentationSubContext(
|
||||
ContextIdentifier="Body",
|
||||
ContextType="Model",
|
||||
TargetView="MODEL_VIEW",
|
||||
ParentContext=model,
|
||||
)
|
||||
return body
|
||||
|
||||
def _add_body_rep(self, element):
|
||||
body = self._body_context()
|
||||
rep = ifcopenshell.api.geometry.add_wall_representation(
|
||||
self.file, context=body, length=5.0, height=3.0, thickness=0.2
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep)
|
||||
return rep
|
||||
|
||||
def test_copy_to_empty_target(self):
|
||||
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
self._add_body_rep(wall_a)
|
||||
|
||||
result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
|
||||
|
||||
assert result is not None
|
||||
assert result.is_a("IfcShapeRepresentation")
|
||||
target_rep = ifcopenshell.util.representation.get_representation(wall_b, "Model", "Body")
|
||||
assert target_rep is not None
|
||||
assert target_rep == result
|
||||
|
||||
def test_source_rep_entities_are_distinct(self):
|
||||
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
source_rep = self._add_body_rep(wall_a)
|
||||
|
||||
new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
|
||||
|
||||
assert new_rep.id() != source_rep.id()
|
||||
assert new_rep.Items[0].id() != source_rep.Items[0].id()
|
||||
|
||||
def test_context_is_shared_not_copied(self):
|
||||
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
source_rep = self._add_body_rep(wall_a)
|
||||
|
||||
new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
|
||||
|
||||
assert new_rep.ContextOfItems.id() == source_rep.ContextOfItems.id()
|
||||
|
||||
def test_replaces_existing_target_rep(self):
|
||||
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
self._add_body_rep(wall_a)
|
||||
old_rep = self._add_body_rep(wall_b)
|
||||
old_rep_id = old_rep.id()
|
||||
|
||||
ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
|
||||
|
||||
try:
|
||||
self.file.by_id(old_rep_id)
|
||||
assert False, "old representation still exists"
|
||||
except RuntimeError:
|
||||
pass # entity was removed, as expected
|
||||
|
||||
def test_source_unchanged_after_copy(self):
|
||||
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
source_rep = self._add_body_rep(wall_a)
|
||||
source_rep_id = source_rep.id()
|
||||
|
||||
ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
|
||||
|
||||
assert self.file.by_id(source_rep_id) is not None # source must still exist
|
||||
assert ifcopenshell.util.representation.get_representation(wall_a, "Model", "Body") is not None
|
||||
|
||||
def test_returns_none_when_no_matching_rep(self):
|
||||
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
|
||||
result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_custom_context_identifier(self):
|
||||
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
self._add_body_rep(wall_a)
|
||||
|
||||
# "Axis" doesn't exist on wall_a, so should return None
|
||||
result = ifcopenshell.api.geometry.copy_representation(
|
||||
self.file, source=wall_a, target=wall_b, context_identifier="Axis"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCopyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestCopyRepresentation):
|
||||
pass
|
||||
@@ -51,6 +51,38 @@ class TestAddGeoreferencing(test.bootstrap.IFC4):
|
||||
assert len(self.file.by_type("IfcMapConversion")) == 1
|
||||
assert len(self.file.by_type("IfcProjectedCRS")) == 1
|
||||
|
||||
def test_recovering_from_orphan_projected_crs(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
ifcopenshell.api.context.add_context(self.file, "Model")
|
||||
self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234")
|
||||
assert len(self.file.by_type("IfcProjectedCRS")) == 1
|
||||
assert len(self.file.by_type("IfcCoordinateOperation")) == 0
|
||||
ifcopenshell.api.georeference.add_georeferencing(self.file)
|
||||
assert len(self.file.by_type("IfcMapConversion")) == 1
|
||||
assert len(self.file.by_type("IfcProjectedCRS")) == 1
|
||||
|
||||
def test_recovering_from_orphan_coordinate_operation(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
context = ifcopenshell.api.context.add_context(self.file, "Model")
|
||||
self.file.create_entity(
|
||||
"IfcMapConversion",
|
||||
SourceCRS=context,
|
||||
TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"),
|
||||
)
|
||||
ifcopenshell.api.georeference.remove_georeferencing(self.file)
|
||||
# Simulate orphan by re-adding just a conversion without CRS
|
||||
self.file.create_entity(
|
||||
"IfcMapConversion",
|
||||
SourceCRS=context,
|
||||
TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"),
|
||||
)
|
||||
self.file.remove(self.file.by_type("IfcProjectedCRS")[0])
|
||||
assert len(self.file.by_type("IfcProjectedCRS")) == 0
|
||||
assert len(self.file.by_type("IfcCoordinateOperation")) == 1
|
||||
ifcopenshell.api.georeference.add_georeferencing(self.file)
|
||||
assert len(self.file.by_type("IfcMapConversion")) == 1
|
||||
assert len(self.file.by_type("IfcProjectedCRS")) == 1
|
||||
|
||||
|
||||
class TestAddGeoreferencingIFC2X3(test.bootstrap.IFC2X3):
|
||||
def test_adding_georeferencing(self):
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,499 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# ifcquery
|
||||
|
||||
A CLI tool for querying and inspecting IFC building models. All output is
|
||||
structured JSON (or human-readable text), making it easy to pipe into other
|
||||
tools or scripts.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcquery
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`. The `clash` subcommand additionally requires the
|
||||
IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
ifcquery <ifc_file> <command> [options] [--format json|text|ids]
|
||||
```
|
||||
|
||||
The `--format` flag controls output:
|
||||
|
||||
- `json` (default) -- structured JSON, suitable for piping to `jq` or `ifcedit foreach`
|
||||
- `text` -- indented human-readable output
|
||||
- `ids` -- comma-separated step IDs extracted from list results, suitable for piping directly into `ifcedit run` parameters
|
||||
|
||||
## Subcommands
|
||||
|
||||
### summary
|
||||
|
||||
Get a model overview: schema version, entity counts, and project info.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc summary
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "IFC4",
|
||||
"total_entities": 1847,
|
||||
"project": {
|
||||
"id": 1,
|
||||
"name": "Office Building",
|
||||
"description": null
|
||||
},
|
||||
"types": {
|
||||
"IfcWall": 42,
|
||||
"IfcSlab": 12,
|
||||
"IfcWindow": 36
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### tree
|
||||
|
||||
Display the spatial hierarchy from IfcProject down through sites, buildings,
|
||||
storeys, and their contained elements.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc tree
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"type": "IfcProject",
|
||||
"name": "Office Building",
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "IfcSite",
|
||||
"name": "Default Site",
|
||||
"children": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "IfcBuilding",
|
||||
"name": "Main Building",
|
||||
"children": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "IfcBuildingStorey",
|
||||
"name": "Ground Floor",
|
||||
"elements": [
|
||||
{"id": 10, "type": "IfcWall", "name": "Wall001"},
|
||||
{"id": 11, "type": "IfcSlab", "name": "Floor001"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### info
|
||||
|
||||
Get detailed information about a specific element by step ID.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc info 10
|
||||
ifcquery model.ifc info '#10'
|
||||
```
|
||||
|
||||
Returns attributes, property sets, type relationship, material assignment,
|
||||
spatial container, and placement matrix.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"type": "IfcWall",
|
||||
"attributes": {
|
||||
"Name": "Wall001",
|
||||
"Description": null,
|
||||
"ObjectType": "LOADBEARING"
|
||||
},
|
||||
"property_sets": {
|
||||
"Pset_WallCommon": {
|
||||
"IsExternal": true,
|
||||
"FireRating": "2HR"
|
||||
}
|
||||
},
|
||||
"element_type": {"id": 50, "type": "IfcWallType", "name": "Standard"},
|
||||
"material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"},
|
||||
"container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
|
||||
"placement": [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### select
|
||||
|
||||
Filter elements using the ifcopenshell selector syntax.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc select 'IfcWall'
|
||||
ifcquery model.ifc select 'IfcWall, IfcSlab'
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": 10, "type": "IfcWall", "name": "Wall001"},
|
||||
{"id": 11, "type": "IfcWall", "name": "Wall002"},
|
||||
{"id": 20, "type": "IfcSlab", "name": "Floor001"}
|
||||
]
|
||||
```
|
||||
|
||||
Results are sorted by ID.
|
||||
|
||||
Use `--format ids` to get a comma-separated list of step IDs for direct use
|
||||
in `ifcedit run` parameters:
|
||||
|
||||
```bash
|
||||
ifcedit run model.ifc type.assign_type \
|
||||
--related_objects "$(ifcquery model.ifc --format ids select 'IfcWall')" \
|
||||
--relating_type 456
|
||||
```
|
||||
|
||||
### relations
|
||||
|
||||
Show all relationships for an element, organized by category: hierarchy,
|
||||
children, type relationships, groups, systems, material, and connections.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc relations 10
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"type": "IfcWall",
|
||||
"name": "Wall001",
|
||||
"hierarchy": {
|
||||
"parent": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
|
||||
"container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}
|
||||
},
|
||||
"children": {
|
||||
"openings": [{"id": 30, "type": "IfcOpeningElement", "name": "Opening01"}]
|
||||
},
|
||||
"type_relationship": {
|
||||
"type_of": {"id": 50, "type": "IfcWallType", "name": "Standard"}
|
||||
},
|
||||
"material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"}
|
||||
}
|
||||
```
|
||||
|
||||
Empty categories are omitted from output.
|
||||
|
||||
Use `--traverse up` to walk the spatial hierarchy from the element up to
|
||||
IfcProject:
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc relations 10 --traverse up
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": 10, "type": "IfcWall", "name": "Wall001"},
|
||||
{"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
|
||||
{"id": 3, "type": "IfcBuilding", "name": "Main Building"},
|
||||
{"id": 2, "type": "IfcSite", "name": "Default Site"},
|
||||
{"id": 1, "type": "IfcProject", "name": "Office Building"}
|
||||
]
|
||||
```
|
||||
|
||||
### validate
|
||||
|
||||
Check the model for schema and constraint violations.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc validate
|
||||
ifcquery model.ifc validate --rules
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--rules` -- also run the slower EXPRESS rules check (default: off)
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"issues": []
|
||||
}
|
||||
```
|
||||
|
||||
On an invalid model:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"issues": [
|
||||
{"level": "ERROR", "message": "Entity #42 IfcWall.GlobalId is not a valid IfcGloballyUniqueId"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### schedule
|
||||
|
||||
List all work schedules and their task trees from the model.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc schedule
|
||||
ifcquery model.ifc schedule --depth 1
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--depth N` -- expand at most N levels of subtasks (default: unlimited). At the
|
||||
cutoff, `subtasks` is replaced with `{"truncated": true, "count": N}`.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Construction Schedule",
|
||||
"predefined_type": "BASELINE",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 55,
|
||||
"name": "Phase 1",
|
||||
"start": "2024-01-01T09:00:00",
|
||||
"finish": "2024-06-30T17:00:00",
|
||||
"is_milestone": false,
|
||||
"outputs": [{"id": 10, "type": "IfcWall", "name": "Wall A"}],
|
||||
"subtasks": [
|
||||
{"id": 56, "name": "Foundations", "start": null, "finish": null,
|
||||
"is_milestone": false, "outputs": [], "subtasks": []}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### cost
|
||||
|
||||
List all cost schedules and their cost item trees from the model.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc cost
|
||||
ifcquery model.ifc cost --depth 2
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--depth N` -- expand at most N levels of subitems (default: unlimited). At the
|
||||
cutoff, `subitems` is replaced with `{"truncated": true, "count": N}`.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 100,
|
||||
"name": "Bill of Quantities",
|
||||
"predefined_type": "COSTPLAN",
|
||||
"items": [
|
||||
{
|
||||
"id": 110,
|
||||
"name": "Concrete Works",
|
||||
"values": [{"formula": "1200.00 = material(1200.0)", "category": "material"}],
|
||||
"subitems": [
|
||||
{"id": 111, "name": "Formwork", "values": [], "subitems": []}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### schema
|
||||
|
||||
Show IFC class documentation for any entity type, using the schema version of
|
||||
the loaded model.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc schema IfcWall
|
||||
ifcquery model.ifc schema IfcBuildingStorey
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "The wall represents a vertical construction ...",
|
||||
"predefined_types": {"STANDARD": "A standard wall, extruded vertically ..."},
|
||||
"spec_url": "https://standards.buildingsmart.org/...",
|
||||
"attributes": {
|
||||
"Name": "Optional name for use by the participating software systems",
|
||||
"ObjectPlacement": "Placement of the product in space ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
|
||||
|
||||
### contexts
|
||||
|
||||
List all geometric representation contexts and subcontexts in the model.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc contexts
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 5,
|
||||
"type": "IfcGeometricRepresentationContext",
|
||||
"context_type": "Model",
|
||||
"subcontexts": [
|
||||
{"id": 6, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Body", "target_view": "MODEL_VIEW"},
|
||||
{"id": 7, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Axis", "target_view": "GRAPH_VIEW"}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### materials
|
||||
|
||||
List all materials and material sets used in the model, with their assigned elements.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc materials
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 60,
|
||||
"type": "IfcMaterial",
|
||||
"name": "Concrete",
|
||||
"elements": [{"id": 10, "type": "IfcWall", "name": "Wall001"}]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### plot
|
||||
|
||||
Generate a 2D technical drawing (floor plan, elevation, or section) of the model and write it to a file.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc plot output.svg
|
||||
ifcquery model.ifc plot output.png --view floorplan --scale 0.01
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--view {floorplan,elevation,section,auto}` -- drawing view (default: `floorplan`)
|
||||
- `--scale <ratio>` -- model-to-paper scale ratio (default: 0.01 = 1:100)
|
||||
- `--width-mm <mm>` -- paper width in mm (default: 297)
|
||||
- `--height-mm <mm>` -- paper height in mm (default: 420)
|
||||
- `--png-width <px>` -- raster output width in pixels (default: 1024)
|
||||
- `--png-height <px>` -- raster output height in pixels (default: 1024)
|
||||
|
||||
Writes SVG when the output path ends in `.svg`, otherwise PNG.
|
||||
Requires the IfcOpenShell drawing module (`ifcopenshell.draw`).
|
||||
|
||||
### render
|
||||
|
||||
Render a 3D view of the model geometry to a PNG file.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc render output.png
|
||||
ifcquery model.ifc render output.png --view iso --selector IfcWall
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--view {iso,top,south,north,east,west}` -- camera angle (default: `iso`)
|
||||
- `--selector <query>` -- ifcopenshell selector to restrict rendered elements
|
||||
|
||||
Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
|
||||
|
||||
### clash
|
||||
|
||||
Check a single element for geometric intersections and clearance violations
|
||||
against other elements.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc clash 10
|
||||
ifcquery model.ifc clash 10 --clearance 0.5
|
||||
ifcquery model.ifc clash 10 --scope all --tolerance 0.001
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--clearance <meters>` -- minimum clearance distance to check
|
||||
- `--tolerance <meters>` -- intersection tolerance (default: 0.002)
|
||||
- `--scope {storey,all}` -- check against same-storey elements or all elements (default: storey)
|
||||
|
||||
```json
|
||||
{
|
||||
"element": {"id": 10, "type": "IfcWall", "name": "Wall001"},
|
||||
"scope": "storey",
|
||||
"pass": false,
|
||||
"checks": {
|
||||
"intersection": {
|
||||
"pass": false,
|
||||
"tolerance": 0.002,
|
||||
"clashes": [
|
||||
{
|
||||
"element": {"id": 11, "type": "IfcWall", "name": "Wall002"},
|
||||
"type": "intersection",
|
||||
"distance": 0.0,
|
||||
"p1": [2.5, 2.5, 1.5],
|
||||
"p2": [2.5, 2.5, 1.5]
|
||||
}
|
||||
]
|
||||
},
|
||||
"clearance": {
|
||||
"pass": true,
|
||||
"clearance": 0.5,
|
||||
"clashes": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requires the IfcOpenShell C++ geometry bindings.
|
||||
|
||||
## Scripting with ifcedit
|
||||
|
||||
`ifcquery` and `ifcedit` are designed to compose. Use `--format ids` to pass
|
||||
query results directly into `ifcedit run` parameters, or pipe JSON into
|
||||
`ifcedit foreach` to apply an operation to every matching element.
|
||||
|
||||
```bash
|
||||
# Remove all walls from their spatial container
|
||||
ifcedit run model.ifc spatial.unassign_container \
|
||||
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
|
||||
|
||||
# Delete every window (model opened and saved once)
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
|
||||
# Bulk rename all doors
|
||||
ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
|
||||
--product {id} --attributes '{"Name": "Door"}'
|
||||
|
||||
# Render an element highlighted against everything related to it
|
||||
ifcquery model.ifc render relations.png \
|
||||
--element "$(ifcquery model.ifc --format ids relations 42)"
|
||||
|
||||
# Render a clash — subject and clashing elements highlighted together
|
||||
ifcquery model.ifc render clash.png \
|
||||
--element "$(ifcquery model.ifc --format ids clash 42)"
|
||||
```
|
||||
|
||||
See the `ifcedit` documentation for the full `foreach` reference.
|
||||
|
||||
## Error handling
|
||||
|
||||
Errors are written to stderr. Exit code is 0 on success, 1 on error.
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,397 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcquery import clash as clash_mod
|
||||
from ifcquery import contexts as contexts_mod
|
||||
from ifcquery import cost as cost_mod
|
||||
from ifcquery import (
|
||||
info,
|
||||
plot,
|
||||
relations,
|
||||
schedule,
|
||||
schema,
|
||||
select,
|
||||
summary,
|
||||
tree,
|
||||
)
|
||||
from ifcquery import (
|
||||
materials as materials_mod,
|
||||
)
|
||||
from ifcquery import (
|
||||
render as render_mod,
|
||||
)
|
||||
from ifcquery import validate as validate_mod
|
||||
|
||||
|
||||
def parse_element_id(raw: str) -> int:
|
||||
"""Parse an element ID from '#123' or '123' format."""
|
||||
raw = raw.strip().lstrip("#")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def format_output(data, fmt: str) -> str:
|
||||
if fmt == "json":
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
elif fmt == "text":
|
||||
return _format_text(data)
|
||||
elif fmt == "ids":
|
||||
return _format_ids(data)
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _format_ids(data) -> str:
|
||||
"""Extract 'id' fields from a list of dicts and return as comma-separated string.
|
||||
|
||||
For dicts with a top-level 'elements' key (e.g. clash, relations output),
|
||||
extracts from that flat summary list rather than the nested structure.
|
||||
"""
|
||||
if isinstance(data, list):
|
||||
ids = [str(item["id"]) for item in data if isinstance(item, dict) and "id" in item]
|
||||
return ",".join(ids)
|
||||
if isinstance(data, dict):
|
||||
if "elements" in data and isinstance(data["elements"], list):
|
||||
return _format_ids(data["elements"])
|
||||
if "id" in data:
|
||||
return str(data["id"])
|
||||
return ""
|
||||
|
||||
|
||||
def _format_text(data, indent: int = 0) -> str:
|
||||
prefix = " " * indent
|
||||
lines = []
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
lines.append(f"{prefix}{key}:")
|
||||
lines.append(_format_text(value, indent + 1))
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {value}")
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
lines.append(_format_text(item, indent))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"{prefix}- {item}")
|
||||
else:
|
||||
lines.append(f"{prefix}{data}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ifcquery",
|
||||
description="Query and inspect IFC building models",
|
||||
)
|
||||
parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text", "ids"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format: json (default), text (human-readable), ids (comma-separated step IDs)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("summary", help="Model overview: schema, element counts, project info")
|
||||
|
||||
subparsers.add_parser("tree", help="Spatial hierarchy tree")
|
||||
|
||||
info_parser = subparsers.add_parser("info", help="Deep inspection of a specific element")
|
||||
info_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
|
||||
|
||||
select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax")
|
||||
select_parser.add_argument("query", help="Selector query string")
|
||||
|
||||
relations_parser = subparsers.add_parser("relations", help="Show relationships for an element")
|
||||
relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
|
||||
relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)")
|
||||
|
||||
clash_parser = subparsers.add_parser("clash", help="Check element placement for clashes")
|
||||
clash_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
|
||||
clash_parser.add_argument("--clearance", type=float, help="Minimum clearance distance")
|
||||
clash_parser.add_argument("--tolerance", type=float, default=0.002, help="Intersection tolerance (default: 0.002)")
|
||||
clash_parser.add_argument(
|
||||
"--scope", choices=["storey", "all"], default="storey", help="Scope of elements to check (default: storey)"
|
||||
)
|
||||
|
||||
validate_parser = subparsers.add_parser("validate", help="Schema/constraint validation")
|
||||
validate_parser.add_argument(
|
||||
"--rules", action="store_true", help="Also check EXPRESS rules (slower, default: false)"
|
||||
)
|
||||
|
||||
schedule_parser = subparsers.add_parser("schedule", help="List work plans and tasks from the model")
|
||||
schedule_parser.add_argument(
|
||||
"--depth", type=int, default=None, metavar="N", help="Limit subtask expansion to N levels (default: unlimited)"
|
||||
)
|
||||
|
||||
cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model")
|
||||
cost_parser.add_argument(
|
||||
"--depth",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Limit cost item expansion to N levels (default: unlimited)",
|
||||
)
|
||||
|
||||
subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts")
|
||||
|
||||
subparsers.add_parser("materials", help="List materials and material sets")
|
||||
|
||||
schema_parser = subparsers.add_parser("schema", help="IFC class documentation")
|
||||
schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
|
||||
|
||||
render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image")
|
||||
render_parser.add_argument(
|
||||
"-o", "--output", default="", metavar="FILE", help="Output PNG path (default: <ifc_file>.png)"
|
||||
)
|
||||
render_parser.add_argument(
|
||||
"--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements"
|
||||
)
|
||||
render_parser.add_argument(
|
||||
"--element",
|
||||
default="",
|
||||
metavar="ID[,ID...]",
|
||||
help="Comma-separated step IDs of elements to highlight (rest rendered in grey)",
|
||||
)
|
||||
render_parser.add_argument(
|
||||
"--view",
|
||||
choices=render_mod.VIEWS,
|
||||
default="iso",
|
||||
help="Camera angle (default: iso)",
|
||||
)
|
||||
|
||||
plot_parser = subparsers.add_parser(
|
||||
"plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)"
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
default="",
|
||||
metavar="FILE",
|
||||
help="Output file path. Default depends on --out-format: <ifc_file>.svg/.png",
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--out-format",
|
||||
choices=["svg", "png", "base64"],
|
||||
default="png",
|
||||
help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png",
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict plotted elements"
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight"
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--view",
|
||||
choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")),
|
||||
default="floorplan",
|
||||
help="Drawing view (default: floorplan)",
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--width-mm",
|
||||
type=float,
|
||||
default=297.0,
|
||||
metavar="MM",
|
||||
help="Paper width in mm (default: 297)",
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--height-mm",
|
||||
type=float,
|
||||
default=420.0,
|
||||
metavar="MM",
|
||||
help="Paper height in mm (default: 420)",
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--scale",
|
||||
type=float,
|
||||
default=1.0 / 100.0,
|
||||
metavar="S",
|
||||
help="Model-to-paper scale (default: 0.01 = 1:100)",
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--png-width",
|
||||
type=int,
|
||||
default=1024,
|
||||
metavar="PX",
|
||||
help="PNG width in pixels (default: 1024)",
|
||||
)
|
||||
plot_parser.add_argument(
|
||||
"--png-height",
|
||||
type=int,
|
||||
default=1024,
|
||||
metavar="PX",
|
||||
help="PNG height in pixels (default: 1024)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.command == "summary":
|
||||
result = summary.summary(model)
|
||||
elif args.command == "tree":
|
||||
result = tree.tree(model)
|
||||
elif args.command == "info":
|
||||
try:
|
||||
element_id = parse_element_id(args.element_id)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
element = model.by_id(element_id)
|
||||
except RuntimeError:
|
||||
print(f"Error: Element #{element_id} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
result = info.info(model, element)
|
||||
elif args.command == "select":
|
||||
result = select.select(model, args.query)
|
||||
elif args.command == "relations":
|
||||
try:
|
||||
element_id = parse_element_id(args.element_id)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
element = model.by_id(element_id)
|
||||
except RuntimeError:
|
||||
print(f"Error: Element #{element_id} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
result = relations.relations(model, element, traverse=args.traverse)
|
||||
elif args.command == "clash":
|
||||
try:
|
||||
element_id = parse_element_id(args.element_id)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
element = model.by_id(element_id)
|
||||
except RuntimeError:
|
||||
print(f"Error: Element #{element_id} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
result = clash_mod.clash(
|
||||
model, element, clearance=args.clearance, tolerance=args.tolerance, scope=args.scope
|
||||
)
|
||||
except ImportError:
|
||||
print("Error: ifcopenshell geometry engine not available (C++ bindings required)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif args.command == "validate":
|
||||
result = validate_mod.validate(model, express_rules=args.rules)
|
||||
elif args.command == "schedule":
|
||||
result = schedule.schedule(model, max_depth=args.depth)
|
||||
elif args.command == "cost":
|
||||
result = cost_mod.cost(model, max_depth=args.depth)
|
||||
elif args.command == "contexts":
|
||||
result = contexts_mod.contexts(model)
|
||||
elif args.command == "materials":
|
||||
result = materials_mod.materials(model)
|
||||
elif args.command == "schema":
|
||||
result = schema.schema(model, args.entity_type)
|
||||
elif args.command == "render":
|
||||
element_ids = None
|
||||
if args.element:
|
||||
try:
|
||||
element_ids = [parse_element_id(part) for part in args.element.split(",")]
|
||||
except ValueError:
|
||||
print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
out_path = args.output or (os.path.splitext(args.ifc_file)[0] + ".png")
|
||||
try:
|
||||
png_bytes = render_mod.render(
|
||||
model,
|
||||
selector=args.selector or None,
|
||||
element_ids=element_ids,
|
||||
view=args.view,
|
||||
)
|
||||
except ImportError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
print(f"Saved render to {out_path}", file=sys.stderr)
|
||||
return
|
||||
elif args.command == "plot":
|
||||
element_ids = None
|
||||
if args.element:
|
||||
try:
|
||||
element_ids = [parse_element_id(part) for part in args.element.split(",")]
|
||||
except ValueError:
|
||||
print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
result = plot.plot(
|
||||
model,
|
||||
selector=args.selector or None,
|
||||
element_ids=element_ids,
|
||||
view=args.view,
|
||||
width_mm=args.width_mm,
|
||||
height_mm=args.height_mm,
|
||||
scale=args.scale,
|
||||
output_format=args.out_format,
|
||||
)
|
||||
except ImportError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.out_format == "base64":
|
||||
# result is a dict; serialise to stdout so callers can consume it
|
||||
print(format_output(result, args.output_format))
|
||||
return
|
||||
|
||||
# svg or png: write to a file
|
||||
base = os.path.splitext(args.ifc_file)[0]
|
||||
if args.out_format == "svg":
|
||||
out_path = args.output or (base + ".svg")
|
||||
else:
|
||||
out_path = args.output or (base + ".png")
|
||||
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(result)
|
||||
|
||||
print(f"Saved drawing to {out_path}", file=sys.stderr)
|
||||
return
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Serialize an element to a compact reference dict."""
|
||||
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
|
||||
if hasattr(element, "Name") and element.Name:
|
||||
result["name"] = element.Name
|
||||
return result
|
||||
|
||||
|
||||
def _get_scope_elements(
|
||||
model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str
|
||||
) -> tuple[set[ifcopenshell.entity_instance], str]:
|
||||
"""Return set of elements to check against and the effective scope used.
|
||||
|
||||
Returns (elements, effective_scope) where effective_scope may differ from
|
||||
the requested scope if fallback was needed.
|
||||
"""
|
||||
if scope == "storey":
|
||||
container = ifcopenshell.util.element.get_container(element)
|
||||
if container is not None:
|
||||
siblings = set(ifcopenshell.util.element.get_contained(container))
|
||||
siblings.discard(element)
|
||||
return siblings, "storey"
|
||||
else:
|
||||
print(
|
||||
f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# scope == "all" or fallback
|
||||
elements = set(model.by_type("IfcElement"))
|
||||
elements -= set(model.by_type("IfcFeatureElement"))
|
||||
elements.discard(element)
|
||||
return elements, "all"
|
||||
|
||||
|
||||
def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None:
|
||||
"""Build geometry tree for given elements using iterator.
|
||||
|
||||
Returns None if iterator fails to initialize (no geometry available).
|
||||
"""
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
geom_settings.set("use-world-coords", True)
|
||||
geom_tree = ifcopenshell.geom.tree()
|
||||
iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements))
|
||||
if not iterator.initialize():
|
||||
return None
|
||||
while True:
|
||||
geom_tree.add_element(iterator.get())
|
||||
if not iterator.next():
|
||||
break
|
||||
return geom_tree
|
||||
|
||||
|
||||
def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]:
|
||||
"""Format a single clash result to dict."""
|
||||
# clash result .a/.b are C++ wrapper entity_instances without .Name;
|
||||
# look up the Python entity from the model by id for proper serialization
|
||||
other = model.by_id(clash_result.b.id())
|
||||
return {
|
||||
"element": _ref(other),
|
||||
"type": geom_tree.get_clash_type(clash_result.clash_type),
|
||||
"distance": clash_result.distance,
|
||||
"p1": list(clash_result.p1),
|
||||
"p2": list(clash_result.p2),
|
||||
}
|
||||
|
||||
|
||||
def clash(
|
||||
model: ifcopenshell.file,
|
||||
element: ifcopenshell.entity_instance,
|
||||
clearance: float | None = None,
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes against other elements.
|
||||
|
||||
:param model: The IFC model.
|
||||
:param element: The element to check.
|
||||
:param clearance: Minimum clearance distance; if provided, runs clearance check.
|
||||
:param tolerance: Intersection tolerance in meters (default 0.002).
|
||||
:param scope: Which elements to check against: "storey" or "all".
|
||||
:return: Dict with clash results suitable for JSON serialization.
|
||||
"""
|
||||
result: dict[str, Any] = {"element": _ref(element)}
|
||||
|
||||
# Get scope elements
|
||||
scope_elements, effective_scope = _get_scope_elements(model, element, scope)
|
||||
result["scope"] = effective_scope
|
||||
|
||||
if not scope_elements:
|
||||
result["pass"] = True
|
||||
result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}}
|
||||
if clearance is not None:
|
||||
result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []}
|
||||
return result
|
||||
|
||||
# Build geometry tree for target element + scope elements
|
||||
all_elements = scope_elements | {element}
|
||||
geom_tree = _build_tree(model, all_elements)
|
||||
|
||||
if geom_tree is None:
|
||||
result["pass"] = None
|
||||
result["error"] = f"No geometry for element #{element.id()}"
|
||||
return result
|
||||
|
||||
# Run intersection check
|
||||
intersection_clashes = geom_tree.clash_intersection_many(
|
||||
[element], list(scope_elements), tolerance=tolerance, check_all=True
|
||||
)
|
||||
intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes]
|
||||
checks: dict[str, Any] = {
|
||||
"intersection": {
|
||||
"pass": len(intersection_results) == 0,
|
||||
"tolerance": tolerance,
|
||||
"clashes": intersection_results,
|
||||
}
|
||||
}
|
||||
|
||||
all_pass = len(intersection_results) == 0
|
||||
|
||||
# Run clearance check if requested
|
||||
if clearance is not None:
|
||||
clearance_clashes = geom_tree.clash_clearance_many(
|
||||
[element], list(scope_elements), clearance=clearance, check_all=True
|
||||
)
|
||||
clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes]
|
||||
checks["clearance"] = {
|
||||
"pass": len(clearance_results) == 0,
|
||||
"clearance": clearance,
|
||||
"clashes": clearance_results,
|
||||
}
|
||||
if clearance_results:
|
||||
all_pass = False
|
||||
|
||||
result["pass"] = all_pass
|
||||
result["checks"] = checks
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,44 @@
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def contexts(model: ifcopenshell.file) -> list[dict]:
|
||||
"""Return all geometric representation contexts and subcontexts.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:return: List of dicts with id, type, context_type, context_identifier,
|
||||
and (for subcontexts) target_view and parent_context_id.
|
||||
"""
|
||||
results = []
|
||||
for ctx in model.by_type("IfcGeometricRepresentationContext"):
|
||||
entry = {
|
||||
"id": ctx.id(),
|
||||
"type": ctx.is_a(),
|
||||
"context_type": getattr(ctx, "ContextType", None),
|
||||
"context_identifier": getattr(ctx, "ContextIdentifier", None),
|
||||
}
|
||||
if ctx.is_a("IfcGeometricRepresentationSubContext"):
|
||||
entry["target_view"] = ctx.TargetView
|
||||
parent = ctx.ParentContext
|
||||
entry["parent_context_id"] = parent.id() if parent else None
|
||||
results.append(entry)
|
||||
return results
|
||||
@@ -0,0 +1,45 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.cost as cost_util
|
||||
|
||||
|
||||
def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
|
||||
raw_values = cost_util.get_cost_values(item)
|
||||
values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values]
|
||||
|
||||
if max_depth is not None and depth >= max_depth:
|
||||
child_count = len(cost_util.get_nested_cost_items(item))
|
||||
subitems = {"truncated": True, "count": child_count} if child_count else []
|
||||
else:
|
||||
subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)]
|
||||
|
||||
return {
|
||||
"id": item.id(),
|
||||
"name": getattr(item, "Name", None),
|
||||
"values": values,
|
||||
"subitems": subitems,
|
||||
}
|
||||
|
||||
|
||||
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of IfcCostSchedule entries with nested cost item trees.
|
||||
|
||||
max_depth limits how many levels of subitems are expanded (None = unlimited).
|
||||
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
result = []
|
||||
for cost_schedule in model.by_type("IfcCostSchedule"):
|
||||
items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)]
|
||||
result.append(
|
||||
{
|
||||
"id": cost_schedule.id(),
|
||||
"name": getattr(cost_schedule, "Name", None),
|
||||
"predefined_type": getattr(cost_schedule, "PredefinedType", None),
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,262 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geometry summary helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_PROFILE_POINTS = 20
|
||||
|
||||
|
||||
def _rc(coords) -> list[float]:
|
||||
"""Round a coordinate sequence to 6 decimal places."""
|
||||
return [round(float(c), 6) for c in coords]
|
||||
|
||||
|
||||
def _curve_points(curve) -> list | None:
|
||||
if curve.is_a("IfcPolyline"):
|
||||
return [_rc(p.Coordinates) for p in curve.Points]
|
||||
if curve.is_a("IfcIndexedPolyCurve"):
|
||||
return [_rc(c) for c in curve.Points.CoordList]
|
||||
return None
|
||||
|
||||
|
||||
def _profile_summary(profile) -> dict:
|
||||
t = profile.is_a()
|
||||
result: dict[str, Any] = {"type": t}
|
||||
if t == "IfcRectangleProfileDef":
|
||||
result["x_dim"] = profile.XDim
|
||||
result["y_dim"] = profile.YDim
|
||||
elif t in ("IfcCircleProfileDef", "IfcCircleHollowProfileDef"):
|
||||
result["radius"] = profile.Radius
|
||||
if t == "IfcCircleHollowProfileDef":
|
||||
result["wall_thickness"] = profile.WallThickness
|
||||
elif t in ("IfcArbitraryClosedProfileDef", "IfcArbitraryProfileDefWithVoids"):
|
||||
pts = _curve_points(profile.OuterCurve)
|
||||
if pts is not None:
|
||||
if len(pts) <= _MAX_PROFILE_POINTS:
|
||||
result["points"] = pts
|
||||
else:
|
||||
result["point_count"] = len(pts)
|
||||
elif t == "IfcCompositeProfileDef":
|
||||
result["profiles"] = [_profile_summary(p) for p in profile.Profiles]
|
||||
return result
|
||||
|
||||
|
||||
def _half_space_plane(half_space) -> dict | None:
|
||||
if not half_space.is_a("IfcHalfSpaceSolid"):
|
||||
return None
|
||||
surface = half_space.BaseSurface
|
||||
if not surface or not surface.is_a("IfcPlane"):
|
||||
return None
|
||||
pos = surface.Position
|
||||
loc = _rc(pos.Location.Coordinates)
|
||||
normal = _rc(pos.Axis.DirectionRatios) if pos.Axis else [0.0, 0.0, 1.0]
|
||||
return {"location": loc, "normal": normal}
|
||||
|
||||
|
||||
def _walk_clipping(item) -> tuple:
|
||||
"""Return (base_solid, [clipping_plane_dicts]) from a BooleanClippingResult chain."""
|
||||
planes = []
|
||||
current = item
|
||||
while current.is_a("IfcBooleanClippingResult"):
|
||||
plane = _half_space_plane(current.SecondOperand)
|
||||
if plane:
|
||||
planes.append(plane)
|
||||
current = current.FirstOperand
|
||||
return current, planes
|
||||
|
||||
|
||||
def _swept_solid_dict(item) -> dict:
|
||||
result: dict[str, Any] = {"solid_type": item.is_a()}
|
||||
if item.is_a("IfcExtrudedAreaSolid"):
|
||||
result["depth"] = item.Depth
|
||||
if item.ExtrudedDirection:
|
||||
result["direction"] = _rc(item.ExtrudedDirection.DirectionRatios)
|
||||
if item.SweptArea:
|
||||
result["profile"] = _profile_summary(item.SweptArea)
|
||||
return result
|
||||
|
||||
|
||||
def _summarize_rep(rep) -> dict:
|
||||
rep_type = rep.RepresentationType or ""
|
||||
result: dict[str, Any] = {"representation_type": rep_type}
|
||||
items = list(rep.Items)
|
||||
|
||||
if rep_type == "MappedRepresentation":
|
||||
for item in items:
|
||||
if item.is_a("IfcMappedItem"):
|
||||
return _summarize_rep(item.MappingSource.MappedRepresentation)
|
||||
|
||||
elif rep_type == "SweptSolid":
|
||||
result["solids"] = [_swept_solid_dict(item) for item in items]
|
||||
|
||||
elif rep_type == "Clipping":
|
||||
solids = []
|
||||
for item in items:
|
||||
base, planes = _walk_clipping(item)
|
||||
solid = _swept_solid_dict(base)
|
||||
if planes:
|
||||
solid["clipping_planes"] = planes
|
||||
solids.append(solid)
|
||||
result["solids"] = solids
|
||||
|
||||
elif rep_type == "CSG":
|
||||
ops = []
|
||||
for item in items:
|
||||
if hasattr(item, "Operator"):
|
||||
ops.append({"operator": str(item.Operator), "type": item.is_a()})
|
||||
if ops:
|
||||
result["operations"] = ops
|
||||
|
||||
elif rep_type in ("Brep", "Tessellation", "SolidModel"):
|
||||
face_count = 0
|
||||
vertex_count = 0
|
||||
for item in items:
|
||||
if item.is_a("IfcPolygonalFaceSet"):
|
||||
face_count += len(item.Faces)
|
||||
vertex_count += len(item.Coordinates.CoordList)
|
||||
elif item.is_a("IfcFacetedBrep"):
|
||||
face_count += len(item.Outer.CfsFaces)
|
||||
if face_count:
|
||||
result["face_count"] = face_count
|
||||
if vertex_count:
|
||||
result["vertex_count"] = vertex_count
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _geometry_summary(element) -> dict | None:
|
||||
if not hasattr(element, "Representation") or not element.Representation:
|
||||
return None
|
||||
body_rep = next(
|
||||
(r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"),
|
||||
None,
|
||||
)
|
||||
if body_rep is None:
|
||||
return None
|
||||
try:
|
||||
return _summarize_rep(body_rep)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_attribute(value: Any) -> Any:
|
||||
"""Convert an IFC attribute value to a JSON-serializable form."""
|
||||
if isinstance(value, ifcopenshell.entity_instance):
|
||||
return {"id": value.id(), "type": value.is_a()}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize_attribute(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
|
||||
"""Convert a material entity to a summary dict."""
|
||||
if material is None:
|
||||
return None
|
||||
result: dict[str, Any] = {
|
||||
"id": material.id(),
|
||||
"type": material.is_a(),
|
||||
}
|
||||
if hasattr(material, "Name"):
|
||||
result["name"] = material.Name
|
||||
return result
|
||||
|
||||
|
||||
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Return deep inspection data for an element."""
|
||||
result: dict[str, Any] = {
|
||||
"id": element.id(),
|
||||
"type": element.is_a(),
|
||||
}
|
||||
|
||||
# Direct attributes via get_info() which returns a dict of all attributes
|
||||
element_info = element.get_info()
|
||||
attrs = {}
|
||||
for key, value in element_info.items():
|
||||
if key in ("id", "type"):
|
||||
continue
|
||||
attrs[key] = _serialize_attribute(value)
|
||||
result["attributes"] = attrs
|
||||
|
||||
# Property sets and quantity sets
|
||||
try:
|
||||
psets = ifcopenshell.util.element.get_psets(element)
|
||||
if psets:
|
||||
result["property_sets"] = psets
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Element type
|
||||
try:
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
type_info: dict[str, Any] = {
|
||||
"id": element_type.id(),
|
||||
"type": element_type.is_a(),
|
||||
}
|
||||
if hasattr(element_type, "Name"):
|
||||
type_info["name"] = element_type.Name
|
||||
result["element_type"] = type_info
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Material
|
||||
try:
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
mat_dict = _material_to_dict(material)
|
||||
if mat_dict:
|
||||
result["material"] = mat_dict
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Spatial container
|
||||
try:
|
||||
container = ifcopenshell.util.element.get_container(element)
|
||||
if container:
|
||||
result["container"] = {
|
||||
"id": container.id(),
|
||||
"type": container.is_a(),
|
||||
"name": container.Name if hasattr(container, "Name") else None,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Placement (as 4x4 matrix)
|
||||
try:
|
||||
if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
|
||||
matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
||||
result["placement"] = matrix.tolist()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Geometry summary
|
||||
geom = _geometry_summary(element)
|
||||
if geom:
|
||||
result["geometry_summary"] = geom
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,100 @@
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def materials(model: ifcopenshell.file) -> list[dict]:
|
||||
"""Return all materials and material sets from the model.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
|
||||
IfcMaterialConstituentSet, and IfcMaterialProfileSet entities.
|
||||
"""
|
||||
results = []
|
||||
|
||||
for m in model.by_type("IfcMaterial"):
|
||||
results.append(
|
||||
{
|
||||
"id": m.id(),
|
||||
"type": "IfcMaterial",
|
||||
"name": m.Name,
|
||||
"category": getattr(m, "Category", None),
|
||||
}
|
||||
)
|
||||
|
||||
for ls in model.by_type("IfcMaterialLayerSet"):
|
||||
layers = []
|
||||
for layer in ls.MaterialLayers or []:
|
||||
layers.append(
|
||||
{
|
||||
"name": layer.Name,
|
||||
"thickness": layer.LayerThickness,
|
||||
"material": layer.Material.Name if layer.Material else None,
|
||||
"is_ventilated": layer.IsVentilated,
|
||||
}
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"id": ls.id(),
|
||||
"type": "IfcMaterialLayerSet",
|
||||
"name": ls.LayerSetName,
|
||||
"layers": layers,
|
||||
}
|
||||
)
|
||||
|
||||
for cs in model.by_type("IfcMaterialConstituentSet"):
|
||||
constituents = []
|
||||
for c in cs.MaterialConstituents or []:
|
||||
constituents.append(
|
||||
{
|
||||
"name": c.Name,
|
||||
"material": c.Material.Name if c.Material else None,
|
||||
"fraction": c.Fraction,
|
||||
}
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"id": cs.id(),
|
||||
"type": "IfcMaterialConstituentSet",
|
||||
"name": cs.Name,
|
||||
"constituents": constituents,
|
||||
}
|
||||
)
|
||||
|
||||
for ps in model.by_type("IfcMaterialProfileSet"):
|
||||
profiles = []
|
||||
for p in ps.MaterialProfiles or []:
|
||||
profiles.append(
|
||||
{
|
||||
"name": p.Name,
|
||||
"material": p.Material.Name if p.Material else None,
|
||||
}
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"id": ps.id(),
|
||||
"type": "IfcMaterialProfileSet",
|
||||
"name": ps.Name,
|
||||
"profiles": profiles,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,284 @@
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.selector
|
||||
|
||||
try:
|
||||
import ifcopenshell.draw
|
||||
|
||||
_HAS_DRAW = True
|
||||
except ImportError:
|
||||
_HAS_DRAW = False
|
||||
|
||||
from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace
|
||||
|
||||
try:
|
||||
import cairosvg # type: ignore
|
||||
|
||||
_HAS_CAIROSVG = True
|
||||
except Exception:
|
||||
_HAS_CAIROSVG = False
|
||||
|
||||
|
||||
try:
|
||||
from PIL import Image # type: ignore
|
||||
|
||||
_HAS_PIL = True
|
||||
except Exception:
|
||||
_HAS_PIL = False
|
||||
|
||||
VIEWS = ("floorplan", "elevation", "section", "auto")
|
||||
OUTPUT_FORMATS = ("svg", "png", "base64")
|
||||
|
||||
|
||||
def _escape_css_attr(name: str) -> str:
|
||||
# CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid)
|
||||
return name.replace(":", "\\:")
|
||||
|
||||
|
||||
def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str:
|
||||
guids: list[str] = []
|
||||
for sid in element_ids:
|
||||
try:
|
||||
e = model.by_id(int(sid))
|
||||
except RuntimeError:
|
||||
continue
|
||||
if e is None:
|
||||
continue
|
||||
gid = getattr(e, "GlobalId", None)
|
||||
if isinstance(gid, str) and gid:
|
||||
guids.append(gid)
|
||||
|
||||
if not guids:
|
||||
return ""
|
||||
|
||||
attr = _escape_css_attr("ifc:guid")
|
||||
|
||||
css = [
|
||||
"/* Auto-highlight injected by ifcquery.plot */",
|
||||
f"[{attr}] path {{ opacity: 0.10; }}",
|
||||
f"[{attr}] text {{ opacity: 0.25; }}",
|
||||
]
|
||||
for gid in guids:
|
||||
css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}')
|
||||
css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}')
|
||||
return "\n".join(css) + "\n"
|
||||
|
||||
|
||||
def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator:
|
||||
# Avoid multiprocessing in WASM; os.cpu_count is good enough.
|
||||
n_threads = os.cpu_count() or 1
|
||||
|
||||
# These flags mirror the defaults used by ifcopenshell.draw in v0.8.x.
|
||||
geom_settings = ifcopenshell.geom.settings(
|
||||
REORIENT_SHELLS=False,
|
||||
ELEMENT_HIERARCHY=True,
|
||||
)
|
||||
|
||||
# IfcOpenShell wrapper constants may live in different places across builds.
|
||||
wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None)
|
||||
if wrapper is not None:
|
||||
try:
|
||||
geom_settings.set("iterator-output", wrapper.NATIVE)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
geom_settings.set("apply-default-materials", True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements)
|
||||
|
||||
|
||||
def _diagnose_empty_drawing(model: ifcopenshell.file, view: str) -> str:
|
||||
"""Return a helpful error message when ifcopenshell.draw produces no geometry groups."""
|
||||
hints = []
|
||||
|
||||
if view in ("floorplan", "auto"):
|
||||
storeys = model.by_type("IfcBuildingStorey")
|
||||
if not storeys:
|
||||
hints.append("the model has no IfcBuildingStorey entities (required for auto_floorplan)")
|
||||
else:
|
||||
null_elevation = [s for s in storeys if getattr(s, "Elevation", None) is None]
|
||||
if null_elevation:
|
||||
names = ", ".join(f'"{s.Name or s.GlobalId}"' for s in null_elevation)
|
||||
hints.append(
|
||||
f"storey Elevation is None for: {names} — "
|
||||
"set IfcBuildingStorey.Elevation (e.g. 0.0) so the section cut height can be determined"
|
||||
)
|
||||
|
||||
has_geom = any(getattr(e, "Representation", None) is not None for e in model.by_type("IfcProduct"))
|
||||
if not has_geom:
|
||||
hints.append("no IfcProduct entities have geometric representations")
|
||||
|
||||
base = f"No plan geometry found for view={view!r}."
|
||||
if hints:
|
||||
return base + " Possible causes: " + "; ".join(hints) + "."
|
||||
return base + " The model may lack geometry visible in this view."
|
||||
|
||||
|
||||
def plot(
|
||||
model: ifcopenshell.file,
|
||||
*,
|
||||
output_format: str = "png",
|
||||
selector: str | None = None,
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "floorplan",
|
||||
# SVG / page sizing (draw works in mm coordinates)
|
||||
width_mm: float = 297.0,
|
||||
height_mm: float = 420.0,
|
||||
scale: float = 1.0 / 100.0,
|
||||
merge_projection: bool = True,
|
||||
# PNG sizing (only for output_format png/base64)
|
||||
png_width: int = 1024,
|
||||
png_height: int = 1024,
|
||||
) -> bytes | dict[str, Any]:
|
||||
"""
|
||||
Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG).
|
||||
|
||||
Args:
|
||||
model: In-memory IFC model.
|
||||
output_format: 'svg' | 'png' | 'base64'
|
||||
- 'svg' -> returns SVG bytes
|
||||
- 'png' -> returns PNG bytes
|
||||
- 'base64'-> returns dict: {mime, png_b64, width, height, view}
|
||||
selector: ifcopenshell selector query to restrict plotted elements.
|
||||
element_ids: STEP ids to highlight; non-highlighted geometry is faded.
|
||||
view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto').
|
||||
width_mm, height_mm: Page size in mm.
|
||||
scale: Model-to-paper scale (0.01 means 1:100).
|
||||
merge_projection: Passed through to ifcopenshell.draw.main.
|
||||
png_width, png_height: Raster size in pixels for png/base64 outputs.
|
||||
|
||||
Raises:
|
||||
ImportError: if ifcopenshell.draw or CairoSVG is not available (as required).
|
||||
ValueError: invalid args or selector matches nothing.
|
||||
"""
|
||||
if output_format not in OUTPUT_FORMATS:
|
||||
raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}")
|
||||
if view not in VIEWS:
|
||||
raise ValueError(f"view must be one of {VIEWS}, got {view!r}")
|
||||
if not _HAS_DRAW:
|
||||
raise ImportError("ifcopenshell.draw is not available in this environment.")
|
||||
|
||||
# Configure draw settings
|
||||
settings = ifcopenshell.draw.draw_settings(
|
||||
auto_floorplan=(view in ("floorplan", "auto")),
|
||||
auto_elevation=(view in ("elevation", "auto")),
|
||||
auto_section=(view in ("section", "auto")),
|
||||
width=width_mm,
|
||||
height=height_mm,
|
||||
scale=scale,
|
||||
css="",
|
||||
)
|
||||
|
||||
# Optional highlight CSS overlay
|
||||
if element_ids:
|
||||
settings.css = _highlight_css_from_ids(model, element_ids)
|
||||
|
||||
# Optional element restriction via selector -> custom iterator
|
||||
iterators: tuple[Any, ...] = ()
|
||||
if selector:
|
||||
include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||
if not include_elements:
|
||||
raise ValueError(f"Selector {selector!r} matched no elements")
|
||||
it = _make_filtered_iterator(model, include_elements)
|
||||
iterators = (it,)
|
||||
# If we explicitly include elements, don't rely on exclude_entities (best-effort).
|
||||
settings.exclude_entities = ""
|
||||
|
||||
# Generate SVG
|
||||
svg_bytes = ifcopenshell.draw.main(
|
||||
settings,
|
||||
files=[model],
|
||||
iterators=iterators,
|
||||
merge_projection=merge_projection,
|
||||
)
|
||||
|
||||
register_namespace("", "http://www.w3.org/2000/svg")
|
||||
|
||||
def svg_split(f):
|
||||
x = ElementTree(file=f)
|
||||
svg = x.getroot()
|
||||
resources = []
|
||||
for child in svg:
|
||||
if child.tag == "{http://www.w3.org/2000/svg}g":
|
||||
root = Element(svg.tag, svg.attrib)
|
||||
n = ElementTree(root)
|
||||
for r in resources + [child]:
|
||||
root.append(r)
|
||||
b = BytesIO()
|
||||
n.write(b, xml_declaration=True, encoding="utf-8", method="xml")
|
||||
yield b.getvalue()
|
||||
else:
|
||||
resources.append(child)
|
||||
|
||||
if output_format == "svg":
|
||||
return svg_bytes
|
||||
|
||||
# Need CairoSVG for png/base64
|
||||
if not _HAS_CAIROSVG:
|
||||
raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg")
|
||||
|
||||
svgs = list(svg_split(BytesIO(svg_bytes)))
|
||||
if not svgs:
|
||||
raise ValueError(_diagnose_empty_drawing(model, view))
|
||||
|
||||
composite = None
|
||||
png_bytes = None
|
||||
for i, svgb in enumerate(svgs):
|
||||
png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height)
|
||||
if len(svgs) == 1:
|
||||
break
|
||||
|
||||
# Need Pillow for concatenating images
|
||||
if not _HAS_PIL:
|
||||
raise ImportError("Pillow is not installed. Install with: pip install Pillow")
|
||||
|
||||
if composite is None:
|
||||
composite = Image.new("RGBA", (png_width, png_height * len(svgs)))
|
||||
img = Image.open(BytesIO(png_bytes))
|
||||
composite.paste(img, (0, png_height * i))
|
||||
if composite is not None:
|
||||
b = BytesIO()
|
||||
composite.save(b, "png")
|
||||
png_bytes = b.getvalue()
|
||||
|
||||
if output_format == "base64":
|
||||
return {
|
||||
"mime": "image/png",
|
||||
"png_b64": base64.b64encode(png_bytes).decode(),
|
||||
"width": png_width,
|
||||
"height": png_height,
|
||||
"view": view,
|
||||
}
|
||||
|
||||
return png_bytes
|
||||
@@ -0,0 +1,197 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.system
|
||||
|
||||
|
||||
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Serialize an element to a compact reference dict."""
|
||||
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
|
||||
if hasattr(element, "Name") and element.Name:
|
||||
result["name"] = element.Name
|
||||
return result
|
||||
|
||||
|
||||
def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
|
||||
return _ref(element) if element is not None else None
|
||||
|
||||
|
||||
def _ref_list(elements) -> list[dict[str, Any]]:
|
||||
return [_ref(e) for e in elements]
|
||||
|
||||
|
||||
def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]:
|
||||
"""Walk the hierarchy from element up to IfcProject."""
|
||||
chain = [_ref(element)]
|
||||
current = element
|
||||
while True:
|
||||
parent = ifcopenshell.util.element.get_parent(current)
|
||||
if parent is None:
|
||||
break
|
||||
chain.append(_ref(parent))
|
||||
current = parent
|
||||
return chain
|
||||
|
||||
|
||||
def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Collect all relationships for an element."""
|
||||
result: dict[str, Any] = {
|
||||
"id": element.id(),
|
||||
"type": element.is_a(),
|
||||
}
|
||||
if hasattr(element, "Name") and element.Name:
|
||||
result["name"] = element.Name
|
||||
|
||||
# Hierarchy (upward)
|
||||
hierarchy: dict[str, Any] = {}
|
||||
parent = ifcopenshell.util.element.get_parent(element)
|
||||
if parent is not None:
|
||||
hierarchy["parent"] = _ref(parent)
|
||||
container = ifcopenshell.util.element.get_container(element)
|
||||
if container is not None:
|
||||
hierarchy["container"] = _ref(container)
|
||||
aggregate = ifcopenshell.util.element.get_aggregate(element)
|
||||
if aggregate is not None:
|
||||
hierarchy["aggregate"] = _ref(aggregate)
|
||||
nest = ifcopenshell.util.element.get_nest(element)
|
||||
if nest is not None:
|
||||
hierarchy["nest"] = _ref(nest)
|
||||
filled_void = ifcopenshell.util.element.get_filled_void(element)
|
||||
if filled_void is not None:
|
||||
hierarchy["filled_void"] = _ref(filled_void)
|
||||
voided_element = ifcopenshell.util.element.get_voided_element(element)
|
||||
if voided_element is not None:
|
||||
hierarchy["voided_element"] = _ref(voided_element)
|
||||
if hierarchy:
|
||||
result["hierarchy"] = hierarchy
|
||||
|
||||
# Children (downward)
|
||||
children: dict[str, Any] = {}
|
||||
contained = ifcopenshell.util.element.get_contained(element)
|
||||
if contained:
|
||||
children["contained"] = _ref_list(contained)
|
||||
parts = ifcopenshell.util.element.get_parts(element)
|
||||
if parts:
|
||||
children["parts"] = _ref_list(parts)
|
||||
components = ifcopenshell.util.element.get_components(element)
|
||||
if components:
|
||||
children["components"] = _ref_list(components)
|
||||
openings = list(ifcopenshell.util.element.get_openings(element))
|
||||
if openings:
|
||||
children["openings"] = _ref_list(openings)
|
||||
if children:
|
||||
result["children"] = children
|
||||
|
||||
# Type relationship
|
||||
type_relationship: dict[str, Any] = {}
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is not None:
|
||||
type_relationship["type_of"] = _ref(element_type)
|
||||
try:
|
||||
occurrences = ifcopenshell.util.element.get_types(element)
|
||||
if occurrences:
|
||||
type_relationship["occurrences"] = _ref_list(occurrences)
|
||||
except Exception:
|
||||
pass
|
||||
if type_relationship:
|
||||
result["type_relationship"] = type_relationship
|
||||
|
||||
# Groups
|
||||
groups = ifcopenshell.util.element.get_groups(element)
|
||||
if groups:
|
||||
result["groups"] = _ref_list(groups)
|
||||
|
||||
# Systems
|
||||
systems = ifcopenshell.util.system.get_element_systems(element)
|
||||
if systems:
|
||||
result["systems"] = _ref_list(systems)
|
||||
|
||||
# Zones
|
||||
zones = ifcopenshell.util.system.get_element_zones(element)
|
||||
if zones:
|
||||
result["zones"] = _ref_list(zones)
|
||||
|
||||
# Material
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
if material is not None:
|
||||
result["material"] = _ref(material)
|
||||
|
||||
# Referenced structures
|
||||
referenced = ifcopenshell.util.element.get_referenced_structures(element)
|
||||
if referenced:
|
||||
result["referenced_structures"] = _ref_list(referenced)
|
||||
|
||||
# Connections
|
||||
connections: dict[str, Any] = {}
|
||||
connected_to = ifcopenshell.util.system.get_connected_to(element)
|
||||
if connected_to:
|
||||
connections["connected_to"] = _ref_list(connected_to)
|
||||
connected_from = ifcopenshell.util.system.get_connected_from(element)
|
||||
if connected_from:
|
||||
connections["connected_from"] = _ref_list(connected_from)
|
||||
ports = ifcopenshell.util.system.get_ports(element)
|
||||
if ports:
|
||||
connections["ports"] = _ref_list(ports)
|
||||
if connections:
|
||||
result["connections"] = connections
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -> None:
|
||||
"""Recursively collect all element refs (dicts with 'id') from a nested structure."""
|
||||
if isinstance(data, dict):
|
||||
if "id" in data and isinstance(data["id"], int):
|
||||
eid = data["id"]
|
||||
if eid not in seen:
|
||||
seen.add(eid)
|
||||
result.append(
|
||||
{"id": data["id"], "type": data.get("type"), "name": data.get("name")}
|
||||
if "name" in data
|
||||
else {"id": data["id"], "type": data.get("type")}
|
||||
)
|
||||
for v in data.values():
|
||||
_collect_elements(v, seen, result)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
_collect_elements(item, seen, result)
|
||||
|
||||
|
||||
def relations(
|
||||
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
|
||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
|
||||
if traverse == "up":
|
||||
return _traverse_up(element)
|
||||
result = _all_relations(model, element)
|
||||
|
||||
# Flat list of subject + all referenced elements, deduplicated.
|
||||
# Allows --format ids to extract all involved IDs without jq.
|
||||
seen: set[int] = set()
|
||||
elements: list[dict[str, Any]] = []
|
||||
_collect_elements(result, seen, elements)
|
||||
result["elements"] = elements
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,465 @@
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.selector
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import pyvista as pv
|
||||
|
||||
_HAS_PYVISTA = True
|
||||
except ImportError:
|
||||
_HAS_PYVISTA = False
|
||||
|
||||
VIEWS = ("iso", "top", "south", "north", "east", "west")
|
||||
|
||||
|
||||
def _apply_view(plotter: pv.Plotter, view: str) -> None:
|
||||
"""Set the camera to the requested named view. Z is up (IFC convention)."""
|
||||
if view == "top":
|
||||
plotter.view_xy()
|
||||
elif view == "south":
|
||||
# Camera at -Y looking toward +Y (south face of building)
|
||||
plotter.view_xz(negative=True)
|
||||
elif view == "north":
|
||||
plotter.view_xz(negative=False)
|
||||
elif view == "east":
|
||||
plotter.view_yz(negative=False)
|
||||
elif view == "west":
|
||||
plotter.view_yz(negative=True)
|
||||
else:
|
||||
plotter.view_isometric()
|
||||
# Ensure Z is world up for elevation views
|
||||
if view not in ("top",):
|
||||
plotter.camera.up = (0, 0, 1)
|
||||
|
||||
|
||||
def _add_shape(
|
||||
shape: object,
|
||||
plotter: pv.Plotter,
|
||||
highlight_ids: frozenset[int] | None,
|
||||
) -> None:
|
||||
"""Triangulate and add a geometry shape to the plotter."""
|
||||
geom = shape.geometry
|
||||
verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
|
||||
if verts.size == 0:
|
||||
return
|
||||
|
||||
raw_faces = np.array(geom.faces, dtype=int)
|
||||
if raw_faces.size == 0 or raw_faces.size % 3 != 0:
|
||||
return # degenerate geometry from kernel — skip silently
|
||||
faces = raw_faces.reshape(-1, 3)
|
||||
material_ids = np.array(geom.material_ids, dtype=int)
|
||||
|
||||
is_subject = highlight_ids is not None and shape.product.id() in highlight_ids
|
||||
|
||||
for midx, mat in enumerate(geom.materials):
|
||||
tri_mask = material_ids == midx
|
||||
if not np.any(tri_mask):
|
||||
continue
|
||||
|
||||
sub_faces = faces[tri_mask]
|
||||
faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel()
|
||||
mesh = pv.PolyData(verts, faces_pv)
|
||||
|
||||
if highlight_ids is not None and not is_subject:
|
||||
color = (180, 180, 180)
|
||||
opacity = 0.10
|
||||
else:
|
||||
diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0)
|
||||
color = tuple((diffuse * 255).astype(np.uint8))
|
||||
transparency = mat.transparency if mat.transparency == mat.transparency else 0.0
|
||||
opacity = float(np.clip(1.0 - transparency, 0.0, 1.0))
|
||||
|
||||
plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
|
||||
|
||||
|
||||
def _render_iterator(
|
||||
iterator: object,
|
||||
highlight_ids: list[int] | None,
|
||||
view: str,
|
||||
) -> bytes:
|
||||
"""Drive a geometry iterator into a pyvista plotter and return PNG bytes."""
|
||||
plotter = pv.Plotter(off_screen=True, window_size=(1280, 960))
|
||||
plotter.background_color = "white"
|
||||
|
||||
while True:
|
||||
try:
|
||||
_add_shape(iterator.get(), plotter, highlight_ids=frozenset(highlight_ids) if highlight_ids else None)
|
||||
except Exception:
|
||||
pass # skip broken shapes, keep rendering the rest
|
||||
if not iterator.next():
|
||||
break
|
||||
|
||||
plotter.reset_camera()
|
||||
_apply_view(plotter, view)
|
||||
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png")
|
||||
os.close(tmp_fd)
|
||||
try:
|
||||
plotter.show(screenshot=tmp_path, auto_close=True)
|
||||
with open(tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings:
|
||||
"""Build geometry settings, excluding Clearance subcontexts."""
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("use-world-coords", True)
|
||||
|
||||
clearance_ids = {
|
||||
c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance"
|
||||
}
|
||||
if clearance_ids:
|
||||
ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids]
|
||||
if ctx_ids:
|
||||
settings.set("context-ids", ctx_ids)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def _get_occurrence_class(type_entity) -> str:
|
||||
"""Derive the occurrence IFC class from a type entity class name."""
|
||||
type_class = type_entity.is_a()
|
||||
if type_class.endswith("Type"):
|
||||
return type_class[:-4]
|
||||
return "IfcBuildingElementProxy"
|
||||
|
||||
|
||||
def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
|
||||
"""Create a temporary occurrence for *type_entity* using its RepresentationMaps.
|
||||
|
||||
The occurrence is added to *model* and references the type's existing
|
||||
RepresentationMap entities via IfcMappedItem. Returns the occurrence entity,
|
||||
or ``None`` when the type has no usable RepresentationMaps.
|
||||
|
||||
.. note::
|
||||
This function is intended for use on a temporary model copy. The
|
||||
caller is responsible for discarding that copy after rendering.
|
||||
"""
|
||||
rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
|
||||
if not rep_maps:
|
||||
return None
|
||||
|
||||
# One IfcMappedItem per RepresentationMap.
|
||||
mapped_items = []
|
||||
for rep_map in rep_maps:
|
||||
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||
transform = model.create_entity(
|
||||
"IfcCartesianTransformationOperator3D",
|
||||
LocalOrigin=origin,
|
||||
)
|
||||
mapped_item = model.create_entity(
|
||||
"IfcMappedItem",
|
||||
MappingSource=rep_map,
|
||||
MappingTarget=transform,
|
||||
)
|
||||
mapped_items.append(mapped_item)
|
||||
|
||||
context = rep_maps[0].MappedRepresentation.ContextOfItems
|
||||
shape_rep = model.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=context,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="MappedRepresentation",
|
||||
Items=mapped_items,
|
||||
)
|
||||
prod_def_shape = model.create_entity(
|
||||
"IfcProductDefinitionShape",
|
||||
Representations=[shape_rep],
|
||||
)
|
||||
|
||||
# Identity placement.
|
||||
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||
z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
|
||||
x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
|
||||
axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir)
|
||||
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
|
||||
|
||||
occ_class = _get_occurrence_class(type_entity)
|
||||
try:
|
||||
occurrence = model.create_entity(
|
||||
occ_class,
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
Name=f"_type_preview_{type_entity.id()}",
|
||||
ObjectPlacement=placement,
|
||||
Representation=prod_def_shape,
|
||||
)
|
||||
except Exception:
|
||||
occurrence = model.create_entity(
|
||||
"IfcBuildingElementProxy",
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
Name=f"_type_preview_{type_entity.id()}",
|
||||
ObjectPlacement=placement,
|
||||
Representation=prod_def_shape,
|
||||
)
|
||||
return occurrence
|
||||
|
||||
|
||||
def _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
|
||||
"""Create a temporary occurrence for a type that has a material profile set.
|
||||
|
||||
Finds the first profile in the type's IfcMaterialProfileSet and creates a
|
||||
1-metre IfcExtrudedAreaSolid body representation from it. Returns the
|
||||
occurrence, or ``None`` when no usable profile is found.
|
||||
|
||||
.. note::
|
||||
Intended for use on a temporary model copy; caller discards it after
|
||||
rendering.
|
||||
"""
|
||||
# Locate the first profile from the type's material profile set.
|
||||
profile = None
|
||||
for rel in getattr(type_entity, "HasAssociations", []):
|
||||
if not rel.is_a("IfcRelAssociatesMaterial"):
|
||||
continue
|
||||
mat = rel.RelatingMaterial
|
||||
if mat.is_a("IfcMaterialProfileSetUsage"):
|
||||
mat = mat.ForProfileSet
|
||||
if mat.is_a("IfcMaterialProfileSet"):
|
||||
mat_profiles = list(getattr(mat, "MaterialProfiles", None) or [])
|
||||
if mat_profiles:
|
||||
profile = getattr(mat_profiles[0], "Profile", None)
|
||||
if profile is not None:
|
||||
break
|
||||
if profile is None:
|
||||
return None
|
||||
|
||||
# Find a Body subcontext, or fall back to any Model context.
|
||||
body_ctx = None
|
||||
for ctx in model.by_type("IfcGeometricRepresentationSubContext"):
|
||||
if ctx.ContextIdentifier == "Body":
|
||||
body_ctx = ctx
|
||||
break
|
||||
if body_ctx is None:
|
||||
for ctx in model.by_type("IfcGeometricRepresentationContext"):
|
||||
if ctx.ContextType == "Model":
|
||||
body_ctx = ctx
|
||||
break
|
||||
if body_ctx is None:
|
||||
return None
|
||||
|
||||
# Extrude 1 metre along Z (profile lies in XY plane).
|
||||
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||
z_axis = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
|
||||
x_axis = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
|
||||
position = model.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis)
|
||||
extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
|
||||
extrusion = model.create_entity(
|
||||
"IfcExtrudedAreaSolid",
|
||||
SweptArea=profile,
|
||||
Position=position,
|
||||
ExtrudedDirection=extrude_dir,
|
||||
Depth=1.0,
|
||||
)
|
||||
shape_rep = model.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=body_ctx,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="SweptSolid",
|
||||
Items=[extrusion],
|
||||
)
|
||||
prod_def_shape = model.create_entity(
|
||||
"IfcProductDefinitionShape",
|
||||
Representations=[shape_rep],
|
||||
)
|
||||
|
||||
# Identity placement.
|
||||
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||
z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
|
||||
x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
|
||||
axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir)
|
||||
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
|
||||
|
||||
occ_class = _get_occurrence_class(type_entity)
|
||||
try:
|
||||
occurrence = model.create_entity(
|
||||
occ_class,
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
Name=f"_profile_preview_{type_entity.id()}",
|
||||
ObjectPlacement=placement,
|
||||
Representation=prod_def_shape,
|
||||
)
|
||||
except Exception:
|
||||
occurrence = model.create_entity(
|
||||
"IfcBuildingElementProxy",
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
Name=f"_profile_preview_{type_entity.id()}",
|
||||
ObjectPlacement=placement,
|
||||
Representation=prod_def_shape,
|
||||
)
|
||||
return occurrence
|
||||
|
||||
|
||||
def _render_with_types(
|
||||
model: ifcopenshell.file,
|
||||
types: list,
|
||||
selector_elements: list | None,
|
||||
element_ids: list[int] | None,
|
||||
type_highlight_ids: set[int],
|
||||
view: str,
|
||||
) -> bytes:
|
||||
"""Render type entities by creating occurrences in a temporary model copy.
|
||||
|
||||
*types* — list of IfcTypeProduct entities to render.
|
||||
*selector_elements* — non-type elements from the selector (or ``None``).
|
||||
*element_ids* — original highlight IDs (may contain type IDs).
|
||||
*type_highlight_ids* — subset of *element_ids* that are type IDs.
|
||||
"""
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc")
|
||||
os.close(tmp_fd)
|
||||
try:
|
||||
model.write(tmp_path)
|
||||
tmp = ifcopenshell.open(tmp_path)
|
||||
|
||||
# Map original type step-ID → new occurrence step-ID in the tmp model.
|
||||
type_id_to_occ_id: dict[int, int] = {}
|
||||
for t in types:
|
||||
tmp_type = tmp.by_id(t.id())
|
||||
occ = _make_type_occurrence(tmp, tmp_type) or _make_profile_occurrence(tmp, tmp_type)
|
||||
if occ:
|
||||
type_id_to_occ_id[t.id()] = occ.id()
|
||||
|
||||
if not type_id_to_occ_id:
|
||||
raise ValueError("Type entities have no RepresentationMaps or material profile sets to render")
|
||||
|
||||
include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()]
|
||||
if selector_elements:
|
||||
include.extend(tmp.by_id(e.id()) for e in selector_elements)
|
||||
|
||||
settings = _build_geom_settings(tmp)
|
||||
iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include)
|
||||
if not iterator.initialize():
|
||||
raise ValueError("Type entities have no renderable geometry")
|
||||
|
||||
# Remap type IDs → occurrence IDs in the highlight list.
|
||||
new_highlight = None
|
||||
if element_ids:
|
||||
new_highlight = []
|
||||
for hid in element_ids:
|
||||
if hid in type_highlight_ids:
|
||||
mapped = type_id_to_occ_id.get(hid)
|
||||
if mapped:
|
||||
new_highlight.append(mapped)
|
||||
else:
|
||||
new_highlight.append(hid)
|
||||
|
||||
return _render_iterator(iterator, new_highlight, view)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def render(
|
||||
model: ifcopenshell.file,
|
||||
selector: str | None = None,
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "iso",
|
||||
) -> bytes:
|
||||
"""Render IFC model geometry to a PNG image.
|
||||
|
||||
Supports both element instances and element types (e.g. ``IfcWallType``).
|
||||
When type entities are targeted — via *selector* or *element_ids* — a
|
||||
temporary copy of the model is used to create proxy occurrences that
|
||||
reference the type's RepresentationMaps; the original model is not
|
||||
modified.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param selector: ifcopenshell selector to restrict rendered elements
|
||||
(e.g. ``'IfcWall'``, ``'IfcWallType'``, or
|
||||
``'IfcBuildingStorey[Name="Ground Floor"]'``).
|
||||
When omitted the whole model is rendered.
|
||||
:param element_ids: Step IDs of elements (or types) to highlight. The
|
||||
rest of the model is rendered in translucent grey so the highlighted
|
||||
items stand out.
|
||||
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
|
||||
``east``, or ``west``. Defaults to ``iso``.
|
||||
:return: PNG image as raw bytes.
|
||||
:raises ImportError: If pyvista is not installed.
|
||||
:raises ValueError: If the selector matches nothing or the model has no
|
||||
renderable geometry.
|
||||
"""
|
||||
if not _HAS_PYVISTA:
|
||||
raise ImportError("pyvista is not installed. Install with: pip install pyvista")
|
||||
|
||||
# --- Partition selector results into types and elements ---
|
||||
if selector:
|
||||
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||
if not matched:
|
||||
raise ValueError(f"Selector {selector!r} matched no elements")
|
||||
types = [e for e in matched if e.is_a("IfcTypeProduct")]
|
||||
selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")]
|
||||
else:
|
||||
types = []
|
||||
selector_elements = None # no restriction — render all elements
|
||||
|
||||
# --- Collect any type entities from element_ids ---
|
||||
type_highlight_ids: set[int] = set()
|
||||
if element_ids:
|
||||
for eid in element_ids:
|
||||
entity = model.by_id(eid)
|
||||
if entity.is_a("IfcTypeProduct"):
|
||||
type_highlight_ids.add(eid)
|
||||
seen = {t.id() for t in types}
|
||||
if eid not in seen:
|
||||
types.append(entity)
|
||||
|
||||
# --- Delegate to temp-copy path when any type entities are involved ---
|
||||
if types:
|
||||
return _render_with_types(model, types, selector_elements, element_ids, type_highlight_ids, view)
|
||||
|
||||
# --- Regular element rendering ---
|
||||
settings = _build_geom_settings(model)
|
||||
|
||||
if selector_elements is not None:
|
||||
if not selector_elements:
|
||||
raise ValueError(f"Selector {selector!r} matched only type entities (use a type selector or IfcElement)")
|
||||
iterator = ifcopenshell.geom.iterator(
|
||||
settings,
|
||||
model,
|
||||
multiprocessing.cpu_count(),
|
||||
include=selector_elements,
|
||||
)
|
||||
else:
|
||||
exclude = list(model.by_type("IfcOpeningElement"))
|
||||
iterator = ifcopenshell.geom.iterator(
|
||||
settings,
|
||||
model,
|
||||
multiprocessing.cpu_count(),
|
||||
exclude=exclude if exclude else None,
|
||||
)
|
||||
|
||||
if not iterator.initialize():
|
||||
raise ValueError("No renderable geometry found in model (or selector matched nothing)")
|
||||
|
||||
return _render_iterator(iterator, element_ids, view)
|
||||
@@ -0,0 +1,56 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.sequence as seq
|
||||
|
||||
|
||||
def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
|
||||
task_time = task.TaskTime
|
||||
start = None
|
||||
finish = None
|
||||
if task_time:
|
||||
start = task_time.ScheduleStart
|
||||
finish = task_time.ScheduleFinish
|
||||
|
||||
outputs = []
|
||||
for product in seq.get_task_outputs(task):
|
||||
outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)})
|
||||
|
||||
if max_depth is not None and depth >= max_depth:
|
||||
child_count = len(seq.get_nested_tasks(task))
|
||||
subtasks = {"truncated": True, "count": child_count} if child_count else []
|
||||
else:
|
||||
subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)]
|
||||
|
||||
return {
|
||||
"id": task.id(),
|
||||
"name": getattr(task, "Name", None),
|
||||
"start": start,
|
||||
"finish": finish,
|
||||
"is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False,
|
||||
"outputs": outputs,
|
||||
"subtasks": subtasks,
|
||||
}
|
||||
|
||||
|
||||
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of IfcWorkSchedule entries with nested task trees.
|
||||
|
||||
max_depth limits how many levels of subtasks are expanded (None = unlimited).
|
||||
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
result = []
|
||||
for work_schedule in model.by_type("IfcWorkSchedule"):
|
||||
tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)]
|
||||
result.append(
|
||||
{
|
||||
"id": work_schedule.id(),
|
||||
"name": getattr(work_schedule, "Name", None),
|
||||
"predefined_type": getattr(work_schedule, "PredefinedType", None),
|
||||
"tasks": tasks,
|
||||
}
|
||||
)
|
||||
return result
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user