mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
Add ifcquery relations subcommand for IFC relationship traversal
New subcommand: ifcquery <file> relations <element_id> Returns all relationships for an element organised by category: hierarchy (parent, container, aggregate, nest), children (contained, parts, components, openings), type relationships, groups, systems, zones, material, referenced structures, and connections/ports. Empty categories are omitted from output. Optional --traverse up flag walks the hierarchy from the element up to IfcProject, returning the chain as a list.
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user