Skip to content

Commit 1c26ee8

Browse files
committed
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.
1 parent 0ed96d3 commit 1c26ee8

11 files changed

Lines changed: 523 additions & 7 deletions

File tree

src/ifcedit/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,50 @@ ifcedit run model.ifc pset.edit_pset --pset 15 \
181181
--properties '{"IsExternal": true, "FireRating": "2HR"}'
182182
```
183183

184+
### foreach
185+
186+
Apply an API function to each element in a JSON array read from stdin.
187+
`{field}` placeholders in argument values are substituted with fields from
188+
each JSON object. The model is opened once and saved once regardless of how
189+
many elements are processed.
190+
191+
```bash
192+
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
193+
```
194+
195+
```json
196+
{"ok": true, "count": 36, "errors": []}
197+
```
198+
199+
Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`,
200+
`{type}`, and `{name}`:
201+
202+
```bash
203+
ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
204+
--product {id} --attributes '{"Name": "Door"}'
205+
```
206+
207+
**Options:**
208+
209+
- `-o, --output <path>` -- write to a different file instead of overwriting the input
210+
211+
**Output:**
212+
213+
- `count` -- number of elements successfully processed
214+
- `errors` -- list of per-element failures, each with `index`, `item`, and `error`; processing continues past errors
215+
216+
```json
217+
{
218+
"ok": false,
219+
"count": 34,
220+
"errors": [
221+
{"index": 2, "item": {"id": 55, "type": "IfcWindow", "name": "W03"}, "error": "Entity #55 not found in model"}
222+
]
223+
}
224+
```
225+
226+
Exit code is 1 if any element failed.
227+
184228
### quantify
185229
186230
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.
243287
A typical workflow: inspect with `ifcquery`, look up the right API function
244288
with `ifcedit docs`, then apply changes with `ifcedit run`.
245289
290+
The two tools also compose directly in shell scripts. Use `ifcquery --format ids`
291+
to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON
292+
into `ifcedit foreach` to apply an operation to every matching element:
293+
294+
```bash
295+
# Aggregate — pass all IDs as a list parameter
296+
ifcedit run model.ifc spatial.unassign_container \
297+
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
298+
299+
# Fan-out — one operation per element, model opened and saved once
300+
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
301+
```
302+
246303
## License
247304
248305
LGPLv3+ -- see the IfcOpenShell project license.

src/ifcedit/ifcedit/__main__.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ifcedit.discover import function_docs, list_functions, list_modules
2929
from ifcedit.quantify import list_rules, run_quantify
3030
from ifcedit.run import run_api
31+
from ifcedit.foreach import run_foreach
3132

3233

3334
def format_output(data, fmt: str) -> str:
@@ -138,6 +139,42 @@ def _parse_extra_args(extra: list[str]) -> dict[str, str]:
138139
return kwargs
139140

140141

142+
def cmd_foreach(args, extra_args):
143+
try:
144+
model = ifcopenshell.open(args.ifc_file)
145+
except Exception as e:
146+
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
147+
sys.exit(1)
148+
149+
parts = args.function_path.split(".")
150+
if len(parts) != 2:
151+
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
152+
sys.exit(1)
153+
module, function = parts
154+
155+
raw_kwargs_template = _parse_extra_args(extra_args)
156+
157+
try:
158+
stdin_data = json.load(sys.stdin)
159+
except json.JSONDecodeError as e:
160+
print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr)
161+
sys.exit(1)
162+
163+
if not isinstance(stdin_data, list):
164+
print("Error: stdin must be a JSON array", file=sys.stderr)
165+
sys.exit(1)
166+
167+
result = run_foreach(model, module, function, raw_kwargs_template, stdin_data)
168+
169+
if result["ok"]:
170+
output_path = args.output or args.ifc_file
171+
model.write(output_path)
172+
173+
print(format_output(result, args.output_format))
174+
if not result["ok"]:
175+
sys.exit(1)
176+
177+
141178
def cmd_quantify(args, extra_args):
142179
if args.quantify_command == "list":
143180
result = list_rules()
@@ -191,6 +228,15 @@ def main():
191228
run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
192229
run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
193230

231+
# foreach
232+
foreach_parser = subparsers.add_parser(
233+
"foreach",
234+
help="Apply an API function to each element in a JSON array read from stdin",
235+
)
236+
foreach_parser.add_argument("ifc_file", help="Path to the IFC file")
237+
foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)")
238+
foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
239+
194240
# quantify
195241
quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
196242
quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
@@ -209,6 +255,8 @@ def main():
209255
cmd_docs(args)
210256
elif args.command == "run":
211257
cmd_run(args, extra)
258+
elif args.command == "foreach":
259+
cmd_foreach(args, extra)
212260
elif args.command == "quantify":
213261
cmd_quantify(args, extra)
214262

