mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 22:33:33 +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
|
import ifcopenshell
|
||||||
|
|
||||||
from ifcquery import info, select, summary, tree
|
from ifcquery import info, relations, select, summary, tree
|
||||||
|
|
||||||
|
|
||||||
def parse_element_id(raw: str) -> int:
|
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 = subparsers.add_parser("select", help="Filter elements using selector syntax")
|
||||||
select_parser.add_argument("query", help="Selector query string")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -115,6 +119,18 @@ def main():
|
|||||||
result = info.info(model, element)
|
result = info.info(model, element)
|
||||||
elif args.command == "select":
|
elif args.command == "select":
|
||||||
result = select.select(model, args.query)
|
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))
|
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