From bf5b48d037b04dae39f275dfa217e02fde23b291 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 1 Mar 2026 19:49:53 +0000 Subject: [PATCH] Add ifc_contexts and ifc_materials tools New ifcquery modules list geometric representation contexts (with step IDs, context type, identifier, target view, parent) and material sets (IfcMaterial, layer sets, constituent sets, profile sets). Exposed as CLI subcommands and MCP tools. Generated with the assistance of an AI coding tool. --- src/ifcmcp/ifcmcp/core.py | 23 +++++++- src/ifcmcp/ifcmcp/server.py | 8 +++ src/ifcquery/ifcquery/__main__.py | 11 +++- src/ifcquery/ifcquery/contexts.py | 44 ++++++++++++++ src/ifcquery/ifcquery/materials.py | 86 ++++++++++++++++++++++++++++ src/ifcquery/tests/test_contexts.py | 49 ++++++++++++++++ src/ifcquery/tests/test_materials.py | 52 +++++++++++++++++ 7 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/ifcquery/ifcquery/contexts.py create mode 100644 src/ifcquery/ifcquery/materials.py create mode 100644 src/ifcquery/tests/test_contexts.py create mode 100644 src/ifcquery/tests/test_materials.py diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py index 191b529052..77aa41dd54 100644 --- a/src/ifcmcp/ifcmcp/core.py +++ b/src/ifcmcp/ifcmcp/core.py @@ -11,8 +11,9 @@ 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, render as render_mod, schedule, schema, select, summary, tree +from ifcquery import info, materials as materials_mod, relations, render as render_mod, schedule, schema, select, summary, tree from ifcquery import validate as validate_mod @@ -150,6 +151,14 @@ class IfcSession: 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 # ------------------------ @@ -329,6 +338,18 @@ class IfcSession: "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", diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py index 3fd37513ee..0c2857bda9 100644 --- a/src/ifcmcp/ifcmcp/server.py +++ b/src/ifcmcp/ifcmcp/server.py @@ -85,6 +85,14 @@ def build_server() -> Any: 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]: diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py index ef593022c8..f36325d6e1 100644 --- a/src/ifcquery/ifcquery/__main__.py +++ b/src/ifcquery/ifcquery/__main__.py @@ -27,8 +27,9 @@ import sys import ifcopenshell 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, render as render_mod, schedule, schema, select, summary, tree +from ifcquery import info, materials as materials_mod, relations, render as render_mod, schedule, schema, select, summary, tree from ifcquery import validate as validate_mod @@ -121,6 +122,10 @@ def main(): "--depth", type=int, default=None, metavar="N", help="Limit cost item expansion to N levels (default: unlimited)" ) + subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts") + + subparsers.add_parser("materials", help="List materials and material sets") + schema_parser = subparsers.add_parser("schema", help="IFC class documentation") schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)") @@ -204,6 +209,10 @@ def main(): result = schedule.schedule(model, max_depth=args.depth) elif args.command == "cost": result = cost_mod.cost(model, max_depth=args.depth) + elif args.command == "contexts": + result = contexts_mod.contexts(model) + elif args.command == "materials": + result = materials_mod.materials(model) elif args.command == "schema": result = schema.schema(model, args.entity_type) elif args.command == "render": diff --git a/src/ifcquery/ifcquery/contexts.py b/src/ifcquery/ifcquery/contexts.py new file mode 100644 index 0000000000..912df173d7 --- /dev/null +++ b/src/ifcquery/ifcquery/contexts.py @@ -0,0 +1,44 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import ifcopenshell + + +def contexts(model: ifcopenshell.file) -> list[dict]: + """Return all geometric representation contexts and subcontexts. + + :param model: The in-memory IFC model. + :return: List of dicts with id, type, context_type, context_identifier, + and (for subcontexts) target_view and parent_context_id. + """ + results = [] + for ctx in model.by_type("IfcGeometricRepresentationContext"): + entry = { + "id": ctx.id(), + "type": ctx.is_a(), + "context_type": getattr(ctx, "ContextType", None), + "context_identifier": getattr(ctx, "ContextIdentifier", None), + } + if ctx.is_a("IfcGeometricRepresentationSubContext"): + entry["target_view"] = ctx.TargetView + parent = ctx.ParentContext + entry["parent_context_id"] = parent.id() if parent else None + results.append(entry) + return results diff --git a/src/ifcquery/ifcquery/materials.py b/src/ifcquery/ifcquery/materials.py new file mode 100644 index 0000000000..f1b5ac7b2c --- /dev/null +++ b/src/ifcquery/ifcquery/materials.py @@ -0,0 +1,86 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import ifcopenshell + + +def materials(model: ifcopenshell.file) -> list[dict]: + """Return all materials and material sets from the model. + + :param model: The in-memory IFC model. + :return: List of dicts covering IfcMaterial, IfcMaterialLayerSet, + IfcMaterialConstituentSet, and IfcMaterialProfileSet entities. + """ + results = [] + + for m in model.by_type("IfcMaterial"): + results.append({ + "id": m.id(), + "type": "IfcMaterial", + "name": m.Name, + "category": getattr(m, "Category", None), + }) + + for ls in model.by_type("IfcMaterialLayerSet"): + layers = [] + for layer in (ls.MaterialLayers or []): + layers.append({ + "name": layer.Name, + "thickness": layer.LayerThickness, + "material": layer.Material.Name if layer.Material else None, + "is_ventilated": layer.IsVentilated, + }) + results.append({ + "id": ls.id(), + "type": "IfcMaterialLayerSet", + "name": ls.LayerSetName, + "layers": layers, + }) + + for cs in model.by_type("IfcMaterialConstituentSet"): + constituents = [] + for c in (cs.MaterialConstituents or []): + constituents.append({ + "name": c.Name, + "material": c.Material.Name if c.Material else None, + "fraction": c.Fraction, + }) + results.append({ + "id": cs.id(), + "type": "IfcMaterialConstituentSet", + "name": cs.Name, + "constituents": constituents, + }) + + for ps in model.by_type("IfcMaterialProfileSet"): + profiles = [] + for p in (ps.MaterialProfiles or []): + profiles.append({ + "name": p.Name, + "material": p.Material.Name if p.Material else None, + }) + results.append({ + "id": ps.id(), + "type": "IfcMaterialProfileSet", + "name": ps.Name, + "profiles": profiles, + }) + + return results diff --git a/src/ifcquery/tests/test_contexts.py b/src/ifcquery/tests/test_contexts.py new file mode 100644 index 0000000000..f629373cea --- /dev/null +++ b/src/ifcquery/tests/test_contexts.py @@ -0,0 +1,49 @@ +import ifcopenshell.api.context +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit + +from ifcquery.contexts import contexts + + +class TestContexts: + def test_empty_model(self): + f = ifcopenshell.api.project.create_file() + result = contexts(f) + assert isinstance(result, list) + assert len(result) == 0 + + def test_model_context(self, model): + import ifcopenshell.api.context + ifcopenshell.api.context.add_context(model, context_type="Model") + result = contexts(model) + assert len(result) == 1 + entry = result[0] + assert entry["type"] == "IfcGeometricRepresentationContext" + assert entry["context_type"] == "Model" + assert "id" in entry + assert "context_identifier" in entry + + def test_subcontext(self, model): + import ifcopenshell.api.context + model_ctx = ifcopenshell.api.context.add_context(model, context_type="Model") + ifcopenshell.api.context.add_context( + model, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_ctx, + ) + result = contexts(model) + assert len(result) == 2 + subctx = next(e for e in result if e["type"] == "IfcGeometricRepresentationSubContext") + assert subctx["context_identifier"] == "Body" + assert subctx["target_view"] == "MODEL_VIEW" + assert subctx["parent_context_id"] == model_ctx.id() + + def test_ids_are_integers(self, model): + import ifcopenshell.api.context + ifcopenshell.api.context.add_context(model, context_type="Model") + result = contexts(model) + for entry in result: + assert isinstance(entry["id"], int) diff --git a/src/ifcquery/tests/test_materials.py b/src/ifcquery/tests/test_materials.py new file mode 100644 index 0000000000..aa6886156f --- /dev/null +++ b/src/ifcquery/tests/test_materials.py @@ -0,0 +1,52 @@ +import ifcopenshell.api.material +import ifcopenshell.api.project + +from ifcquery.materials import materials + + +class TestMaterials: + def test_empty_model(self, model): + result = materials(model) + assert isinstance(result, list) + assert len(result) == 0 + + def test_single_material(self, model): + ifcopenshell.api.material.add_material(model, name="Concrete", category="concrete") + result = materials(model) + assert len(result) == 1 + m = result[0] + assert m["type"] == "IfcMaterial" + assert m["name"] == "Concrete" + assert m["category"] == "concrete" + assert isinstance(m["id"], int) + + def test_material_layer_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Brick") + layer_set = ifcopenshell.api.material.add_material_set(model, name="BrickSet", set_type="IfcMaterialLayerSet") + ifcopenshell.api.material.add_layer(model, layer_set=layer_set, material=mat) + result = materials(model) + layer_sets = [e for e in result if e["type"] == "IfcMaterialLayerSet"] + assert len(layer_sets) == 1 + ls = layer_sets[0] + assert ls["name"] == "BrickSet" + assert isinstance(ls["layers"], list) + assert len(ls["layers"]) == 1 + layer = ls["layers"][0] + assert layer["material"] == "Brick" + + def test_material_constituent_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Steel") + cs = ifcopenshell.api.material.add_material_set(model, name="CompSet", set_type="IfcMaterialConstituentSet") + ifcopenshell.api.material.add_constituent(model, constituent_set=cs, material=mat) + result = materials(model) + constituent_sets = [e for e in result if e["type"] == "IfcMaterialConstituentSet"] + assert len(constituent_sets) == 1 + entry = constituent_sets[0] + assert entry["name"] == "CompSet" + assert isinstance(entry["constituents"], list) + + def test_ids_are_integers(self, model): + ifcopenshell.api.material.add_material(model, name="Wood") + result = materials(model) + for entry in result: + assert isinstance(entry["id"], int)