src/ifcedit/ifcedit/foreach.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
2+
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
3+
#
4+
# This file is part of IfcEdit.
5+
#
6+
# IfcEdit is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Lesser General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# IfcEdit is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Lesser General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Lesser General Public License
17+
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
18+
19+
from __future__ import annotations
20+
21+
import ifcopenshell
22+
23+
from ifcedit.run import run_api
24+
25+
26+
def _substitute(template: str, item: dict) -> str:
27+
"""Replace {key} placeholders in template with values from item."""
28+
for key, value in item.items():
29+
template = template.replace(f"{{{key}}}", str(value))
30+
return template
31+
32+
33+
def run_foreach(
34+
model: ifcopenshell.file,
35+
module: str,
36+
function: str,
37+
raw_kwargs_template: dict[str, str],
38+
items: list[dict],
39+
) -> dict:
40+
"""Apply an API function to each item in a list, substituting {field} placeholders.
41+
42+
Opens the model once, applies the mutation for every item, and returns a summary.
43+
The caller is responsible for saving the model.
44+
45+
Args:
46+
model: The open IFC model (mutated in place).
47+
module: API module name (e.g. "root").
48+
function: Function name (e.g. "remove_product").
49+
raw_kwargs_template: Arg templates with {field} placeholders, e.g. {"product": "{id}"}.
50+
items: List of dicts (e.g. from ifcquery select output).
51+
52+
Returns:
53+
{"ok": True, "count": N, "errors": []} on full success,
54+
{"ok": False, "count": N, "errors": [{...}]} if any item failed.
55+
"""
56+
errors = []
57+
count = 0
58+
59+
for i, item in enumerate(items):
60+
if not isinstance(item, dict):
61+
errors.append({"index": i, "item": item, "error": "item is not a dict"})
62+
continue
63+
64+
substituted = {k: _substitute(v, item) for k, v in raw_kwargs_template.items()}
65+
result = run_api(model, module, function, substituted)
66+
67+
if result["ok"]:
68+
count += 1
69+
else:
70+
errors.append({"index": i, "item": item, "error": result["error"]})
71+
72+
return {
73+
"ok": len(errors) == 0,
74+
"count": count,
75+
"errors": errors,
76+
}

src/ifcedit/tests/test_foreach.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Tests for ifcedit.foreach
2+
import ifcopenshell
3+
import ifcopenshell.api.owner.settings
4+
import ifcopenshell.api.project
5+
import ifcopenshell.api.root
6+
import ifcopenshell.api.spatial
7+
import ifcopenshell.api.unit
8+
import pytest
9+
10+
from ifcedit.foreach import _substitute, run_foreach
11+
12+
13+
@pytest.fixture
14+
def model(model):
15+
return model
16+
17+
18+
class TestSubstitute:
19+
def test_single_field(self):
20+
assert _substitute("--product {id}", {"id": 42}) == "--product 42"
21+
22+
def test_multiple_fields(self):
23+
result = _substitute("{type} #{id} ({name})", {"id": 5, "type": "IfcWall", "name": "W1"})
24+
assert result == "IfcWall #5 (W1)"
25+
26+
def test_no_placeholder(self):
27+
assert _substitute("hello", {"id": 1}) == "hello"
28+
29+
def test_unknown_placeholder_unchanged(self):
30+
assert _substitute("{unknown}", {"id": 1}) == "{unknown}"
31+
32+
33+
class TestRunForeach:
34+
def _items(self, model, ifc_class):
35+
return [{"id": e.id(), "type": e.is_a(), "name": e.Name} for e in model.by_type(ifc_class)]
36+
37+
def test_rename_single(self, model):
38+
items = self._items(model, "IfcWall")
39+
result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items)
40+
assert result["ok"] is True
41+
assert result["count"] == 1
42+
assert result["errors"] == []
43+
assert model.by_type("IfcWall")[0].Name == "R"
44+
45+
def test_rename_multiple(self, model):
46+
items = self._items(model, "IfcElement")
47+
result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items)
48+
assert result["ok"] is True
49+
assert result["count"] == len(items)
50+
51+
def test_empty_list(self, model):
52+
result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, [])
53+
assert result["ok"] is True
54+
assert result["count"] == 0
55+
assert result["errors"] == []
56+
57+
def test_bad_id_collects_error(self, model):
58+
items = [{"id": 999999, "type": "IfcWall", "name": "X"}]
59+
result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, items)
60+
assert result["ok"] is False
61+
assert result["count"] == 0
62+
assert len(result["errors"]) == 1
63+
assert result["errors"][0]["index"] == 0
64+
65+
def test_non_dict_item_collects_error(self, model):
66+
result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, ["not_a_dict"])
67+
assert result["ok"] is False
68+
assert len(result["errors"]) == 1
69+
70+
def test_partial_failure_counts_successes(self, model):
71+
wall_id = model.by_type("IfcWall")[0].id()
72+
items = [
73+
{"id": wall_id, "type": "IfcWall", "name": "W"},
74+
{"id": 999999, "type": "IfcWall", "name": "Bad"},
75+
]
76+
result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items)
77+
assert result["ok"] is False
78+
assert result["count"] == 1
79+
assert len(result["errors"]) == 1

0 commit comments

Comments
 (0)