Compare commits

...

6 Commits

Author SHA1 Message Date
Bruno Postle d8e9ae39a2 README: add missing tools (ifc_new, ifc_reset, ifc_contexts, ifc_materials, ifc_plot, ifc_render, ifc_shape_*); fix mcp dependency note; add readme to pyproject.toml 2026-03-23 23:41:50 +00:00
Bruno Postle 9fd4c5a6a8 Add ifcmcp MCP server for IFC model querying and editing 2026-03-23 23:23:49 +00:00
Bruno Postle 8b8f78095d geometry_creation.rst: add sections for assemblies, clipping normals, openings (#7844)
Generated with the assistance of an AI coding tool.
2026-03-23 23:02:15 +00:00
Bruno Postle 23ba9e4db0 Add geometry.clip_solid, clip_solid_bounded, and copy_representation APIs (#7843)
* Add geometry.clip_solid API
* Add geometry.clip_solid_bounded API
* Add geometry.copy_representation API
Deep-copies the named representation from a source element to a target
element.

Generated with the assistance of an AI coding tool.
2026-03-23 23:00:12 +00:00
Bruno Postle 1aec991f08 api: docstring improvements across geometry, sequence, and feature modules (#7842)
* Doc clarification for api.sequence.assign_process
* Doc clarification for api.geometry.edit_object_placement
* Doc clarification for api.feature.remove_feature
* Doc clarification for api.geometry.add_wall_representation clippings normal
* regenerate_wall_representation: document BBIM_Boolean preservation requirement

Generated with the assistance of an AI coding tool.
2026-03-23 22:57:28 +00:00
Bruno Postle bddf9b85f8 shape_builder: complete docstrings and return type annotations (#7841)
* shape_builder: complete docstrings and return type annotations
* shape_builder: warn about mixed item types in get_representation
* shape_builder: fix half_space_solid agreement_flag docstring

Generated with the assistance of an AI coding tool.
2026-03-23 22:54:55 +00:00
28 changed files with 3070 additions and 49 deletions
+401
View File
@@ -0,0 +1,401 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# ifcmcp
An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`,
holding the IFC model in memory across tool calls for fast interactive editing
sessions.
## Installation
```bash
pip install ifcmcp
```
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
## Running the server
```bash
python3 -m ifcmcp
```
This starts the server on stdio transport, suitable for use with Claude Code
or any MCP client.
### Claude Code configuration
Use the `claude mcp add` command:
```bash
claude mcp add --transport stdio ifc -- python3 -m ifcmcp
```
Or create a `.mcp.json` file in your project root:
```json
{
"mcpServers": {
"ifc": {
"type": "stdio",
"command": "python3",
"args": ["-m", "ifcmcp"]
}
}
}
```
After adding the server, restart Claude Code for the tools to become available.
Then load a model by asking Claude to use `ifc_load`:
```
load model.ifc using ifc_load
```
## Tools
### Session
#### ifc_new
Create a new empty IFC model in memory, replacing any currently loaded model.
```
ifc_new()
ifc_new(schema="IFC4X3")
```
Default schema is `IFC4`.
#### ifc_load
Open an IFC file into memory.
```
ifc_load(path="/path/to/model.ifc")
-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
```
#### ifc_reset
Unload the current model from memory, freeing all session state.
```
ifc_reset()
```
#### ifc_save
Write the in-memory model to disk. Empty path overwrites the original file.
```
ifc_save()
ifc_save(path="/path/to/output.ifc")
```
### Query tools
All query tools require a model to be loaded first via `ifc_load`.
#### ifc_summary
Model overview: schema, entity counts, project info.
```json
{
"schema": "IFC4",
"total_entities": 1847,
"project": {"id": 1, "name": "Office Building"},
"types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36}
}
```
#### ifc_tree
Full spatial hierarchy from IfcProject down through sites, buildings, storeys,
and contained elements.
```json
{
"id": 1,
"type": "IfcProject",
"name": "Office Building",
"children": [
{
"id": 2,
"type": "IfcSite",
"children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}]
}
]
}
```
#### ifc_info
Deep inspection of an entity by step ID: attributes, property sets, type,
material, container, and 4x4 placement matrix.
```
ifc_info(element_id=10)
```
#### ifc_select
Filter elements using ifcopenshell selector syntax.
```
ifc_select(query="IfcWall")
ifc_select(query="IfcWindow")
```
Returns a sorted list of `{"id", "type", "name"}` references.
#### ifc_relations
Show all relationships for an element: hierarchy, children, type, groups,
systems, material, connections.
```
ifc_relations(element_id=10)
ifc_relations(element_id=10, traverse="up")
```
With `traverse="up"`, walks the hierarchy from element up to IfcProject.
#### ifc_contexts
List all geometric representation contexts and subcontexts in the loaded model.
```
ifc_contexts()
```
#### ifc_materials
List all materials and material sets in the loaded model, with their assigned elements.
```
ifc_materials()
```
#### ifc_clash
Check an element for geometric intersections and clearance violations.
```
ifc_clash(element_id=10)
ifc_clash(element_id=10, clearance=0.5, scope="all")
```
Parameters:
- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check)
- `tolerance` -- intersection tolerance in meters (default: 0.002)
- `scope` -- `"storey"` or `"all"` (default: `"storey"`)
#### ifc_validate
Check the model for schema and constraint violations.
```
ifc_validate()
ifc_validate(express_rules=True)
```
Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`.
#### ifc_schedule
List all work schedules and their nested task trees.
```
ifc_schedule()
ifc_schedule(max_depth=1) # top-level phases only
```
`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced
with `{"truncated": true, "count": N}` so you know children exist without
fetching them all. Omit for unlimited depth.
#### ifc_cost
List all cost schedules and their nested cost item trees.
```
ifc_cost()
ifc_cost(max_depth=2) # top two levels of the BoQ
```
`max_depth` limits cost item expansion, same truncation convention as
`ifc_schedule`.
#### ifc_schema
Return IFC class documentation for any entity type, using the loaded model's
schema version.
```
ifc_schema(entity_type="IfcWall")
ifc_schema(entity_type="IfcBuildingStorey")
```
Returns description, predefined types, spec URL, and attribute descriptions.
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
#### ifc_quantify
Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule.
Computes physical measurements (volume, area, length, count, weight) and
writes them back as `IfcElementQuantity` property sets. Modifies the model
in-place -- call `ifc_save()` when done.
```
ifc_quantify(rule="IFC4QtoBaseQuantities")
ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
```
Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
`selector` is an optional ifcopenshell selector to restrict which elements
are quantified (default: all `IfcElement`).
Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
### Drawing and rendering tools
#### ifc_plot
Generate a 2D technical drawing of the loaded model and return it as an inline image.
```
ifc_plot()
ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg")
ifc_plot(element_ids=[10, 11], view="floorplan")
```
Parameters:
- `selector` -- ifcopenshell selector to restrict plotted elements
- `element_ids` -- step IDs of elements to highlight; others are faded
- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"`
- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420)
- `scale` -- model-to-paper ratio (default: 0.01 = 1:100)
- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024)
- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG)
Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`.
#### ifc_render
Render the loaded model to a 3D PNG image.
```
ifc_render()
ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png")
ifc_render(element_ids=[10, 11], view="south")
```
Parameters:
- `selector` -- ifcopenshell selector to restrict rendered elements
- `element_ids` -- step IDs of elements to highlight; others are shown translucent
- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"`
- `output_path` -- optional path to save the PNG to disk
Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
### Shape builder tools
#### ifc_shape_list
List all available `ShapeBuilder` methods with brief descriptions.
```
ifc_shape_list()
```
#### ifc_shape_docs
Show full documentation for a specific `ShapeBuilder` method.
```
ifc_shape_docs(method="extrude")
ifc_shape_docs(method="create_ellipse")
```
#### ifc_shape
Execute a `ShapeBuilder` method on the loaded model.
```
ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}')
```
`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`).
### Edit discovery tools
#### ifc_list
List all API modules, or functions within a specific module.
```
ifc_list() # all modules
ifc_list(module="root") # functions in the root module
```
#### ifc_docs
Show full documentation for an API function including parameters, types,
defaults, and descriptions.
```
ifc_docs(function_path="root.create_entity")
```
### Edit execution
#### ifc_edit
Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
JSON string with string values that get coerced by ifcedit's type system.
```
ifc_edit(
function_path="root.create_entity",
params='{"ifc_class": "IfcWall", "name": "My Wall"}'
)
```
Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
**Parameter coercion:**
| Type | JSON value | Python value |
|------|------------|--------------|
| `entity_instance` | `"42"` | resolved from model by step ID |
| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
| `dict` | `'{"key": "val"}'` | parsed JSON object |
| `bool` | `"true"` | `True` |
| `Optional[X]` | `"none"` | `None` |
## Typical workflow
1. **Load** a model: `ifc_load`
2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
3. **Validate** if needed: `ifc_validate`
4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
5. **Look up IFC classes**: `ifc_schema`
6. **Find** the right API function: `ifc_list`, `ifc_docs`
7. **Edit** the model: `ifc_edit`
8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
9. **Verify** changes with query tools
10. **Save** when satisfied: `ifc_save`
The model stays in memory across all calls, so multi-step editing sessions
are fast -- no file I/O between operations.
## License
LGPLv3+ -- see the IfcOpenShell project license.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcMCP - MCP server for IFC building models
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcMCP.
#
# IfcMCP is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcMCP is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcMCP. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+11
View File
@@ -0,0 +1,11 @@
# This file was generated with the assistance of an AI coding tool.
from ifcmcp.server import build_server
def main():
server = build_server()
server.run(transport="stdio")
if __name__ == "__main__":
main()
+731
View File
@@ -0,0 +1,731 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
# inside ifcmcp/core.py
import json
from collections.abc import Callable # noqa: F401 — Callable used in helpers below
from dataclasses import dataclass
from typing import Any
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.quantify import run_quantify
from ifcedit.run import run_api
from ifcquery import clash as clash_mod
from ifcquery import contexts as contexts_mod
from ifcquery import cost as cost_mod
from ifcquery import (
info,
relations,
schedule,
schema,
select,
summary,
tree,
)
from ifcquery import (
materials as materials_mod,
)
from ifcquery import (
plot as plot_mod,
)
from ifcquery import (
render as render_mod,
)
from ifcquery import validate as validate_mod
def _jsonify(x: Any) -> Any:
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
if x is None or isinstance(x, (str, int, float, bool)):
return x
# numpy arrays (and any array-like with tolist)
if hasattr(x, "tolist"):
return x.tolist()
# IfcOpenShell entity instances: normalize
if isinstance(x, ifcopenshell.entity_instance):
return {
"id": int(x.id()),
"type": x.is_a(),
"repr": str(x),
"name": getattr(x, "Name", None),
}
if isinstance(x, dict):
return {str(k): _jsonify(v) for k, v in x.items()}
if isinstance(x, (list, tuple, set)):
return [_jsonify(v) for v in x]
# Try JSON as-is, else fallback to string
try:
json.dumps(x)
return x
except Exception:
return str(x)
# ---------------------------------------------------------------------------
# Shape builder helpers
# ---------------------------------------------------------------------------
def _list_shape_methods() -> list[dict]:
"""Introspect ShapeBuilder and return a summary of all public methods."""
import inspect
from ifcedit.discover import _extract_params
from ifcopenshell.util.shape_builder import ShapeBuilder
results = []
for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction):
if name.startswith("_"):
continue
doc = fn.__doc__ or ""
description = doc.strip().split("\n")[0] if doc.strip() else ""
results.append({"method": name, "description": description, "params": _extract_params(fn)})
return results
def _shape_method_docs(method_name: str) -> dict:
"""Return full documentation for a single ShapeBuilder method."""
import typing
from ifcedit.discover import (
_extract_params,
_format_type_hint,
_parse_docstring_body,
_parse_param_docs,
_parse_return_doc,
)
from ifcopenshell.util.shape_builder import ShapeBuilder
if method_name.startswith("_"):
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
fn = getattr(ShapeBuilder, method_name, None)
if fn is None:
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
doc = fn.__doc__ or ""
description, long_description = _parse_docstring_body(doc)
params = _extract_params(fn)
for param in params:
param_desc = _parse_param_docs(doc)
if param["name"] in param_desc:
param["description"] = param_desc[param["name"]]
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
result: dict[str, Any] = {
"method": method_name,
"description": description,
"long_description": long_description,
"params": params,
}
return_type = _format_type_hint(hints.get("return"))
if return_type:
result["return_type"] = return_type
return_description = _parse_return_doc(doc)
if return_description:
result["return_description"] = return_description
return result
def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict:
"""Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method."""
import inspect
import typing
sig = inspect.signature(fn)
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
return {
key: _coerce_shape_value(value, hints.get(key), model)
for key, value in raw_kwargs.items()
if key in sig.parameters and key != "self"
}
def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any:
"""Convert a single JSON-parsed value to the correct Python type."""
import typing
if hint is None or value is None:
return value
origin = typing.get_origin(hint)
args = typing.get_args(hint)
# Optional[X] / Union — try each non-None branch in order
if origin is typing.Union:
if value is None:
return None
for t in (a for a in args if a is not type(None)):
try:
return _coerce_shape_value(value, t, model)
except (ValueError, TypeError):
continue
return value
# entity_instance: resolve integer or "#N" string step ID
if hint is ifcopenshell.entity_instance or (
isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance)
):
entity_id = int(str(value).lstrip("#"))
entity = model.by_id(entity_id)
if entity is None:
raise ValueError(f"Entity #{entity_id} not found in model")
return entity
# Sequence[entity_instance]: resolve each element in the list
import collections.abc
if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str):
if args and (
args[0] is ifcopenshell.entity_instance
or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance))
):
if isinstance(value, (list, tuple)):
return [_coerce_shape_value(v, args[0], model) for v in value]
# bool: JSON gives actual bools; also accept string representations
if hint is bool:
if isinstance(value, bool):
return value
return str(value).lower() in ("true", "1", "yes")
# Everything else (float, int, VectorType lists, dicts, Literals) passes through
return value
class IfcSessionError(RuntimeError):
pass
@dataclass
class IfcSession:
"""In-memory IFC session (no FastMCP dependency).
Designed to work in:
- FastMCP server (single global session)
- Embedded runtimes like Pyodide (one session per browser tab/worker)
"""
model: ifcopenshell.file | None = None
model_path: str | None = None
# -----------------
# Session lifecycle
# -----------------
def _require_model(self) -> ifcopenshell.file:
if self.model is None:
raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.")
return self.model
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
"""Create a new empty IFC model in memory."""
self.model = ifcopenshell.file(schema=schema)
self.model_path = None
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
def ifc_load(self, path: str) -> str:
"""Open an IFC file into memory. Returns confirmation string."""
self.model = ifcopenshell.open(path)
self.model_path = path
count = sum(1 for _ in self.model)
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
def ifc_save(self, path: str = "") -> str:
"""Write the in-memory model to disk. Empty path overwrites the original file."""
model = self._require_model()
target = path if path else self.model_path
if not target:
raise IfcSessionError("No path specified and no original path available.")
model.write(target)
return f"Saved to {target}"
def ifc_reset(self) -> dict[str, Any]:
"""Drop the in-memory model."""
self.model = None
self.model_path = None
return {"ok": True}
# -------------
# Query tools
# -------------
def ifc_summary(self) -> dict[str, Any]:
"""Model overview: schema, entity counts, project info."""
return summary.summary(self._require_model())
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
return tree.tree(self._require_model())
def ifc_info(self, element_id: int) -> dict[str, Any]:
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return info.info(model, element)
def ifc_select(self, query: str) -> list[dict[str, Any]]:
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
return select.select(self._require_model(), query)
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return relations.relations(model, element, traverse=traverse if traverse else None)
def ifc_clash(
self,
element_id: int,
clearance: float = 0.0,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return clash_mod.clash(
model,
element,
clearance=clearance if clearance and clearance > 0.0 else None,
tolerance=tolerance,
scope=scope,
)
def ifc_contexts(self) -> list[dict[str, Any]]:
"""List all geometric representation contexts and subcontexts with their step IDs."""
return contexts_mod.contexts(self._require_model())
def ifc_materials(self) -> list[dict[str, Any]]:
"""List all materials and material sets (layers, constituents, profiles)."""
return materials_mod.materials(self._require_model())
# ------------------------
# Edit discovery + execute
# ------------------------
def ifc_list(self, module: str = "") -> list[dict]:
"""List all API modules, or functions within a module. Empty module = all modules."""
return list_functions(module) if module else list_modules()
def ifc_docs(self, function_path: str) -> dict:
"""Show full documentation for an API function. Input format: 'module.function'."""
module, function = function_path.split(".", 1)
return function_docs(module, function)
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
"""Execute an ifcopenshell.api mutation.
params may be:
- JSON string
- dict (from tool calling / JS)
- JsProxy (handled upstream in embedded.py)
"""
model = self._require_model()
module, function = function_path.split(".", 1)
if isinstance(params, str):
raw_kwargs = json.loads(params) if params.strip() else {}
elif isinstance(params, dict):
raw_kwargs = params
else:
# e.g. list/None/etc
raw_kwargs = dict(params) if params is not None else {}
res = run_api(model, module, function, raw_kwargs)
return _jsonify(res)
# ------------------------
# Extended query + edit tools
# ------------------------
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
return validate_mod.validate(self._require_model(), express_rules=express_rules)
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
"""List work schedules and nested tasks from the model.
max_depth limits subtask expansion (None = unlimited). At the cutoff,
subtasks is replaced with {"truncated": True, "count": N}.
"""
return schedule.schedule(self._require_model(), max_depth=max_depth)
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
"""List cost schedules and nested cost items from the model.
max_depth limits cost item expansion (None = unlimited). At the cutoff,
subitems is replaced with {"truncated": True, "count": N}.
"""
return cost_mod.cost(self._require_model(), max_depth=max_depth)
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type using the model's schema version."""
return schema.schema(self._require_model(), entity_type)
def ifc_plot(
self,
selector: str = "",
element_ids: list[int] | None = None,
view: str = "floorplan",
width_mm: float = 297.0,
height_mm: float = 420.0,
scale: float = 1.0 / 100.0,
png_width: int = 1024,
png_height: int = 1024,
output_format: str = "png",
) -> bytes:
"""Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes.
Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG
when output_format is 'png'.
:param selector: ifcopenshell selector to restrict plotted elements
(e.g. ``'IfcWall'``). Omit to plot the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are faded to 10% opacity so the subject stands out.
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
``section``, or ``auto``.
:param width_mm: Paper width in mm (default 297 = A4).
:param height_mm: Paper height in mm (default 420 = A4).
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
:param png_width: Raster output width in pixels (default 1024).
:param png_height: Raster output height in pixels (default 1024).
:param output_format: ``'svg'`` or ``'png'`` (default ``'png'``).
:return: SVG or PNG bytes depending on output_format.
"""
model = self._require_model()
return plot_mod.plot(
model,
output_format=output_format,
selector=selector if selector else None,
element_ids=element_ids,
view=view,
width_mm=width_mm,
height_mm=height_mm,
scale=scale,
png_width=png_width,
png_height=png_height,
)
def ifc_render(
self,
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
) -> bytes:
"""Render the loaded model to a PNG image and return raw bytes.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``). Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey.
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
``east``, or ``west``.
:return: PNG image as raw bytes.
"""
model = self._require_model()
return render_mod.render(
model,
selector=selector if selector else None,
element_ids=element_ids,
view=view,
)
# ------------------------
# Shape builder tools
# ------------------------
def ifc_shape_list(self) -> list[dict]:
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
return _list_shape_methods()
def ifc_shape_docs(self, method: str) -> dict:
"""Full documentation for a ShapeBuilder method: params, types, return value."""
return _shape_method_docs(method)
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
params is a JSON string of keyword arguments. Pass entity references as integer
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
"""
model = self._require_model()
from ifcopenshell.util.shape_builder import ShapeBuilder
if method.startswith("_"):
raise IfcSessionError(f"Private method '{method}' is not accessible")
fn = getattr(ShapeBuilder, method, None)
if fn is None:
return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"}
if isinstance(params, str):
raw_kwargs = json.loads(params) if params.strip() else {}
elif isinstance(params, dict):
raw_kwargs = params
else:
raw_kwargs = {}
try:
coerced = _coerce_shape_params(fn, raw_kwargs, model)
result = fn(ShapeBuilder(model), **coerced)
return {"ok": True, "result": _jsonify(result)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
Modifies the model in-place; call ifc_save() after.
"""
model = self._require_model()
return run_quantify(model, rule, selector=selector if selector else None)
# ------------------------
# Generic dispatcher + tool specs for LLMs
# ------------------------
def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
args = args or {}
fn = getattr(self, name, None)
if not callable(fn):
raise IfcSessionError(f"Unknown tool: {name}")
return _jsonify(fn(**args))
def openai_tools(self) -> list[dict[str, Any]]:
"""Tool schemas in the OpenAI 'Responses API' format (type=function)."""
# Keep schemas tight so the model calls tools correctly.
return [
{
"type": "function",
"name": "ifc_new",
"description": "Create a new empty IFC model in memory.",
"parameters": {
"type": "object",
"properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_summary",
"description": "Get a concise overview of the loaded IFC model.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_tree",
"description": "Get the full spatial hierarchy tree.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_select",
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_info",
"description": "Inspect an entity by STEP id.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_relations",
"description": "Get relationships for an element. traverse='up' walks to IfcProject.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_clash",
"description": "Run clash/clearance checks for an element.",
"parameters": {
"type": "object",
"properties": {
"element_id": {"type": "integer"},
"clearance": {"type": "number"},
"tolerance": {"type": "number"},
"scope": {"type": "string", "description": "storey or all"},
},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_contexts",
"description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_materials",
"description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_list",
"description": "List ifcopenshell.api modules or functions within a module.",
"parameters": {
"type": "object",
"properties": {"module": {"type": "string"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_docs",
"description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}},
"required": ["function_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_edit",
"description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
"required": ["function_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_validate",
"description": "Validate the loaded model. Returns valid bool and list of issues.",
"parameters": {
"type": "object",
"properties": {
"express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}
},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_schedule",
"description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
"parameters": {
"type": "object",
"properties": {
"max_depth": {
"type": "integer",
"description": "Max levels of subtask expansion (omit for unlimited)",
}
},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_cost",
"description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
"parameters": {
"type": "object",
"properties": {
"max_depth": {
"type": "integer",
"description": "Max levels of cost item expansion (omit for unlimited)",
}
},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_schema",
"description": "Return IFC class documentation for an entity type.",
"parameters": {
"type": "object",
"properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}},
"required": ["entity_type"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_quantify",
"description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
"parameters": {
"type": "object",
"properties": {
"rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
"selector": {
"type": "string",
"description": "ifcopenshell selector to restrict elements (default: all IfcElement)",
},
},
"required": ["rule"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_render",
"description": (
"Render the loaded IFC model to a PNG image for visual inspection. "
"Use selector to restrict which elements are rendered (e.g. a single storey). "
"Use element_ids to highlight elements against a greyed-out background. "
"Returns base64-encoded PNG bytes."
),
"parameters": {
"type": "object",
"properties": {
"selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
"element_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "Step IDs of elements to highlight",
},
"view": {
"type": "string",
"enum": ["iso", "top", "south", "north", "east", "west"],
"description": "Camera angle (default: iso)",
},
},
"required": [],
"additionalProperties": False,
},
},
]
+60
View File
@@ -0,0 +1,60 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from ifcmcp.core import IfcSession
session = IfcSession()
# Optional imports only available under Pyodide
try:
from pyodide.ffi import JsProxy, to_py # type: ignore
except Exception: # pragma: no cover
JsProxy = None # type: ignore
to_py = None # type: ignore
def _coerce_args(args: Any) -> dict[str, Any]:
"""Convert JS objects / JsProxy / mappings into a real Python dict."""
if args is None:
return {}
# Pyodide: JS object arrives as JsProxy; convert recursively to Python.
if JsProxy is not None and isinstance(args, JsProxy):
# dict_converter=dict ensures JS object -> Python dict (not Map)
return to_py(args, dict_converter=dict)
# Already a Python dict
if isinstance(args, dict):
return args
# Any Mapping-like object
if isinstance(args, Mapping):
return dict(args)
# Last resort: try dict() coercion
try:
return dict(args)
except Exception as e:
raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e
def tools_openai() -> list[dict[str, Any]]:
return session.openai_tools()
def call_tool(name: str, args: Any = None) -> dict[str, Any]:
"""
Non-throwing tool dispatcher.
Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...}
"""
try:
py_args = _coerce_args(args)
data = session.dispatch(name, py_args)
return {"ok": True, "data": data}
except Exception as e:
# Keep it short; avoid full tracebacks in tool output unless debugging.
return {"ok": False, "error_type": type(e).__name__, "error": str(e)}
+231
View File
@@ -0,0 +1,231 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import base64
from typing import Any
from ifcmcp.core import IfcSession
try:
from mcp.server.fastmcp import FastMCP # type: ignore
from mcp.types import ImageContent # type: ignore
except Exception: # pragma: no cover
FastMCP = None # type: ignore
ImageContent = None # type: ignore
def build_server() -> Any:
"""Create the FastMCP server if the dependency is available."""
if FastMCP is None:
raise ImportError(
"FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)."
)
session = IfcSession()
server = FastMCP(
name="ifc-mcp",
instructions=(
"MCP server for querying and editing IFC building models. "
"Load a file first with ifc_load, then use query/edit tools. "
"Save changes with ifc_save."
),
)
# ---- Lifecycle ----
@server.tool()
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
return session.ifc_new(schema=schema)
@server.tool()
def ifc_load(path: str) -> str:
return session.ifc_load(path)
@server.tool()
def ifc_save(path: str = "") -> str:
return session.ifc_save(path)
@server.tool()
def ifc_reset() -> dict[str, Any]:
return session.ifc_reset()
# ---- Query ----
@server.tool()
def ifc_summary() -> dict[str, Any]:
return session.ifc_summary()
@server.tool()
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_tree()
@server.tool()
def ifc_info(element_id: int) -> dict[str, Any]:
return session.ifc_info(element_id)
@server.tool()
def ifc_select(query: str) -> list[dict[str, Any]]:
return session.ifc_select(query)
@server.tool()
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_relations(element_id, traverse=traverse)
@server.tool()
def ifc_clash(
element_id: int,
clearance: float = 0.0,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
return session.ifc_clash(
element_id=element_id,
clearance=clearance,
tolerance=tolerance,
scope=scope,
)
@server.tool()
def ifc_contexts() -> list[dict[str, Any]]:
return session.ifc_contexts()
@server.tool()
def ifc_materials() -> list[dict[str, Any]]:
return session.ifc_materials()
# ---- Edit ----
@server.tool()
def ifc_list(module: str = "") -> list[dict]:
return session.ifc_list(module=module)
@server.tool()
def ifc_docs(function_path: str) -> dict:
return session.ifc_docs(function_path=function_path)
@server.tool()
def ifc_edit(function_path: str, params: str = "{}") -> dict:
return session.ifc_edit(function_path=function_path, params=params)
# ---- Extended query + edit ----
@server.tool()
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
return session.ifc_validate(express_rules=express_rules)
@server.tool()
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_schedule(max_depth=max_depth)
@server.tool()
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_cost(max_depth=max_depth)
@server.tool()
def ifc_schema(entity_type: str) -> dict[str, Any]:
return session.ifc_schema(entity_type=entity_type)
@server.tool()
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
return session.ifc_quantify(rule=rule, selector=selector)
# ---- Shape builder ----
@server.tool()
def ifc_shape_list() -> list[dict]:
return session.ifc_shape_list()
@server.tool()
def ifc_shape_docs(method: str) -> dict:
return session.ifc_shape_docs(method=method)
@server.tool()
def ifc_shape(method: str, params: str = "{}") -> dict:
return session.ifc_shape(method=method, params=params)
@server.tool(structured_output=False)
def ifc_plot(
selector: str = "",
element_ids: list[int] | None = None,
view: str = "floorplan",
width_mm: float = 297.0,
height_mm: float = 420.0,
scale: float = 1.0 / 100.0,
png_width: int = 1024,
png_height: int = 1024,
output_path: str = "",
) -> list[ImageContent]:
"""Generate a 2D technical drawing of the loaded IFC model.
Returns an inline PNG image (floor plan, elevation, or section) that the
LLM can inspect to understand the 2D layout of the model. If
``output_path`` is provided the drawing is also saved to disk — as SVG
when the path ends in ``.svg``, otherwise as PNG.
:param selector: ifcopenshell selector to restrict plotted elements
(e.g. ``'IfcWall'``). Omit to plot the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are faded so the subject stands out.
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
``section``, or ``auto``.
:param width_mm: Paper width in mm (default 297 = A4 landscape width).
:param height_mm: Paper height in mm (default 420 = A4 landscape height).
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
:param png_width: Raster output width in pixels (default 1024).
:param png_height: Raster output height in pixels (default 1024).
:param output_path: Optional file path to save the drawing to disk.
"""
png_bytes = session.ifc_plot(
selector=selector,
element_ids=element_ids,
view=view,
width_mm=width_mm,
height_mm=height_mm,
scale=scale,
png_width=png_width,
png_height=png_height,
output_format="png",
)
if output_path:
if output_path.endswith(".svg"):
svg_bytes = session.ifc_plot(
selector=selector,
element_ids=element_ids,
view=view,
width_mm=width_mm,
height_mm=height_mm,
scale=scale,
output_format="svg",
)
with open(output_path, "wb") as f:
f.write(svg_bytes)
else:
with open(output_path, "wb") as f:
f.write(png_bytes)
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
@server.tool(structured_output=False)
def ifc_render(
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
output_path: str = "",
) -> list[ImageContent]:
"""Render the loaded IFC model to a PNG image.
Returns an inline image the LLM can inspect to understand the spatial
layout of the model or a specific element in context. If
``output_path`` is provided the PNG is also saved to that file path.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey so the subject stands out.
:param view: Camera angle — ``iso`` (default), ``top``, ``south``,
``north``, ``east``, or ``west``.
:param output_path: Optional file path to save the PNG to disk.
"""
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
if output_path:
with open(output_path, "wb") as f:
f.write(png_bytes)
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
return server
+36
View File
@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcmcp"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
]
description = "MCP server for querying and editing IFC building models"
readme = "README.md"
keywords = ["IFC", "BIM", "MCP"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
[project.optional-dependencies]
mcp = ["mcp"]
[project.scripts]
ifcmcp = "ifcmcp.__main__:main"
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcmcp*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+59
View File
@@ -0,0 +1,59 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
from ifcmcp.core import IfcSession
@pytest.fixture
def session():
return IfcSession()
@pytest.fixture
def model():
"""IFC4 model with a spatial hierarchy, a wall, and a slab."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
return f
@pytest.fixture
def model_file(model, tmp_path):
"""Write the model fixture to a temp file and return the path."""
path = tmp_path / "test.ifc"
model.write(str(path))
return str(path)
@pytest.fixture
def loaded_session(model):
"""An IfcSession with an in-memory model already loaded (no file path)."""
s = IfcSession()
s.model = model
return s
+96
View File
@@ -0,0 +1,96 @@
# This file was generated with the assistance of an AI coding tool.
import json
import ifcopenshell
import pytest
from ifcmcp.core import IfcSession, IfcSessionError
class TestNoModel:
def test_edit_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_edit("root.create_entity")
class TestList:
def test_list_all_modules(self, loaded_session):
result = loaded_session.ifc_list()
assert isinstance(result, list)
assert len(result) > 0
modules = [m["module"] for m in result]
assert "root" in modules
assert "spatial" in modules
def test_list_module_functions(self, loaded_session):
result = loaded_session.ifc_list(module="root")
assert isinstance(result, list)
names = [f["name"] for f in result]
assert "create_entity" in names
def test_list_empty_string_returns_modules(self, loaded_session):
result = loaded_session.ifc_list(module="")
assert isinstance(result, list)
assert any(m["module"] == "root" for m in result)
class TestDocs:
def test_docs_create_entity(self, loaded_session):
result = loaded_session.ifc_docs("root.create_entity")
assert result["module"] == "root"
assert result["function"] == "create_entity"
assert "params" in result
def test_docs_bad_format(self, loaded_session):
with pytest.raises(ValueError):
loaded_session.ifc_docs("no_dot_here")
class TestEdit:
def test_create_entity(self, loaded_session):
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcWall"
assert result["result"]["name"] == "NewWall"
def test_create_entity_default_params(self, loaded_session):
result = loaded_session.ifc_edit("root.create_entity", "{}")
assert result["ok"] is True
def test_unknown_function(self, loaded_session):
result = loaded_session.ifc_edit("root.nonexistent", "{}")
assert result["ok"] is False
assert "Cannot find" in result["error"]
def test_unknown_parameter(self, loaded_session):
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"}))
assert result["ok"] is False
assert "Unknown parameter" in result["error"]
def test_bad_json(self, loaded_session):
with pytest.raises(json.JSONDecodeError):
loaded_session.ifc_edit("root.create_entity", "not json")
def test_edit_does_not_save(self, loaded_session, tmp_path):
"""Verify that ifc_edit mutates the in-memory model but does not write to disk."""
path = str(tmp_path / "test.ifc")
loaded_session.model.write(path)
loaded_session.model_path = path
before_count = sum(1 for _ in loaded_session.model)
loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
after_count = sum(1 for _ in loaded_session.model)
assert after_count == before_count + 1
on_disk = ifcopenshell.open(path)
disk_count = sum(1 for _ in on_disk)
assert disk_count == before_count
def test_assign_container(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
storey = loaded_session.model.by_type("IfcBuildingStorey")[0]
result = loaded_session.ifc_edit(
"spatial.assign_container",
json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
)
assert result["ok"] is True
+113
View File
@@ -0,0 +1,113 @@
# This file was generated with the assistance of an AI coding tool.
import pytest
from ifcmcp.core import IfcSessionError
class TestNoModel:
"""All query tools should fail when no model is loaded."""
def test_summary_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_summary()
def test_tree_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_tree()
def test_info_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_info(1)
def test_select_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_select("IfcWall")
def test_relations_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_relations(1)
class TestSummary:
def test_schema(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["schema"] == "IFC4"
def test_total_entities(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["total_entities"] > 0
def test_project_name(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["project"]["name"] == "TestProject"
def test_type_counts(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["types"]["IfcWall"] == 1
assert result["types"]["IfcSlab"] == 1
class TestTree:
def test_root_is_project(self, loaded_session):
result = loaded_session.ifc_tree()
assert result["type"] == "IfcProject"
assert result["name"] == "TestProject"
def test_hierarchy_depth(self, loaded_session):
result = loaded_session.ifc_tree()
site = result["children"][0]
assert site["type"] == "IfcSite"
building = site["children"][0]
assert building["type"] == "IfcBuilding"
storey = building["children"][0]
assert storey["type"] == "IfcBuildingStorey"
class TestInfo:
def test_wall_info(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.ifc_info(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
def test_invalid_id(self, loaded_session):
with pytest.raises(Exception):
loaded_session.ifc_info(999999)
class TestSelect:
def test_select_walls(self, loaded_session):
result = loaded_session.ifc_select("IfcWall")
assert len(result) == 1
assert result[0]["type"] == "IfcWall"
assert result[0]["name"] == "Wall001"
def test_select_slabs(self, loaded_session):
result = loaded_session.ifc_select("IfcSlab")
assert len(result) == 1
assert result[0]["name"] == "Slab001"
def test_select_no_match(self, loaded_session):
result = loaded_session.ifc_select("IfcWindow")
assert result == []
class TestRelations:
def test_wall_relations(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.ifc_relations(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert "hierarchy" in result
def test_traverse_up(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.ifc_relations(wall.id(), traverse="up")
assert isinstance(result, list)
assert result[0]["type"] == "IfcWall"
assert result[-1]["type"] == "IfcProject"
def test_traverse_empty_string_means_no_traverse(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.ifc_relations(wall.id(), traverse="")
assert isinstance(result, dict)
+106
View File
@@ -0,0 +1,106 @@
# This file was generated with the assistance of an AI coding tool.
from unittest.mock import patch
import pytest
from ifcmcp.server import build_server
class TestServerRegistration:
def test_server_name(self):
server = build_server()
assert server.name == "ifc-mcp"
def test_all_tools_registered(self):
server = build_server()
tools = [t.name for t in server._tool_manager.list_tools()]
expected = [
"ifc_load",
"ifc_save",
"ifc_summary",
"ifc_tree",
"ifc_info",
"ifc_select",
"ifc_relations",
"ifc_clash",
"ifc_list",
"ifc_docs",
"ifc_edit",
]
for name in expected:
assert name in tools, f"Tool {name} not registered"
@pytest.fixture
def tool_fns():
"""Return a dict of tool name → raw function from a freshly built server."""
server = build_server()
return {t.name: t.fn for t in server._tool_manager.list_tools()}
PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE"
SVG_FAKE = b"<svg>FAKE</svg>"
class TestRenderOutputPath:
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="")
assert list(tmp_path.iterdir()) == []
def test_png_output_path_writes_file(self, tool_fns, tmp_path):
out = str(tmp_path / "render.png")
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out)
assert open(out, "rb").read() == PNG_FAKE
class TestPlotOutputPath:
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
tool_fns["ifc_plot"](
selector="",
element_ids=None,
view="floorplan",
width_mm=297.0,
height_mm=420.0,
scale=0.01,
png_width=1024,
png_height=1024,
output_path="",
)
assert list(tmp_path.iterdir()) == []
def test_png_output_path_writes_png(self, tool_fns, tmp_path):
out = str(tmp_path / "plot.png")
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
tool_fns["ifc_plot"](
selector="",
element_ids=None,
view="floorplan",
width_mm=297.0,
height_mm=420.0,
scale=0.01,
png_width=1024,
png_height=1024,
output_path=out,
)
assert open(out, "rb").read() == PNG_FAKE
def test_svg_output_path_writes_svg(self, tool_fns, tmp_path):
out = str(tmp_path / "plot.svg")
# ifc_plot is called twice: once with "png" for the inline image,
# once with "svg" for the file.
with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]):
tool_fns["ifc_plot"](
selector="",
element_ids=None,
view="floorplan",
width_mm=297.0,
height_mm=420.0,
scale=0.01,
png_width=1024,
png_height=1024,
output_path=out,
)
assert open(out, "rb").read() == SVG_FAKE
+62
View File
@@ -0,0 +1,62 @@
# This file was generated with the assistance of an AI coding tool.
from unittest.mock import patch
import ifcopenshell
import pytest
from ifcmcp.core import IfcSession, IfcSessionError
class TestLoad:
def test_load_file(self, session, model_file):
result = session.ifc_load(model_file)
assert "IFC4" in result
assert session.model is not None
assert session.model_path == model_file
def test_load_sets_entity_count(self, session, model_file):
result = session.ifc_load(model_file)
assert "entities" in result
def test_load_nonexistent_file(self, session):
with pytest.raises(Exception):
session.ifc_load("/nonexistent/path/model.ifc")
class TestSave:
def test_save_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_save()
def test_save_overwrites_original(self, session, model_file):
session.ifc_load(model_file)
result = session.ifc_save()
assert model_file in result
def test_save_to_new_path(self, session, model_file, tmp_path):
session.ifc_load(model_file)
new_path = str(tmp_path / "output.ifc")
result = session.ifc_save(new_path)
assert new_path in result
reloaded = ifcopenshell.open(new_path)
assert reloaded.schema == "IFC4"
def test_save_no_path_no_original(self, loaded_session):
with pytest.raises(IfcSessionError, match="No path specified"):
loaded_session.ifc_save()
class TestIfcPlotOutputFormat:
"""ifc_plot should pass output_format through to the underlying plot function."""
def test_default_output_format_is_png(self, loaded_session):
with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot:
loaded_session.ifc_plot()
mock_plot.assert_called_once()
assert mock_plot.call_args.kwargs["output_format"] == "png"
def test_svg_output_format(self, loaded_session):
with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot:
result = loaded_session.ifc_plot(output_format="svg")
assert result == b"SVG_FAKE"
assert mock_plot.call_args.kwargs["output_format"] == "svg"
+141
View File
@@ -0,0 +1,141 @@
# This file was generated with the assistance of an AI coding tool.
import json
import pytest
from ifcmcp.core import IfcSessionError
class TestShapeList:
def test_returns_list(self, loaded_session):
result = loaded_session.ifc_shape_list()
assert isinstance(result, list)
assert len(result) > 0
def test_has_expected_methods(self, loaded_session):
result = loaded_session.ifc_shape_list()
names = [m["method"] for m in result]
assert "polyline" in names
assert "rectangle" in names
assert "extrude" in names
assert "profile" in names
assert "get_representation" in names
def test_well_documented_methods_have_descriptions(self, loaded_session):
result = loaded_session.ifc_shape_list()
by_name = {m["method"]: m for m in result}
# These methods have detailed docstrings
for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"):
assert by_name[name]["description"], f"'{name}' has no description"
def test_no_private_methods(self, loaded_session):
result = loaded_session.ifc_shape_list()
assert not any(m["method"].startswith("_") for m in result)
def test_does_not_require_model(self, session):
# ifc_shape_list is pure introspection — no model needed
result = session.ifc_shape_list()
assert isinstance(result, list)
class TestShapeDocs:
def test_extrude_docs(self, loaded_session):
result = loaded_session.ifc_shape_docs("extrude")
assert result["method"] == "extrude"
assert result["description"]
assert "params" in result
param_names = [p["name"] for p in result["params"]]
assert "profile_or_curve" in param_names
assert "magnitude" in param_names
def test_has_return_type(self, loaded_session):
result = loaded_session.ifc_shape_docs("rectangle")
assert "return_type" in result
def test_has_param_descriptions(self, loaded_session):
result = loaded_session.ifc_shape_docs("polyline")
params_with_desc = [p for p in result["params"] if "description" in p]
assert len(params_with_desc) > 0
def test_unknown_method(self, loaded_session):
with pytest.raises(ValueError, match="no method"):
loaded_session.ifc_shape_docs("nonexistent_method")
def test_private_method_rejected(self, loaded_session):
with pytest.raises(ValueError):
loaded_session.ifc_shape_docs("__init__")
def test_does_not_require_model(self, session):
result = session.ifc_shape_docs("circle")
assert result["method"] == "circle"
class TestShapeExecute:
def test_rectangle(self, loaded_session):
result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcIndexedPolyCurve"
def test_circle(self, loaded_session):
result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcCircle"
def test_extrude_chained_from_rectangle(self, loaded_session):
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
rect_id = rect["result"]["id"]
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcExtrudedAreaSolid"
def test_entity_id_as_integer(self, loaded_session):
"""Entity IDs should be accepted as plain integers (from JSON)."""
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]}))
rect_id = rect["result"]["id"]
# Pass as int, not string
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0}))
assert result["ok"] is True
def test_rotate_2d_point_returns_list(self, loaded_session):
"""Methods returning numpy arrays should give back plain lists."""
result = loaded_session.ifc_shape(
"rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True})
)
assert result["ok"] is True
assert isinstance(result["result"], list)
assert len(result["result"]) == 2
def test_set_polyline_coords_returns_none(self, loaded_session):
"""In-place methods that return None should give ok=True, result=None."""
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]}))
rect_id = rect["result"]["id"]
result = loaded_session.ifc_shape(
"set_polyline_coords",
json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}),
)
assert result["ok"] is True
assert result["result"] is None
def test_unknown_method(self, loaded_session):
result = loaded_session.ifc_shape("nonexistent_method", "{}")
assert result["ok"] is False
assert "error" in result
def test_private_method_rejected(self, loaded_session):
with pytest.raises(IfcSessionError):
loaded_session.ifc_shape("__init__", "{}")
def test_no_model_raises(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_shape("rectangle", "{}")
def test_params_as_dict(self, loaded_session):
"""params can be passed as a dict (not just a JSON string)."""
result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]})
assert result["ok"] is True
def test_error_on_bad_params(self, loaded_session):
"""Bad parameters should give ok=False with an error message."""
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0}))
assert result["ok"] is False
assert "error" in result
@@ -775,3 +775,52 @@ responsibility to make sure the geometry is correct.
# Assign our new body geometry back to our beam
ifcopenshell.api.geometry.assign_representation(model, product=beam, representation=representation)
Moving assemblies
-----------------
When moving an assembly and you want all children to follow, pass
``should_transform_children=True``. The default (``False``) rewrites each
child's local placement to preserve its world position, so the parent moves
but the children stay where they are.
.. code-block:: python
matrix = numpy.eye(4)
matrix[:,3][0:3] = (0, 0, 6)
# Move the assembly; children travel with it.
ifcopenshell.api.geometry.edit_object_placement(model,
product=assembly, matrix=matrix, is_si=True,
should_transform_children=True)
Clipping normals convention
---------------------------
The ``normal`` passed to :func:`geometry.clip_solid`,
:func:`geometry.clip_solid_bounded`, and the ``clippings`` parameter of
:func:`geometry.add_wall_representation` points toward the **removed**
material (the discarded side), not toward the kept material.
.. code-block:: python
# Clip the top of a wall to a lean-to slope.
# normal points upward into the wedge that will be removed.
bcr = ifcopenshell.api.geometry.clip_solid(model,
item=extrusion,
location=[0.0, 0.0, 3.26],
normal=[0.419, 0.0, 0.908])
shape_representation.RepresentationType = "Clipping"
Opening lifecycle
-----------------
``feature.remove_feature`` permanently deletes the feature entity from the
model. Any fillings (windows, doors) that occupied the opening become
orphaned and must be separately removed via ``root.remove_product``.
.. code-block:: python
# Remove a window and its opening from a wall.
ifcopenshell.api.root.remove_product(model, product=window)
ifcopenshell.api.feature.remove_feature(model, feature=opening)
@@ -22,11 +22,13 @@ import ifcopenshell.util.element
def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instance) -> None:
"""Remove a feature
"""Permanently delete a feature element and its void or projection relationship.
Fillings are retained as orphans. Featured elements remain. Features
cannot exist by themselves, so not only is the relationship removed, the
feature is also removed.
The feature entity (e.g. IfcOpeningElement) is removed from the model
along with its IfcRelVoidsElement or IfcRelProjectsElement relationship.
The host element (wall, slab, etc.) is unaffected. Any fillings (windows,
doors) that occupied the opening become orphaned and must be separately
deleted via root.remove_product.
:param feature: The IfcFeatureElement to remove.
@@ -26,6 +26,8 @@ geometry extrusions).
from .. import wrap_usecases
from .add_axis_representation import add_axis_representation
from .add_boolean import add_boolean
from .clip_solid import clip_solid
from .clip_solid_bounded import clip_solid_bounded
from .add_door_representation import add_door_representation
from .add_footprint_representation import add_footprint_representation
from .add_mesh_representation import add_mesh_representation
@@ -50,6 +52,7 @@ from .disconnect_element import disconnect_element
from .disconnect_path import disconnect_path
from .edit_object_placement import edit_object_placement
from .map_representation import map_representation
from .copy_representation import copy_representation
from .regenerate_wall_representation import regenerate_wall_representation
from .remove_boolean import remove_boolean
from .remove_representation import remove_representation
@@ -61,6 +64,9 @@ wrap_usecases(__path__, __name__)
__all__ = [
"add_axis_representation",
"add_boolean",
"clip_solid",
"clip_solid_bounded",
"copy_representation",
"add_door_representation",
"add_footprint_representation",
"add_mesh_representation",
@@ -47,7 +47,9 @@ def add_wall_representation(
:param thickness: The thickness of the wall in meters.
:param x_angle: The slope angle along the wall's X-axis, in radians.
:param clippings: List of clipping definitions. Clippings can be `Clipping` objects
or dictionaries of arguments for `Clipping.parse`.
or dictionaries of arguments for `Clipping.parse`. Each clipping has a
``normal`` that points toward the removed material (the discarded side),
not toward the kept material; see :func:`clip_solid` for details.
:param booleans: List of any existing IfcBooleanResults.
:return: IfcShapeRepresentation.
"""
@@ -0,0 +1,86 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
from typing import Optional, Sequence
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.data import Clipping
def clip_solid(
file: ifcopenshell.file,
item: ifcopenshell.entity_instance,
location: Sequence[float],
normal: Sequence[float],
element: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
"""Clip a solid with a half-space plane, returning an IfcBooleanClippingResult.
Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for
use with any solid. This is the same convention used by the ``clippings``
parameter of :func:`add_wall_representation`.
.. warning::
The ``normal`` points toward the **removed** material (the discarded
side), not toward the kept material. For a slope clip the normal
points upward into the removed wedge above the slope line. For a
side mitre the normal points outward away from the wall body.
After clipping, set the parent ``IfcShapeRepresentation``
``RepresentationType`` to ``"Clipping"``.
Example — trim an extruded solid to a lean-to slope (removed material is
above the slope)::
bcr = ifcopenshell.api.run(
"geometry.clip_solid", model,
item=extrusion,
location=[0.0, 0.0, 3.26],
normal=[0.419, 0.0, 0.908], # points UP toward removed material
)
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
or ``IfcBooleanClippingResult``).
:param location: A point on the clipping plane in the representation's
local coordinate system.
:param normal: Plane normal pointing toward the material to be removed
(see warning above).
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
registered in the element's ``BBIM_Boolean`` property set so that
:func:`regenerate_wall_representation` preserves it during regeneration.
:return: The resulting ``IfcBooleanClippingResult``.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
clipping = Clipping(location=tuple(location), normal=tuple(normal))
result = clipping.apply(file, item, unit_scale)
if element is not None:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if pset_data:
pset = file.by_id(pset_data["id"])
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
else:
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
data = [result.id()]
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
return result
@@ -0,0 +1,116 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
from typing import Optional, Sequence
import numpy as np
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder
def clip_solid_bounded(
file: ifcopenshell.file,
item: ifcopenshell.entity_instance,
location: Sequence[float],
normal: Sequence[float],
boundary_points: Sequence[Sequence[float]],
boundary_position: Sequence[float] = (0.0, 0.0, 0.0),
element: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
"""Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult.
Like :func:`clip_solid`, but the boolean subtraction is restricted to the
region enclosed by ``boundary_points`` rather than extending across the
entire half-space. The clipping plane is still infinite, but material is
only removed within the extruded footprint of the polygon.
The ``normal`` convention is the same as :func:`clip_solid`: it points
toward the **removed** material.
After clipping, set the parent ``IfcShapeRepresentation``
``RepresentationType`` to ``"Clipping"``.
Example::
bcr = ifcopenshell.api.run(
"geometry.clip_solid_bounded", model,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
or ``IfcBooleanClippingResult``).
:param location: A point on the clipping plane in the representation's
local coordinate system.
:param normal: Plane normal pointing toward the material to be removed.
:param boundary_points: 2D ``[x, y]`` points defining the closed polygonal
boundary in the coordinate system of ``boundary_position``. The polygon
is automatically closed — do not repeat the first point.
:param boundary_position: 3D origin of the boundary coordinate system
(axes default to the global X/Y/Z directions). Defaults to the origin.
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
registered in the element's ``BBIM_Boolean`` property set so that
:func:`regenerate_wall_representation` preserves it during regeneration.
:return: The resulting ``IfcBooleanClippingResult``.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
builder = ShapeBuilder(file)
normal_arr = np.array(normal)
if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2):
arbitrary_vector = np.array([0.0, 1.0, 0.0])
else:
arbitrary_vector = np.array([0.0, 0.0, 1.0])
x_axis = np.cross(normal_arr, arbitrary_vector)
x_axis /= np.linalg.norm(x_axis)
scaled_location = [i / unit_scale for i in location]
plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis)
plane = file.create_entity("IfcPlane", plane_placement)
scaled_boundary_position = [i / unit_scale for i in boundary_position]
boundary_pos_entity = file.create_entity(
"IfcAxis2Placement3D",
file.create_entity("IfcCartesianPoint", scaled_boundary_position),
)
scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points]
scaled_pts.append(scaled_pts[0]) # close the polygon
ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts]
boundary = file.createIfcPolyline(ifc_pts)
half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary)
result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space)
if element is not None:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if pset_data:
pset = file.by_id(pset_data["id"])
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
else:
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
data = [result.id()]
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
return result
@@ -0,0 +1,76 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Optional
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.representation
def copy_representation(
file: ifcopenshell.file,
source: ifcopenshell.entity_instance,
target: ifcopenshell.entity_instance,
context_identifier: str = "Body",
) -> Optional[ifcopenshell.entity_instance]:
"""Copy a geometric representation from one element to another.
Finds the named representation on ``source``, deep-copies its entity
graph (geometry items, profiles, placements, etc.), and assigns the copy
to ``target``. Representation contexts are shared rather than copied.
If ``target`` already has a matching representation it is removed and
replaced.
If no matching representation is found on ``source``, returns ``None``
and leaves ``target`` unchanged.
:param source: The element to copy the representation from.
:param target: The element to assign the copied representation to.
:param context_identifier: The RepresentationIdentifier to look up on
``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``).
Defaults to ``"Body"``.
:return: The newly created IfcShapeRepresentation, or None if no
matching representation was found on ``source``.
Example:
.. code:: python
wall_a = model.by_id(1)
wall_b = model.by_id(2)
# Give wall_b the same body geometry as wall_a.
ifcopenshell.api.geometry.copy_representation(model,
source=wall_a, target=wall_b)
"""
source_rep = ifcopenshell.util.representation.get_representation(source, "Model", context_identifier)
if source_rep is None:
return None
new_rep = ifcopenshell.util.element.copy_deep(file, source_rep, exclude=["IfcGeometricRepresentationContext"])
existing_rep = ifcopenshell.util.representation.get_representation(target, "Model", context_identifier)
if existing_rep:
ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep)
ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep)
ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep)
return new_rep
@@ -52,10 +52,11 @@ def edit_object_placement(
:param is_si: If True, the matrix is given in SI units. If false, in
project units.
:param should_transform_children: A child element is a nested element,
opening, filling, etc. If true, child elements will move along with the
parent. If false, child elements will stay where they are. Because most
placements in IFC are relative, this means that if a child moves, we
actually don't change their placement.
opening, filling, etc. If True, child elements move along with the
parent; pass True when moving an assembly (roof, furniture group, etc.)
and you want all children to follow. If False (default), child elements
keep their current world positions; their local placements are rewritten
to compensate for the parent move.
:return: The new or updated IfcLocalPlacement entity
"""
usecase = Usecase()
@@ -69,6 +69,12 @@ def regenerate_wall_representation(
additional extrusions are generated for each connection that boolean
difference the base extrusion.
Clippings applied via :func:`geometry.clip_solid` or
:func:`geometry.clip_solid_bounded` are preserved only if the ``element``
parameter was passed when creating them, which registers the result in the
``BBIM_Boolean`` property set. Clippings created without that parameter
are silently discarded during regeneration.
This will also update the axis line representation (e.g. trim the axis line
to any connections).
@@ -26,7 +26,7 @@ def assign_process(
relating_process: ifcopenshell.entity_instance,
related_object: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
"""Assigns an object to be related to a process, typically a construction task
"""Assigns an object as an input, control, or resource of a process
Processes work using the ICOM (Input, Controls, Outputs, Mechanisms)
paradigm in IFC. This process model is commonly used in modeling
@@ -63,6 +63,17 @@ def assign_process(
For resources, any construction resource may be assigned to a task.
.. warning::
This function creates an **Input** relationship
(``IfcRelAssignsToProcess``), meaning the product is *consumed* or
*operated on* by the task — the typical case is demolition or
maintenance.
If the task *constructs or installs* a product (e.g. erecting a wall
or fitting a window), use :func:`assign_product` instead, which
creates an **Output** relationship (``IfcRelAssignsToProduct``).
:param relating_process: The IfcProcess (typically IfcTask) that the
input, control, or resource is related to.
:param related_object: The IfcProduct (for input), IfcCostItem (for
@@ -77,7 +88,7 @@ def assign_process(
# need to be part of a work schedule.
schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Construction Schedule A")
# Let's create a construction task. Note that the predefined type is
# Let's create a demolition task. Note that the predefined type is
# important to distinguish types of tasks.
task = ifcopenshell.api.sequence.add_task(model,
work_schedule=schedule, name="Demolish existing", identification="A", predefined_type="DEMOLITION")
@@ -85,8 +96,12 @@ def assign_process(
# Let's say we have a wall somewhere.
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
# Let's demolish that wall!
# The wall is an INPUT to the demolition task (it will be consumed).
ifcopenshell.api.sequence.assign_process(model, relating_process=task, related_object=wall)
# For a construction task that BUILDS a wall, use assign_product instead:
# build_task = ifcopenshell.api.sequence.add_task(model, ..., predefined_type="CONSTRUCTION")
# ifcopenshell.api.sequence.assign_product(model, relating_product=wall, related_object=build_task)
"""
if related_object.HasAssignments:
for assignment in related_object.HasAssignments:
@@ -517,11 +517,18 @@ class ShapeBuilder:
trim_points_mask: Sequence[int],
position_offset: Optional[VectorType] = None,
) -> np.ndarray:
"""Handy way to get edge points of the ellipse like shape of a given radiuses.
"""Get cardinal-point coordinates of an ellipse by index mask.
Mask points are numerated from 0 to 3 ccw starting from (x_axis_radius/2; 0).
The four cardinal points are numbered 03 counter-clockwise starting from the
positive X axis: 0 → ``(x, 0)``, 1 → ``(0, y)``, 2 → ``(-x, 0)``, 3 → ``(0, -y)``.
Example: mask (0, 1, 2, 3) will return points (x, 0), (0, y), (-x, 0), (0, -y)
Example: mask ``(0, 1, 2, 3)`` returns all four points in order.
:param x_axis_radius: Radius (semi-axis length) along the X axis.
:param y_axis_radius: Radius (semi-axis length) along the Y axis.
:param trim_points_mask: Sequence of cardinal-point indices (03) to select.
:param position_offset: Optional 2D offset added to all returned points.
:return: Numpy array of the selected 2D points.
"""
points = np.array(
(
@@ -546,15 +553,23 @@ class ShapeBuilder:
ref_x_direction: VectorType = (1.0, 0.0),
trim_points_mask: Sequence[int] = (),
) -> ifcopenshell.entity_instance:
"""
Ellipse trimming points should be specified in counter clockwise order.
"""Create an IfcEllipse, optionally trimmed to an arc.
For example, if you need to get the part of the ellipse ABOVE y-axis, you need to use mask (0,2). Below y-axis - (2,0)
If neither ``trim_points`` nor ``trim_points_mask`` is provided, a full IfcEllipse is returned.
Trimming points must be given in counter-clockwise order. For example, to get the arc
above the Y-axis use mask ``(0, 2)``; below the Y-axis use ``(2, 0)``.
For more information about trim_points_mask check builder.get_trim_points_from_mask
A trimmed result (IfcTrimmedCurve) includes a closing segment between the trim points,
making it suitable for use as a profile in :meth:`extrude`.
Notion: trimmed ellipse also contains polyline between trim points, meaning IfcTrimmedCurve could be used
for further extrusion.
:param x_axis_radius: Semi-axis length along the local X axis.
:param y_axis_radius: Semi-axis length along the local Y axis.
:param position: 2D centre of the ellipse.
:param trim_points: Explicit pair of 2D trim points. Takes precedence over ``trim_points_mask``.
:param ref_x_direction: Direction of the local X axis.
:param trim_points_mask: Pair of cardinal-point indices (03) used when ``trim_points`` is empty.
See :meth:`get_trim_points_from_mask` for index definitions.
:return: IfcEllipse (untrimmed) or IfcTrimmedCurve (trimmed).
"""
ifc_position = self.create_axis2_placement_2d(position, ref_x_direction)
ifc_ellipse = self.file.createIfcEllipse(
@@ -685,6 +700,14 @@ class ShapeBuilder:
pivot_point: VectorType = (0.0, 0.0),
counter_clockwise: bool = False,
) -> np.ndarray:
"""Rotate a single 2D point around a pivot.
:param point_2d: The 2D point to rotate.
:param angle: Rotation angle, in degrees. Defaults to 90.
:param pivot_point: The point to rotate around.
:param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise.
:return: Rotated 2D point as a numpy array.
"""
angle_rad = radians(angle) * (1 if counter_clockwise else -1)
relative_point = np.array(point_2d) - pivot_point
relative_point = np_rotation_matrix(angle_rad, 2) @ relative_point
@@ -752,7 +775,16 @@ class ShapeBuilder:
mirror_axes: VectorType = (1.0, 1.0),
mirror_point: VectorType = (0.0, 0.0),
) -> np.ndarray:
"""mirror_axes - along which axes mirror will be applied"""
"""Mirror a single 2D point across the specified axes.
:param point_2d: The 2D point to mirror.
:param mirror_axes: Indicates which axes to mirror across. A positive value in a
component means that axis is mirrored (negated relative to ``mirror_point``).
Example: ``(1, 0)`` mirrors across the Y-axis (negates X only),
``(1, 1)`` mirrors across both axes.
:param mirror_point: Origin of the mirror operation.
:return: Mirrored 2D point as a numpy array.
"""
mirror_axes: np.ndarray = np.where(np.array(mirror_axes) > 0, -1, 1)
mirror_point: np.ndarray = np.array(mirror_point)
relative_point = point_2d - mirror_point
@@ -798,7 +830,13 @@ class ShapeBuilder:
def create_axis2_placement_2d(
self, position: VectorType = (0.0, 0.0), x_direction: Optional[VectorType] = None
) -> ifcopenshell.entity_instance:
"""Create IfcAxis2Placement2D."""
"""Create IfcAxis2Placement2D.
:param position: 2D origin of the placement.
:param x_direction: Direction of the local X axis. If not provided, defaults to
the global X axis ``(1, 0)``.
:return: IfcAxis2Placement2D
"""
ref_direction = (
self.file.create_entity("IfcDirection", ifc_safe_vector_type(x_direction)) if x_direction else None
)
@@ -1000,7 +1038,7 @@ class ShapeBuilder:
) -> ifcopenshell.entity_instance:
"""
:param plane: The IfcPlane representing the half space.
:param agreement_flag: False if +Z represents the void
:param agreement_flag: If False (default), the plane normal points toward the **removed** material (the void). The kept region is on the opposite side from the normal.
:return: IfcHalfSpaceSolid
"""
return self.file.createIfcHalfSpaceSolid(plane, AgreementFlag=agreement_flag)
@@ -1053,7 +1091,14 @@ class ShapeBuilder:
def create_swept_disk_solid(
self, path_curve: ifcopenshell.entity_instance, radius: float
) -> ifcopenshell.entity_instance:
"""Create IfcSweptDiskSolid from `path_curve` (must be 3D) and `radius`"""
"""Create an IfcSweptDiskSolid — a circular cross-section swept along a 3D path.
Useful for modelling round pipes, conduits, and cables.
:param path_curve: A 3D curve entity defining the centreline path. Must have ``Dim == 3``.
:param radius: Radius of the circular disk cross-section.
:return: IfcSweptDiskSolid
"""
if path_curve.Dim != 3:
raise Exception(
f"Path curve for IfcSweptDiskSolid should be 3D to be valid, currently it has {path_curve.Dim} dimensions.\n"
@@ -1071,10 +1116,22 @@ class ShapeBuilder:
) -> ifcopenshell.entity_instance:
"""Create IFC representation for the specified context and items.
**All items must belong to the same geometry category.** IFC prohibits
mixing incompatible item types in one representation (e.g.
``IfcExtrudedAreaSolid`` with ``IfcBlock``, or solids with curves).
When ``representation_type`` is omitted the type is inferred via
:func:`ifcopenshell.util.representation.guess_type`; if the items are
heterogeneous ``guess_type`` returns ``None`` and the representation is
written with no ``RepresentationType``, which fails IFC validation.
Avoid mixing swept-solid primitives (``IfcExtrudedAreaSolid``,
``IfcRevolvedAreaSolid``) with CSG primitives (``IfcBlock``,
``IfcSphere``, etc.) or any other category in a single call.
:param context: IfcGeometricRepresentationSubContext
:param items: could be a list or single curve/IfcExtrudedAreaSolid
:param items: A single item or list of items, all of the same geometry
category (e.g. all ``IfcExtrudedAreaSolid``, all ``IfcIndexedPolyCurve``)
:param representation_type: Explicitly specified RepresentationType.
If not provided it will be guessed from the items types
If not provided it will be guessed from the items types.
:return: IfcShapeRepresentation
"""
if not isinstance(items, collections.abc.Iterable):
@@ -1096,18 +1153,26 @@ class ShapeBuilder:
)
def deep_copy(self, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
"""Create a deep copy of an IFC element and all its referenced entities.
:param element: The IFC entity to copy.
:return: A new independent copy of the element.
"""
return ifcopenshell.util.element.copy_deep(self.file, element)
# UTILITIES
def extrude_kwargs(self, axis: Literal["Y", "X", "Z"]) -> dict[str, tuple[float, float, float]]:
"""Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by some axis.
"""Shortcut to get kwargs for :meth:`extrude` to extrude along a principal axis.
It assumes you have 2D profile in:
XZ plane for Y axis extrusion, \n
YZ plane for X axis extrusion, \n
XY plane for Z axis extrusion, \n
Assumes the 2D profile lies in the plane perpendicular to the extrusion axis:
XZ plane for Y-axis extrusion, YZ plane for X-axis extrusion, XY plane for Z-axis extrusion.
Extruding by X/Y using other kwargs might break ValidExtrusionDirection."""
Extruding along X or Y with other kwargs may violate the IFC ValidExtrusionDirection constraint.
:param axis: The extrusion axis: ``'X'``, ``'Y'``, or ``'Z'``.
:return: A dict with keys ``position_x_axis``, ``position_z_axis``, and ``extrusion_vector``
suitable for passing as ``**kwargs`` to :meth:`extrude`.
"""
if axis == "Y":
return {
@@ -1131,13 +1196,16 @@ class ShapeBuilder:
def rotate_extrusion_kwargs_by_z(
self, kwargs: dict[str, Any], angle: float, counter_clockwise: bool = False
) -> dict[str, VectorType]:
"""shortcut to rotate extrusion kwargs by z axis
"""Rotate extrusion kwargs around the Z axis.
`kwargs` expected to have `position_x_axis` and `position_z_axis` keys
A shortcut to rotate the ``position_x_axis`` and ``position_z_axis`` values returned by
:meth:`extrude_kwargs` around the Z axis before passing them to :meth:`extrude`.
`angle` is a rotation value in radians
by default rotation is clockwise, to make it counter clockwise use `counter_clockwise` flag
:param kwargs: A dict with ``position_x_axis`` and ``position_z_axis`` keys,
as returned by :meth:`extrude_kwargs`. The original dict is not mutated.
:param angle: Rotation angle, in radians.
:param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise.
:return: A new dict with ``position_x_axis`` and ``position_z_axis`` rotated around Z.
"""
rot = np_rotation_matrix(-angle, 3, "Z")
kwargs = kwargs.copy() # prevent mutation of original kwargs
@@ -1146,7 +1214,11 @@ class ShapeBuilder:
return kwargs
def get_polyline_coords(self, polyline: ifcopenshell.entity_instance) -> np.ndarray:
"""polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`"""
"""Extract the coordinate array from a polyline entity.
:param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity.
:return: Numpy array of the polyline's point coordinates.
"""
coords = None
if polyline.is_a("IfcIndexedPolyCurve"):
coords = np.array(polyline.Points.CoordList)
@@ -1157,7 +1229,12 @@ class ShapeBuilder:
return coords
def set_polyline_coords(self, polyline: ifcopenshell.entity_instance, coords: SequenceOfVectors) -> None:
"""polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`"""
"""Update the coordinates of a polyline entity in-place.
:param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity.
:param coords: New sequence of point coordinates. Must contain the same number of
points as the original polyline.
"""
if polyline.is_a("IfcIndexedPolyCurve"):
polyline.Points.CoordList = ifc_safe_vector_type(coords)
elif polyline.is_a("IfcPolyline"):
@@ -1296,6 +1373,18 @@ class ShapeBuilder:
WallThickness: float,
FilletRadius: float,
) -> ifcopenshell.entity_instance:
"""Create a Z-profile (cold-formed steel section) outline curve with lips and fillets.
All dimensions are in the IFC project's length units.
:param FirstFlangeWidth: Width of the first (top) flange, measured from the web centreline.
:param SecondFlangeWidth: Width of the second (bottom) flange, measured from the web centreline.
:param Depth: Total depth of the section (web height).
:param Girth: Length of the return lips on each flange.
:param WallThickness: Uniform material thickness.
:param FilletRadius: Inner bend radius at each corner.
:return: IfcIndexedPolyCurve representing the closed Z-profile outline.
"""
x1 = FirstFlangeWidth
x2 = SecondFlangeWidth
y = Depth / 2
@@ -1337,10 +1426,17 @@ class ShapeBuilder:
def create_transition_arc_ifc(
self, width: float, height: float, create_ifc_curve: bool = False
) -> tuple[SequenceOfVectors, list[list[int]], Union[ifcopenshell.entity_instance, None]]:
"""Create an arc in the rectangle with specified width and height.
"""Create an arc fitting inside a rectangle of the given width and height.
If it's not possible to make a complete arc, create an arc with longest radius possible
and straight segment in the middle.
If a single arc cannot span the full width, the longest possible radius is used and
a straight segment is inserted in the middle.
:param width: Width of the bounding rectangle.
:param height: Height of the bounding rectangle (also the maximum arc radius).
:param create_ifc_curve: If True, also create and return an ``IfcIndexedPolyCurve``.
If False, only return the raw point and segment data.
:return: A tuple ``(points, segments, ifc_curve)`` where ``ifc_curve`` is an
``IfcIndexedPolyCurve`` when ``create_ifc_curve=True``, otherwise ``None``.
"""
fillet_size = (width / 2) / height
if fillet_size <= 1:
@@ -1370,6 +1466,14 @@ class ShapeBuilder:
return points, segments, transition_arc
def mesh(self, points: SequenceOfVectors, faces: Sequence[Sequence[int]]) -> ifcopenshell.entity_instance:
"""Create a tessellated mesh from points and face indices.
Delegates to :meth:`faceted_brep` for IFC2X3, or :meth:`polygonal_face_set` for IFC4 and later.
:param points: List of 3D coordinates.
:param faces: List of faces, each face a sequence of zero-based point indices.
:return: IfcFacetedBrep (IFC2X3) or IfcPolygonalFaceSet (IFC4+).
"""
if self.file.schema == "IFC2X3":
return self.faceted_brep(points, faces)
return self.polygonal_face_set(points, faces)
@@ -1723,11 +1827,20 @@ class ShapeBuilder:
angle: float,
profile_offset: VectorType = (0.0, 0.0),
verbose: bool = True,
):
"""get the final transition length for two profiles dimensions, angle and XY offset between them,
) -> Optional[float]:
"""Get the transition length for two profile half-dimensions, an angle, and an XY offset.
the difference from `calculate_transition` - `get_transition_length` is making sure
that length will fit both sides of the transition
Unlike :meth:`mep_transition_calculate`, this method checks that the resulting length
satisfies the angle constraint from both the start and end profile perspectives.
:param start_half_dim: Half-dimensions of the start profile as a 3-element array
``[half_x, half_y, depth]``. For circular profiles ``half_x == half_y == radius``.
:param end_half_dim: Half-dimensions of the end profile in the same format.
:param angle: Maximum allowed transition angle, in degrees.
:param profile_offset: 2D XY offset between the centrelines of the start and end profiles.
:param verbose: If True, print diagnostic values during calculation.
:return: Transition length in project length units, or ``None`` if no valid length exists
for the given angle and offset.
"""
print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None
np_X, np_Y = 0, 1
@@ -1788,9 +1901,23 @@ class ShapeBuilder:
angle: Optional[float] = None,
verbose: bool = True,
) -> Union[float, None]:
"""will return transition length based on the profile dimension differences and offset.
"""Calculate MEP transition length from angle, or transition angle from length.
If `length` is provided will return transition angle"""
Low-level calculation kernel used by :meth:`mep_transition_length`. Provide either
``angle`` or ``length`` (not both); the other value is computed and returned.
:param start_half_dim: Half-dimensions of the start profile ``[half_x, half_y, depth]``.
:param end_half_dim: Half-dimensions of the end profile ``[half_x, half_y, depth]``.
:param offset: 2D XY offset between profile centrelines.
:param diff: Pre-computed absolute difference of start and end half-dimensions (XY only).
Computed from ``start_half_dim`` and ``end_half_dim`` if not provided.
:param end_profile: If True, swap X and Y axes to compute from the end-profile perspective.
:param length: Known transition length. If provided, the corresponding angle is returned.
:param angle: Known transition angle, in degrees. If provided, the corresponding length is returned.
:param verbose: If True, print diagnostic values during calculation.
:return: Transition length (if ``angle`` was given) or transition angle in degrees
(if ``length`` was given), or ``None`` if the geometry is not feasible.
"""
print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None
@@ -0,0 +1,154 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import json
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.shape_builder
import test.bootstrap
class TestClipSolid(test.bootstrap.IFC4):
def make_extrusion(self):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
rect = builder.rectangle(size=(1.0, 1.0))
return builder.extrude(rect, magnitude=4.0)
def test_returns_boolean_clipping_result(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.Operator == "DIFFERENCE"
def test_first_operand_is_the_item(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert result.FirstOperand == extrusion
def test_second_operand_is_half_space_solid(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
def test_clip_plane_location_matches(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
plane = result.SecondOperand.BaseSurface
coords = plane.Position.Location.Coordinates
assert list(coords) == [0.0, 0.0, 3.0]
def test_chaining_two_clips(self):
extrusion = self.make_extrusion()
first_clip = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
second_clip = ifcopenshell.api.geometry.clip_solid(
self.file,
item=first_clip,
location=[0.0, 0.0, 1.0],
normal=[0.0, 0.0, -1.0],
)
assert second_clip.is_a("IfcBooleanClippingResult")
assert second_clip.FirstOperand == first_clip
assert first_clip.FirstOperand == extrusion
def test_angled_clip_plane(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.26],
normal=[0.419, 0.0, 0.908],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
def test_element_registers_result_in_bbim_boolean(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
element=wall,
)
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
assert pset is not None
assert result.id() in json.loads(pset["Data"])
def test_element_appends_to_existing_bbim_boolean(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
first = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
element=wall,
)
second = ifcopenshell.api.geometry.clip_solid(
self.file,
item=first,
location=[0.0, 0.0, 1.0],
normal=[0.0, 0.0, -1.0],
element=wall,
)
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
ids = json.loads(pset["Data"])
assert first.id() in ids
assert second.id() in ids
def test_no_element_does_not_create_pset(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid):
pass
@@ -0,0 +1,179 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import json
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.shape_builder
import test.bootstrap
class TestClipSolidBounded(test.bootstrap.IFC4):
def make_extrusion(self):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
rect = builder.rectangle(size=(4.0, 1.0))
return builder.extrude(rect, magnitude=3.0)
def test_returns_boolean_clipping_result(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.Operator == "DIFFERENCE"
def test_first_operand_is_the_item(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.FirstOperand == extrusion
def test_second_operand_is_polygonal_bounded_half_space(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.SecondOperand.is_a("IfcPolygonalBoundedHalfSpace")
def test_agreement_flag_is_false(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.SecondOperand.AgreementFlag is False
def test_clip_plane_location_matches(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
plane = result.SecondOperand.BaseSurface
coords = plane.Position.Location.Coordinates
assert list(coords) == [2.5, 0.0, 2.0]
def test_boundary_is_closed_polyline(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
boundary = result.SecondOperand.PolygonalBoundary
assert boundary.is_a("IfcPolyline")
pts = [list(p.Coordinates) for p in boundary.Points]
assert pts[0] == pts[-1], "polygon should be closed"
assert len(pts) == 5 # 4 unique + closing repeat
def test_boundary_position_defaults_to_origin(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
pos = result.SecondOperand.Position
assert list(pos.Location.Coordinates) == [0.0, 0.0, 0.0]
def test_custom_boundary_position(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
boundary_position=[1.0, 2.0, 3.0],
)
pos = result.SecondOperand.Position
assert list(pos.Location.Coordinates) == [1.0, 2.0, 3.0]
def test_chaining_with_clip_solid(self):
extrusion = self.make_extrusion()
first_clip = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=first_clip,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.FirstOperand == first_clip
assert first_clip.FirstOperand == extrusion
def test_element_registers_result_in_bbim_boolean(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
element=wall,
)
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
assert pset is not None
assert result.id() in json.loads(pset["Data"])
def test_no_element_does_not_create_pset(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded):
pass
@@ -0,0 +1,134 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
import test.bootstrap
class TestCopyRepresentation(test.bootstrap.IFC4):
def _body_context(self):
body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW")
if body is None:
model = self.file.createIfcGeometricRepresentationContext(
ContextType="Model",
CoordinateSpaceDimension=3,
Precision=1e-5,
WorldCoordinateSystem=self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
),
)
body = self.file.createIfcGeometricRepresentationSubContext(
ContextIdentifier="Body",
ContextType="Model",
TargetView="MODEL_VIEW",
ParentContext=model,
)
return body
def _add_body_rep(self, element):
body = self._body_context()
rep = ifcopenshell.api.geometry.add_wall_representation(
self.file, context=body, length=5.0, height=3.0, thickness=0.2
)
ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep)
return rep
def test_copy_to_empty_target(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
self._add_body_rep(wall_a)
result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
assert result is not None
assert result.is_a("IfcShapeRepresentation")
target_rep = ifcopenshell.util.representation.get_representation(wall_b, "Model", "Body")
assert target_rep is not None
assert target_rep == result
def test_source_rep_entities_are_distinct(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
source_rep = self._add_body_rep(wall_a)
new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
assert new_rep.id() != source_rep.id()
assert new_rep.Items[0].id() != source_rep.Items[0].id()
def test_context_is_shared_not_copied(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
source_rep = self._add_body_rep(wall_a)
new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
assert new_rep.ContextOfItems.id() == source_rep.ContextOfItems.id()
def test_replaces_existing_target_rep(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
self._add_body_rep(wall_a)
old_rep = self._add_body_rep(wall_b)
old_rep_id = old_rep.id()
ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
try:
self.file.by_id(old_rep_id)
assert False, "old representation still exists"
except RuntimeError:
pass # entity was removed, as expected
def test_source_unchanged_after_copy(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
source_rep = self._add_body_rep(wall_a)
source_rep_id = source_rep.id()
ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
assert self.file.by_id(source_rep_id) is not None # source must still exist
assert ifcopenshell.util.representation.get_representation(wall_a, "Model", "Body") is not None
def test_returns_none_when_no_matching_rep(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b)
assert result is None
def test_custom_context_identifier(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
self._add_body_rep(wall_a)
# "Axis" doesn't exist on wall_a, so should return None
result = ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b, context_identifier="Axis"
)
assert result is None
class TestCopyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestCopyRepresentation):
pass