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:
Bruno Postle
2026-03-29 15:17:22 +01:00
parent 0ed96d32dd
commit 1c26ee86c9
11 changed files with 523 additions and 7 deletions
+57
View File
@@ -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.
+48
View File
@@ -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)
+76
View File
@@ -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,
}
+79
View File
@@ -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
+82 -1
View File
@@ -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
View File
@@ -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.
+21 -2
View File
@@ -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)
+13
View File
@@ -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
+25 -1
View File
@@ -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
+37
View File
@@ -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())
+41
View File
@@ -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]