Merge branch 'v0.8.0' into ifcmax/initial-refresh

This commit is contained in:
Josef Wienerroither
2026-04-01 16:49:01 +02:00
39 changed files with 1463 additions and 106 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
# Download Blender.
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.1.0-linux-x64.tar.xz
tar -xf blender.tar.xz
# Setup Blender.
+35
View File
@@ -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
+36
View File
@@ -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
+35
View File
@@ -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
+65
View File
@@ -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
+24 -17
View File
@@ -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
+3
View File
@@ -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\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcconvert/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcconvert-*&label=GitHub&color=f6f8fa)](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 | [![PyPI](https://img.shields.io/pypi/v/ifccsv?label=PyPI&color=006dad)](https://pypi.org/project/ifccsv/) |
| [ifcdiff](https://docs.ifcopenshell.org/ifcdiff.html) | Compare changes between IFC models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcdiff?label=PyPI&color=006dad)](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 | [![PyPI](https://img.shields.io/pypi/v/ifcedit?label=PyPI&color=006dad)](https://pypi.org/project/ifcedit/) |
| [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcfm?label=PyPI&color=006dad)](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\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](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 | [![PyPI](https://img.shields.io/pypi/v/ifcmcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcmcp/) |
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](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 | [![PyPI](https://img.shields.io/pypi/v/ifcpatch?label=PyPI&color=006dad)](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 | [![PyPI](https://img.shields.io/pypi/v/ifcquery?label=PyPI&color=006dad)](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 | [![GitHub Unstable](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*.*&label=GitHub-Unstable&color=f6f8fa)](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 | [![PyPI](https://img.shields.io/pypi/v/ifctester?label=PyPI&color=006dad)](https://pypi.org/project/ifctester/) |
+2 -2
View File
@@ -64,8 +64,8 @@ class MaterialCreator:
mesh: Union[OBJECT_DATA_TYPE, None],
shape_has_openings: bool,
) -> None:
if (((rep := getattr(element, "Representation", ...)) is not ... and not rep) or
((rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep)
if ((rep := getattr(element, "Representation", ...)) is not ... and not rep) or (
(rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep
):
return
@@ -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():
+19 -21
View File
@@ -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
+10 -5
View File
@@ -360,7 +360,6 @@ class Snap(bonsai.core.tool.Snap):
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
return plane_origin, plane_normal
# Polyline
polyline_props = tool.Model.get_polyline_props()
try:
@@ -392,7 +391,9 @@ class Snap(bonsai.core.tool.Snap):
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
detected_snaps.extend(closest_snaps)
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe)
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
)
for snap_obj in objs_to_raycast:
for snap in closest_snaps:
@@ -405,15 +406,19 @@ class Snap(bonsai.core.tool.Snap):
detected_snaps.append(point)
else:
# If it is a solid object that is closest to camera it ignores all the rest
if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
closest_snap = [snap] # discards objects that aren't the closest
if (
"is_closest_to_camera" in snap
and snap["is_closest_to_camera"]
and snap["group"] == "Object"
):
closest_snap = [snap] # discards objects that aren't the closest
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
closest_snap.append(point)
detected_snaps = closest_snap
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
cut_snaps = []
+1 -5
View File
@@ -997,9 +997,7 @@ class Spatial(bonsai.core.tool.Spatial):
return obj
@classmethod
def set_obj_origin_to_polygon_center(
cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True
) -> None:
def set_obj_origin_to_polygon_center(cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True) -> None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
centroid = poly.centroid
if polygon_is_si:
@@ -1007,7 +1005,6 @@ class Spatial(bonsai.core.tool.Spatial):
else:
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
@classmethod
def get_2d_vertices_from_polygon(
cls,
@@ -1195,7 +1192,6 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
if tool.Ifc.get().schema == "IFC2X3":
+10
View File
@@ -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
```
+320
View File
@@ -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 = `
<div class="role ${role}">${role}</div>
<div class="bubble"></div>`;
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}`);
}
})();
+110
View File
@@ -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);
}
};
+322
View File
@@ -0,0 +1,322 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IfcOpenShell AI Assistant</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
header {
padding: 12px 16px;
border-bottom: 1px solid #ddd;
}
header input,
header select {
padding: 8px;
}
main {
display: grid;
grid-template-columns: 320px 1fr;
height: 100vh;
}
.side {
border-right: 1px solid #ddd;
padding: 100px 12px 12px 12px;
overflow: auto;
background: #f9f9f9;
}
.chat {
display: flex;
flex-direction: column;
height: 100%;
}
.msgs {
flex: 1;
overflow: auto;
padding: 16px;
}
.msg {
margin: 10px 0;
}
.msg .role {
font-size: 12px;
opacity: 0.7;
margin: 12px 0 4px 0;
}
.role.user {
text-align: right;
}
.msg .bubble {
padding: 0;
border-radius: 10px;
white-space: pre-wrap;
}
.msg.user .bubble {
padding: 10px 20px;
background: #eee;
width: 50%;
margin-left: auto;
border: solid 1px #ddd;
}
.msg.tool .bubble {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 60%;
}
.composer {
display: flex;
gap: 8px;
padding: 12px;
justify-content: center;
}
.composer textarea {
flex: 1;
resize: none;
height: 88px;
border: none;
}
.composer button {
align-self: center;
}
.composer .inner {
border: solid 1px #ddd;
border-radius: 20px;
padding: 10px;
display: flex;
width: 50%;
}
.status {
font-size: 12px;
opacity: 0.7;
}
section>.row>label {
display: block;
font-size: 12px;
opacity: 0.75;
margin-bottom: 6px;
font-weight: bold;
}
section>.row>label .small {
font-weight: normal;
display: block;
font-size: 80%;
}
.side .row {
margin-bottom: 32px;
}
.row button {
padding: 8px 10px;
}
#model {
background: white;
color: gray;
border: solid 1px #eee;
border-radius: 6px;
}
hr {
border: dashed 1px #ddd;
}
.btn-row {
display: flex;
gap: 10px;
}
.btn-row>.btn,
.btn-row>button.btn {
flex: 1 1 0;
min-width: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d0d0d0;
background: #eee;
cursor: pointer;
user-select: none;
text-decoration: none;
}
.btn:hover {
background: #ddd;
}
.btn:active {
transform: translateY(1px);
}
.btn-wide {
width: 100%;
}
.material-icons {
font-size: 18px;
line-height: 1;
}
/* Disabled state for label-button + normal buttons */
.btn.disabled,
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
.msg.tool .bubble {
max-height: 100px;
overflow: hidden;
}
#input {
border: none;
outline: none;
}
#input:focus,
#input:focus-visible {
outline: none;
box-shadow: none;
}
.code {
font-family: 'Courier New', Courier, monospace;
font-size: 90%;
background-color: #eee;
border: solid 1px #ddd;
padding: 2px;
display: inline-block;
}
</style>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<main>
<section class="side">
<div class="row">
<label>OpenAI API key<span class="small">stored in browser memory; only sent to OpenAI servers</span></label>
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
</div>
<hr />
<div class="row" style="margin-bottom: 10px;">
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to OpenAI servers</span></label>
<div class="btn-row">
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
<span class="material-icons">folder_open</span>
Browse
</label>
<button class="btn" id="newModel" type="button">
<span class="material-icons">add_box</span>
New IFC
</button>
</div>
<input id="ifcFile" type="file" accept=".ifc,.ifczip,.ifcxml,.zip" hidden />
</div>
<div class="row">
<button class="btn btn-wide" id="downloadIfc" type="button">
<span class="material-icons">download</span>
Download IFC
</button>
</div>
<hr />
<div class="row">
<label>Tips</label>
<div class="status">
• Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.<br />
• Try “Add a new site and building named X” (will use ifc_edit).
</div>
</div>
<div class="row">
<label>About</label>
<div class="status">
Chat with your IFC model. This app uses <span class="code">ifcopenshell-mcp</span> 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 <span class="code">ifcopenshell-mcp</span> on your local machine with any LLM provider that supports the MCP protocol.
</div>
</div>
</section>
<section class="chat">
<header>
<div class="row">
<img src="https://ifcopenshell.org/assets/images/logo.png" style="float: left; padding: 10px 20px 10px 0; ">
<strong>IfcOpenShell AI Assistant</strong>
<select id="model">
<option value="gpt-5">gpt-5</option>
<option value="gpt-4.1">gpt-4.1</option>
</select>
</div>
<div class="row">
Status: <span class="status" id="status">Booting…</span>
</div>
</header>
<div class="msgs" id="msgs"></div>
<div class="composer">
<div class="inner">
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
<button id="send" class="btn">Send <span class="material-icons">send</span></button>
</div>
</div>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
PACKAGE_NAME:=ifcedit
include ../common.mk
.PHONY: test
test:
pytest tests
.PHONY: qa
qa:
black .
+1 -1
View File
@@ -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:
+9 -3
View File
@@ -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
+41 -11
View File
@@ -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}"
+10
View File
@@ -0,0 +1,10 @@
PACKAGE_NAME:=ifcmcp
include ../common.mk
.PHONY: test
test:
pytest tests
.PHONY: qa
qa:
black .
+3 -4
View File
@@ -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"
}
}
}
+2 -2
View File
@@ -31,9 +31,9 @@ def main():
from mcp.server.fastmcp import FastMCP # noqa: F401
except ImportError:
import sys
print(
"error: the 'mcp' package is required to run the server.\n"
"Install it with: pip install mcp",
"error: the 'mcp' package is required to run the server.\n" "Install it with: pip install mcp",
file=sys.stderr,
)
sys.exit(1)
+1 -1
View File
@@ -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" },
+119
View File
@@ -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 <ifcopenshell-python/installation>`
2. `Clone the IfcOpenShell repository <https://github.com/IfcOpenShell/IfcOpenShell>`_.
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 <ifcquery>` for read-only inspection of IFC files, and
:doc:`IfcMCP <ifcmcp>` for interactive AI-assisted editing with an in-memory
session.
+110
View File
@@ -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 <ifcquery>` and :doc:`IfcEdit <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 <ifcopenshell-python/installation>`
2. `Clone the IfcOpenShell repository <https://github.com/IfcOpenShell/IfcOpenShell>`_.
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 <ifcquery>` and :doc:`IfcEdit <ifcedit>` provide the same
functionality as standalone CLI tools for scripting and automation.
+102
View File
@@ -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 <ifcopenshell-python/installation>`
2. `Clone the IfcOpenShell repository <https://github.com/IfcOpenShell/IfcOpenShell>`_.
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 <ifcedit>` to make mutations to IFC files from the command
line, and :doc:`IfcMCP <ifcmcp>` for interactive AI-assisted editing.
+3
View File
@@ -30,9 +30,12 @@ Let's learn IfcOpenShell!
ifcclash
ifccsv
ifcdiff
ifcedit
ifcfm
ifcmax
ifcmcp
ifcpatch
ifcquery
ifcsverchok
ifctester
other
@@ -69,9 +69,12 @@ IfcOpenShell is a modular ecosystem of tools that work together, where each tool
"`IfcClash <https://docs.ifcopenshell.org/ifcclash.html>`_", "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 <https://docs.ifcopenshell.org/ifccsv.html>`_", "View and edit IFC data using spreadsheets or tabular datasets, such as CSV, ODS, XLSX, Pandas DataFrames, and regular Python lists."
"`IfcDiff <https://docs.ifcopenshell.org/ifcdiff.html>`_", "A CLI utility and library that lets you compare the changes between two IFC models."
"`IfcEdit <https://docs.ifcopenshell.org/ifcedit.html>`_", "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 <https://docs.ifcopenshell.org/ifcfm.html>`_", "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 <https://docs.ifcopenshell.org/ifcmax.html>`_", "A 3ds Max importer plugin able to import the IFC file format."
"`IfcMCP <https://docs.ifcopenshell.org/ifcmcp.html>`_", "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 <https://docs.ifcopenshell.org/ifcpatch.html>`_", "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 <https://docs.ifcopenshell.org/ifcquery.html>`_", "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 <https://docs.ifcopenshell.org/ifcsverchok.html>`_", "A node based visual programming add-on for Blender to interact with IFC and Sverchok."
"`IfcTester <https://docs.ifcopenshell.org/ifctester.html>`_", "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 <https://github.com/opensourceBIM/voxelization_toolkit>`_", "Converts .ifc geometry into voxels, and lets you perform voxel based geometric analysis."
@@ -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``
@@ -21,7 +21,6 @@ from typing import Optional
import ifcopenshell
_ITEM_TYPE_TO_REP_TYPE = {
"IfcVertex": "Vertex",
"IfcVertexPoint": "Vertex",
@@ -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): ...
@@ -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
@@ -19,7 +19,6 @@
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
import test.bootstrap
@@ -64,10 +64,18 @@ class TestAddGeoreferencing(test.bootstrap.IFC4):
def test_recovering_from_orphan_coordinate_operation(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
context = ifcopenshell.api.context.add_context(self.file, "Model")
self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
self.file.create_entity(
"IfcMapConversion",
SourceCRS=context,
TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"),
)
ifcopenshell.api.georeference.remove_georeferencing(self.file)
# Simulate orphan by re-adding just a conversion without CRS
self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
self.file.create_entity(
"IfcMapConversion",
SourceCRS=context,
TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"),
)
self.file.remove(self.file.by_type("IfcProjectedCRS")[0])
assert len(self.file.by_type("IfcProjectedCRS")) == 0
assert len(self.file.by_type("IfcCoordinateOperation")) == 1
@@ -24,9 +24,7 @@ class TestRemoveResourceQuantity(test.bootstrap.IFC4):
def test_removing_a_resource_quantity(self):
self.file.create_entity("IfcProject")
resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcLaborResource")
ifcopenshell.api.resource.add_resource_quantity(
self.file, resource=resource, ifc_class="IfcQuantityTime"
)
ifcopenshell.api.resource.add_resource_quantity(self.file, resource=resource, ifc_class="IfcQuantityTime")
assert resource.BaseQuantity is not None
ifcopenshell.api.resource.remove_resource_quantity(self.file, resource=resource)
assert resource.BaseQuantity is None
+10
View File
@@ -0,0 +1,10 @@
PACKAGE_NAME:=ifcquery
include ../common.mk
.PHONY: test
test:
pytest tests
.PHONY: qa
qa:
black .
+7 -6
View File
@@ -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 <file>` -- output file path (default: `<ifc_file>.svg` or `<ifc_file>.png`)
- `--out-format {svg,png,base64}` -- output format (default: `png`)
- `--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)
@@ -395,16 +397,15 @@ Options:
- `--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`).
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:
+5 -1
View File
@@ -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):