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)