mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 03:19:53 +00:00
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.
This commit is contained in:
@@ -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 <path>` -- 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
+44
-3
@@ -17,11 +17,14 @@ IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`).
|
||||
## Usage
|
||||
|
||||
```
|
||||
ifcquery <ifc_file> <command> [options] [--format json|text]
|
||||
ifcquery <ifc_file> <command> [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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user