mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 18:16:40 +00:00
ifcmcp: add ifc_shape_list, ifc_shape_docs, ifc_shape tools
Exposes ShapeBuilder geometry methods via the MCP server: discovery (list), documentation (docs), and execution (shape) with entity ID coercion and numpy serialisation. Generated with the assistance of an AI coding tool.
This commit is contained in:
+182
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable # noqa: F401 — Callable used in helpers below
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
@@ -26,6 +26,10 @@ def _jsonify(x: Any) -> Any:
|
||||
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 {
|
||||
@@ -48,6 +52,142 @@ def _jsonify(x: Any) -> Any:
|
||||
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
|
||||
|
||||
@@ -244,6 +384,47 @@ class IfcSession:
|
||||
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.
|
||||
|
||||
|
||||
@@ -127,6 +127,19 @@ def build_server() -> Any:
|
||||
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_render(
|
||||
selector: str = "",
|
||||
|
||||
@@ -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