diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py index b26a93f6c6..76ddc05710 100644 --- a/src/ifcedit/ifcedit/discover.py +++ b/src/ifcedit/ifcedit/discover.py @@ -94,9 +94,15 @@ def list_functions(module: str) -> list[dict]: def function_docs(module: str, function: str) -> dict: - """Full documentation for a single API function. + """Show the full documentation for one ifcopenshell.api function. - Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type + Returns the summary and long description, every parameter with its type, + default and description, and the return type. Read this before calling + ``run_api()`` so that parameter names and value types are correct. + + :param module: API module name, for example ``'root'``. + :param function: Function name within the module, for example + ``'create_entity'``. """ fn = _get_underlying_function(module, function) if fn is None: diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py index adc024ca2f..303963d37e 100644 --- a/src/ifcedit/ifcedit/quantify.py +++ b/src/ifcedit/ifcedit/quantify.py @@ -14,10 +14,21 @@ def list_rules() -> list[dict[str, str]]: def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]: - """Run quantity take-off on the model using the named rule. + """Compute base quantities for elements and write them into the model. - Modifies the model in-place by adding/updating IfcElementQuantity psets. - Returns a summary dict with ok, rule, and elements_quantified. + This is a write operation: it derives lengths, areas and volumes from + element geometry and adds or updates their ``IfcElementQuantity`` sets. + It does not report a schedule — see ``ifcquery.schedule()`` for the + construction programme and ``ifcquery.cost()`` for cost schedules. An + unrecognised ``rule`` is reported as an error listing the rules that are + available. + + :param model: The in-memory IFC model. Modified in-place. + :param rule: Quantity take-off rule set, for example + ``'IFC4QtoBaseQuantities'`` or ``'IFC4X3QtoBaseQuantities'``. + :param selector: ifcopenshell selector restricting which elements are + measured, e.g. ``'IfcWall'``. Omit to measure every ``IfcElement`` and + ``IfcSpace``. """ from ifc5d.qto import edit_qtos, quantify from ifc5d.qto import rules as rule_sets diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py index 3f249a57bb..a5a44bb26e 100644 --- a/src/ifcmcp/ifcmcp/core.py +++ b/src/ifcmcp/ifcmcp/core.py @@ -35,6 +35,27 @@ from ifcquery import ( from ifcquery import validate as validate_mod +def _use_doc(source: Callable, extra: str = "") -> Callable: + """Decorator: copy `source`'s docstring onto the decorated method. + + Keeps the query/edit logic in ``ifcquery``/``ifcedit`` as the single + source of truth for what a delegating ``IfcSession`` method does, rather + than maintaining a second prose description here. Only ``__doc__`` is + copied — unlike `functools.wraps`, this leaves the method's own signature + (and MCP tool schema derived from it) untouched. + + :param extra: Optional session-specific note appended after `source`'s + docstring, for the handful of methods that translate an argument + (e.g. a JSON/MCP-friendly default) before delegating. + """ + + def decorator(fn: Callable) -> Callable: + fn.__doc__ = (source.__doc__ or "").rstrip() + extra + return fn + + return decorator + + def _jsonify(x: Any) -> Any: """Convert IfcOpenShell objects / iterables into JSON-safe primitives.""" if x is None or isinstance(x, (str, int, float, bool)): @@ -231,20 +252,47 @@ class IfcSession: return self.model def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]: - """Create a new empty IFC model in memory.""" + """Create a new empty IFC model in memory. + + Replaces the model currently held by the session, discarding any unsaved + edits. The new model has no file path of its own, so ``ifc_save`` must be + given an explicit path. + + :param schema: IFC schema version — ``IFC2X3``, ``IFC4``, ``IFC4X1``, + ``IFC4X2`` or ``IFC4X3`` — passed straight to ``ifcopenshell.file()`` + (default ``IFC4``). ``IFC4X3_ADD2`` is also accepted and, like + ``IFC4X3``, produces a model whose ``schema`` reports ``IFC4X3``. + """ 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.""" + """Open an IFC file from disk into the session. + + Replaces the model currently held by the session, discarding any unsaved + edits, and remembers the path so a later ``ifc_save`` can overwrite it. + Call this before any query or edit method. Returns a confirmation string + naming the schema version and entity count. + + :param path: Filesystem path of the IFC file to open. + """ 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.""" + """Write the in-memory model to disk. + + Overwrites the target file without further confirmation. Edits made by + ``ifc_edit``, ``ifc_shape`` and ``ifc_quantify`` exist only in memory + until this is called. + + :param path: Destination path. Omit to overwrite the file the model was + loaded from; this fails for a model created by ``ifc_new``, which has + no original path. + """ model = self._require_model() target = path if path else self.model_path if not target: @@ -253,7 +301,11 @@ class IfcSession: return f"Saved to {target}" def ifc_reset(self) -> dict[str, Any]: - """Drop the in-memory model.""" + """Discard the in-memory model. + + Drops the model and its file path, throwing away any edits not already + written with ``ifc_save``. Succeeds even when no model is loaded. + """ self.model = None self.model_path = None return {"ok": True} @@ -261,39 +313,42 @@ class IfcSession: # ------------- # Query tools # ------------- + @_use_doc(summary.summary) def ifc_summary(self) -> dict[str, Any]: - """Model overview: schema, entity counts, project info.""" return summary.summary(self._require_model()) + @_use_doc(tree.tree) 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()) + @_use_doc(info.info) 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) + @_use_doc(select.select) def ifc_select(self, query: str) -> list[dict[str, Any]]: - """Filter elements using ifcopenshell selector syntax. - - Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``, - ``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``, - ``material = "Concrete"``. - """ return select.select(self._require_model(), query) + @_use_doc(relations.relations) 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) + @_use_doc( + clash_mod.clash, + extra=( + "\n\nNote: this method takes a plain ``clearance: float`` rather than\n" + '``clearance: float | None`` — ``0.0`` (the default) means "skip the\n' + 'clearance check", matching ``None`` in ``ifcquery.clash.clash()``.' + ), + ) def ifc_clash( self, element_id: int, @@ -301,7 +356,6 @@ class IfcSession: 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: @@ -314,33 +368,53 @@ class IfcSession: scope=scope, ) + @_use_doc(contexts_mod.contexts) 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()) + @_use_doc(materials_mod.materials) 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.""" + """Discover the ifcopenshell.api functions available for editing. + + With no argument returns every API module with its description, + function names and function count. With a module name returns that + module's functions, each with a one-line description and its + parameters. This is the starting point for ``ifc_docs`` and + ``ifc_edit``; it inspects the installed ifcopenshell package and works + without a model loaded. + + :param module: API module name, for example ``'root'``, ``'geometry'`` + or ``'pset'``. Omit to list all modules. + """ return list_functions(module) if module else list_modules() + @_use_doc(function_docs) 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. + """Run an ifcopenshell.api function to modify the model. - params may be: - - JSON string - - dict (from tool calling / JS) - - JsProxy (handled upstream in embedded.py) + This is the general-purpose edit method; use ``ifc_list`` and + ``ifc_docs`` first to find the function and its parameters. Changes + are made to the in-memory model only, so ``ifc_save`` is needed to + persist them. Returns ``{"ok": True, "result": ...}``, or + ``{"ok": False, "error": ...}`` when the function is unknown, a + parameter cannot be converted, or the call raises. + + :param function_path: ``'module.function'``, for example + ``'root.create_entity'``. + :param params: Keyword arguments as a JSON string, a dict (tool + calling) or a JsProxy (handled upstream in embedded.py). Pass + entity references as integer step IDs, and arguments typed as an + IFC file as a file path string. """ model = self._require_model() module, function = function_path.split(".", 1) @@ -359,28 +433,20 @@ class IfcSession: # ------------------------ # Extended query + edit tools # ------------------------ + @_use_doc(validate_mod.validate) 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) + @_use_doc(schedule.schedule) 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) + @_use_doc(cost_mod.cost) 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) + @_use_doc(schema.schema) 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( @@ -456,18 +522,43 @@ class IfcSession: # Shape builder tools # ------------------------ def ifc_shape_list(self) -> list[dict]: - """List all ShapeBuilder geometry methods with one-line descriptions and parameter names.""" + """List the ShapeBuilder methods available for constructing geometry. + + Returns every public ``ifcopenshell.util.shape_builder.ShapeBuilder`` + method with a one-line description and its parameter names, read + directly from that class's own docstrings. Use it to find a method, + then ``ifc_shape_docs`` for the details and ``ifc_shape`` to call it. + Works without a model loaded. + """ return _list_shape_methods() def ifc_shape_docs(self, method: str) -> dict: - """Full documentation for a ShapeBuilder method: params, types, return value.""" + """Show the full documentation for one ShapeBuilder method. + + Returns the summary and long description, every parameter with its + type and default, and the return type — read directly from + ``ShapeBuilder``'s own docstring. Read this before ``ifc_shape`` so + that argument names and value shapes are correct. Works without a + model loaded. + + :param method: ShapeBuilder method name, for example ``'polyline'``, + ``'rectangle'`` or ``'extrude'``. + """ 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. + """Call a ShapeBuilder method to build geometry in the model. - 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]). + The created entities are added to the in-memory model, so + ``ifc_save`` is needed to persist them. On success the result + identifies the created entity by step ID and type; an unknown method + or a failed call is reported as an error instead. + + :param method: ShapeBuilder method name, as listed by + ``ifc_shape_list``. + :param params: JSON string of keyword arguments. Pass entity + references as integer step IDs and vectors as JSON arrays, e.g. + ``[1.0, 0.0, 0.0]``. """ model = self._require_model() @@ -493,11 +584,8 @@ class IfcSession: except Exception as e: return {"ok": False, "error": f"{type(e).__name__}: {e}"} + @_use_doc(run_quantify, extra="\n\nCall ``ifc_save`` afterwards to persist the result.") 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) diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py index 202333b2ac..91a207486f 100644 --- a/src/ifcmcp/ifcmcp/server.py +++ b/src/ifcmcp/ifcmcp/server.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 +import inspect from typing import Any from ifcmcp.core import IfcSession @@ -23,6 +24,11 @@ def build_server() -> Any: session = IfcSession() + def _tool(fn): + """Register a tool, taking its MCP description from the identically-named + IfcSession method rather than duplicating it here.""" + return server.tool(description=inspect.getdoc(getattr(IfcSession, fn.__name__)))(fn) + server = FastMCP( name="ifc-mcp", instructions=( @@ -33,44 +39,44 @@ def build_server() -> Any: ) # ---- Lifecycle ---- - @server.tool() + @_tool def ifc_new(schema: str = "IFC4") -> dict[str, Any]: return session.ifc_new(schema=schema) - @server.tool() + @_tool def ifc_load(path: str) -> str: return session.ifc_load(path) - @server.tool() + @_tool def ifc_save(path: str = "") -> str: return session.ifc_save(path) - @server.tool() + @_tool def ifc_reset() -> dict[str, Any]: return session.ifc_reset() # ---- Query ---- - @server.tool() + @_tool def ifc_summary() -> dict[str, Any]: return session.ifc_summary() - @server.tool() + @_tool def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]: return session.ifc_tree() - @server.tool() + @_tool def ifc_info(element_id: int) -> dict[str, Any]: return session.ifc_info(element_id) - @server.tool() + @_tool def ifc_select(query: str) -> list[dict[str, Any]]: return session.ifc_select(query) - @server.tool() + @_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() + @_tool def ifc_clash( element_id: int, clearance: float = 0.0, @@ -84,58 +90,58 @@ def build_server() -> Any: scope=scope, ) - @server.tool() + @_tool def ifc_contexts() -> list[dict[str, Any]]: return session.ifc_contexts() - @server.tool() + @_tool def ifc_materials() -> list[dict[str, Any]]: return session.ifc_materials() # ---- Edit ---- - @server.tool() + @_tool def ifc_list(module: str = "") -> list[dict]: return session.ifc_list(module=module) - @server.tool() + @_tool def ifc_docs(function_path: str) -> dict: return session.ifc_docs(function_path=function_path) - @server.tool() + @_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() + @_tool def ifc_validate(express_rules: bool = False) -> dict[str, Any]: return session.ifc_validate(express_rules=express_rules) - @server.tool() + @_tool def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]: return session.ifc_schedule(max_depth=max_depth) - @server.tool() + @_tool def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]: return session.ifc_cost(max_depth=max_depth) - @server.tool() + @_tool def ifc_schema(entity_type: str) -> dict[str, Any]: return session.ifc_schema(entity_type=entity_type) - @server.tool() + @_tool def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]: return session.ifc_quantify(rule=rule, selector=selector) # ---- Shape builder ---- - @server.tool() + @_tool def ifc_shape_list() -> list[dict]: return session.ifc_shape_list() - @server.tool() + @_tool def ifc_shape_docs(method: str) -> dict: return session.ifc_shape_docs(method=method) - @server.tool() + @_tool def ifc_shape(method: str, params: str = "{}") -> dict: return session.ifc_shape(method=method, params=params) diff --git a/src/ifcmcp/tests/test_server.py b/src/ifcmcp/tests/test_server.py index ed41434df7..0bd1f7b11d 100644 --- a/src/ifcmcp/tests/test_server.py +++ b/src/ifcmcp/tests/test_server.py @@ -30,6 +30,12 @@ class TestServerRegistration: for name in expected: assert name in tools, f"Tool {name} not registered" + def test_all_tools_have_descriptions(self): + server = build_server() + tools = server._tool_manager.list_tools() + missing = [t.name for t in tools if not (t.description or "").strip()] + assert not missing, f"Tools with no description: {missing}" + @pytest.fixture def tool_fns(): diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index d364c8fc91..e0d5684164 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -25,7 +25,7 @@ are automatically created and maintained. Alignments are created with stationing referents. Each layout segment is assigned a position referent that informs about the start point of the segment. An example is the point of curvature of a horizontal circular curve. The referent is -nested to the segment representing the circular arc and is named with a indicator of the position and the station, e.g. "P.C. (145+98.32)" +nested to the segment representing the circular arc and is named with the alignment name and an indicator of the position and the station, e.g. "MyAlignment 145+98.32 (P.C.)" This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance. @@ -79,7 +79,7 @@ from .get_layout_curve import get_layout_curve from .get_layout_segments import get_layout_segments from .get_mapped_segments import get_mapped_segments from .get_parent_alignment import get_parent_alignment -from .get_referent_nest import get_referent_nest +from .get_stationing_nest import get_stationing_nest from .get_vertical_layout import get_vertical_layout from .has_zero_length_segment import has_zero_length_segment from .layout_horizontal_alignment_by_pi_method import ( @@ -89,8 +89,10 @@ from .layout_vertical_alignment_by_pi_method import ( layout_vertical_alignment_by_pi_method, ) from .name_segments import name_segments +from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags from .update_end_point import update_end_point from .update_fallback_position import update_fallback_position +from .update_key_point_referents import update_key_point_referents from .util import * __all__ = [ @@ -124,14 +126,16 @@ __all__ = [ "get_layout_curve", "get_layout_segments", "get_parent_alignment", - "get_referent_nest", + "get_stationing_nest", "get_vertical_layout", "has_zero_length_segment", "layout_horizontal_alignment_by_pi_method", "layout_vertical_alignment_by_pi_method", "name_segments", "register_referent_name_callback", + "update_alignment_parameter_segment_tags", "update_end_point", "update_fallback_position", + "update_key_point_referents", "get_mapped_segments", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_key_point_tag.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_key_point_tag.py new file mode 100644 index 0000000000..ca888f2e9c --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_key_point_tag.py @@ -0,0 +1,30 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell +import ifcopenshell.util.alignment + + +def _get_key_point_tag(file: ifcopenshell.file, label: str, station: float) -> str: + """ + Builds the station-and-label text shared by update_alignment_parameter_segment_tags (used + directly as IfcAlignmentParameterSegment.StartTag/EndTag) and update_key_point_referents (used, + prefixed with the alignment name, as IfcReferent.Name): " (