diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py index f7064a7af8..635697b5e0 100644 --- a/src/ifcquery/ifcquery/__main__.py +++ b/src/ifcquery/ifcquery/__main__.py @@ -24,7 +24,7 @@ import sys import ifcopenshell -from ifcquery import info, select, summary, tree +from ifcquery import info, relations, select, summary, tree def parse_element_id(raw: str) -> int: @@ -89,6 +89,10 @@ def main(): select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax") select_parser.add_argument("query", help="Selector query string") + relations_parser = subparsers.add_parser("relations", help="Show relationships for an element") + relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)") + args = parser.parse_args() try: @@ -115,6 +119,18 @@ def main(): result = info.info(model, element) elif args.command == "select": result = select.select(model, args.query) + elif args.command == "relations": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + result = relations.relations(model, element, traverse=args.traverse) print(format_output(result, args.output_format)) diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py new file mode 100644 index 0000000000..a06fe04b2a --- /dev/null +++ b/src/ifcquery/ifcquery/relations.py @@ -0,0 +1,168 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2025 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 + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.system + + +def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Serialize an element to a compact reference dict.""" + result: dict[str, Any] = {"id": element.id(), "type": element.is_a()} + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + return result + + +def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None: + return _ref(element) if element is not None else None + + +def _ref_list(elements) -> list[dict[str, Any]]: + return [_ref(e) for e in elements] + + +def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]: + """Walk the hierarchy from element up to IfcProject.""" + chain = [_ref(element)] + current = element + while True: + parent = ifcopenshell.util.element.get_parent(current) + if parent is None: + break + chain.append(_ref(parent)) + current = parent + return chain + + +def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Collect all relationships for an element.""" + result: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + } + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + + # Hierarchy (upward) + hierarchy: dict[str, Any] = {} + parent = ifcopenshell.util.element.get_parent(element) + if parent is not None: + hierarchy["parent"] = _ref(parent) + container = ifcopenshell.util.element.get_container(element) + if container is not None: + hierarchy["container"] = _ref(container) + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate is not None: + hierarchy["aggregate"] = _ref(aggregate) + nest = ifcopenshell.util.element.get_nest(element) + if nest is not None: + hierarchy["nest"] = _ref(nest) + filled_void = ifcopenshell.util.element.get_filled_void(element) + if filled_void is not None: + hierarchy["filled_void"] = _ref(filled_void) + voided_element = ifcopenshell.util.element.get_voided_element(element) + if voided_element is not None: + hierarchy["voided_element"] = _ref(voided_element) + if hierarchy: + result["hierarchy"] = hierarchy + + # Children (downward) + children: dict[str, Any] = {} + contained = ifcopenshell.util.element.get_contained(element) + if contained: + children["contained"] = _ref_list(contained) + parts = ifcopenshell.util.element.get_parts(element) + if parts: + children["parts"] = _ref_list(parts) + components = ifcopenshell.util.element.get_components(element) + if components: + children["components"] = _ref_list(components) + openings = list(ifcopenshell.util.element.get_openings(element)) + if openings: + children["openings"] = _ref_list(openings) + if children: + result["children"] = children + + # Type relationship + type_relationship: dict[str, Any] = {} + element_type = ifcopenshell.util.element.get_type(element) + if element_type is not None: + type_relationship["type_of"] = _ref(element_type) + try: + occurrences = ifcopenshell.util.element.get_types(element) + if occurrences: + type_relationship["occurrences"] = _ref_list(occurrences) + except Exception: + pass + if type_relationship: + result["type_relationship"] = type_relationship + + # Groups + groups = ifcopenshell.util.element.get_groups(element) + if groups: + result["groups"] = _ref_list(groups) + + # Systems + systems = ifcopenshell.util.system.get_element_systems(element) + if systems: + result["systems"] = _ref_list(systems) + + # Zones + zones = ifcopenshell.util.system.get_element_zones(element) + if zones: + result["zones"] = _ref_list(zones) + + # Material + material = ifcopenshell.util.element.get_material(element) + if material is not None: + result["material"] = _ref(material) + + # Referenced structures + referenced = ifcopenshell.util.element.get_referenced_structures(element) + if referenced: + result["referenced_structures"] = _ref_list(referenced) + + # Connections + connections: dict[str, Any] = {} + connected_to = ifcopenshell.util.system.get_connected_to(element) + if connected_to: + connections["connected_to"] = _ref_list(connected_to) + connected_from = ifcopenshell.util.system.get_connected_from(element) + if connected_from: + connections["connected_from"] = _ref_list(connected_from) + ports = ifcopenshell.util.system.get_ports(element) + if ports: + connections["ports"] = _ref_list(ports) + if connections: + result["connections"] = connections + + return result + + +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'.""" + if traverse == "up": + return _traverse_up(element) + return _all_relations(model, element) diff --git a/src/ifcquery/tests/test_relations.py b/src/ifcquery/tests/test_relations.py new file mode 100644 index 0000000000..35ea0231d8 --- /dev/null +++ b/src/ifcquery/tests/test_relations.py @@ -0,0 +1,157 @@ +import json +import os +import subprocess +import sys +import tempfile + +from ifcquery.relations import relations + + +class TestWallRelations: + def test_wall_has_container(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["hierarchy"]["container"]["type"] == "IfcBuildingStorey" + assert result["hierarchy"]["container"]["name"] == "Ground Floor" + + def test_wall_has_parent(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["hierarchy"]["parent"]["type"] == "IfcBuildingStorey" + + def test_wall_no_children(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "children" not in result + + def test_wall_empty_categories_omitted(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "groups" not in result + assert "systems" not in result + assert "zones" not in result + assert "connections" not in result + assert "referenced_structures" not in result + + +class TestStoreyRelations: + def test_storey_has_contained(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + contained_types = {e["type"] for e in result["children"]["contained"]} + assert "IfcWall" in contained_types + assert "IfcSlab" in contained_types + + def test_storey_has_aggregate_parent(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + assert result["hierarchy"]["aggregate"]["type"] == "IfcBuilding" + assert result["hierarchy"]["aggregate"]["name"] == "TestBuilding" + + +class TestProjectRelations: + def test_project_has_parts(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + parts = result["children"]["parts"] + assert any(p["type"] == "IfcSite" for p in parts) + + def test_project_no_hierarchy(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + assert "hierarchy" not in result + + +class TestTraverseUp: + def test_wall_to_project(self, model): + wall = model.by_type("IfcWall")[0] + chain = relations(model, wall, traverse="up") + assert isinstance(chain, list) + assert chain[0]["type"] == "IfcWall" + assert chain[-1]["type"] == "IfcProject" + types = [e["type"] for e in chain] + assert "IfcBuildingStorey" in types + assert "IfcBuilding" in types + assert "IfcSite" in types + + def test_project_traverse(self, model): + project = model.by_type("IfcProject")[0] + chain = relations(model, project, traverse="up") + assert len(chain) == 1 + assert chain[0]["type"] == "IfcProject" + + def test_storey_to_project(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + chain = relations(model, storey, traverse="up") + assert chain[0]["type"] == "IfcBuildingStorey" + assert chain[-1]["type"] == "IfcProject" + assert len(chain) == 4 # storey -> building -> site -> project + + +class TestJsonSerializable: + def test_relations_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + json.dumps(result) + + def test_traverse_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall, traverse="up") + json.dumps(result) + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_relations_json(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id())], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["type"] == "IfcWall" + assert "hierarchy" in data + finally: + os.unlink(path) + + def test_relations_traverse_up(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id()), "--traverse", "up"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert data[0]["type"] == "IfcWall" + assert data[-1]["type"] == "IfcProject" + finally: + os.unlink(path) + + def test_relations_bad_id(self, model): + path = self._ifc_path(model) + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", "999999"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "Error" in result.stderr + finally: + os.unlink(path)