ifcmcp: source tool descriptions from ifcquery/ifcedit instead of duplicating them

Alternative to #8955, for #8951 (23 of 25 ifcmcp tools reach MCP clients
with an empty description because FastMCP reads each wrapper's own
__doc__, and the server.py wrappers had none).

#8955 fixes this by hand-writing a new docstring directly onto each
server.py wrapper. Most of those wrappers are thin passthroughs to
IfcSession methods in core.py, which already had short docstrings, which
themselves mostly delegate to already-documented ifcquery/ifcedit
functions -- so that fix tripled up content across three layers that can
drift out of sync.

This instead enriches the true source (the ifcquery/ifcedit library
functions, useful independently of MCP) and has core.py's IfcSession
methods copy __doc__ from their delegate via a small _use_doc()
decorator, and server.py's tool registration pull description= from the
matching IfcSession method. Methods that aren't pure passthroughs
(session lifecycle, generic API/shape dispatch) keep their own
hand-written docs. Keeps #8955's regression test.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-08-02 14:45:20 +01:00
parent e077390e3d
commit 8f890868b5
15 changed files with 303 additions and 89 deletions
+8 -2
View File
@@ -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:
+14 -3
View File
@@ -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
+133 -45
View File
@@ -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)
+29 -23
View File
@@ -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)
+6
View File
@@ -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():
+16 -3
View File
@@ -102,13 +102,26 @@ def clash(
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes against other elements.
"""Check one element for geometric clashes against other elements.
Reports hard intersections and, optionally, violations of a required
clearance. Returns the overall ``pass``, the ``scope`` actually used, a
``checks`` block in which each clash names the other ``element``, the
clash ``type``, the ``distance`` and the two closest points ``p1``/``p2``,
and a de-duplicated flat ``elements`` list of everything involved.
Geometry is computed for every element in scope, so this is slow on large
models; ``pass`` is ``None`` with an ``error`` when the element has no
usable geometry.
:param model: The IFC model.
:param element: The element to check.
:param clearance: Minimum clearance distance; if provided, runs clearance check.
:param clearance: Minimum required clearance distance; when given, also
runs the clearance check alongside the intersection check.
:param tolerance: Intersection tolerance in meters (default 0.002).
:param scope: Which elements to check against: "storey" or "all".
:param scope: ``"storey"`` (default) checks only elements sharing the
same spatial container; ``"all"`` checks every ``IfcElement``.
``"storey"`` falls back to ``"all"`` when the element has no spatial
container.
:return: Dict with clash results suitable for JSON serialization.
"""
result: dict[str, Any] = {"element": _ref(element)}
+12 -3
View File
@@ -26,10 +26,19 @@ def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcCostSchedule entries with nested cost item trees.
"""List the cost schedules: bills of quantities and their cost items.
max_depth limits how many levels of subitems are expanded (None = unlimited).
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
Covers ``IfcCostSchedule`` only this is the money dimension of the
model; see ``schedule()`` in this module for the construction programme.
Each cost item reports its cost ``values`` as ``formula`` label and
``category`` pairs, together with its nested ``subitems``. Returns an
empty list when the model has no cost schedules.
:param model: The in-memory IFC model.
:param max_depth: Levels of cost item nesting to expand, counting root
items as level 1. Past the cutoff ``subitems`` is replaced by a
``{"truncated": True, "count": N}`` marker giving the number of items
not expanded. ``None`` (default) expands to unlimited depth.
"""
result = []
for cost_schedule in model.by_type("IfcCostSchedule"):
+10 -1
View File
@@ -188,7 +188,16 @@ def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return deep inspection data for an element."""
"""Inspect a single entity in depth.
Returns the entity's direct ``attributes`` plus, where present,
``property_sets``, ``element_type``, ``material``, ``container``,
``placement`` and ``geometry_summary``. Keys are omitted when the
information is unavailable.
:param model: The in-memory IFC model.
:param element: The entity to inspect.
"""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
+7 -1
View File
@@ -22,7 +22,13 @@ import ifcopenshell
def materials(model: ifcopenshell.file) -> list[dict]:
"""Return all materials and material sets from the model.
"""List the materials and material sets defined in the model.
Returns a single list covering ``IfcMaterial`` (with its category),
``IfcMaterialLayerSet`` (each layer's name, thickness, material and
ventilation flag), ``IfcMaterialConstituentSet`` (constituent names,
materials and fractions) and ``IfcMaterialProfileSet`` (profile names and
materials). Every entry carries the step ID of the material entity.
:param model: The in-memory IFC model.
:return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
+16 -1
View File
@@ -182,7 +182,22 @@ def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -
def relations(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
) -> dict[str, Any] | list[dict[str, Any]]:
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
"""Show how an element relates to the rest of the model.
By default returns a dict whose optional blocks are ``hierarchy`` (parent,
container, aggregate, nest, filled void, voided element), ``children``
(contained, parts, components, openings), ``type_relationship``,
``groups``, ``systems``, ``zones``, ``material``, ``referenced_structures``
and ``connections`` (connected to/from, ports), plus a de-duplicated flat
``elements`` list of everything referenced. Blocks with nothing to report
are omitted.
:param model: The IFC model.
:param element: The element to examine.
:param traverse: Set to ``'up'`` to instead return the chain of ancestors
from the element to ``IfcProject`` as a flat list. Any other value
gives the default behaviour.
"""
if traverse == "up":
return _traverse_up(element)
result = _all_relations(model, element)
+13 -3
View File
@@ -37,10 +37,20 @@ def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, dep
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcWorkSchedule entries with nested task trees.
"""List the construction programme: work schedules and their task trees.
max_depth limits how many levels of subtasks are expanded (None = unlimited).
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
Covers ``IfcWorkSchedule`` only this is the time dimension of the
model; see ``cost()`` in this module for the money dimension. Each
schedule lists its tasks recursively, and each task carries its scheduled
``start`` and ``finish``, an ``is_milestone`` flag, the products it
``outputs`` and its ``subtasks``. Returns an empty list when the model has
no work schedules.
:param model: The in-memory IFC model.
:param max_depth: Levels of subtask nesting to expand, counting root tasks
as level 1. Past the cutoff ``subtasks`` is replaced by a
``{"truncated": True, "count": N}`` marker giving the number of tasks
not expanded. ``None`` (default) expands to unlimited depth.
"""
result = []
for work_schedule in model.by_type("IfcWorkSchedule"):
+9 -1
View File
@@ -8,7 +8,15 @@ import ifcopenshell.util.doc
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type from model's schema version."""
"""Look up the IFC documentation for an entity class.
Returns the class ``description``, its ``predefined_types``, per-attribute
documentation and a ``spec_url``, resolved against the model's schema
version. Returns an ``error`` key for an unknown class.
:param model: The in-memory IFC model, used only for its schema version.
:param entity_type: IFC class name, for example ``'IfcWall'``.
"""
schema_name = model.schema
try:
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
+9 -1
View File
@@ -26,7 +26,15 @@ import ifcopenshell
def summary(model: ifcopenshell.file) -> dict[str, Any]:
"""Return a model overview with schema, element counts, and project info."""
"""Summarise the model: schema, entity counts and project info.
Returns the ``schema`` version, ``total_entities``, and a ``project``
block with the id, name and description of the first ``IfcProject``
(omitted if the model has none). The count covers every entity in the
file, not just physical elements.
:param model: The in-memory IFC model.
"""
# Count elements by IFC type, sorted by count descending
type_counter: Counter[str] = Counter()
total = 0
+11 -1
View File
@@ -59,7 +59,17 @@ def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
"""Return the spatial hierarchy tree starting from IfcProject."""
"""Return the spatial hierarchy of the model as a nested tree.
Starts at ``IfcProject`` and descends through decomposition (site,
building, storeys) and containment (the elements placed in each storey).
Every node carries ``id``, ``type`` and ``name``; ``children`` holds
decomposed sub-spaces and ``elements`` holds contained elements, and
either key is omitted when empty. Returns a list when the file contains
several projects, or an ``error`` key when it contains none.
:param model: The in-memory IFC model.
"""
projects = model.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in model"}
+10 -1
View File
@@ -8,7 +8,16 @@ import ifcopenshell.validate
def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
"""Validate the model and return a dict with 'valid' bool and 'issues' list."""
"""Validate the model against the IFC schema.
Returns ``valid`` together with a list of ``issues``, each carrying a
``level`` and a ``message``. Worth running after a batch of edits and
before writing the model back to disk.
:param model: The in-memory IFC model.
:param express_rules: Also evaluate the schema's EXPRESS rules. Catches
more problems but is considerably slower (default ``False``).
"""
logger = ifcopenshell.validate.json_logger()
ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]