mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-24 05:17:58 +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
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable # noqa: F401 — Callable used in helpers below
|
||||||
|
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@ def _jsonify(x: Any) -> Any:
|
|||||||
if x is None or isinstance(x, (str, int, float, bool)):
|
if x is None or isinstance(x, (str, int, float, bool)):
|
||||||
return x
|
return x
|
||||||
|
|
||||||
|
# numpy arrays (and any array-like with tolist)
|
||||||
|
if hasattr(x, "tolist"):
|
||||||
|
return x.tolist()
|
||||||
|
|
||||||
# IfcOpenShell entity instances: normalize
|
# IfcOpenShell entity instances: normalize
|
||||||
if isinstance(x, ifcopenshell.entity_instance):
|
if isinstance(x, ifcopenshell.entity_instance):
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +52,142 @@ def _jsonify(x: Any) -> Any:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return str(x)
|
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):
|
class IfcSessionError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -244,6 +384,47 @@ class IfcSession:
|
|||||||
view=view,
|
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]:
|
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||||
"""Run quantity take-off on the model using the named rule.
|
"""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]:
|
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||||
return session.ifc_quantify(rule=rule, selector=selector)
|
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)
|
@server.tool(structured_output=False)
|
||||||
def ifc_render(
|
def ifc_render(
|
||||||
selector: str = "",
|
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