mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 06:58:56 +00:00
Add ifcmcp MCP server for IFC model querying and editing
This commit is contained in:
@@ -0,0 +1,294 @@
|
|||||||
|
<!-- 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`, `ifcedit`, and `mcp`.
|
||||||
|
|
||||||
|
## 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_load
|
||||||
|
|
||||||
|
Open an IFC file into memory.
|
||||||
|
|
||||||
|
```
|
||||||
|
ifc_load(path="/path/to/model.ifc")
|
||||||
|
-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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_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}`.
|
||||||
|
|
||||||
|
### Edit discovery tools
|
||||||
|
|
||||||
|
#### ifc_list
|
||||||
|
|
||||||
|
List all API modules, or functions within a specific module.
|
||||||
|
|
||||||
|
```
|
||||||
|
ifc_list() # all modules
|
||||||
|
ifc_list(module="root") # functions in the root module
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ifc_docs
|
||||||
|
|
||||||
|
Show full documentation for an API function including parameters, types,
|
||||||
|
defaults, and descriptions.
|
||||||
|
|
||||||
|
```
|
||||||
|
ifc_docs(function_path="root.create_entity")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit execution
|
||||||
|
|
||||||
|
#### ifc_edit
|
||||||
|
|
||||||
|
Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
|
||||||
|
JSON string with string values that get coerced by ifcedit's type system.
|
||||||
|
|
||||||
|
```
|
||||||
|
ifc_edit(
|
||||||
|
function_path="root.create_entity",
|
||||||
|
params='{"ifc_class": "IfcWall", "name": "My Wall"}'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
|
||||||
|
|
||||||
|
Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
|
||||||
|
|
||||||
|
**Parameter coercion:**
|
||||||
|
|
||||||
|
| Type | JSON value | Python value |
|
||||||
|
|------|------------|--------------|
|
||||||
|
| `entity_instance` | `"42"` | resolved from model by step ID |
|
||||||
|
| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
|
||||||
|
| `dict` | `'{"key": "val"}'` | parsed JSON object |
|
||||||
|
| `bool` | `"true"` | `True` |
|
||||||
|
| `Optional[X]` | `"none"` | `None` |
|
||||||
|
|
||||||
|
## Typical workflow
|
||||||
|
|
||||||
|
1. **Load** a model: `ifc_load`
|
||||||
|
2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
|
||||||
|
3. **Validate** if needed: `ifc_validate`
|
||||||
|
4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
|
||||||
|
5. **Look up IFC classes**: `ifc_schema`
|
||||||
|
6. **Find** the right API function: `ifc_list`, `ifc_docs`
|
||||||
|
7. **Edit** the model: `ifc_edit`
|
||||||
|
8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
|
||||||
|
9. **Verify** changes with query tools
|
||||||
|
10. **Save** when satisfied: `ifc_save`
|
||||||
|
|
||||||
|
The model stays in memory across all calls, so multi-step editing sessions
|
||||||
|
are fast -- no file I/O between operations.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPLv3+ -- see the IfcOpenShell project license.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
# IfcMCP - MCP server for IFC building models
|
||||||
|
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||||
|
#
|
||||||
|
# This file is part of IfcMCP.
|
||||||
|
#
|
||||||
|
# IfcMCP is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# IfcMCP is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with IfcMCP. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
__version__ = version = "0.0.0"
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# inside ifcmcp/core.py
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable # noqa: F401 — Callable used in helpers below
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import ifcopenshell
|
||||||
|
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||||
|
from ifcedit.quantify import run_quantify
|
||||||
|
from ifcedit.run import run_api
|
||||||
|
from ifcquery import clash as clash_mod
|
||||||
|
from ifcquery import contexts as contexts_mod
|
||||||
|
from ifcquery import cost as cost_mod
|
||||||
|
from ifcquery import (
|
||||||
|
info,
|
||||||
|
relations,
|
||||||
|
schedule,
|
||||||
|
schema,
|
||||||
|
select,
|
||||||
|
summary,
|
||||||
|
tree,
|
||||||
|
)
|
||||||
|
from ifcquery import (
|
||||||
|
materials as materials_mod,
|
||||||
|
)
|
||||||
|
from ifcquery import (
|
||||||
|
plot as plot_mod,
|
||||||
|
)
|
||||||
|
from ifcquery import (
|
||||||
|
render as render_mod,
|
||||||
|
)
|
||||||
|
from ifcquery import validate as validate_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonify(x: Any) -> Any:
|
||||||
|
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
|
||||||
|
if x is None or isinstance(x, (str, int, float, bool)):
|
||||||
|
return x
|
||||||
|
|
||||||
|
# numpy arrays (and any array-like with tolist)
|
||||||
|
if hasattr(x, "tolist"):
|
||||||
|
return x.tolist()
|
||||||
|
|
||||||
|
# IfcOpenShell entity instances: normalize
|
||||||
|
if isinstance(x, ifcopenshell.entity_instance):
|
||||||
|
return {
|
||||||
|
"id": int(x.id()),
|
||||||
|
"type": x.is_a(),
|
||||||
|
"repr": str(x),
|
||||||
|
"name": getattr(x, "Name", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(x, dict):
|
||||||
|
return {str(k): _jsonify(v) for k, v in x.items()}
|
||||||
|
|
||||||
|
if isinstance(x, (list, tuple, set)):
|
||||||
|
return [_jsonify(v) for v in x]
|
||||||
|
|
||||||
|
# Try JSON as-is, else fallback to string
|
||||||
|
try:
|
||||||
|
json.dumps(x)
|
||||||
|
return x
|
||||||
|
except Exception:
|
||||||
|
return str(x)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shape builder helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _list_shape_methods() -> list[dict]:
|
||||||
|
"""Introspect ShapeBuilder and return a summary of all public methods."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from ifcedit.discover import _extract_params
|
||||||
|
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction):
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
doc = fn.__doc__ or ""
|
||||||
|
description = doc.strip().split("\n")[0] if doc.strip() else ""
|
||||||
|
results.append({"method": name, "description": description, "params": _extract_params(fn)})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_method_docs(method_name: str) -> dict:
|
||||||
|
"""Return full documentation for a single ShapeBuilder method."""
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from ifcedit.discover import (
|
||||||
|
_extract_params,
|
||||||
|
_format_type_hint,
|
||||||
|
_parse_docstring_body,
|
||||||
|
_parse_param_docs,
|
||||||
|
_parse_return_doc,
|
||||||
|
)
|
||||||
|
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||||
|
|
||||||
|
if method_name.startswith("_"):
|
||||||
|
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
|
||||||
|
fn = getattr(ShapeBuilder, method_name, None)
|
||||||
|
if fn is None:
|
||||||
|
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
|
||||||
|
|
||||||
|
doc = fn.__doc__ or ""
|
||||||
|
description, long_description = _parse_docstring_body(doc)
|
||||||
|
params = _extract_params(fn)
|
||||||
|
for param in params:
|
||||||
|
param_desc = _parse_param_docs(doc)
|
||||||
|
if param["name"] in param_desc:
|
||||||
|
param["description"] = param_desc[param["name"]]
|
||||||
|
|
||||||
|
try:
|
||||||
|
hints = typing.get_type_hints(fn)
|
||||||
|
except Exception:
|
||||||
|
hints = {}
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"method": method_name,
|
||||||
|
"description": description,
|
||||||
|
"long_description": long_description,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
return_type = _format_type_hint(hints.get("return"))
|
||||||
|
if return_type:
|
||||||
|
result["return_type"] = return_type
|
||||||
|
return_description = _parse_return_doc(doc)
|
||||||
|
if return_description:
|
||||||
|
result["return_description"] = return_description
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict:
|
||||||
|
"""Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method."""
|
||||||
|
import inspect
|
||||||
|
import typing
|
||||||
|
|
||||||
|
sig = inspect.signature(fn)
|
||||||
|
try:
|
||||||
|
hints = typing.get_type_hints(fn)
|
||||||
|
except Exception:
|
||||||
|
hints = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: _coerce_shape_value(value, hints.get(key), model)
|
||||||
|
for key, value in raw_kwargs.items()
|
||||||
|
if key in sig.parameters and key != "self"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any:
|
||||||
|
"""Convert a single JSON-parsed value to the correct Python type."""
|
||||||
|
import typing
|
||||||
|
|
||||||
|
if hint is None or value is None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
origin = typing.get_origin(hint)
|
||||||
|
args = typing.get_args(hint)
|
||||||
|
|
||||||
|
# Optional[X] / Union — try each non-None branch in order
|
||||||
|
if origin is typing.Union:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
for t in (a for a in args if a is not type(None)):
|
||||||
|
try:
|
||||||
|
return _coerce_shape_value(value, t, model)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return value
|
||||||
|
|
||||||
|
# entity_instance: resolve integer or "#N" string step ID
|
||||||
|
if hint is ifcopenshell.entity_instance or (
|
||||||
|
isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance)
|
||||||
|
):
|
||||||
|
entity_id = int(str(value).lstrip("#"))
|
||||||
|
entity = model.by_id(entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise ValueError(f"Entity #{entity_id} not found in model")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
# Sequence[entity_instance]: resolve each element in the list
|
||||||
|
import collections.abc
|
||||||
|
|
||||||
|
if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str):
|
||||||
|
if args and (
|
||||||
|
args[0] is ifcopenshell.entity_instance
|
||||||
|
or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance))
|
||||||
|
):
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_coerce_shape_value(v, args[0], model) for v in value]
|
||||||
|
|
||||||
|
# bool: JSON gives actual bools; also accept string representations
|
||||||
|
if hint is bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
# Everything else (float, int, VectorType lists, dicts, Literals) passes through
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class IfcSessionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IfcSession:
|
||||||
|
"""In-memory IFC session (no FastMCP dependency).
|
||||||
|
|
||||||
|
Designed to work in:
|
||||||
|
- FastMCP server (single global session)
|
||||||
|
- Embedded runtimes like Pyodide (one session per browser tab/worker)
|
||||||
|
"""
|
||||||
|
|
||||||
|
model: ifcopenshell.file | None = None
|
||||||
|
model_path: str | None = None
|
||||||
|
|
||||||
|
# -----------------
|
||||||
|
# Session lifecycle
|
||||||
|
# -----------------
|
||||||
|
def _require_model(self) -> ifcopenshell.file:
|
||||||
|
if self.model is None:
|
||||||
|
raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.")
|
||||||
|
return self.model
|
||||||
|
|
||||||
|
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
|
||||||
|
"""Create a new empty IFC model in memory."""
|
||||||
|
self.model = ifcopenshell.file(schema=schema)
|
||||||
|
self.model_path = None
|
||||||
|
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
|
||||||
|
|
||||||
|
def ifc_load(self, path: str) -> str:
|
||||||
|
"""Open an IFC file into memory. Returns confirmation string."""
|
||||||
|
self.model = ifcopenshell.open(path)
|
||||||
|
self.model_path = path
|
||||||
|
count = sum(1 for _ in self.model)
|
||||||
|
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
|
||||||
|
|
||||||
|
def ifc_save(self, path: str = "") -> str:
|
||||||
|
"""Write the in-memory model to disk. Empty path overwrites the original file."""
|
||||||
|
model = self._require_model()
|
||||||
|
target = path if path else self.model_path
|
||||||
|
if not target:
|
||||||
|
raise IfcSessionError("No path specified and no original path available.")
|
||||||
|
model.write(target)
|
||||||
|
return f"Saved to {target}"
|
||||||
|
|
||||||
|
def ifc_reset(self) -> dict[str, Any]:
|
||||||
|
"""Drop the in-memory model."""
|
||||||
|
self.model = None
|
||||||
|
self.model_path = None
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# -------------
|
||||||
|
# Query tools
|
||||||
|
# -------------
|
||||||
|
def ifc_summary(self) -> dict[str, Any]:
|
||||||
|
"""Model overview: schema, entity counts, project info."""
|
||||||
|
return summary.summary(self._require_model())
|
||||||
|
|
||||||
|
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
|
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
|
||||||
|
return tree.tree(self._require_model())
|
||||||
|
|
||||||
|
def ifc_info(self, element_id: int) -> dict[str, Any]:
|
||||||
|
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
|
||||||
|
model = self._require_model()
|
||||||
|
element = model.by_id(element_id)
|
||||||
|
if element is None:
|
||||||
|
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||||
|
return info.info(model, element)
|
||||||
|
|
||||||
|
def ifc_select(self, query: str) -> list[dict[str, Any]]:
|
||||||
|
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
|
||||||
|
return select.select(self._require_model(), query)
|
||||||
|
|
||||||
|
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
|
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
|
||||||
|
model = self._require_model()
|
||||||
|
element = model.by_id(element_id)
|
||||||
|
if element is None:
|
||||||
|
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||||
|
return relations.relations(model, element, traverse=traverse if traverse else None)
|
||||||
|
|
||||||
|
def ifc_clash(
|
||||||
|
self,
|
||||||
|
element_id: int,
|
||||||
|
clearance: float = 0.0,
|
||||||
|
tolerance: float = 0.002,
|
||||||
|
scope: str = "storey",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
|
||||||
|
model = self._require_model()
|
||||||
|
element = model.by_id(element_id)
|
||||||
|
if element is None:
|
||||||
|
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||||
|
return clash_mod.clash(
|
||||||
|
model,
|
||||||
|
element,
|
||||||
|
clearance=clearance if clearance and clearance > 0.0 else None,
|
||||||
|
tolerance=tolerance,
|
||||||
|
scope=scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ifc_contexts(self) -> list[dict[str, Any]]:
|
||||||
|
"""List all geometric representation contexts and subcontexts with their step IDs."""
|
||||||
|
return contexts_mod.contexts(self._require_model())
|
||||||
|
|
||||||
|
def ifc_materials(self) -> list[dict[str, Any]]:
|
||||||
|
"""List all materials and material sets (layers, constituents, profiles)."""
|
||||||
|
return materials_mod.materials(self._require_model())
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Edit discovery + execute
|
||||||
|
# ------------------------
|
||||||
|
def ifc_list(self, module: str = "") -> list[dict]:
|
||||||
|
"""List all API modules, or functions within a module. Empty module = all modules."""
|
||||||
|
return list_functions(module) if module else list_modules()
|
||||||
|
|
||||||
|
def ifc_docs(self, function_path: str) -> dict:
|
||||||
|
"""Show full documentation for an API function. Input format: 'module.function'."""
|
||||||
|
module, function = function_path.split(".", 1)
|
||||||
|
return function_docs(module, function)
|
||||||
|
|
||||||
|
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
|
||||||
|
"""Execute an ifcopenshell.api mutation.
|
||||||
|
|
||||||
|
params may be:
|
||||||
|
- JSON string
|
||||||
|
- dict (from tool calling / JS)
|
||||||
|
- JsProxy (handled upstream in embedded.py)
|
||||||
|
"""
|
||||||
|
model = self._require_model()
|
||||||
|
module, function = function_path.split(".", 1)
|
||||||
|
|
||||||
|
if isinstance(params, str):
|
||||||
|
raw_kwargs = json.loads(params) if params.strip() else {}
|
||||||
|
elif isinstance(params, dict):
|
||||||
|
raw_kwargs = params
|
||||||
|
else:
|
||||||
|
# e.g. list/None/etc
|
||||||
|
raw_kwargs = dict(params) if params is not None else {}
|
||||||
|
|
||||||
|
res = run_api(model, module, function, raw_kwargs)
|
||||||
|
return _jsonify(res)
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Extended query + edit tools
|
||||||
|
# ------------------------
|
||||||
|
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
|
||||||
|
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
|
||||||
|
return validate_mod.validate(self._require_model(), express_rules=express_rules)
|
||||||
|
|
||||||
|
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""List work schedules and nested tasks from the model.
|
||||||
|
|
||||||
|
max_depth limits subtask expansion (None = unlimited). At the cutoff,
|
||||||
|
subtasks is replaced with {"truncated": True, "count": N}.
|
||||||
|
"""
|
||||||
|
return schedule.schedule(self._require_model(), max_depth=max_depth)
|
||||||
|
|
||||||
|
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""List cost schedules and nested cost items from the model.
|
||||||
|
|
||||||
|
max_depth limits cost item expansion (None = unlimited). At the cutoff,
|
||||||
|
subitems is replaced with {"truncated": True, "count": N}.
|
||||||
|
"""
|
||||||
|
return cost_mod.cost(self._require_model(), max_depth=max_depth)
|
||||||
|
|
||||||
|
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
|
||||||
|
"""Return IFC class documentation for entity_type using the model's schema version."""
|
||||||
|
return schema.schema(self._require_model(), entity_type)
|
||||||
|
|
||||||
|
def ifc_plot(
|
||||||
|
self,
|
||||||
|
selector: str = "",
|
||||||
|
element_ids: list[int] | None = None,
|
||||||
|
view: str = "floorplan",
|
||||||
|
width_mm: float = 297.0,
|
||||||
|
height_mm: float = 420.0,
|
||||||
|
scale: float = 1.0 / 100.0,
|
||||||
|
png_width: int = 1024,
|
||||||
|
png_height: int = 1024,
|
||||||
|
output_format: str = "png",
|
||||||
|
) -> bytes:
|
||||||
|
"""Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes.
|
||||||
|
|
||||||
|
Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG
|
||||||
|
when output_format is 'png'.
|
||||||
|
|
||||||
|
:param selector: ifcopenshell selector to restrict plotted elements
|
||||||
|
(e.g. ``'IfcWall'``). Omit to plot the whole model.
|
||||||
|
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||||
|
are faded to 10% opacity so the subject stands out.
|
||||||
|
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
|
||||||
|
``section``, or ``auto``.
|
||||||
|
:param width_mm: Paper width in mm (default 297 = A4).
|
||||||
|
:param height_mm: Paper height in mm (default 420 = A4).
|
||||||
|
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
|
||||||
|
:param png_width: Raster output width in pixels (default 1024).
|
||||||
|
:param png_height: Raster output height in pixels (default 1024).
|
||||||
|
:param output_format: ``'svg'`` or ``'png'`` (default ``'png'``).
|
||||||
|
:return: SVG or PNG bytes depending on output_format.
|
||||||
|
"""
|
||||||
|
model = self._require_model()
|
||||||
|
return plot_mod.plot(
|
||||||
|
model,
|
||||||
|
output_format=output_format,
|
||||||
|
selector=selector if selector else None,
|
||||||
|
element_ids=element_ids,
|
||||||
|
view=view,
|
||||||
|
width_mm=width_mm,
|
||||||
|
height_mm=height_mm,
|
||||||
|
scale=scale,
|
||||||
|
png_width=png_width,
|
||||||
|
png_height=png_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ifc_render(
|
||||||
|
self,
|
||||||
|
selector: str = "",
|
||||||
|
element_ids: list[int] | None = None,
|
||||||
|
view: str = "iso",
|
||||||
|
) -> bytes:
|
||||||
|
"""Render the loaded model to a PNG image and return raw bytes.
|
||||||
|
|
||||||
|
:param selector: ifcopenshell selector to restrict rendered elements
|
||||||
|
(e.g. ``'IfcWall'``). Omit to render the whole model.
|
||||||
|
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||||
|
are rendered in translucent grey.
|
||||||
|
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
|
||||||
|
``east``, or ``west``.
|
||||||
|
:return: PNG image as raw bytes.
|
||||||
|
"""
|
||||||
|
model = self._require_model()
|
||||||
|
return render_mod.render(
|
||||||
|
model,
|
||||||
|
selector=selector if selector else None,
|
||||||
|
element_ids=element_ids,
|
||||||
|
view=view,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Shape builder tools
|
||||||
|
# ------------------------
|
||||||
|
def ifc_shape_list(self) -> list[dict]:
|
||||||
|
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
|
||||||
|
return _list_shape_methods()
|
||||||
|
|
||||||
|
def ifc_shape_docs(self, method: str) -> dict:
|
||||||
|
"""Full documentation for a ShapeBuilder method: params, types, return value."""
|
||||||
|
return _shape_method_docs(method)
|
||||||
|
|
||||||
|
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
|
||||||
|
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
|
||||||
|
|
||||||
|
params is a JSON string of keyword arguments. Pass entity references as integer
|
||||||
|
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
|
||||||
|
"""
|
||||||
|
model = self._require_model()
|
||||||
|
|
||||||
|
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||||
|
|
||||||
|
if method.startswith("_"):
|
||||||
|
raise IfcSessionError(f"Private method '{method}' is not accessible")
|
||||||
|
fn = getattr(ShapeBuilder, method, None)
|
||||||
|
if fn is None:
|
||||||
|
return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"}
|
||||||
|
|
||||||
|
if isinstance(params, str):
|
||||||
|
raw_kwargs = json.loads(params) if params.strip() else {}
|
||||||
|
elif isinstance(params, dict):
|
||||||
|
raw_kwargs = params
|
||||||
|
else:
|
||||||
|
raw_kwargs = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
coerced = _coerce_shape_params(fn, raw_kwargs, model)
|
||||||
|
result = fn(ShapeBuilder(model), **coerced)
|
||||||
|
return {"ok": True, "result": _jsonify(result)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||||
|
"""Run quantity take-off on the model using the named rule.
|
||||||
|
|
||||||
|
Modifies the model in-place; call ifc_save() after.
|
||||||
|
"""
|
||||||
|
model = self._require_model()
|
||||||
|
return run_quantify(model, rule, selector=selector if selector else None)
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Generic dispatcher + tool specs for LLMs
|
||||||
|
# ------------------------
|
||||||
|
def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
|
||||||
|
args = args or {}
|
||||||
|
fn = getattr(self, name, None)
|
||||||
|
if not callable(fn):
|
||||||
|
raise IfcSessionError(f"Unknown tool: {name}")
|
||||||
|
return _jsonify(fn(**args))
|
||||||
|
|
||||||
|
def openai_tools(self) -> list[dict[str, Any]]:
|
||||||
|
"""Tool schemas in the OpenAI 'Responses API' format (type=function)."""
|
||||||
|
# Keep schemas tight so the model calls tools correctly.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_new",
|
||||||
|
"description": "Create a new empty IFC model in memory.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_summary",
|
||||||
|
"description": "Get a concise overview of the loaded IFC model.",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_tree",
|
||||||
|
"description": "Get the full spatial hierarchy tree.",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_select",
|
||||||
|
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
"required": ["query"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_info",
|
||||||
|
"description": "Inspect an entity by STEP id.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"element_id": {"type": "integer"}},
|
||||||
|
"required": ["element_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_relations",
|
||||||
|
"description": "Get relationships for an element. traverse='up' walks to IfcProject.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
|
||||||
|
"required": ["element_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_clash",
|
||||||
|
"description": "Run clash/clearance checks for an element.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"element_id": {"type": "integer"},
|
||||||
|
"clearance": {"type": "number"},
|
||||||
|
"tolerance": {"type": "number"},
|
||||||
|
"scope": {"type": "string", "description": "storey or all"},
|
||||||
|
},
|
||||||
|
"required": ["element_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_contexts",
|
||||||
|
"description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_materials",
|
||||||
|
"description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_list",
|
||||||
|
"description": "List ifcopenshell.api modules or functions within a module.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"module": {"type": "string"}},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_docs",
|
||||||
|
"description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"function_path": {"type": "string"}},
|
||||||
|
"required": ["function_path"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_edit",
|
||||||
|
"description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
|
||||||
|
"required": ["function_path"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_validate",
|
||||||
|
"description": "Validate the loaded model. Returns valid bool and list of issues.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_schedule",
|
||||||
|
"description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"max_depth": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max levels of subtask expansion (omit for unlimited)",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_cost",
|
||||||
|
"description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"max_depth": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max levels of cost item expansion (omit for unlimited)",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_schema",
|
||||||
|
"description": "Return IFC class documentation for an entity type.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}},
|
||||||
|
"required": ["entity_type"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_quantify",
|
||||||
|
"description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
|
||||||
|
"selector": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ifcopenshell selector to restrict elements (default: all IfcElement)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["rule"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "ifc_render",
|
||||||
|
"description": (
|
||||||
|
"Render the loaded IFC model to a PNG image for visual inspection. "
|
||||||
|
"Use selector to restrict which elements are rendered (e.g. a single storey). "
|
||||||
|
"Use element_ids to highlight elements against a greyed-out background. "
|
||||||
|
"Returns base64-encoded PNG bytes."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
|
||||||
|
"element_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "integer"},
|
||||||
|
"description": "Step IDs of elements to highlight",
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["iso", "top", "south", "north", "east", "west"],
|
||||||
|
"description": "Camera angle (default: iso)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ifcmcp.core import IfcSession
|
||||||
|
|
||||||
|
session = IfcSession()
|
||||||
|
|
||||||
|
# Optional imports only available under Pyodide
|
||||||
|
try:
|
||||||
|
from pyodide.ffi import JsProxy, to_py # type: ignore
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
JsProxy = None # type: ignore
|
||||||
|
to_py = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_args(args: Any) -> dict[str, Any]:
|
||||||
|
"""Convert JS objects / JsProxy / mappings into a real Python dict."""
|
||||||
|
if args is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Pyodide: JS object arrives as JsProxy; convert recursively to Python.
|
||||||
|
if JsProxy is not None and isinstance(args, JsProxy):
|
||||||
|
# dict_converter=dict ensures JS object -> Python dict (not Map)
|
||||||
|
return to_py(args, dict_converter=dict)
|
||||||
|
|
||||||
|
# Already a Python dict
|
||||||
|
if isinstance(args, dict):
|
||||||
|
return args
|
||||||
|
|
||||||
|
# Any Mapping-like object
|
||||||
|
if isinstance(args, Mapping):
|
||||||
|
return dict(args)
|
||||||
|
|
||||||
|
# Last resort: try dict() coercion
|
||||||
|
try:
|
||||||
|
return dict(args)
|
||||||
|
except Exception as e:
|
||||||
|
raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def tools_openai() -> list[dict[str, Any]]:
|
||||||
|
return session.openai_tools()
|
||||||
|
|
||||||
|
|
||||||
|
def call_tool(name: str, args: Any = None) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Non-throwing tool dispatcher.
|
||||||
|
Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
py_args = _coerce_args(args)
|
||||||
|
data = session.dispatch(name, py_args)
|
||||||
|
return {"ok": True, "data": data}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Keep it short; avoid full tracebacks in tool output unless debugging.
|
||||||
|
return {"ok": False, "error_type": type(e).__name__, "error": str(e)}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ifcmcp.core import IfcSession
|
||||||
|
|
||||||
|
try:
|
||||||
|
from mcp.server.fastmcp import FastMCP # type: ignore
|
||||||
|
from mcp.types import ImageContent # type: ignore
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
FastMCP = None # type: ignore
|
||||||
|
ImageContent = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def build_server() -> Any:
|
||||||
|
"""Create the FastMCP server if the dependency is available."""
|
||||||
|
if FastMCP is None:
|
||||||
|
raise ImportError(
|
||||||
|
"FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)."
|
||||||
|
)
|
||||||
|
|
||||||
|
session = IfcSession()
|
||||||
|
|
||||||
|
server = FastMCP(
|
||||||
|
name="ifc-mcp",
|
||||||
|
instructions=(
|
||||||
|
"MCP server for querying and editing IFC building models. "
|
||||||
|
"Load a file first with ifc_load, then use query/edit tools. "
|
||||||
|
"Save changes with ifc_save."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Lifecycle ----
|
||||||
|
@server.tool()
|
||||||
|
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
|
||||||
|
return session.ifc_new(schema=schema)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_load(path: str) -> str:
|
||||||
|
return session.ifc_load(path)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_save(path: str = "") -> str:
|
||||||
|
return session.ifc_save(path)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_reset() -> dict[str, Any]:
|
||||||
|
return session.ifc_reset()
|
||||||
|
|
||||||
|
# ---- Query ----
|
||||||
|
@server.tool()
|
||||||
|
def ifc_summary() -> dict[str, Any]:
|
||||||
|
return session.ifc_summary()
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
|
return session.ifc_tree()
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_info(element_id: int) -> dict[str, Any]:
|
||||||
|
return session.ifc_info(element_id)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_select(query: str) -> list[dict[str, Any]]:
|
||||||
|
return session.ifc_select(query)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||||
|
return session.ifc_relations(element_id, traverse=traverse)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_clash(
|
||||||
|
element_id: int,
|
||||||
|
clearance: float = 0.0,
|
||||||
|
tolerance: float = 0.002,
|
||||||
|
scope: str = "storey",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return session.ifc_clash(
|
||||||
|
element_id=element_id,
|
||||||
|
clearance=clearance,
|
||||||
|
tolerance=tolerance,
|
||||||
|
scope=scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_contexts() -> list[dict[str, Any]]:
|
||||||
|
return session.ifc_contexts()
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_materials() -> list[dict[str, Any]]:
|
||||||
|
return session.ifc_materials()
|
||||||
|
|
||||||
|
# ---- Edit ----
|
||||||
|
@server.tool()
|
||||||
|
def ifc_list(module: str = "") -> list[dict]:
|
||||||
|
return session.ifc_list(module=module)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_docs(function_path: str) -> dict:
|
||||||
|
return session.ifc_docs(function_path=function_path)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_edit(function_path: str, params: str = "{}") -> dict:
|
||||||
|
return session.ifc_edit(function_path=function_path, params=params)
|
||||||
|
|
||||||
|
# ---- Extended query + edit ----
|
||||||
|
@server.tool()
|
||||||
|
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
|
||||||
|
return session.ifc_validate(express_rules=express_rules)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
return session.ifc_schedule(max_depth=max_depth)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
return session.ifc_cost(max_depth=max_depth)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_schema(entity_type: str) -> dict[str, Any]:
|
||||||
|
return session.ifc_schema(entity_type=entity_type)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||||
|
return session.ifc_quantify(rule=rule, selector=selector)
|
||||||
|
|
||||||
|
# ---- Shape builder ----
|
||||||
|
@server.tool()
|
||||||
|
def ifc_shape_list() -> list[dict]:
|
||||||
|
return session.ifc_shape_list()
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_shape_docs(method: str) -> dict:
|
||||||
|
return session.ifc_shape_docs(method=method)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def ifc_shape(method: str, params: str = "{}") -> dict:
|
||||||
|
return session.ifc_shape(method=method, params=params)
|
||||||
|
|
||||||
|
@server.tool(structured_output=False)
|
||||||
|
def ifc_plot(
|
||||||
|
selector: str = "",
|
||||||
|
element_ids: list[int] | None = None,
|
||||||
|
view: str = "floorplan",
|
||||||
|
width_mm: float = 297.0,
|
||||||
|
height_mm: float = 420.0,
|
||||||
|
scale: float = 1.0 / 100.0,
|
||||||
|
png_width: int = 1024,
|
||||||
|
png_height: int = 1024,
|
||||||
|
output_path: str = "",
|
||||||
|
) -> list[ImageContent]:
|
||||||
|
"""Generate a 2D technical drawing of the loaded IFC model.
|
||||||
|
|
||||||
|
Returns an inline PNG image (floor plan, elevation, or section) that the
|
||||||
|
LLM can inspect to understand the 2D layout of the model. If
|
||||||
|
``output_path`` is provided the drawing is also saved to disk — as SVG
|
||||||
|
when the path ends in ``.svg``, otherwise as PNG.
|
||||||
|
|
||||||
|
:param selector: ifcopenshell selector to restrict plotted elements
|
||||||
|
(e.g. ``'IfcWall'``). Omit to plot the whole model.
|
||||||
|
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||||
|
are faded so the subject stands out.
|
||||||
|
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
|
||||||
|
``section``, or ``auto``.
|
||||||
|
:param width_mm: Paper width in mm (default 297 = A4 landscape width).
|
||||||
|
:param height_mm: Paper height in mm (default 420 = A4 landscape height).
|
||||||
|
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
|
||||||
|
:param png_width: Raster output width in pixels (default 1024).
|
||||||
|
:param png_height: Raster output height in pixels (default 1024).
|
||||||
|
:param output_path: Optional file path to save the drawing to disk.
|
||||||
|
"""
|
||||||
|
png_bytes = session.ifc_plot(
|
||||||
|
selector=selector,
|
||||||
|
element_ids=element_ids,
|
||||||
|
view=view,
|
||||||
|
width_mm=width_mm,
|
||||||
|
height_mm=height_mm,
|
||||||
|
scale=scale,
|
||||||
|
png_width=png_width,
|
||||||
|
png_height=png_height,
|
||||||
|
output_format="png",
|
||||||
|
)
|
||||||
|
if output_path:
|
||||||
|
if output_path.endswith(".svg"):
|
||||||
|
svg_bytes = session.ifc_plot(
|
||||||
|
selector=selector,
|
||||||
|
element_ids=element_ids,
|
||||||
|
view=view,
|
||||||
|
width_mm=width_mm,
|
||||||
|
height_mm=height_mm,
|
||||||
|
scale=scale,
|
||||||
|
output_format="svg",
|
||||||
|
)
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(svg_bytes)
|
||||||
|
else:
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(png_bytes)
|
||||||
|
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
|
||||||
|
|
||||||
|
@server.tool(structured_output=False)
|
||||||
|
def ifc_render(
|
||||||
|
selector: str = "",
|
||||||
|
element_ids: list[int] | None = None,
|
||||||
|
view: str = "iso",
|
||||||
|
output_path: str = "",
|
||||||
|
) -> list[ImageContent]:
|
||||||
|
"""Render the loaded IFC model to a PNG image.
|
||||||
|
|
||||||
|
Returns an inline image the LLM can inspect to understand the spatial
|
||||||
|
layout of the model or a specific element in context. If
|
||||||
|
``output_path`` is provided the PNG is also saved to that file path.
|
||||||
|
|
||||||
|
:param selector: ifcopenshell selector to restrict rendered elements
|
||||||
|
(e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
|
||||||
|
Omit to render the whole model.
|
||||||
|
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||||
|
are rendered in translucent grey so the subject stands out.
|
||||||
|
:param view: Camera angle — ``iso`` (default), ``top``, ``south``,
|
||||||
|
``north``, ``east``, or ``west``.
|
||||||
|
:param output_path: Optional file path to save the PNG to disk.
|
||||||
|
"""
|
||||||
|
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
|
||||||
|
if output_path:
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(png_bytes)
|
||||||
|
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
|
||||||
|
|
||||||
|
return server
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[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"
|
||||||
|
keywords = ["IFC", "BIM", "MCP"]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||||
|
]
|
||||||
|
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
mcp = ["mcp"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
ifcmcp = "ifcmcp.__main__:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "http://ifcopenshell.org"
|
||||||
|
Documentation = "https://docs.ifcopenshell.org"
|
||||||
|
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["ifcmcp*"]
|
||||||
|
exclude = ["test*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
extend = "../../pyproject.toml"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
import ifcopenshell
|
||||||
|
import ifcopenshell.api.aggregate
|
||||||
|
import ifcopenshell.api.owner.settings
|
||||||
|
import ifcopenshell.api.project
|
||||||
|
import ifcopenshell.api.root
|
||||||
|
import ifcopenshell.api.spatial
|
||||||
|
import ifcopenshell.api.unit
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcmcp.core import IfcSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session():
|
||||||
|
return IfcSession()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model():
|
||||||
|
"""IFC4 model with a spatial hierarchy, a wall, and a slab."""
|
||||||
|
f = ifcopenshell.api.project.create_file()
|
||||||
|
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||||
|
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||||
|
|
||||||
|
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||||
|
ifcopenshell.api.unit.assign_unit(f)
|
||||||
|
|
||||||
|
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||||
|
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||||
|
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||||
|
|
||||||
|
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||||
|
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||||
|
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||||
|
|
||||||
|
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||||
|
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||||
|
|
||||||
|
slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
|
||||||
|
ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
|
||||||
|
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model_file(model, tmp_path):
|
||||||
|
"""Write the model fixture to a temp file and return the path."""
|
||||||
|
path = tmp_path / "test.ifc"
|
||||||
|
model.write(str(path))
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loaded_session(model):
|
||||||
|
"""An IfcSession with an in-memory model already loaded (no file path)."""
|
||||||
|
s = IfcSession()
|
||||||
|
s.model = model
|
||||||
|
return s
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
import json
|
||||||
|
|
||||||
|
import ifcopenshell
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcmcp.core import IfcSession, IfcSessionError
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoModel:
|
||||||
|
def test_edit_no_model(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_edit("root.create_entity")
|
||||||
|
|
||||||
|
|
||||||
|
class TestList:
|
||||||
|
def test_list_all_modules(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_list()
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) > 0
|
||||||
|
modules = [m["module"] for m in result]
|
||||||
|
assert "root" in modules
|
||||||
|
assert "spatial" in modules
|
||||||
|
|
||||||
|
def test_list_module_functions(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_list(module="root")
|
||||||
|
assert isinstance(result, list)
|
||||||
|
names = [f["name"] for f in result]
|
||||||
|
assert "create_entity" in names
|
||||||
|
|
||||||
|
def test_list_empty_string_returns_modules(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_list(module="")
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert any(m["module"] == "root" for m in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocs:
|
||||||
|
def test_docs_create_entity(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_docs("root.create_entity")
|
||||||
|
assert result["module"] == "root"
|
||||||
|
assert result["function"] == "create_entity"
|
||||||
|
assert "params" in result
|
||||||
|
|
||||||
|
def test_docs_bad_format(self, loaded_session):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
loaded_session.ifc_docs("no_dot_here")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdit:
|
||||||
|
def test_create_entity(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"}))
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["result"]["type"] == "IfcWall"
|
||||||
|
assert result["result"]["name"] == "NewWall"
|
||||||
|
|
||||||
|
def test_create_entity_default_params(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_edit("root.create_entity", "{}")
|
||||||
|
assert result["ok"] is True
|
||||||
|
|
||||||
|
def test_unknown_function(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_edit("root.nonexistent", "{}")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "Cannot find" in result["error"]
|
||||||
|
|
||||||
|
def test_unknown_parameter(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"}))
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "Unknown parameter" in result["error"]
|
||||||
|
|
||||||
|
def test_bad_json(self, loaded_session):
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
loaded_session.ifc_edit("root.create_entity", "not json")
|
||||||
|
|
||||||
|
def test_edit_does_not_save(self, loaded_session, tmp_path):
|
||||||
|
"""Verify that ifc_edit mutates the in-memory model but does not write to disk."""
|
||||||
|
path = str(tmp_path / "test.ifc")
|
||||||
|
loaded_session.model.write(path)
|
||||||
|
loaded_session.model_path = path
|
||||||
|
|
||||||
|
before_count = sum(1 for _ in loaded_session.model)
|
||||||
|
loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
|
||||||
|
after_count = sum(1 for _ in loaded_session.model)
|
||||||
|
assert after_count == before_count + 1
|
||||||
|
|
||||||
|
on_disk = ifcopenshell.open(path)
|
||||||
|
disk_count = sum(1 for _ in on_disk)
|
||||||
|
assert disk_count == before_count
|
||||||
|
|
||||||
|
def test_assign_container(self, loaded_session):
|
||||||
|
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||||
|
storey = loaded_session.model.by_type("IfcBuildingStorey")[0]
|
||||||
|
result = loaded_session.ifc_edit(
|
||||||
|
"spatial.assign_container",
|
||||||
|
json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
|
||||||
|
)
|
||||||
|
assert result["ok"] is True
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcmcp.core import IfcSessionError
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoModel:
|
||||||
|
"""All query tools should fail when no model is loaded."""
|
||||||
|
|
||||||
|
def test_summary_no_model(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_summary()
|
||||||
|
|
||||||
|
def test_tree_no_model(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_tree()
|
||||||
|
|
||||||
|
def test_info_no_model(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_info(1)
|
||||||
|
|
||||||
|
def test_select_no_model(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_select("IfcWall")
|
||||||
|
|
||||||
|
def test_relations_no_model(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_relations(1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummary:
|
||||||
|
def test_schema(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_summary()
|
||||||
|
assert result["schema"] == "IFC4"
|
||||||
|
|
||||||
|
def test_total_entities(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_summary()
|
||||||
|
assert result["total_entities"] > 0
|
||||||
|
|
||||||
|
def test_project_name(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_summary()
|
||||||
|
assert result["project"]["name"] == "TestProject"
|
||||||
|
|
||||||
|
def test_type_counts(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_summary()
|
||||||
|
assert result["types"]["IfcWall"] == 1
|
||||||
|
assert result["types"]["IfcSlab"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestTree:
|
||||||
|
def test_root_is_project(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_tree()
|
||||||
|
assert result["type"] == "IfcProject"
|
||||||
|
assert result["name"] == "TestProject"
|
||||||
|
|
||||||
|
def test_hierarchy_depth(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_tree()
|
||||||
|
site = result["children"][0]
|
||||||
|
assert site["type"] == "IfcSite"
|
||||||
|
building = site["children"][0]
|
||||||
|
assert building["type"] == "IfcBuilding"
|
||||||
|
storey = building["children"][0]
|
||||||
|
assert storey["type"] == "IfcBuildingStorey"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInfo:
|
||||||
|
def test_wall_info(self, loaded_session):
|
||||||
|
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||||
|
result = loaded_session.ifc_info(wall.id())
|
||||||
|
assert result["id"] == wall.id()
|
||||||
|
assert result["type"] == "IfcWall"
|
||||||
|
|
||||||
|
def test_invalid_id(self, loaded_session):
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
loaded_session.ifc_info(999999)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelect:
|
||||||
|
def test_select_walls(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_select("IfcWall")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["type"] == "IfcWall"
|
||||||
|
assert result[0]["name"] == "Wall001"
|
||||||
|
|
||||||
|
def test_select_slabs(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_select("IfcSlab")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "Slab001"
|
||||||
|
|
||||||
|
def test_select_no_match(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_select("IfcWindow")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelations:
|
||||||
|
def test_wall_relations(self, loaded_session):
|
||||||
|
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||||
|
result = loaded_session.ifc_relations(wall.id())
|
||||||
|
assert result["id"] == wall.id()
|
||||||
|
assert result["type"] == "IfcWall"
|
||||||
|
assert "hierarchy" in result
|
||||||
|
|
||||||
|
def test_traverse_up(self, loaded_session):
|
||||||
|
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||||
|
result = loaded_session.ifc_relations(wall.id(), traverse="up")
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert result[0]["type"] == "IfcWall"
|
||||||
|
assert result[-1]["type"] == "IfcProject"
|
||||||
|
|
||||||
|
def test_traverse_empty_string_means_no_traverse(self, loaded_session):
|
||||||
|
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||||
|
result = loaded_session.ifc_relations(wall.id(), traverse="")
|
||||||
|
assert isinstance(result, dict)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcmcp.server import build_server
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerRegistration:
|
||||||
|
def test_server_name(self):
|
||||||
|
server = build_server()
|
||||||
|
assert server.name == "ifc-mcp"
|
||||||
|
|
||||||
|
def test_all_tools_registered(self):
|
||||||
|
server = build_server()
|
||||||
|
tools = [t.name for t in server._tool_manager.list_tools()]
|
||||||
|
expected = [
|
||||||
|
"ifc_load",
|
||||||
|
"ifc_save",
|
||||||
|
"ifc_summary",
|
||||||
|
"ifc_tree",
|
||||||
|
"ifc_info",
|
||||||
|
"ifc_select",
|
||||||
|
"ifc_relations",
|
||||||
|
"ifc_clash",
|
||||||
|
"ifc_list",
|
||||||
|
"ifc_docs",
|
||||||
|
"ifc_edit",
|
||||||
|
]
|
||||||
|
for name in expected:
|
||||||
|
assert name in tools, f"Tool {name} not registered"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tool_fns():
|
||||||
|
"""Return a dict of tool name → raw function from a freshly built server."""
|
||||||
|
server = build_server()
|
||||||
|
return {t.name: t.fn for t in server._tool_manager.list_tools()}
|
||||||
|
|
||||||
|
|
||||||
|
PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE"
|
||||||
|
SVG_FAKE = b"<svg>FAKE</svg>"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderOutputPath:
|
||||||
|
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
|
||||||
|
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
|
||||||
|
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="")
|
||||||
|
assert list(tmp_path.iterdir()) == []
|
||||||
|
|
||||||
|
def test_png_output_path_writes_file(self, tool_fns, tmp_path):
|
||||||
|
out = str(tmp_path / "render.png")
|
||||||
|
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
|
||||||
|
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out)
|
||||||
|
assert open(out, "rb").read() == PNG_FAKE
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlotOutputPath:
|
||||||
|
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
|
||||||
|
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
|
||||||
|
tool_fns["ifc_plot"](
|
||||||
|
selector="",
|
||||||
|
element_ids=None,
|
||||||
|
view="floorplan",
|
||||||
|
width_mm=297.0,
|
||||||
|
height_mm=420.0,
|
||||||
|
scale=0.01,
|
||||||
|
png_width=1024,
|
||||||
|
png_height=1024,
|
||||||
|
output_path="",
|
||||||
|
)
|
||||||
|
assert list(tmp_path.iterdir()) == []
|
||||||
|
|
||||||
|
def test_png_output_path_writes_png(self, tool_fns, tmp_path):
|
||||||
|
out = str(tmp_path / "plot.png")
|
||||||
|
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
|
||||||
|
tool_fns["ifc_plot"](
|
||||||
|
selector="",
|
||||||
|
element_ids=None,
|
||||||
|
view="floorplan",
|
||||||
|
width_mm=297.0,
|
||||||
|
height_mm=420.0,
|
||||||
|
scale=0.01,
|
||||||
|
png_width=1024,
|
||||||
|
png_height=1024,
|
||||||
|
output_path=out,
|
||||||
|
)
|
||||||
|
assert open(out, "rb").read() == PNG_FAKE
|
||||||
|
|
||||||
|
def test_svg_output_path_writes_svg(self, tool_fns, tmp_path):
|
||||||
|
out = str(tmp_path / "plot.svg")
|
||||||
|
# ifc_plot is called twice: once with "png" for the inline image,
|
||||||
|
# once with "svg" for the file.
|
||||||
|
with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]):
|
||||||
|
tool_fns["ifc_plot"](
|
||||||
|
selector="",
|
||||||
|
element_ids=None,
|
||||||
|
view="floorplan",
|
||||||
|
width_mm=297.0,
|
||||||
|
height_mm=420.0,
|
||||||
|
scale=0.01,
|
||||||
|
png_width=1024,
|
||||||
|
png_height=1024,
|
||||||
|
output_path=out,
|
||||||
|
)
|
||||||
|
assert open(out, "rb").read() == SVG_FAKE
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import ifcopenshell
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcmcp.core import IfcSession, IfcSessionError
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoad:
|
||||||
|
def test_load_file(self, session, model_file):
|
||||||
|
result = session.ifc_load(model_file)
|
||||||
|
assert "IFC4" in result
|
||||||
|
assert session.model is not None
|
||||||
|
assert session.model_path == model_file
|
||||||
|
|
||||||
|
def test_load_sets_entity_count(self, session, model_file):
|
||||||
|
result = session.ifc_load(model_file)
|
||||||
|
assert "entities" in result
|
||||||
|
|
||||||
|
def test_load_nonexistent_file(self, session):
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
session.ifc_load("/nonexistent/path/model.ifc")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSave:
|
||||||
|
def test_save_no_model(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_save()
|
||||||
|
|
||||||
|
def test_save_overwrites_original(self, session, model_file):
|
||||||
|
session.ifc_load(model_file)
|
||||||
|
result = session.ifc_save()
|
||||||
|
assert model_file in result
|
||||||
|
|
||||||
|
def test_save_to_new_path(self, session, model_file, tmp_path):
|
||||||
|
session.ifc_load(model_file)
|
||||||
|
new_path = str(tmp_path / "output.ifc")
|
||||||
|
result = session.ifc_save(new_path)
|
||||||
|
assert new_path in result
|
||||||
|
reloaded = ifcopenshell.open(new_path)
|
||||||
|
assert reloaded.schema == "IFC4"
|
||||||
|
|
||||||
|
def test_save_no_path_no_original(self, loaded_session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No path specified"):
|
||||||
|
loaded_session.ifc_save()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIfcPlotOutputFormat:
|
||||||
|
"""ifc_plot should pass output_format through to the underlying plot function."""
|
||||||
|
|
||||||
|
def test_default_output_format_is_png(self, loaded_session):
|
||||||
|
with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot:
|
||||||
|
loaded_session.ifc_plot()
|
||||||
|
mock_plot.assert_called_once()
|
||||||
|
assert mock_plot.call_args.kwargs["output_format"] == "png"
|
||||||
|
|
||||||
|
def test_svg_output_format(self, loaded_session):
|
||||||
|
with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot:
|
||||||
|
result = loaded_session.ifc_plot(output_format="svg")
|
||||||
|
assert result == b"SVG_FAKE"
|
||||||
|
assert mock_plot.call_args.kwargs["output_format"] == "svg"
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcmcp.core import IfcSessionError
|
||||||
|
|
||||||
|
|
||||||
|
class TestShapeList:
|
||||||
|
def test_returns_list(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape_list()
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_has_expected_methods(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape_list()
|
||||||
|
names = [m["method"] for m in result]
|
||||||
|
assert "polyline" in names
|
||||||
|
assert "rectangle" in names
|
||||||
|
assert "extrude" in names
|
||||||
|
assert "profile" in names
|
||||||
|
assert "get_representation" in names
|
||||||
|
|
||||||
|
def test_well_documented_methods_have_descriptions(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape_list()
|
||||||
|
by_name = {m["method"]: m for m in result}
|
||||||
|
# These methods have detailed docstrings
|
||||||
|
for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"):
|
||||||
|
assert by_name[name]["description"], f"'{name}' has no description"
|
||||||
|
|
||||||
|
def test_no_private_methods(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape_list()
|
||||||
|
assert not any(m["method"].startswith("_") for m in result)
|
||||||
|
|
||||||
|
def test_does_not_require_model(self, session):
|
||||||
|
# ifc_shape_list is pure introspection — no model needed
|
||||||
|
result = session.ifc_shape_list()
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShapeDocs:
|
||||||
|
def test_extrude_docs(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape_docs("extrude")
|
||||||
|
assert result["method"] == "extrude"
|
||||||
|
assert result["description"]
|
||||||
|
assert "params" in result
|
||||||
|
param_names = [p["name"] for p in result["params"]]
|
||||||
|
assert "profile_or_curve" in param_names
|
||||||
|
assert "magnitude" in param_names
|
||||||
|
|
||||||
|
def test_has_return_type(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape_docs("rectangle")
|
||||||
|
assert "return_type" in result
|
||||||
|
|
||||||
|
def test_has_param_descriptions(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape_docs("polyline")
|
||||||
|
params_with_desc = [p for p in result["params"] if "description" in p]
|
||||||
|
assert len(params_with_desc) > 0
|
||||||
|
|
||||||
|
def test_unknown_method(self, loaded_session):
|
||||||
|
with pytest.raises(ValueError, match="no method"):
|
||||||
|
loaded_session.ifc_shape_docs("nonexistent_method")
|
||||||
|
|
||||||
|
def test_private_method_rejected(self, loaded_session):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
loaded_session.ifc_shape_docs("__init__")
|
||||||
|
|
||||||
|
def test_does_not_require_model(self, session):
|
||||||
|
result = session.ifc_shape_docs("circle")
|
||||||
|
assert result["method"] == "circle"
|
||||||
|
|
||||||
|
|
||||||
|
class TestShapeExecute:
|
||||||
|
def test_rectangle(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["result"]["type"] == "IfcIndexedPolyCurve"
|
||||||
|
|
||||||
|
def test_circle(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5}))
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["result"]["type"] == "IfcCircle"
|
||||||
|
|
||||||
|
def test_extrude_chained_from_rectangle(self, loaded_session):
|
||||||
|
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
|
||||||
|
rect_id = rect["result"]["id"]
|
||||||
|
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0}))
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["result"]["type"] == "IfcExtrudedAreaSolid"
|
||||||
|
|
||||||
|
def test_entity_id_as_integer(self, loaded_session):
|
||||||
|
"""Entity IDs should be accepted as plain integers (from JSON)."""
|
||||||
|
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]}))
|
||||||
|
rect_id = rect["result"]["id"]
|
||||||
|
# Pass as int, not string
|
||||||
|
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0}))
|
||||||
|
assert result["ok"] is True
|
||||||
|
|
||||||
|
def test_rotate_2d_point_returns_list(self, loaded_session):
|
||||||
|
"""Methods returning numpy arrays should give back plain lists."""
|
||||||
|
result = loaded_session.ifc_shape(
|
||||||
|
"rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True})
|
||||||
|
)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert isinstance(result["result"], list)
|
||||||
|
assert len(result["result"]) == 2
|
||||||
|
|
||||||
|
def test_set_polyline_coords_returns_none(self, loaded_session):
|
||||||
|
"""In-place methods that return None should give ok=True, result=None."""
|
||||||
|
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]}))
|
||||||
|
rect_id = rect["result"]["id"]
|
||||||
|
result = loaded_session.ifc_shape(
|
||||||
|
"set_polyline_coords",
|
||||||
|
json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}),
|
||||||
|
)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["result"] is None
|
||||||
|
|
||||||
|
def test_unknown_method(self, loaded_session):
|
||||||
|
result = loaded_session.ifc_shape("nonexistent_method", "{}")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
def test_private_method_rejected(self, loaded_session):
|
||||||
|
with pytest.raises(IfcSessionError):
|
||||||
|
loaded_session.ifc_shape("__init__", "{}")
|
||||||
|
|
||||||
|
def test_no_model_raises(self, session):
|
||||||
|
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||||
|
session.ifc_shape("rectangle", "{}")
|
||||||
|
|
||||||
|
def test_params_as_dict(self, loaded_session):
|
||||||
|
"""params can be passed as a dict (not just a JSON string)."""
|
||||||
|
result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]})
|
||||||
|
assert result["ok"] is True
|
||||||
|
|
||||||
|
def test_error_on_bad_params(self, loaded_session):
|
||||||
|
"""Bad parameters should give ok=False with an error message."""
|
||||||
|
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0}))
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "error" in result
|
||||||
Reference in New Issue
Block a user