From 1c26ee86c9ba380916b73888533762a5502775e5 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 29 Mar 2026 15:17:22 +0100 Subject: [PATCH] ifcquery/ifcedit: enable shell scripting by composing query and edit commands Add --format ids to ifcquery to output step IDs suitable for piping into ifcedit parameters. Add ifcedit foreach to apply an operation to every element in a query result. Extend clash and relations output so --format ids extracts all involved element IDs, enabling one-liners like clash detection piped directly into render. Generated with the assistance of an AI coding tool. --- src/ifcedit/README.md | 57 +++++++++++++++++++ src/ifcedit/ifcedit/__main__.py | 48 ++++++++++++++++ src/ifcedit/ifcedit/foreach.py | 76 +++++++++++++++++++++++++ src/ifcedit/tests/test_foreach.py | 79 ++++++++++++++++++++++++++ src/ifcedit/tests/test_main.py | 83 +++++++++++++++++++++++++++- src/ifcquery/README.md | 47 +++++++++++++++- src/ifcquery/ifcquery/__main__.py | 23 +++++++- src/ifcquery/ifcquery/clash.py | 13 +++++ src/ifcquery/ifcquery/relations.py | 26 ++++++++- src/ifcquery/tests/test_main.py | 37 +++++++++++++ src/ifcquery/tests/test_relations.py | 41 ++++++++++++++ 11 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 src/ifcedit/ifcedit/foreach.py create mode 100644 src/ifcedit/tests/test_foreach.py diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md index 21b4935cfb..19b1ec8e7a 100644 --- a/src/ifcedit/README.md +++ b/src/ifcedit/README.md @@ -181,6 +181,50 @@ ifcedit run model.ifc pset.edit_pset --pset 15 \ --properties '{"IsExternal": true, "FireRating": "2HR"}' ``` +### foreach + +Apply an API function to each element in a JSON array read from stdin. +`{field}` placeholders in argument values are substituted with fields from +each JSON object. The model is opened once and saved once regardless of how +many elements are processed. + +```bash +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +``` + +```json +{"ok": true, "count": 36, "errors": []} +``` + +Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`, +`{type}`, and `{name}`: + +```bash +ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ + --product {id} --attributes '{"Name": "Door"}' +``` + +**Options:** + +- `-o, --output ` -- write to a different file instead of overwriting the input + +**Output:** + +- `count` -- number of elements successfully processed +- `errors` -- list of per-element failures, each with `index`, `item`, and `error`; processing continues past errors + +```json +{ + "ok": false, + "count": 34, + "errors": [ + {"index": 2, "item": {"id": 55, "type": "IfcWindow", "name": "W03"}, "error": "Entity #55 not found in model"} + ] +} +``` + +Exit code is 1 if any element failed. + ### quantify Run quantity take-off (QTO) on an IFC file, computing physical measurements @@ -243,6 +287,19 @@ Exit code is 0 on success, 1 on error. A typical workflow: inspect with `ifcquery`, look up the right API function with `ifcedit docs`, then apply changes with `ifcedit run`. +The two tools also compose directly in shell scripts. Use `ifcquery --format ids` +to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON +into `ifcedit foreach` to apply an operation to every matching element: + +```bash +# Aggregate — pass all IDs as a list parameter +ifcedit run model.ifc spatial.unassign_container \ + --products "$(ifcquery model.ifc --format ids select 'IfcWall')" + +# Fan-out — one operation per element, model opened and saved once +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +``` + ## License LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py index 7c4e4c642e..1b14863e5b 100644 --- a/src/ifcedit/ifcedit/__main__.py +++ b/src/ifcedit/ifcedit/__main__.py @@ -28,6 +28,7 @@ import ifcopenshell from ifcedit.discover import function_docs, list_functions, list_modules from ifcedit.quantify import list_rules, run_quantify from ifcedit.run import run_api +from ifcedit.foreach import run_foreach def format_output(data, fmt: str) -> str: @@ -138,6 +139,42 @@ def _parse_extra_args(extra: list[str]) -> dict[str, str]: return kwargs +def cmd_foreach(args, extra_args): + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + + raw_kwargs_template = _parse_extra_args(extra_args) + + try: + stdin_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr) + sys.exit(1) + + if not isinstance(stdin_data, list): + print("Error: stdin must be a JSON array", file=sys.stderr) + sys.exit(1) + + result = run_foreach(model, module, function, raw_kwargs_template, stdin_data) + + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + + def cmd_quantify(args, extra_args): if args.quantify_command == "list": result = list_rules() @@ -191,6 +228,15 @@ def main(): run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving") + # foreach + foreach_parser = subparsers.add_parser( + "foreach", + help="Apply an API function to each element in a JSON array read from stdin", + ) + foreach_parser.add_argument("ifc_file", help="Path to the IFC file") + foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)") + foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + # quantify quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules") quantify_sub = quantify_parser.add_subparsers(dest="quantify_command") @@ -209,6 +255,8 @@ def main(): cmd_docs(args) elif args.command == "run": cmd_run(args, extra) + elif args.command == "foreach": + cmd_foreach(args, extra) elif args.command == "quantify": cmd_quantify(args, extra) diff --git a/src/ifcedit/ifcedit/foreach.py b/src/ifcedit/ifcedit/foreach.py new file mode 100644 index 0000000000..587d611c3f --- /dev/null +++ b/src/ifcedit/ifcedit/foreach.py @@ -0,0 +1,76 @@ +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import ifcopenshell + +from ifcedit.run import run_api + + +def _substitute(template: str, item: dict) -> str: + """Replace {key} placeholders in template with values from item.""" + for key, value in item.items(): + template = template.replace(f"{{{key}}}", str(value)) + return template + + +def run_foreach( + model: ifcopenshell.file, + module: str, + function: str, + raw_kwargs_template: dict[str, str], + items: list[dict], +) -> dict: + """Apply an API function to each item in a list, substituting {field} placeholders. + + Opens the model once, applies the mutation for every item, and returns a summary. + The caller is responsible for saving the model. + + Args: + model: The open IFC model (mutated in place). + module: API module name (e.g. "root"). + function: Function name (e.g. "remove_product"). + raw_kwargs_template: Arg templates with {field} placeholders, e.g. {"product": "{id}"}. + items: List of dicts (e.g. from ifcquery select output). + + Returns: + {"ok": True, "count": N, "errors": []} on full success, + {"ok": False, "count": N, "errors": [{...}]} if any item failed. + """ + errors = [] + count = 0 + + for i, item in enumerate(items): + if not isinstance(item, dict): + errors.append({"index": i, "item": item, "error": "item is not a dict"}) + continue + + substituted = {k: _substitute(v, item) for k, v in raw_kwargs_template.items()} + result = run_api(model, module, function, substituted) + + if result["ok"]: + count += 1 + else: + errors.append({"index": i, "item": item, "error": result["error"]}) + + return { + "ok": len(errors) == 0, + "count": count, + "errors": errors, + } diff --git a/src/ifcedit/tests/test_foreach.py b/src/ifcedit/tests/test_foreach.py new file mode 100644 index 0000000000..e51c3c03be --- /dev/null +++ b/src/ifcedit/tests/test_foreach.py @@ -0,0 +1,79 @@ +# Tests for ifcedit.foreach +import ifcopenshell +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcedit.foreach import _substitute, run_foreach + + +@pytest.fixture +def model(model): + return model + + +class TestSubstitute: + def test_single_field(self): + assert _substitute("--product {id}", {"id": 42}) == "--product 42" + + def test_multiple_fields(self): + result = _substitute("{type} #{id} ({name})", {"id": 5, "type": "IfcWall", "name": "W1"}) + assert result == "IfcWall #5 (W1)" + + def test_no_placeholder(self): + assert _substitute("hello", {"id": 1}) == "hello" + + def test_unknown_placeholder_unchanged(self): + assert _substitute("{unknown}", {"id": 1}) == "{unknown}" + + +class TestRunForeach: + def _items(self, model, ifc_class): + return [{"id": e.id(), "type": e.is_a(), "name": e.Name} for e in model.by_type(ifc_class)] + + def test_rename_single(self, model): + items = self._items(model, "IfcWall") + result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items) + assert result["ok"] is True + assert result["count"] == 1 + assert result["errors"] == [] + assert model.by_type("IfcWall")[0].Name == "R" + + def test_rename_multiple(self, model): + items = self._items(model, "IfcElement") + result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items) + assert result["ok"] is True + assert result["count"] == len(items) + + def test_empty_list(self, model): + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, []) + assert result["ok"] is True + assert result["count"] == 0 + assert result["errors"] == [] + + def test_bad_id_collects_error(self, model): + items = [{"id": 999999, "type": "IfcWall", "name": "X"}] + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, items) + assert result["ok"] is False + assert result["count"] == 0 + assert len(result["errors"]) == 1 + assert result["errors"][0]["index"] == 0 + + def test_non_dict_item_collects_error(self, model): + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, ["not_a_dict"]) + assert result["ok"] is False + assert len(result["errors"]) == 1 + + def test_partial_failure_counts_successes(self, model): + wall_id = model.by_type("IfcWall")[0].id() + items = [ + {"id": wall_id, "type": "IfcWall", "name": "W"}, + {"id": 999999, "type": "IfcWall", "name": "Bad"}, + ] + result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items) + assert result["ok"] is False + assert result["count"] == 1 + assert len(result["errors"]) == 1 diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py index 626351a5a3..63bf75a217 100644 --- a/src/ifcedit/tests/test_main.py +++ b/src/ifcedit/tests/test_main.py @@ -3,15 +3,17 @@ import json import subprocess import sys +import ifcopenshell import pytest -def run_ifcedit(*args): +def run_ifcedit(*args, stdin=None): """Run ifcedit as a subprocess and return (stdout, stderr, returncode).""" result = subprocess.run( [sys.executable, "-m", "ifcedit", *args], capture_output=True, text=True, + input=stdin, ) return result.stdout, result.stderr, result.returncode @@ -98,3 +100,82 @@ class TestRunCommand: stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path") assert rc != 0 assert "module.function" in stderr + + +class TestForeachCommand: + def _select_json(self, model, ifc_class): + """Build a JSON array like ifcquery select would produce.""" + elements = model.by_type(ifc_class) + return json.dumps([{"id": e.id(), "type": e.is_a(), "name": getattr(e, "Name", None)} for e in elements]) + + def test_foreach_rename(self, model, model_file): + walls_json = self._select_json(model, "IfcWall") + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "--product", "{id}", "--attributes", '{"Name": "Renamed"}', + stdin=walls_json, + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == 1 + assert data["errors"] == [] + updated = ifcopenshell.open(model_file) + assert updated.by_type("IfcWall")[0].Name == "Renamed" + + def test_foreach_multiple_elements(self, model, model_file): + # Build a two-item list by selecting all IfcObject (includes spatial structure + elements) + elements_json = self._select_json(model, "IfcObject") + items = json.loads(elements_json) + assert len(items) >= 2 + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "--product", "{id}", "--attributes", '{"Name": "Bulk"}', + stdin=elements_json, + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == len(items) + + def test_foreach_empty_list(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "--product", "{id}", "--attributes", '{"Name": "X"}', + stdin="[]", + ) + assert rc == 0 + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == 0 + + def test_foreach_invalid_json_stdin(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "root.remove_product", "--product", "{id}", + stdin="not json", + ) + assert rc != 0 + assert "Error" in stderr + + def test_foreach_not_array_stdin(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "root.remove_product", "--product", "{id}", + stdin='{"id": 1}', + ) + assert rc != 0 + assert "Error" in stderr + + def test_foreach_output_to_different_file(self, model, model_file, tmp_path): + import os + output = str(tmp_path / "out.ifc") + walls_json = self._select_json(model, "IfcWall") + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "-o", output, + "--product", "{id}", "--attributes", '{"Name": "OutFile"}', + stdin=walls_json, + ) + assert rc == 0, f"stderr: {stderr}" + assert os.path.exists(output) + updated = ifcopenshell.open(output) + assert updated.by_type("IfcWall")[0].Name == "OutFile" diff --git a/src/ifcquery/README.md b/src/ifcquery/README.md index 0ba8046d72..0c90ecf596 100644 --- a/src/ifcquery/README.md +++ b/src/ifcquery/README.md @@ -17,11 +17,14 @@ IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`). ## Usage ``` -ifcquery [options] [--format json|text] +ifcquery [options] [--format json|text|ids] ``` -The `--format` flag controls output. Default is `json`; use `text` for -indented human-readable output. +The `--format` flag controls output: + +- `json` (default) -- structured JSON, suitable for piping to `jq` or `ifcedit foreach` +- `text` -- indented human-readable output +- `ids` -- comma-separated step IDs extracted from list results, suitable for piping directly into `ifcedit run` parameters ## Subcommands @@ -150,6 +153,15 @@ ifcquery model.ifc select 'IfcWall, IfcSlab' Results are sorted by ID. +Use `--format ids` to get a comma-separated list of step IDs for direct use +in `ifcedit run` parameters: + +```bash +ifcedit run model.ifc type.assign_type \ + --related_objects "$(ifcquery model.ifc --format ids select 'IfcWall')" \ + --relating_type 456 +``` + ### relations Show all relationships for an element, organized by category: hierarchy, @@ -449,6 +461,35 @@ Options: Requires the IfcOpenShell C++ geometry bindings. +## Scripting with ifcedit + +`ifcquery` and `ifcedit` are designed to compose. Use `--format ids` to pass +query results directly into `ifcedit run` parameters, or pipe JSON into +`ifcedit foreach` to apply an operation to every matching element. + +```bash +# Remove all walls from their spatial container +ifcedit run model.ifc spatial.unassign_container \ + --products "$(ifcquery model.ifc --format ids select 'IfcWall')" + +# Delete every window (model opened and saved once) +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} + +# Bulk rename all doors +ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ + --product {id} --attributes '{"Name": "Door"}' + +# Render an element highlighted against everything related to it +ifcquery model.ifc render relations.png \ + --element "$(ifcquery model.ifc --format ids relations 42)" + +# Render a clash — subject and clashing elements highlighted together +ifcquery model.ifc render clash.png \ + --element "$(ifcquery model.ifc --format ids clash 42)" +``` + +See the `ifcedit` documentation for the full `foreach` reference. + ## Error handling Errors are written to stderr. Exit code is 0 on success, 1 on error. diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py index a7a59f3ae5..d4b6141fd9 100644 --- a/src/ifcquery/ifcquery/__main__.py +++ b/src/ifcquery/ifcquery/__main__.py @@ -59,9 +59,28 @@ def format_output(data, fmt: str) -> str: return json.dumps(data, indent=2, ensure_ascii=False) elif fmt == "text": return _format_text(data) + elif fmt == "ids": + return _format_ids(data) return json.dumps(data, indent=2, ensure_ascii=False) +def _format_ids(data) -> str: + """Extract 'id' fields from a list of dicts and return as comma-separated string. + + For dicts with a top-level 'elements' key (e.g. clash, relations output), + extracts from that flat summary list rather than the nested structure. + """ + if isinstance(data, list): + ids = [str(item["id"]) for item in data if isinstance(item, dict) and "id" in item] + return ",".join(ids) + if isinstance(data, dict): + if "elements" in data and isinstance(data["elements"], list): + return _format_ids(data["elements"]) + if "id" in data: + return str(data["id"]) + return "" + + def _format_text(data, indent: int = 0) -> str: prefix = " " * indent lines = [] @@ -92,10 +111,10 @@ def main(): parser.add_argument("ifc_file", help="Path to the IFC file") parser.add_argument( "--format", - choices=["json", "text"], + choices=["json", "text", "ids"], default="json", dest="output_format", - help="Output format (default: json)", + help="Output format: json (default), text (human-readable), ids (comma-separated step IDs)", ) subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/src/ifcquery/ifcquery/clash.py b/src/ifcquery/ifcquery/clash.py index 7e3583e0aa..52c8d53e3b 100644 --- a/src/ifcquery/ifcquery/clash.py +++ b/src/ifcquery/ifcquery/clash.py @@ -164,4 +164,17 @@ def clash( result["pass"] = all_pass result["checks"] = checks + + # Flat list of subject + all clashing elements across all checks, deduplicated. + # Allows --format ids to extract all involved IDs without jq. + seen: set[int] = {element.id()} + involved = [_ref(element)] + for check in checks.values(): + for clash_item in check.get("clashes", []): + eid = clash_item["element"]["id"] + if eid not in seen: + seen.add(eid) + involved.append(clash_item["element"]) + result["elements"] = involved + return result diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py index f8f12e9989..334a3241a3 100644 --- a/src/ifcquery/ifcquery/relations.py +++ b/src/ifcquery/ifcquery/relations.py @@ -160,10 +160,34 @@ def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instan return result +def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -> None: + """Recursively collect all element refs (dicts with 'id') from a nested structure.""" + if isinstance(data, dict): + if "id" in data and isinstance(data["id"], int): + eid = data["id"] + if eid not in seen: + seen.add(eid) + result.append({"id": data["id"], "type": data.get("type"), "name": data.get("name")} if "name" in data else {"id": data["id"], "type": data.get("type")}) + for v in data.values(): + _collect_elements(v, seen, result) + elif isinstance(data, list): + for item in data: + _collect_elements(item, seen, 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) + result = _all_relations(model, element) + + # Flat list of subject + all referenced elements, deduplicated. + # Allows --format ids to extract all involved IDs without jq. + seen: set[int] = set() + elements: list[dict[str, Any]] = [] + _collect_elements(result, seen, elements) + result["elements"] = elements + + return result diff --git a/src/ifcquery/tests/test_main.py b/src/ifcquery/tests/test_main.py index cc17c1b04c..164b93152d 100644 --- a/src/ifcquery/tests/test_main.py +++ b/src/ifcquery/tests/test_main.py @@ -82,3 +82,40 @@ class TestCLI: def test_no_command(self, ifc_path): rc, stdout, stderr = run_ifcquery(ifc_path) assert rc != 0 + + def test_select_ids_format(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcWall") + assert rc == 0 + # Should be a comma-separated string of integers with no surrounding whitespace + ids = stdout.strip() + assert ids != "" + for part in ids.split(","): + assert part.isdigit() + + def test_select_ids_format_multiple(self, ifc_path, model): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcElement") + assert rc == 0 + ids = stdout.strip().split(",") + assert len(ids) >= 2 + + def test_ids_format_empty_result(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcDoor") + assert rc == 0 + assert stdout.strip() == "" + + def test_relations_ids_format(self, ifc_path, model): + storey = model.by_type("IfcBuildingStorey")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "relations", str(storey.id())) + assert rc == 0 + ids = stdout.strip().split(",") + assert all(i.isdigit() for i in ids) + # should include the storey itself and its contained elements + assert str(storey.id()) in ids + wall_id = str(model.by_type("IfcWall")[0].id()) + assert wall_id in ids + + def test_info_ids_format(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "info", str(wall.id())) + assert rc == 0 + assert stdout.strip() == str(wall.id()) diff --git a/src/ifcquery/tests/test_relations.py b/src/ifcquery/tests/test_relations.py index 4e4df48f21..32bc8bf3cb 100644 --- a/src/ifcquery/tests/test_relations.py +++ b/src/ifcquery/tests/test_relations.py @@ -91,6 +91,47 @@ class TestTraverseUp: assert len(chain) == 4 # storey -> building -> site -> project +class TestElementsSummary: + def test_wall_elements_includes_self(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + ids = [e["id"] for e in result["elements"]] + assert wall.id() in ids + + def test_wall_elements_includes_container(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + ids = [e["id"] for e in result["elements"]] + storey = model.by_type("IfcBuildingStorey")[0] + assert storey.id() in ids + + def test_storey_elements_includes_contained(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + ids = [e["id"] for e in result["elements"]] + wall = model.by_type("IfcWall")[0] + assert wall.id() in ids + + def test_elements_no_duplicates(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + ids = [e["id"] for e in result["elements"]] + assert len(ids) == len(set(ids)) + + def test_elements_all_have_id_and_type(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + for e in result["elements"]: + assert "id" in e + assert "type" in e + + def test_traverse_up_has_no_elements_field(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall, traverse="up") + assert isinstance(result, list) + assert not any("elements" in item for item in result) + + class TestJsonSerializable: def test_relations_serializable(self, model): wall = model.by_type("IfcWall")[0]