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
+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]