mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 00:11:36 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e9ae39a2 | |||
| 9fd4c5a6a8 |
@@ -0,0 +1,401 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# ifcmcp
|
||||
|
||||
An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`,
|
||||
holding the IFC model in memory across tool calls for fast interactive editing
|
||||
sessions.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcmcp
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
|
||||
|
||||
## Running the server
|
||||
|
||||
```bash
|
||||
python3 -m ifcmcp
|
||||
```
|
||||
|
||||
This starts the server on stdio transport, suitable for use with Claude Code
|
||||
or any MCP client.
|
||||
|
||||
### Claude Code configuration
|
||||
|
||||
Use the `claude mcp add` command:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport stdio ifc -- python3 -m ifcmcp
|
||||
```
|
||||
|
||||
Or create a `.mcp.json` file in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ifc": {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["-m", "ifcmcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After adding the server, restart Claude Code for the tools to become available.
|
||||
Then load a model by asking Claude to use `ifc_load`:
|
||||
|
||||
```
|
||||
load model.ifc using ifc_load
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Session
|
||||
|
||||
#### ifc_new
|
||||
|
||||
Create a new empty IFC model in memory, replacing any currently loaded model.
|
||||
|
||||
```
|
||||
ifc_new()
|
||||
ifc_new(schema="IFC4X3")
|
||||
```
|
||||
|
||||
Default schema is `IFC4`.
|
||||
|
||||
#### ifc_load
|
||||
|
||||
Open an IFC file into memory.
|
||||
|
||||
```
|
||||
ifc_load(path="/path/to/model.ifc")
|
||||
-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
|
||||
```
|
||||
|
||||
#### ifc_reset
|
||||
|
||||
Unload the current model from memory, freeing all session state.
|
||||
|
||||
```
|
||||
ifc_reset()
|
||||
```
|
||||
|
||||
#### ifc_save
|
||||
|
||||
Write the in-memory model to disk. Empty path overwrites the original file.
|
||||
|
||||
```
|
||||
ifc_save()
|
||||
ifc_save(path="/path/to/output.ifc")
|
||||
```
|
||||
|
||||
### Query tools
|
||||
|
||||
All query tools require a model to be loaded first via `ifc_load`.
|
||||
|
||||
#### ifc_summary
|
||||
|
||||
Model overview: schema, entity counts, project info.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "IFC4",
|
||||
"total_entities": 1847,
|
||||
"project": {"id": 1, "name": "Office Building"},
|
||||
"types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36}
|
||||
}
|
||||
```
|
||||
|
||||
#### ifc_tree
|
||||
|
||||
Full spatial hierarchy from IfcProject down through sites, buildings, storeys,
|
||||
and contained elements.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"type": "IfcProject",
|
||||
"name": "Office Building",
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "IfcSite",
|
||||
"children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### ifc_info
|
||||
|
||||
Deep inspection of an entity by step ID: attributes, property sets, type,
|
||||
material, container, and 4x4 placement matrix.
|
||||
|
||||
```
|
||||
ifc_info(element_id=10)
|
||||
```
|
||||
|
||||
#### ifc_select
|
||||
|
||||
Filter elements using ifcopenshell selector syntax.
|
||||
|
||||
```
|
||||
ifc_select(query="IfcWall")
|
||||
ifc_select(query="IfcWindow")
|
||||
```
|
||||
|
||||
Returns a sorted list of `{"id", "type", "name"}` references.
|
||||
|
||||
#### ifc_relations
|
||||
|
||||
Show all relationships for an element: hierarchy, children, type, groups,
|
||||
systems, material, connections.
|
||||
|
||||
```
|
||||
ifc_relations(element_id=10)
|
||||
ifc_relations(element_id=10, traverse="up")
|
||||
```
|
||||
|
||||
With `traverse="up"`, walks the hierarchy from element up to IfcProject.
|
||||
|
||||
#### ifc_contexts
|
||||
|
||||
List all geometric representation contexts and subcontexts in the loaded model.
|
||||
|
||||
```
|
||||
ifc_contexts()
|
||||
```
|
||||
|
||||
#### ifc_materials
|
||||
|
||||
List all materials and material sets in the loaded model, with their assigned elements.
|
||||
|
||||
```
|
||||
ifc_materials()
|
||||
```
|
||||
|
||||
#### ifc_clash
|
||||
|
||||
Check an element for geometric intersections and clearance violations.
|
||||
|
||||
```
|
||||
ifc_clash(element_id=10)
|
||||
ifc_clash(element_id=10, clearance=0.5, scope="all")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check)
|
||||
- `tolerance` -- intersection tolerance in meters (default: 0.002)
|
||||
- `scope` -- `"storey"` or `"all"` (default: `"storey"`)
|
||||
|
||||
#### ifc_validate
|
||||
|
||||
Check the model for schema and constraint violations.
|
||||
|
||||
```
|
||||
ifc_validate()
|
||||
ifc_validate(express_rules=True)
|
||||
```
|
||||
|
||||
Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`.
|
||||
|
||||
#### ifc_schedule
|
||||
|
||||
List all work schedules and their nested task trees.
|
||||
|
||||
```
|
||||
ifc_schedule()
|
||||
ifc_schedule(max_depth=1) # top-level phases only
|
||||
```
|
||||
|
||||
`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced
|
||||
with `{"truncated": true, "count": N}` so you know children exist without
|
||||
fetching them all. Omit for unlimited depth.
|
||||
|
||||
#### ifc_cost
|
||||
|
||||
List all cost schedules and their nested cost item trees.
|
||||
|
||||
```
|
||||
ifc_cost()
|
||||
ifc_cost(max_depth=2) # top two levels of the BoQ
|
||||
```
|
||||
|
||||
`max_depth` limits cost item expansion, same truncation convention as
|
||||
`ifc_schedule`.
|
||||
|
||||
#### ifc_schema
|
||||
|
||||
Return IFC class documentation for any entity type, using the loaded model's
|
||||
schema version.
|
||||
|
||||
```
|
||||
ifc_schema(entity_type="IfcWall")
|
||||
ifc_schema(entity_type="IfcBuildingStorey")
|
||||
```
|
||||
|
||||
Returns description, predefined types, spec URL, and attribute descriptions.
|
||||
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
|
||||
|
||||
#### ifc_quantify
|
||||
|
||||
Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule.
|
||||
Computes physical measurements (volume, area, length, count, weight) and
|
||||
writes them back as `IfcElementQuantity` property sets. Modifies the model
|
||||
in-place -- call `ifc_save()` when done.
|
||||
|
||||
```
|
||||
ifc_quantify(rule="IFC4QtoBaseQuantities")
|
||||
ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
|
||||
```
|
||||
|
||||
Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
|
||||
|
||||
`selector` is an optional ifcopenshell selector to restrict which elements
|
||||
are quantified (default: all `IfcElement`).
|
||||
|
||||
Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
|
||||
|
||||
### Drawing and rendering tools
|
||||
|
||||
#### ifc_plot
|
||||
|
||||
Generate a 2D technical drawing of the loaded model and return it as an inline image.
|
||||
|
||||
```
|
||||
ifc_plot()
|
||||
ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg")
|
||||
ifc_plot(element_ids=[10, 11], view="floorplan")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `selector` -- ifcopenshell selector to restrict plotted elements
|
||||
- `element_ids` -- step IDs of elements to highlight; others are faded
|
||||
- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"`
|
||||
- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420)
|
||||
- `scale` -- model-to-paper ratio (default: 0.01 = 1:100)
|
||||
- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024)
|
||||
- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG)
|
||||
|
||||
Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`.
|
||||
|
||||
#### ifc_render
|
||||
|
||||
Render the loaded model to a 3D PNG image.
|
||||
|
||||
```
|
||||
ifc_render()
|
||||
ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png")
|
||||
ifc_render(element_ids=[10, 11], view="south")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `selector` -- ifcopenshell selector to restrict rendered elements
|
||||
- `element_ids` -- step IDs of elements to highlight; others are shown translucent
|
||||
- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"`
|
||||
- `output_path` -- optional path to save the PNG to disk
|
||||
|
||||
Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
|
||||
|
||||
### Shape builder tools
|
||||
|
||||
#### ifc_shape_list
|
||||
|
||||
List all available `ShapeBuilder` methods with brief descriptions.
|
||||
|
||||
```
|
||||
ifc_shape_list()
|
||||
```
|
||||
|
||||
#### ifc_shape_docs
|
||||
|
||||
Show full documentation for a specific `ShapeBuilder` method.
|
||||
|
||||
```
|
||||
ifc_shape_docs(method="extrude")
|
||||
ifc_shape_docs(method="create_ellipse")
|
||||
```
|
||||
|
||||
#### ifc_shape
|
||||
|
||||
Execute a `ShapeBuilder` method on the loaded model.
|
||||
|
||||
```
|
||||
ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}')
|
||||
```
|
||||
|
||||
`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`).
|
||||
|
||||
### Edit discovery tools
|
||||
|
||||
#### ifc_list
|
||||
|
||||
List all API modules, or functions within a specific module.
|
||||
|
||||
```
|
||||
ifc_list() # all modules
|
||||
ifc_list(module="root") # functions in the root module
|
||||
```
|
||||
|
||||
#### ifc_docs
|
||||
|
||||
Show full documentation for an API function including parameters, types,
|
||||
defaults, and descriptions.
|
||||
|
||||
```
|
||||
ifc_docs(function_path="root.create_entity")
|
||||
```
|
||||
|
||||
### Edit execution
|
||||
|
||||
#### ifc_edit
|
||||
|
||||
Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
|
||||
JSON string with string values that get coerced by ifcedit's type system.
|
||||
|
||||
```
|
||||
ifc_edit(
|
||||
function_path="root.create_entity",
|
||||
params='{"ifc_class": "IfcWall", "name": "My Wall"}'
|
||||
)
|
||||
```
|
||||
|
||||
Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
|
||||
|
||||
Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
|
||||
|
||||
**Parameter coercion:**
|
||||
|
||||
| Type | JSON value | Python value |
|
||||
|------|------------|--------------|
|
||||
| `entity_instance` | `"42"` | resolved from model by step ID |
|
||||
| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
|
||||
| `dict` | `'{"key": "val"}'` | parsed JSON object |
|
||||
| `bool` | `"true"` | `True` |
|
||||
| `Optional[X]` | `"none"` | `None` |
|
||||
|
||||
## Typical workflow
|
||||
|
||||
1. **Load** a model: `ifc_load`
|
||||
2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
|
||||
3. **Validate** if needed: `ifc_validate`
|
||||
4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
|
||||
5. **Look up IFC classes**: `ifc_schema`
|
||||
6. **Find** the right API function: `ifc_list`, `ifc_docs`
|
||||
7. **Edit** the model: `ifc_edit`
|
||||
8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
|
||||
9. **Verify** changes with query tools
|
||||
10. **Save** when satisfied: `ifc_save`
|
||||
|
||||
The model stays in memory across all calls, so multi-step editing sessions
|
||||
are fast -- no file I/O between operations.
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcMCP - MCP server for IFC building models
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcMCP.
|
||||
#
|
||||
# IfcMCP is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcMCP is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcMCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,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,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ifcmcp"
|
||||
version = "0.0.0"
|
||||
authors = [
|
||||
{ name="Bruno Postle", email="bruno@postle.net" },
|
||||
]
|
||||
description = "MCP server for querying and editing IFC building models"
|
||||
readme = "README.md"
|
||||
keywords = ["IFC", "BIM", "MCP"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
mcp = ["mcp"]
|
||||
|
||||
[project.scripts]
|
||||
ifcmcp = "ifcmcp.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://ifcopenshell.org"
|
||||
Documentation = "https://docs.ifcopenshell.org"
|
||||
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["ifcmcp*"]
|
||||
exclude = ["test*"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -0,0 +1,59 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
return IfcSession()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model():
|
||||
"""IFC4 model with a spatial hierarchy, a wall, and a slab."""
|
||||
f = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||
ifcopenshell.api.unit.assign_unit(f)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||
|
||||
slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_file(model, tmp_path):
|
||||
"""Write the model fixture to a temp file and return the path."""
|
||||
path = tmp_path / "test.ifc"
|
||||
model.write(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loaded_session(model):
|
||||
"""An IfcSession with an in-memory model already loaded (no file path)."""
|
||||
s = IfcSession()
|
||||
s.model = model
|
||||
return s
|
||||
@@ -0,0 +1,96 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSession, IfcSessionError
|
||||
|
||||
|
||||
class TestNoModel:
|
||||
def test_edit_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_edit("root.create_entity")
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_list_all_modules(self, loaded_session):
|
||||
result = loaded_session.ifc_list()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
modules = [m["module"] for m in result]
|
||||
assert "root" in modules
|
||||
assert "spatial" in modules
|
||||
|
||||
def test_list_module_functions(self, loaded_session):
|
||||
result = loaded_session.ifc_list(module="root")
|
||||
assert isinstance(result, list)
|
||||
names = [f["name"] for f in result]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_list_empty_string_returns_modules(self, loaded_session):
|
||||
result = loaded_session.ifc_list(module="")
|
||||
assert isinstance(result, list)
|
||||
assert any(m["module"] == "root" for m in result)
|
||||
|
||||
|
||||
class TestDocs:
|
||||
def test_docs_create_entity(self, loaded_session):
|
||||
result = loaded_session.ifc_docs("root.create_entity")
|
||||
assert result["module"] == "root"
|
||||
assert result["function"] == "create_entity"
|
||||
assert "params" in result
|
||||
|
||||
def test_docs_bad_format(self, loaded_session):
|
||||
with pytest.raises(ValueError):
|
||||
loaded_session.ifc_docs("no_dot_here")
|
||||
|
||||
|
||||
class TestEdit:
|
||||
def test_create_entity(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcWall"
|
||||
assert result["result"]["name"] == "NewWall"
|
||||
|
||||
def test_create_entity_default_params(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.create_entity", "{}")
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_unknown_function(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.nonexistent", "{}")
|
||||
assert result["ok"] is False
|
||||
assert "Cannot find" in result["error"]
|
||||
|
||||
def test_unknown_parameter(self, loaded_session):
|
||||
result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"}))
|
||||
assert result["ok"] is False
|
||||
assert "Unknown parameter" in result["error"]
|
||||
|
||||
def test_bad_json(self, loaded_session):
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
loaded_session.ifc_edit("root.create_entity", "not json")
|
||||
|
||||
def test_edit_does_not_save(self, loaded_session, tmp_path):
|
||||
"""Verify that ifc_edit mutates the in-memory model but does not write to disk."""
|
||||
path = str(tmp_path / "test.ifc")
|
||||
loaded_session.model.write(path)
|
||||
loaded_session.model_path = path
|
||||
|
||||
before_count = sum(1 for _ in loaded_session.model)
|
||||
loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
|
||||
after_count = sum(1 for _ in loaded_session.model)
|
||||
assert after_count == before_count + 1
|
||||
|
||||
on_disk = ifcopenshell.open(path)
|
||||
disk_count = sum(1 for _ in on_disk)
|
||||
assert disk_count == before_count
|
||||
|
||||
def test_assign_container(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
storey = loaded_session.model.by_type("IfcBuildingStorey")[0]
|
||||
result = loaded_session.ifc_edit(
|
||||
"spatial.assign_container",
|
||||
json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
|
||||
)
|
||||
assert result["ok"] is True
|
||||
@@ -0,0 +1,113 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSessionError
|
||||
|
||||
|
||||
class TestNoModel:
|
||||
"""All query tools should fail when no model is loaded."""
|
||||
|
||||
def test_summary_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_summary()
|
||||
|
||||
def test_tree_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_tree()
|
||||
|
||||
def test_info_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_info(1)
|
||||
|
||||
def test_select_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_select("IfcWall")
|
||||
|
||||
def test_relations_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_relations(1)
|
||||
|
||||
|
||||
class TestSummary:
|
||||
def test_schema(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["schema"] == "IFC4"
|
||||
|
||||
def test_total_entities(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["total_entities"] > 0
|
||||
|
||||
def test_project_name(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["project"]["name"] == "TestProject"
|
||||
|
||||
def test_type_counts(self, loaded_session):
|
||||
result = loaded_session.ifc_summary()
|
||||
assert result["types"]["IfcWall"] == 1
|
||||
assert result["types"]["IfcSlab"] == 1
|
||||
|
||||
|
||||
class TestTree:
|
||||
def test_root_is_project(self, loaded_session):
|
||||
result = loaded_session.ifc_tree()
|
||||
assert result["type"] == "IfcProject"
|
||||
assert result["name"] == "TestProject"
|
||||
|
||||
def test_hierarchy_depth(self, loaded_session):
|
||||
result = loaded_session.ifc_tree()
|
||||
site = result["children"][0]
|
||||
assert site["type"] == "IfcSite"
|
||||
building = site["children"][0]
|
||||
assert building["type"] == "IfcBuilding"
|
||||
storey = building["children"][0]
|
||||
assert storey["type"] == "IfcBuildingStorey"
|
||||
|
||||
|
||||
class TestInfo:
|
||||
def test_wall_info(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_info(wall.id())
|
||||
assert result["id"] == wall.id()
|
||||
assert result["type"] == "IfcWall"
|
||||
|
||||
def test_invalid_id(self, loaded_session):
|
||||
with pytest.raises(Exception):
|
||||
loaded_session.ifc_info(999999)
|
||||
|
||||
|
||||
class TestSelect:
|
||||
def test_select_walls(self, loaded_session):
|
||||
result = loaded_session.ifc_select("IfcWall")
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "IfcWall"
|
||||
assert result[0]["name"] == "Wall001"
|
||||
|
||||
def test_select_slabs(self, loaded_session):
|
||||
result = loaded_session.ifc_select("IfcSlab")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Slab001"
|
||||
|
||||
def test_select_no_match(self, loaded_session):
|
||||
result = loaded_session.ifc_select("IfcWindow")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestRelations:
|
||||
def test_wall_relations(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_relations(wall.id())
|
||||
assert result["id"] == wall.id()
|
||||
assert result["type"] == "IfcWall"
|
||||
assert "hierarchy" in result
|
||||
|
||||
def test_traverse_up(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_relations(wall.id(), traverse="up")
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["type"] == "IfcWall"
|
||||
assert result[-1]["type"] == "IfcProject"
|
||||
|
||||
def test_traverse_empty_string_means_no_traverse(self, loaded_session):
|
||||
wall = loaded_session.model.by_type("IfcWall")[0]
|
||||
result = loaded_session.ifc_relations(wall.id(), traverse="")
|
||||
assert isinstance(result, dict)
|
||||
@@ -0,0 +1,106 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ifcmcp.server import build_server
|
||||
|
||||
|
||||
class TestServerRegistration:
|
||||
def test_server_name(self):
|
||||
server = build_server()
|
||||
assert server.name == "ifc-mcp"
|
||||
|
||||
def test_all_tools_registered(self):
|
||||
server = build_server()
|
||||
tools = [t.name for t in server._tool_manager.list_tools()]
|
||||
expected = [
|
||||
"ifc_load",
|
||||
"ifc_save",
|
||||
"ifc_summary",
|
||||
"ifc_tree",
|
||||
"ifc_info",
|
||||
"ifc_select",
|
||||
"ifc_relations",
|
||||
"ifc_clash",
|
||||
"ifc_list",
|
||||
"ifc_docs",
|
||||
"ifc_edit",
|
||||
]
|
||||
for name in expected:
|
||||
assert name in tools, f"Tool {name} not registered"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_fns():
|
||||
"""Return a dict of tool name → raw function from a freshly built server."""
|
||||
server = build_server()
|
||||
return {t.name: t.fn for t in server._tool_manager.list_tools()}
|
||||
|
||||
|
||||
PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE"
|
||||
SVG_FAKE = b"<svg>FAKE</svg>"
|
||||
|
||||
|
||||
class TestRenderOutputPath:
|
||||
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
|
||||
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="")
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_png_output_path_writes_file(self, tool_fns, tmp_path):
|
||||
out = str(tmp_path / "render.png")
|
||||
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out)
|
||||
assert open(out, "rb").read() == PNG_FAKE
|
||||
|
||||
|
||||
class TestPlotOutputPath:
|
||||
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
|
||||
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_plot"](
|
||||
selector="",
|
||||
element_ids=None,
|
||||
view="floorplan",
|
||||
width_mm=297.0,
|
||||
height_mm=420.0,
|
||||
scale=0.01,
|
||||
png_width=1024,
|
||||
png_height=1024,
|
||||
output_path="",
|
||||
)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_png_output_path_writes_png(self, tool_fns, tmp_path):
|
||||
out = str(tmp_path / "plot.png")
|
||||
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
|
||||
tool_fns["ifc_plot"](
|
||||
selector="",
|
||||
element_ids=None,
|
||||
view="floorplan",
|
||||
width_mm=297.0,
|
||||
height_mm=420.0,
|
||||
scale=0.01,
|
||||
png_width=1024,
|
||||
png_height=1024,
|
||||
output_path=out,
|
||||
)
|
||||
assert open(out, "rb").read() == PNG_FAKE
|
||||
|
||||
def test_svg_output_path_writes_svg(self, tool_fns, tmp_path):
|
||||
out = str(tmp_path / "plot.svg")
|
||||
# ifc_plot is called twice: once with "png" for the inline image,
|
||||
# once with "svg" for the file.
|
||||
with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]):
|
||||
tool_fns["ifc_plot"](
|
||||
selector="",
|
||||
element_ids=None,
|
||||
view="floorplan",
|
||||
width_mm=297.0,
|
||||
height_mm=420.0,
|
||||
scale=0.01,
|
||||
png_width=1024,
|
||||
png_height=1024,
|
||||
output_path=out,
|
||||
)
|
||||
assert open(out, "rb").read() == SVG_FAKE
|
||||
@@ -0,0 +1,62 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from unittest.mock import patch
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSession, IfcSessionError
|
||||
|
||||
|
||||
class TestLoad:
|
||||
def test_load_file(self, session, model_file):
|
||||
result = session.ifc_load(model_file)
|
||||
assert "IFC4" in result
|
||||
assert session.model is not None
|
||||
assert session.model_path == model_file
|
||||
|
||||
def test_load_sets_entity_count(self, session, model_file):
|
||||
result = session.ifc_load(model_file)
|
||||
assert "entities" in result
|
||||
|
||||
def test_load_nonexistent_file(self, session):
|
||||
with pytest.raises(Exception):
|
||||
session.ifc_load("/nonexistent/path/model.ifc")
|
||||
|
||||
|
||||
class TestSave:
|
||||
def test_save_no_model(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_save()
|
||||
|
||||
def test_save_overwrites_original(self, session, model_file):
|
||||
session.ifc_load(model_file)
|
||||
result = session.ifc_save()
|
||||
assert model_file in result
|
||||
|
||||
def test_save_to_new_path(self, session, model_file, tmp_path):
|
||||
session.ifc_load(model_file)
|
||||
new_path = str(tmp_path / "output.ifc")
|
||||
result = session.ifc_save(new_path)
|
||||
assert new_path in result
|
||||
reloaded = ifcopenshell.open(new_path)
|
||||
assert reloaded.schema == "IFC4"
|
||||
|
||||
def test_save_no_path_no_original(self, loaded_session):
|
||||
with pytest.raises(IfcSessionError, match="No path specified"):
|
||||
loaded_session.ifc_save()
|
||||
|
||||
|
||||
class TestIfcPlotOutputFormat:
|
||||
"""ifc_plot should pass output_format through to the underlying plot function."""
|
||||
|
||||
def test_default_output_format_is_png(self, loaded_session):
|
||||
with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot:
|
||||
loaded_session.ifc_plot()
|
||||
mock_plot.assert_called_once()
|
||||
assert mock_plot.call_args.kwargs["output_format"] == "png"
|
||||
|
||||
def test_svg_output_format(self, loaded_session):
|
||||
with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot:
|
||||
result = loaded_session.ifc_plot(output_format="svg")
|
||||
assert result == b"SVG_FAKE"
|
||||
assert mock_plot.call_args.kwargs["output_format"] == "svg"
|
||||
@@ -0,0 +1,141 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ifcmcp.core import IfcSessionError
|
||||
|
||||
|
||||
class TestShapeList:
|
||||
def test_returns_list(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_has_expected_methods(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
names = [m["method"] for m in result]
|
||||
assert "polyline" in names
|
||||
assert "rectangle" in names
|
||||
assert "extrude" in names
|
||||
assert "profile" in names
|
||||
assert "get_representation" in names
|
||||
|
||||
def test_well_documented_methods_have_descriptions(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
by_name = {m["method"]: m for m in result}
|
||||
# These methods have detailed docstrings
|
||||
for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"):
|
||||
assert by_name[name]["description"], f"'{name}' has no description"
|
||||
|
||||
def test_no_private_methods(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_list()
|
||||
assert not any(m["method"].startswith("_") for m in result)
|
||||
|
||||
def test_does_not_require_model(self, session):
|
||||
# ifc_shape_list is pure introspection — no model needed
|
||||
result = session.ifc_shape_list()
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestShapeDocs:
|
||||
def test_extrude_docs(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_docs("extrude")
|
||||
assert result["method"] == "extrude"
|
||||
assert result["description"]
|
||||
assert "params" in result
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "profile_or_curve" in param_names
|
||||
assert "magnitude" in param_names
|
||||
|
||||
def test_has_return_type(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_docs("rectangle")
|
||||
assert "return_type" in result
|
||||
|
||||
def test_has_param_descriptions(self, loaded_session):
|
||||
result = loaded_session.ifc_shape_docs("polyline")
|
||||
params_with_desc = [p for p in result["params"] if "description" in p]
|
||||
assert len(params_with_desc) > 0
|
||||
|
||||
def test_unknown_method(self, loaded_session):
|
||||
with pytest.raises(ValueError, match="no method"):
|
||||
loaded_session.ifc_shape_docs("nonexistent_method")
|
||||
|
||||
def test_private_method_rejected(self, loaded_session):
|
||||
with pytest.raises(ValueError):
|
||||
loaded_session.ifc_shape_docs("__init__")
|
||||
|
||||
def test_does_not_require_model(self, session):
|
||||
result = session.ifc_shape_docs("circle")
|
||||
assert result["method"] == "circle"
|
||||
|
||||
|
||||
class TestShapeExecute:
|
||||
def test_rectangle(self, loaded_session):
|
||||
result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcIndexedPolyCurve"
|
||||
|
||||
def test_circle(self, loaded_session):
|
||||
result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcCircle"
|
||||
|
||||
def test_extrude_chained_from_rectangle(self, loaded_session):
|
||||
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
|
||||
rect_id = rect["result"]["id"]
|
||||
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0}))
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcExtrudedAreaSolid"
|
||||
|
||||
def test_entity_id_as_integer(self, loaded_session):
|
||||
"""Entity IDs should be accepted as plain integers (from JSON)."""
|
||||
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]}))
|
||||
rect_id = rect["result"]["id"]
|
||||
# Pass as int, not string
|
||||
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0}))
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_rotate_2d_point_returns_list(self, loaded_session):
|
||||
"""Methods returning numpy arrays should give back plain lists."""
|
||||
result = loaded_session.ifc_shape(
|
||||
"rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True})
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert isinstance(result["result"], list)
|
||||
assert len(result["result"]) == 2
|
||||
|
||||
def test_set_polyline_coords_returns_none(self, loaded_session):
|
||||
"""In-place methods that return None should give ok=True, result=None."""
|
||||
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]}))
|
||||
rect_id = rect["result"]["id"]
|
||||
result = loaded_session.ifc_shape(
|
||||
"set_polyline_coords",
|
||||
json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}),
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["result"] is None
|
||||
|
||||
def test_unknown_method(self, loaded_session):
|
||||
result = loaded_session.ifc_shape("nonexistent_method", "{}")
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_private_method_rejected(self, loaded_session):
|
||||
with pytest.raises(IfcSessionError):
|
||||
loaded_session.ifc_shape("__init__", "{}")
|
||||
|
||||
def test_no_model_raises(self, session):
|
||||
with pytest.raises(IfcSessionError, match="No model loaded"):
|
||||
session.ifc_shape("rectangle", "{}")
|
||||
|
||||
def test_params_as_dict(self, loaded_session):
|
||||
"""params can be passed as a dict (not just a JSON string)."""
|
||||
result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]})
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_error_on_bad_params(self, loaded_session):
|
||||
"""Bad parameters should give ok=False with an error message."""
|
||||
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0}))
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
Reference in New Issue
Block a user