diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml
index ddfa64a47a..68b6ac2dbb 100644
--- a/.github/workflows/ci-bonsai-daily.yml
+++ b/.github/workflows/ci-bonsai-daily.yml
@@ -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.
diff --git a/.github/workflows/ci-ifcedit-pypi.yaml b/.github/workflows/ci-ifcedit-pypi.yaml
new file mode 100644
index 0000000000..d961c72f56
--- /dev/null
+++ b/.github/workflows/ci-ifcedit-pypi.yaml
@@ -0,0 +1,35 @@
+name: ci-ifcedit-pypi
+
+on:
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ - name: Compile
+ run: |
+ pip install build
+ cd src/ifcedit &&
+ make dist IS_STABLE=TRUE
+ - name: Publish a Python distribution to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: src/ifcedit/dist
diff --git a/.github/workflows/ci-ifcmcp-pypi.yaml b/.github/workflows/ci-ifcmcp-pypi.yaml
new file mode 100644
index 0000000000..388c10217a
--- /dev/null
+++ b/.github/workflows/ci-ifcmcp-pypi.yaml
@@ -0,0 +1,36 @@
+name: ci-ifcmcp-pypi
+
+on:
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ - name: Compile
+ run: |
+ pip install build
+ cd src/ifcmcp &&
+ make dist IS_STABLE=TRUE
+ - name: Publish a Python distribution to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: src/ifcmcp/dist
+ verbose: true
diff --git a/.github/workflows/ci-ifcquery-pypi.yaml b/.github/workflows/ci-ifcquery-pypi.yaml
new file mode 100644
index 0000000000..9dc39a9304
--- /dev/null
+++ b/.github/workflows/ci-ifcquery-pypi.yaml
@@ -0,0 +1,35 @@
+name: ci-ifcquery-pypi
+
+on:
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ - name: Compile
+ run: |
+ pip install build
+ cd src/ifcquery &&
+ make dist IS_STABLE=TRUE
+ - name: Publish a Python distribution to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: src/ifcquery/dist
diff --git a/.github/workflows/publish-aichat-app.yaml b/.github/workflows/publish-aichat-app.yaml
new file mode 100644
index 0000000000..776781428e
--- /dev/null
+++ b/.github/workflows/publish-aichat-app.yaml
@@ -0,0 +1,65 @@
+name: Deploy AI chat App to static page repo
+
+permissions:
+ id-token: write
+ pages: write
+
+on:
+ push:
+ paths:
+ - 'src/ifcchat/**'
+ - '.github/workflows/publish-aichat-app.yaml'
+ branches:
+ - v0.8.0
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout (recursive)
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ fetch-depth: 0
+ - name: Checkout intermediate Pages repo
+ uses: actions/checkout@v4
+ with:
+ repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
+ ref: gh-pages
+ path: output
+ token: ${{ secrets.WEBSITE_PUBLISH }}
+ - name: Sync demo app into target subfolder
+ run: |
+ rsync -av --delete --exclude='.git/' src/ifcchat/ output/
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Download wheels
+ working-directory: output/
+ run: |
+ pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
+ - name: Commit and push if changed
+ working-directory: output
+ run: |
+ git config --global user.name 'IfcOpenBot'
+ git config --global user.email 'IfcOpenBot@users.noreply.github.com'
+
+ git add .
+ if git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+
+ git commit -m "$(git log --oneline -1)"
+ git push origin gh-pages
diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml
index d7107c410d..9f4c3ddbf8 100644
--- a/.github/workflows/publish-pyodide-demo-app.yml
+++ b/.github/workflows/publish-pyodide-demo-app.yml
@@ -1,4 +1,4 @@
-name: Deploy Pyodide Demo App to GitHub Pages
+name: Deploy Pyodide Demo App to static page repo
permissions:
id-token: write
@@ -11,6 +11,7 @@ on:
- '.github/workflows/publish-pyodide-demo-app.yml'
branches:
- v0.8.0
+ workflow_dispatch:
jobs:
activate:
@@ -30,21 +31,27 @@ jobs:
with:
submodules: recursive
fetch-depth: 0
- - name: Setup Pages
- uses: actions/configure-pages@v6
- - name: Upload static files as artifact
- id: deployment
- uses: actions/upload-pages-artifact@v4
+ - name: Checkout intermediate Pages repo
+ uses: actions/checkout@v4
with:
- path: src/pyodide/demo-app/
+ repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
+ ref: gh-pages
+ path: output
+ token: ${{ secrets.WEBSITE_PUBLISH }}
+ - name: Sync demo app into target subfolder
+ run: |
+ rsync -av --delete --exclude='.git/' src/pyodide/demo-app/ output/
+ - name: Commit and push if changed
+ working-directory: output
+ run: |
+ git config --global user.name 'IfcOpenBot'
+ git config --global user.email 'IfcOpenBot@users.noreply.github.com'
- deploy:
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
- needs: build
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v5
+ git add .
+ if git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+
+ git commit -m "$(git log --oneline -1)"
+ git push origin gh-pages
diff --git a/README.md b/README.md
index 6b77a04833..74c2ebe115 100644
--- a/README.md
+++ b/README.md
@@ -50,10 +50,13 @@ Contents
| [ifcconvert](https://docs.ifcopenshell.org/ifcconvert.html) | CLI app to convert IFC to many other formats | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcconvert/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcconvert&expanded=true)
| [ifccsv](https://docs.ifcopenshell.org/ifccsv.html) | Library and CLI app to export and import schedules from IFC | LGPL-3.0-or-later | [](https://pypi.org/project/ifccsv/) |
| [ifcdiff](https://docs.ifcopenshell.org/ifcdiff.html) | Compare changes between IFC models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcdiff/) |
+| [ifcedit](https://docs.ifcopenshell.org/ifcedit.html) | CLI wrapper for ifcopenshell.api IFC model mutation functions | LGPL-3.0-or-later | [](https://pypi.org/project/ifcedit/) |
| [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [](https://pypi.org/project/ifcfm/) |
| [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcmax.html)
+| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcmcp/) |
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [](https://pypi.org/project/ifcopenshell/) [](https://anaconda.org/conda-forge/ifcopenshell) [](https://anaconda.org/ifcopenshell/ifcopenshell) [](https://hub.docker.com/r/aecgeeks/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) |
| [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [](https://pypi.org/project/ifcpatch/) |
+| [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcquery/) |
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
| [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [](https://pypi.org/project/ifctester/) |
diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py
index d6758de903..267800469a 100644
--- a/src/bonsai/bonsai/bim/import_ifc.py
+++ b/src/bonsai/bonsai/bim/import_ifc.py
@@ -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
diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py
index e674cb56f4..e24f6f41d8 100644
--- a/src/bonsai/bonsai/bim/module/project/operator.py
+++ b/src/bonsai/bonsai/bim/module/project/operator.py
@@ -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():
diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py
index 70dae00438..c16bc55564 100644
--- a/src/bonsai/bonsai/tool/raycast.py
+++ b/src/bonsai/bonsai/tool/raycast.py
@@ -27,9 +27,11 @@ import mathutils
import numpy as np
from mathutils import Vector
+from bpy_extras import view3d_utils
+
import bonsai.core.tool
import bonsai.tool as tool
-
+from bpy_extras import view3d_utils
class Raycast(bonsai.core.tool.Raycast):
offset = 10
@@ -76,11 +78,7 @@ class Raycast(bonsai.core.tool.Raycast):
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
obj_matrix = obj.matrix_world.copy()
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
- bbox_edges = [
- (0,1),(1,2),(2,3),(3,0),
- (4,5),(5,6),(6,7),(7,4),
- (0,4),(1,5),(2,6),(3,7)
- ]
+ bbox_edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
transposed_bbox: list[Vector] = []
bbox_2d: list[float] = []
@@ -116,7 +114,9 @@ class Raycast(bonsai.core.tool.Raycast):
new_bbox = [x for x in new_bbox if x is not None]
for edge in bbox_edges:
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
- point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]])
+ point, _ = cls.intersect_edge_region_border(
+ context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]]
+ )
if point:
new_bbox.append(point)
if new_bbox:
@@ -149,7 +149,7 @@ class Raycast(bonsai.core.tool.Raycast):
z_near = -clip_start
za = a_view.z
zb = b_view.z
- denom = (zb - za)
+ denom = zb - za
if denom == 0.0:
return None, None
t = (z_near - za) / denom
@@ -202,25 +202,22 @@ class Raycast(bonsai.core.tool.Raycast):
if inter_world is None:
print("No intersection with viewport near plane found for the segment.")
- return
+ return None, None
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
if init_2d is not None and is_inside_region(init_2d, region):
final_world = inter_world
final_2d = init_2d
- final_t = initial_t
+ final_t = t_on_ab
else:
found_world, found_2d, found_t = find_nearby_onscreen_point(
- region, rv3d,
- onscreen_vert, offscreen_vert,
- t_on_ab,
- max_iters=600, step=0.01
+ region, rv3d, onscreen_vert, offscreen_vert, t_on_ab, max_iters=600, step=0.01
)
if found_world is None:
if init_2d is None:
print("Initial projection invalid and iterative search failed.")
- return
+ return None, None
# fallback: clamp projected point to border via manual mapping
final_2d = clamp_to_region_border(init_2d, region)
final_world = None
@@ -405,7 +402,7 @@ class Raycast(bonsai.core.tool.Raycast):
edge_verts[e] = (v1_2d, point)
else:
edge_verts[e] = (v1_2d, v2_2d)
-
+
snap_threshold = 10.0
for i, point in enumerate(verts_2d):
@@ -731,7 +728,8 @@ class Raycast(bonsai.core.tool.Raycast):
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
snap_obj = cls.create_snap_obj(obj)
- objs_to_raycast.append(snap_obj)
+ if snap_obj is not None:
+ objs_to_raycast.append(snap_obj)
return objs_to_raycast
@@ -823,8 +821,8 @@ class Raycast(bonsai.core.tool.Raycast):
hit = None
for snap_obj in objs_to_raycast:
- if (snap_obj.obj.type in {"EMPTY", "CURVE"}
- or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
+ if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
+ hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
@@ -846,7 +844,6 @@ class Raycast(bonsai.core.tool.Raycast):
hit = closest_wf_point["point"]
face_index = None
-
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
@@ -861,7 +858,6 @@ class Raycast(bonsai.core.tool.Raycast):
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
-
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
@@ -894,6 +890,8 @@ class Raycast(bonsai.core.tool.Raycast):
@classmethod
def create_snap_obj(cls, obj):
+ if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
+ return None
for snap_obj in cls.snap_objs:
if obj.name == snap_obj.obj.name:
return snap_obj
diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py
index f464779ea3..5755c02ba7 100644
--- a/src/bonsai/bonsai/tool/snap.py
+++ b/src/bonsai/bonsai/tool/snap.py
@@ -360,7 +360,6 @@ class Snap(bonsai.core.tool.Snap):
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
return plane_origin, plane_normal
-
# Polyline
polyline_props = tool.Model.get_polyline_props()
try:
@@ -392,7 +391,9 @@ class Snap(bonsai.core.tool.Snap):
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
detected_snaps.extend(closest_snaps)
- xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe)
+ xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
+ space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
+ )
for snap_obj in objs_to_raycast:
for snap in closest_snaps:
@@ -405,15 +406,19 @@ class Snap(bonsai.core.tool.Snap):
detected_snaps.append(point)
else:
# If it is a solid object that is closest to camera it ignores all the rest
- if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
- closest_snap = [snap] # discards objects that aren't the closest
+ if (
+ "is_closest_to_camera" in snap
+ and snap["is_closest_to_camera"]
+ and snap["group"] == "Object"
+ ):
+ closest_snap = [snap] # discards objects that aren't the closest
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
closest_snap.append(point)
detected_snaps = closest_snap
-
+
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
cut_snaps = []
diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py
index 7bbdb28904..11a41672bc 100644
--- a/src/bonsai/bonsai/tool/spatial.py
+++ b/src/bonsai/bonsai/tool/spatial.py
@@ -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":
diff --git a/src/ifcchat/README.md b/src/ifcchat/README.md
new file mode 100644
index 0000000000..f20aa8788d
--- /dev/null
+++ b/src/ifcchat/README.md
@@ -0,0 +1,10 @@
+IfcOpenShell AI Assistant
+=========================
+
+A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application.
+
+### Setup instructions
+
+```
+pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
+```
diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js
new file mode 100644
index 0000000000..516ec8f846
--- /dev/null
+++ b/src/ifcchat/app.js
@@ -0,0 +1,320 @@
+// app.js
+const $ = (id) => document.getElementById(id);
+
+const statusEl = $("status");
+const msgsEl = $("msgs");
+const sendBtn = $("send");
+const inputEl = $("input");
+const apiKeyEl = $("apiKey");
+const modelEl = $("model");
+const ifcFileEl = $("ifcFile");
+const newBtn = $("newModel");
+const downloadBtn = $("downloadIfc");
+
+function setBusy(isBusy, reason = "") {
+ const controls = [
+ $("send"),
+ $("newModel"),
+ $("downloadIfc"),
+ $("ifcFile"),
+ ];
+
+ for (const el of controls) el.disabled = isBusy;
+
+ $("input").disabled = isBusy;
+
+ const browseBtn = $("browseBtn");
+ if (browseBtn) {
+ browseBtn.classList.toggle("disabled", isBusy);
+ browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false");
+ browseBtn.tabIndex = isBusy ? -1 : 0;
+ }
+
+ setStatus(isBusy ? (reason || "Working…") : "Ready");
+}
+
+function addMessage(role, text) {
+ if (text.ok) {
+ text = text.data;
+ }
+ const wrap = document.createElement("div");
+ wrap.className = `msg ${role}`;
+ wrap.innerHTML = `
+
${role}
+ `;
+ const bubble = wrap.querySelector(".bubble");
+ bubble.textContent = text;
+ bubble.onclick = function () {
+ if (bubble.scrollHeight > 100 && role === "tool") {
+ bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
+ bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
+ }
+ }
+ msgsEl.appendChild(wrap);
+ msgsEl.scrollTop = msgsEl.scrollHeight;
+}
+
+function setStatus(text) {
+ statusEl.textContent = text;
+}
+
+const worker = new Worker("./ifc_worker.js", { type: "module" });
+
+function callWorker(type, payload = {}) {
+ return new Promise((resolve, reject) => {
+ const id = crypto.randomUUID();
+ const onMsg = (ev) => {
+ const msg = ev.data;
+ if (!msg || msg.id !== id) return;
+ worker.removeEventListener("message", onMsg);
+ if (msg.ok) resolve(msg);
+ else reject(new Error(msg.error || "Worker error"));
+ };
+ worker.addEventListener("message", onMsg);
+ worker.postMessage({ id, type, payload });
+ });
+}
+
+// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
+// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
+const tools = [
+ {
+ type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
+ parameters: { type: "object", properties: { schema: { type: "string" } }, 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" } },
+ required: ["element_id"], 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" } }, 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" } }, 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" } }, 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" } }, 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" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false }
+ },
+];
+
+const SYSTEM_INSTRUCTIONS = `
+You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
+Rules:
+- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
+- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
+- If there is no model and the user wants to create one, call ifc_new.
+- After edits, explain what changed and suggest downloading the IFC.
+Be concise. Avoid dumping huge trees unless asked.
+`;
+
+let inputItems = []; // running conversation state (Responses API style)
+
+async function openAIResponsesCreate({ apiKey, model, input, tools }) {
+ const res = await fetch("https://api.openai.com/v1/responses", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model,
+ instructions: SYSTEM_INSTRUCTIONS,
+ tools,
+ input,
+ }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`OpenAI error ${res.status}: ${text}`);
+ }
+ return await res.json();
+}
+
+function extractAssistantText(response) {
+ const out = [];
+ for (const item of response.output ?? []) {
+ if (item.type === "message" && item.role === "assistant") {
+ for (const c of item.content ?? []) {
+ if (c.type === "output_text") out.push(c.text);
+ }
+ }
+ }
+ return out.join("\n").trim();
+}
+
+async function runAgentTurn(userText) {
+ const apiKey = apiKeyEl.value.trim();
+ if (!apiKey) throw new Error("Missing API key");
+
+ // Add user message
+ inputItems.push({ role: "user", content: userText });
+
+ // Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
+ for (let i = 0; i < 64; i++) {
+ const response = await openAIResponsesCreate({
+ apiKey,
+ model: modelEl.value,
+ input: inputItems,
+ tools,
+ });
+
+ // Keep ALL output items (incl reasoning/tool calls) in the running state.
+ inputItems.push(...(response.output ?? []));
+
+ // Show any assistant text immediately
+ const text = extractAssistantText(response);
+ if (text) addMessage("assistant", text);
+
+ const calls = (response.output ?? []).filter((x) => x.type === "function_call");
+ if (calls.length === 0) return;
+
+ for (const call of calls) {
+ let args = {};
+ try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
+ catch { args = {}; }
+
+ addMessage("tool", `→ ${call.name}(${JSON.stringify(args)})`);
+
+ const toolRes = await callWorker("toolCall", { name: call.name, args });
+
+ // Feed tool result back to the model
+ inputItems.push({
+ type: "function_call_output",
+ call_id: call.call_id,
+ output: JSON.stringify(toolRes.result),
+ });
+
+ addMessage("tool", `← ${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
+ }
+ }
+
+ addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
+}
+
+sendBtn.onclick = async () => {
+ const text = inputEl.value.trim();
+ if (!text) return;
+ inputEl.value = "";
+ addMessage("user", text);
+ try {
+ setBusy(true, "Thinking…");
+ await runAgentTurn(text);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Error: ${e.message}`);
+ }
+};
+
+inputEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendBtn.click();
+ }
+});
+
+ifcFileEl.onchange = async () => {
+ const f = ifcFileEl.files?.[0];
+ if (!f) return;
+ setBusy(true, "Loading IFC into Pyodide…");
+ const buf = await f.arrayBuffer();
+ try {
+ const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]);
+ addMessage("assistant", r.result);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setStatus(true, "Error");
+ addMessage("assistant", `Load error: ${e.message}`);
+ }
+};
+
+newBtn.onclick = async () => {
+ try {
+ setBusy(true, "Creating new model…");
+ const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } });
+ addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Error: ${e.message}`);
+ }
+};
+
+downloadBtn.onclick = async () => {
+ try {
+ setBusy(true, "Exporting IFC…");
+ const r = await callWorker("exportIfc", {});
+ const blob = new Blob([r.bytes], { type: "application/octet-stream" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = r.filename || "model.ifc";
+ a.click();
+ URL.revokeObjectURL(url);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Export error: ${e.message}`);
+ }
+};
+
+(async () => {
+ try {
+ setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
+ await callWorker("init", {});
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Worker init failed: ${e.message}`);
+ }
+})();
\ No newline at end of file
diff --git a/src/ifcchat/ifc_worker.js b/src/ifcchat/ifc_worker.js
new file mode 100644
index 0000000000..517bc4b24c
--- /dev/null
+++ b/src/ifcchat/ifc_worker.js
@@ -0,0 +1,110 @@
+// ifc_worker.js (MODULE WORKER)
+import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
+
+let pyodide = null;
+let callToolPy = null;
+let initPromise = null;
+
+function ok(id, extra = {}, transfer = []) {
+ self.postMessage({ id, ok: true, ...extra }, transfer);
+}
+function fail(id, error) {
+ self.postMessage({ id, ok: false, error: String(error?.message || error) });
+}
+
+async function ensurePyodide() {
+ if (initPromise) return initPromise;
+
+ initPromise = (async () => {
+ // Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
+ pyodide = await loadPyodide({
+ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
+ });
+
+ await pyodide.loadPackage("micropip");
+ await pyodide.loadPackage("numpy");
+ await pyodide.loadPackage("shapely");
+ await pyodide.loadPackage("typing-extensions");
+
+ const micropip = pyodide.pyimport("micropip");
+ micropip.install("python-dateutil")
+
+ // Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
+ const pyVer = pyodide.runPython(`
+import sys
+f"{sys.version_info.major}.{sys.version_info.minor}"
+ `);
+
+ const wheelUrl =
+ pyVer === "3.13"
+ ? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
+ : "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
+
+ await micropip.install(wheelUrl);
+
+ await micropip.install([
+ "./dist/ifcquery-0.8.5-py3-none-any.whl",
+ "./dist/ifcedit-0.8.5-py3-none-any.whl",
+ "./dist/ifcopenshell_mcp-0.8.5-py3-none-any.whl",
+ "./dist/lark-1.3.1-py3-none-any.whl",
+ "./dist/isodate-0.7.2-py3-none-any.whl",
+ ])
+ await pyodide.runPythonAsync(`
+from ifcmcp.embedded import call_tool as _call_tool
+ `);
+ callToolPy = pyodide.globals.get("_call_tool");
+ })();
+
+ return initPromise;
+}
+
+function callTool(name, args) {
+ const pyArgs = pyodide.toPy(args);
+ const res = callToolPy(name, pyArgs);
+ pyArgs.destroy();
+ const resJs = res.toJs({ dict_converter: Object.fromEntries });
+ res.destroy();
+ return resJs;
+}
+
+self.onmessage = async (ev) => {
+ const { id, type, payload } = ev.data || {};
+ try {
+ if (type === "init") {
+ await ensurePyodide();
+ ok(id, { result: "ok" });
+ return;
+ }
+
+ await ensurePyodide();
+
+ if (type === "loadIfc") {
+ const { filename, bytes } = payload;
+ const path = `/tmp/${filename || "model.ifc"}`;
+ pyodide.FS.mkdirTree("/tmp");
+ pyodide.FS.writeFile(path, new Uint8Array(bytes));
+ const result = callTool("ifc_load", { path });
+ ok(id, { result });
+ return;
+ }
+
+ if (type === "exportIfc") {
+ const path = "/tmp/export.ifc";
+ const result = callTool("ifc_save", { path });
+ const data = pyodide.FS.readFile(path);
+ ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
+ return;
+ }
+
+ if (type === "toolCall") {
+ const { name, args } = payload;
+ const result = callTool(name, args || {});
+ ok(id, { result });
+ return;
+ }
+
+ throw new Error(`Unknown message type: ${type}`);
+ } catch (e) {
+ fail(id, e);
+ }
+};
\ No newline at end of file
diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html
new file mode 100644
index 0000000000..ac878ae615
--- /dev/null
+++ b/src/ifcchat/index.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+
+ IfcOpenShell AI Assistant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ • Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.
+ • Try “Add a new site and building named X” (will use ifc_edit).
+
+
+
+
+
+
+ Chat with your IFC model. This app uses ifcopenshell-mcp to interact with an IFC model. IFC is in its raw-serialized form provides little textual context on the meaning of attributes, the intent behind element instances and the resulting geometrical form. By using the IfcOpenShell high-level API through an MCP-like interface (Model Context Protocol; allows for structured interaction with APIs and tools) these limitations are addressed and the model can be interrogated on a higher semantic abstraction level. You can also use ifcopenshell-mcp on your local machine with any LLM provider that supports the MCP protocol.
+
+
+
+
+
+
+
+
+
+
+ IfcOpenShell AI Assistant
+
+
+
+
+ Status: Booting…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ifcedit/Makefile b/src/ifcedit/Makefile
new file mode 100644
index 0000000000..114e5f9ddc
--- /dev/null
+++ b/src/ifcedit/Makefile
@@ -0,0 +1,10 @@
+PACKAGE_NAME:=ifcedit
+include ../common.mk
+
+.PHONY: test
+test:
+ pytest tests
+
+.PHONY: qa
+qa:
+ black .
diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py
index 1b14863e5b..28292f378e 100644
--- a/src/ifcedit/ifcedit/__main__.py
+++ b/src/ifcedit/ifcedit/__main__.py
@@ -26,9 +26,9 @@ 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
-from ifcedit.foreach import run_foreach
def format_output(data, fmt: str) -> str:
diff --git a/src/ifcedit/tests/test_foreach.py b/src/ifcedit/tests/test_foreach.py
index e51c3c03be..d464fedcf3 100644
--- a/src/ifcedit/tests/test_foreach.py
+++ b/src/ifcedit/tests/test_foreach.py
@@ -36,7 +36,9 @@ class TestRunForeach:
def test_rename_single(self, model):
items = self._items(model, "IfcWall")
- result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items)
+ 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"] == []
@@ -44,7 +46,9 @@ class TestRunForeach:
def test_rename_multiple(self, model):
items = self._items(model, "IfcElement")
- result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items)
+ result = run_foreach(
+ model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items
+ )
assert result["ok"] is True
assert result["count"] == len(items)
@@ -73,7 +77,9 @@ class TestRunForeach:
{"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)
+ result = run_foreach(
+ model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items
+ )
assert result["ok"] is False
assert result["count"] == 1
assert len(result["errors"]) == 1
diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py
index 63bf75a217..00585d5172 100644
--- a/src/ifcedit/tests/test_main.py
+++ b/src/ifcedit/tests/test_main.py
@@ -111,8 +111,13 @@ class TestForeachCommand:
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"}',
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "Renamed"}',
stdin=walls_json,
)
assert rc == 0, f"stderr: {stderr}"
@@ -129,8 +134,13 @@ class TestForeachCommand:
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"}',
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "Bulk"}',
stdin=elements_json,
)
assert rc == 0, f"stderr: {stderr}"
@@ -140,8 +150,13 @@ class TestForeachCommand:
def test_foreach_empty_list(self, model_file):
stdout, stderr, rc = run_ifcedit(
- "foreach", model_file, "attribute.edit_attributes",
- "--product", "{id}", "--attributes", '{"Name": "X"}',
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "X"}',
stdin="[]",
)
assert rc == 0
@@ -151,7 +166,11 @@ class TestForeachCommand:
def test_foreach_invalid_json_stdin(self, model_file):
stdout, stderr, rc = run_ifcedit(
- "foreach", model_file, "root.remove_product", "--product", "{id}",
+ "foreach",
+ model_file,
+ "root.remove_product",
+ "--product",
+ "{id}",
stdin="not json",
)
assert rc != 0
@@ -159,7 +178,11 @@ class TestForeachCommand:
def test_foreach_not_array_stdin(self, model_file):
stdout, stderr, rc = run_ifcedit(
- "foreach", model_file, "root.remove_product", "--product", "{id}",
+ "foreach",
+ model_file,
+ "root.remove_product",
+ "--product",
+ "{id}",
stdin='{"id": 1}',
)
assert rc != 0
@@ -167,12 +190,19 @@ class TestForeachCommand:
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"}',
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "-o",
+ output,
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "OutFile"}',
stdin=walls_json,
)
assert rc == 0, f"stderr: {stderr}"
diff --git a/src/ifcmcp/Makefile b/src/ifcmcp/Makefile
new file mode 100644
index 0000000000..3dbcbb4e93
--- /dev/null
+++ b/src/ifcmcp/Makefile
@@ -0,0 +1,10 @@
+PACKAGE_NAME:=ifcmcp
+include ../common.mk
+
+.PHONY: test
+test:
+ pytest tests
+
+.PHONY: qa
+qa:
+ black .
diff --git a/src/ifcmcp/README.md b/src/ifcmcp/README.md
index 3867f7b79c..a0fe01c8ea 100644
--- a/src/ifcmcp/README.md
+++ b/src/ifcmcp/README.md
@@ -16,7 +16,7 @@ Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an opti
## Running the server
```bash
-python3 -m ifcmcp
+ifcmcp
```
This starts the server on stdio transport, suitable for use with Claude Code
@@ -27,7 +27,7 @@ or any MCP client.
Use the `claude mcp add` command:
```bash
-claude mcp add --transport stdio ifc -- python3 -m ifcmcp
+claude mcp add --transport stdio ifc -- ifcmcp
```
Or create a `.mcp.json` file in your project root:
@@ -37,8 +37,7 @@ Or create a `.mcp.json` file in your project root:
"mcpServers": {
"ifc": {
"type": "stdio",
- "command": "python3",
- "args": ["-m", "ifcmcp"]
+ "command": "ifcmcp"
}
}
}
diff --git a/src/ifcmcp/ifcmcp/__main__.py b/src/ifcmcp/ifcmcp/__main__.py
index 47419dc209..da3f1deab7 100644
--- a/src/ifcmcp/ifcmcp/__main__.py
+++ b/src/ifcmcp/ifcmcp/__main__.py
@@ -31,9 +31,9 @@ def main():
from mcp.server.fastmcp import FastMCP # noqa: F401
except ImportError:
import sys
+
print(
- "error: the 'mcp' package is required to run the server.\n"
- "Install it with: pip install mcp",
+ "error: the 'mcp' package is required to run the server.\n" "Install it with: pip install mcp",
file=sys.stderr,
)
sys.exit(1)
diff --git a/src/ifcmcp/pyproject.toml b/src/ifcmcp/pyproject.toml
index 1cfe734e4b..295954d0c5 100644
--- a/src/ifcmcp/pyproject.toml
+++ b/src/ifcmcp/pyproject.toml
@@ -3,7 +3,7 @@ requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
-name = "ifcmcp"
+name = "ifcopenshell-mcp"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
diff --git a/src/ifcopenshell-python/docs/ifcedit.rst b/src/ifcopenshell-python/docs/ifcedit.rst
new file mode 100644
index 0000000000..d5db9b7c82
--- /dev/null
+++ b/src/ifcopenshell-python/docs/ifcedit.rst
@@ -0,0 +1,119 @@
+.. This file was generated with the assistance of an AI coding tool.
+
+IfcEdit
+=======
+
+IfcEdit is a CLI wrapper for the full ``ifcopenshell.api`` mutation API. It
+exposes all editor functions — over 350 across 30+ modules — without requiring
+you to write a Python script. It supports four subcommands:
+
+- **list** — list all API modules, or all functions within a module
+- **docs** — show full documentation for a function (parameters, types, descriptions)
+- **run** — execute a mutation against an IFC file
+- **foreach** — apply an API function to each element in a JSON array read from stdin
+- **quantify** — run quantity take-off using ifc5d rules; requires the IfcOpenShell C++ geometry bindings
+
+Installation
+------------
+
+::
+
+ pip install ifcedit
+
+Or install from source:
+
+1. :doc:`Install IfcOpenShell `
+2. `Clone the IfcOpenShell repository `_.
+3. ``cd /path/to/IfcOpenShell/src/ifcedit``
+4. ``pip install .``
+
+Usage
+-----
+
+Discover available API functions::
+
+ $ ifcedit list
+ $ ifcedit list root
+ $ ifcedit list geometry
+
+Read documentation for a function::
+
+ $ ifcedit docs root.remove_product
+ $ ifcedit docs type.assign_type
+
+Execute a mutation (overwrites the input file by default)::
+
+ $ ifcedit run model.ifc root.remove_product --product 42
+ $ ifcedit run model.ifc type.assign_type --related_objects 10 --relating_type 20
+
+Write to a separate output file::
+
+ $ ifcedit run model.ifc root.create_entity -o output.ifc --ifc_class IfcWall
+
+Dry-run to validate without modifying the file::
+
+ $ ifcedit run model.ifc root.remove_product --dry-run --product 42
+
+Apply an API function to each element in a JSON array from stdin (``{field}``
+placeholders are substituted from each item; model is opened and saved once)::
+
+ $ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
+ $ ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
+ --product {id} --attributes '{"Name": "Door"}'
+
+Write to a separate output file instead of overwriting::
+
+ $ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product {id}
+
+Quantity take-off (writes ``IfcElementQuantity`` psets back to the file; requires C++ geometry bindings)::
+
+ $ ifcedit quantify list
+ $ ifcedit quantify run model.ifc IFC4QtoBaseQuantities
+ $ ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
+ $ ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
+
+Parameter types
+---------------
+
+IfcEdit automatically coerces CLI string arguments to the correct Python types
+using the type hints on each API function:
+
+.. list-table::
+ :header-rows: 1
+
+ * - 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"``
+ - list of resolved entities
+ * - ``dict``
+ - ``'{"key": "val"}'``
+ - parsed JSON object
+ * - ``Literal["A", "B"]``
+ - ``"A"``
+ - validated against allowed values
+
+.. seealso::
+
+ Use :doc:`IfcQuery ` for read-only inspection of IFC files, and
+ :doc:`IfcMCP ` for interactive AI-assisted editing with an in-memory
+ session.
diff --git a/src/ifcopenshell-python/docs/ifcmcp.rst b/src/ifcopenshell-python/docs/ifcmcp.rst
new file mode 100644
index 0000000000..2f7f0c39f0
--- /dev/null
+++ b/src/ifcopenshell-python/docs/ifcmcp.rst
@@ -0,0 +1,110 @@
+.. This file was generated with the assistance of an AI coding tool.
+
+IfcMCP
+======
+
+IfcMCP is an MCP (Model Context Protocol) server that exposes IfcOpenShell
+query and edit tools to AI coding assistants such as Claude. It wraps
+:doc:`IfcQuery ` and :doc:`IfcEdit `, holding the IFC model
+in memory across tool calls so no file I/O is required between operations.
+
+The ``ifcmcp`` package can also be used directly as a Python library without
+the MCP server layer.
+
+Installation
+------------
+
+To use IfcMCP as an MCP server, install it together with the ``mcp`` package::
+
+ pip install 'ifcmcp[mcp]'
+
+If you only want to use the library directly (without an MCP client)::
+
+ pip install ifcmcp
+
+Or install from source:
+
+1. :doc:`Install IfcOpenShell `
+2. `Clone the IfcOpenShell repository `_.
+3. ``cd /path/to/IfcOpenShell/src/ifcmcp``
+4. ``pip install '.[mcp]'``
+
+Setup
+-----
+
+Add the server to your MCP client. For Claude Code::
+
+ claude mcp add --transport stdio ifc -- ifcmcp
+
+Or add to ``.mcp.json``:
+
+.. code-block:: json
+
+ {
+ "mcpServers": {
+ "ifc": {
+ "type": "stdio",
+ "command": "ifcmcp"
+ }
+ }
+ }
+
+Available tools
+---------------
+
+**Session tools**
+
+- ``ifc_new(schema="IFC4")`` — create a new empty model in memory
+- ``ifc_load(path)`` — open an IFC file into memory
+- ``ifc_reset()`` — unload the current model, freeing all session state
+- ``ifc_save(path="")`` — write model to disk; empty path overwrites the original
+
+**Query tools**
+
+- ``ifc_summary()`` — schema version, entity counts, project metadata
+- ``ifc_tree()`` — full spatial hierarchy
+- ``ifc_info(element_id)`` — deep inspection by step ID
+- ``ifc_select(query)`` — filter elements by IFC class
+- ``ifc_relations(element_id, traverse="")`` — relationships for an element
+- ``ifc_clash(element_id, ...)`` — geometric intersection and clearance checks
+- ``ifc_validate(express_rules=False)`` — schema and constraint validation
+- ``ifc_schedule(max_depth=None)`` — work schedules with nested task trees
+- ``ifc_cost(max_depth=None)`` — cost schedules with nested cost item trees
+- ``ifc_schema(entity_type)`` — IFC class documentation
+- ``ifc_contexts()`` — geometric representation contexts
+- ``ifc_materials()`` — material definitions
+
+**Drawing and rendering tools**
+
+- ``ifc_plot(...)`` — generate a 2D drawing via ``ifcopenshell.draw`` and return it as an inline image the AI assistant can inspect; SVG always available, PNG requires ``cairosvg``
+- ``ifc_render(...)`` — off-screen 3D render returned as an inline PNG image the AI assistant can inspect; requires ``pyvista`` and the IfcOpenShell C++ geometry bindings
+
+**ShapeBuilder tools**
+
+- ``ifc_shape_list()`` — list all available ``ShapeBuilder`` methods
+- ``ifc_shape_docs(method)`` — documentation for a specific ``ShapeBuilder`` method
+- ``ifc_shape(method, params="{}")`` — execute a ``ShapeBuilder`` method; entity references resolved by step ID
+
+**Edit tools**
+
+- ``ifc_list(module="")`` — list API modules or functions
+- ``ifc_docs(function_path)`` — documentation for an API function
+- ``ifc_edit(function_path, params="{}")`` — execute an ``ifcopenshell.api`` mutation
+- ``ifc_quantify(rule, selector="")`` — run quantity take-off; writes ``IfcElementQuantity`` psets in-place
+
+Typical workflow
+----------------
+
+.. code-block:: text
+
+ ifc_load("/path/to/model.ifc")
+ ifc_summary()
+ ifc_tree()
+ ifc_info(42)
+ ifc_edit("root.remove_product", '{"product": "42"}')
+ ifc_save()
+
+.. seealso::
+
+ :doc:`IfcQuery ` and :doc:`IfcEdit ` provide the same
+ functionality as standalone CLI tools for scripting and automation.
diff --git a/src/ifcopenshell-python/docs/ifcquery.rst b/src/ifcopenshell-python/docs/ifcquery.rst
new file mode 100644
index 0000000000..8735b63da2
--- /dev/null
+++ b/src/ifcopenshell-python/docs/ifcquery.rst
@@ -0,0 +1,102 @@
+.. This file was generated with the assistance of an AI coding tool.
+
+IfcQuery
+========
+
+IfcQuery is a CLI tool for querying and inspecting IFC building models. It
+provides read-only subcommands for common inspection tasks, all outputting JSON
+so results can be piped into other tools.
+
+Subcommands:
+
+- **summary** — schema version, entity counts, project metadata
+- **tree** — full spatial hierarchy (IfcProject → Site → Building → Storeys → Spaces → Elements)
+- **info** — deep inspection of any entity by step ID (attributes, property sets, placement matrix, type, material)
+- **select** — filter elements by IFC class using the IfcOpenShell selector syntax
+- **relations** — relationships for an element; use ``--traverse up`` to walk the hierarchy to IfcProject
+- **clash** — geometric intersection and clearance checks; requires the IfcOpenShell C++ geometry bindings
+- **validate** — schema and constraint validation; add ``--rules`` for a full EXPRESS check
+- **schedule** — work schedules with nested task trees
+- **cost** — cost schedules with nested cost item trees
+- **schema** — IFC class documentation using the loaded model's schema version
+- **contexts** — geometric representation contexts
+- **materials** — material definitions (IfcMaterial, layer sets, constituent sets, profile sets)
+- **plot** — generate a drawing (SVG or PNG) using ``ifcopenshell.draw``; PNG output requires ``cairosvg``
+- **render** — off-screen 3D render to a PNG image; requires ``pyvista`` and the IfcOpenShell C++ geometry bindings
+
+All subcommands accept ``--format json|text|ids`` to control output (default: ``json``):
+
+- ``json`` — 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
+
+Installation
+------------
+
+::
+
+ pip install ifcquery
+
+For PNG output from ``plot``, also install ``cairosvg``::
+
+ pip install cairosvg
+
+For 3D rendering with ``render``, also install ``pyvista``::
+
+ pip install pyvista
+
+Or install from source:
+
+1. :doc:`Install IfcOpenShell `
+2. `Clone the IfcOpenShell repository `_.
+3. ``cd /path/to/IfcOpenShell/src/ifcquery``
+4. ``pip install .``
+
+Usage
+-----
+
+::
+
+ $ ifcquery model.ifc summary
+ $ ifcquery model.ifc tree
+ $ ifcquery model.ifc info 42
+ $ ifcquery model.ifc select 'IfcWall'
+ $ ifcquery model.ifc relations 42
+ $ ifcquery model.ifc relations 42 --traverse up
+ $ ifcquery model.ifc validate
+ $ ifcquery model.ifc validate --rules
+ $ ifcquery model.ifc schedule
+ $ ifcquery model.ifc cost
+ $ ifcquery model.ifc schema IfcWall
+ $ ifcquery model.ifc materials
+ $ ifcquery model.ifc plot -o floorplan.svg --out-format svg --view floorplan
+ $ ifcquery model.ifc plot -o floorplan.png --view floorplan
+ $ ifcquery model.ifc render -o model.png
+ $ ifcquery model.ifc --format ids select 'IfcWall'
+
+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::
+
+ # 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}
+
+ # Render an element highlighted against everything related to it
+ $ ifcquery model.ifc render -o relations.png \
+ --element "$(ifcquery model.ifc --format ids relations 42)"
+
+ # Render a clash — subject and clashing elements highlighted together
+ $ ifcquery model.ifc render -o clash.png \
+ --element "$(ifcquery model.ifc --format ids clash 42)"
+
+.. seealso::
+
+ Use :doc:`IfcEdit ` to make mutations to IFC files from the command
+ line, and :doc:`IfcMCP ` for interactive AI-assisted editing.
diff --git a/src/ifcopenshell-python/docs/index.rst b/src/ifcopenshell-python/docs/index.rst
index 8d241bbf74..895dbece88 100644
--- a/src/ifcopenshell-python/docs/index.rst
+++ b/src/ifcopenshell-python/docs/index.rst
@@ -30,9 +30,12 @@ Let's learn IfcOpenShell!
ifcclash
ifccsv
ifcdiff
+ ifcedit
ifcfm
ifcmax
+ ifcmcp
ifcpatch
+ ifcquery
ifcsverchok
ifctester
other
diff --git a/src/ifcopenshell-python/docs/introduction.rst b/src/ifcopenshell-python/docs/introduction.rst
index 58430b6da8..39b963e8f7 100644
--- a/src/ifcopenshell-python/docs/introduction.rst
+++ b/src/ifcopenshell-python/docs/introduction.rst
@@ -69,9 +69,12 @@ IfcOpenShell is a modular ecosystem of tools that work together, where each tool
"`IfcClash `_", "A CLI utility and library that lets you perform clash detection on one or more IFC models. Clashes are defined in terms of clash sets with filters using the IFC query syntax."
"`IfcCSV `_", "View and edit IFC data using spreadsheets or tabular datasets, such as CSV, ODS, XLSX, Pandas DataFrames, and regular Python lists."
"`IfcDiff `_", "A CLI utility and library that lets you compare the changes between two IFC models."
+ "`IfcEdit `_", "A CLI wrapper for all ifcopenshell.api mutation functions. Browse available API modules, read per-function documentation, and run any API function against an IFC file from the command line."
"`IfcFM `_", "A highly standards-compliant tool (e.g. COBie 2.4, COBie 3.0, AOH-BSEM) to convert FM data in IFC databases to spreadsheets and other machine readable formats, such as ODS, XLSX, CSV, Pandas, XML, and JSON."
"`IfcMax `_", "A 3ds Max importer plugin able to import the IFC file format."
+ "`IfcMCP `_", "An MCP (Model Context Protocol) server that exposes IfcOpenShell query and edit tools to AI coding assistants. Loads a model into memory and keeps it there across tool calls, so no file I/O is needed between operations."
"`IfcPatch `_", "A CLI utility and library that lets you run and distribute predetermined modifications on an IFC file, known as a patch recipe. Useful in deploying a data pipeline or batch-fixing external models."
+ "`IfcQuery `_", "A CLI tool for querying and inspecting IFC building models. Subcommands cover spatial hierarchy, element inspection, relationship traversal, clash detection, schema documentation, work schedules, and cost schedules."
"`IfcSverchok `_", "A node based visual programming add-on for Blender to interact with IFC and Sverchok."
"`IfcTester `_", "Author and read Information Delivery Specification (IDS) files. You can validate IFC models against IDS and generate reports in multiple formats. It works from the command line, as a web app, or as a library."
"`VoxelisationToolkit `_", "Converts .ifc geometry into voxels, and lets you perform voxel based geometric analysis."
diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py
index 51638f6954..7d247c6fab 100644
--- a/src/ifcopenshell-python/ifcopenshell/__init__.py
+++ b/src/ifcopenshell-python/ifcopenshell/__init__.py
@@ -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``
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py
index 14c4fa9dcc..e01284fd4f 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py
@@ -21,7 +21,6 @@ from typing import Optional
import ifcopenshell
-
_ITEM_TYPE_TO_REP_TYPE = {
"IfcVertex": "Vertex",
"IfcVertexPoint": "Vertex",
diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi
index b2f868ec24..28caafc262 100644
--- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi
+++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi
@@ -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): ...
diff --git a/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py b/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py
index c8935c8e9b..38b6775c08 100644
--- a/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py
+++ b/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py
@@ -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
diff --git a/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py
index 8faf07baa9..b997a91bcf 100644
--- a/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py
+++ b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py
@@ -19,7 +19,6 @@
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.util.representation
-import ifcopenshell.util.shape_builder
import test.bootstrap
diff --git a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py
index 44e45dec28..4eb0e4e609 100644
--- a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py
+++ b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py
@@ -64,10 +64,18 @@ class TestAddGeoreferencing(test.bootstrap.IFC4):
def test_recovering_from_orphan_coordinate_operation(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
context = ifcopenshell.api.context.add_context(self.file, "Model")
- self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
+ self.file.create_entity(
+ "IfcMapConversion",
+ SourceCRS=context,
+ TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"),
+ )
ifcopenshell.api.georeference.remove_georeferencing(self.file)
# Simulate orphan by re-adding just a conversion without CRS
- self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
+ self.file.create_entity(
+ "IfcMapConversion",
+ SourceCRS=context,
+ TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"),
+ )
self.file.remove(self.file.by_type("IfcProjectedCRS")[0])
assert len(self.file.by_type("IfcProjectedCRS")) == 0
assert len(self.file.by_type("IfcCoordinateOperation")) == 1
diff --git a/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py b/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py
index c91e2f27c6..74182d1c69 100644
--- a/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py
+++ b/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py
@@ -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
diff --git a/src/ifcquery/Makefile b/src/ifcquery/Makefile
new file mode 100644
index 0000000000..f77132db5e
--- /dev/null
+++ b/src/ifcquery/Makefile
@@ -0,0 +1,10 @@
+PACKAGE_NAME:=ifcquery
+include ../common.mk
+
+.PHONY: test
+test:
+ pytest tests
+
+.PHONY: qa
+qa:
+ black .
diff --git a/src/ifcquery/README.md b/src/ifcquery/README.md
index 0c90ecf596..5cb895dc44 100644
--- a/src/ifcquery/README.md
+++ b/src/ifcquery/README.md
@@ -382,12 +382,14 @@ ifcquery model.ifc materials
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
+ifcquery model.ifc plot -o output.svg --out-format svg
+ifcquery model.ifc plot -o output.png --view floorplan --scale 0.01
```
Options:
+- `-o, --output ` -- output file path (default: `.svg` or `.png`)
+- `--out-format {svg,png,base64}` -- output format (default: `png`)
- `--view {floorplan,elevation,section,auto}` -- drawing view (default: `floorplan`)
- `--scale ` -- model-to-paper scale ratio (default: 0.01 = 1:100)
- `--width-mm ` -- paper width in mm (default: 297)
@@ -395,16 +397,15 @@ Options:
- `--png-width ` -- raster output width in pixels (default: 1024)
- `--png-height ` -- raster output height in pixels (default: 1024)
-Writes SVG when the output path ends in `.svg`, otherwise PNG.
-Requires the IfcOpenShell drawing module (`ifcopenshell.draw`).
+Requires the IfcOpenShell drawing module (`ifcopenshell.draw`). PNG output additionally requires `cairosvg`.
### 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
+ifcquery model.ifc render -o output.png
+ifcquery model.ifc render -o output.png --view iso --selector IfcWall
```
Options:
diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py
index 334a3241a3..556ee0324d 100644
--- a/src/ifcquery/ifcquery/relations.py
+++ b/src/ifcquery/ifcquery/relations.py
@@ -167,7 +167,11 @@ def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -
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")})
+ 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):