ifcquery 0.8.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ifcquery/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ # This file was generated with the assistance of an AI coding tool.
2
+ # IfcQuery - IFC model interrogation CLI
3
+ # Copyright (C) 2026 Bruno Postle <bruno@postle.net>
4
+ #
5
+ # This file is part of IfcQuery.
6
+ #
7
+ # IfcQuery is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU Lesser General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # IfcQuery is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU Lesser General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU Lesser General Public License
18
+ # along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ __version__ = version = "0.8.5"
ifcquery/__main__.py ADDED
@@ -0,0 +1,397 @@
1
+ # This file was generated with the assistance of an AI coding tool.
2
+ # IfcQuery - IFC model interrogation CLI
3
+ # Copyright (C) 2026 Bruno Postle <bruno@postle.net>
4
+ #
5
+ # This file is part of IfcQuery.
6
+ #
7
+ # IfcQuery is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU Lesser General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # IfcQuery is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU Lesser General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU Lesser General Public License
18
+ # along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+
27
+ import ifcopenshell
28
+
29
+ from ifcquery import clash as clash_mod
30
+ from ifcquery import contexts as contexts_mod
31
+ from ifcquery import cost as cost_mod
32
+ from ifcquery import (
33
+ info,
34
+ plot,
35
+ relations,
36
+ schedule,
37
+ schema,
38
+ select,
39
+ summary,
40
+ tree,
41
+ )
42
+ from ifcquery import (
43
+ materials as materials_mod,
44
+ )
45
+ from ifcquery import (
46
+ render as render_mod,
47
+ )
48
+ from ifcquery import validate as validate_mod
49
+
50
+
51
+ def parse_element_id(raw: str) -> int:
52
+ """Parse an element ID from '#123' or '123' format."""
53
+ raw = raw.strip().lstrip("#")
54
+ return int(raw)
55
+
56
+
57
+ def format_output(data, fmt: str) -> str:
58
+ if fmt == "json":
59
+ return json.dumps(data, indent=2, ensure_ascii=False)
60
+ elif fmt == "text":
61
+ return _format_text(data)
62
+ elif fmt == "ids":
63
+ return _format_ids(data)
64
+ return json.dumps(data, indent=2, ensure_ascii=False)
65
+
66
+
67
+ def _format_ids(data) -> str:
68
+ """Extract 'id' fields from a list of dicts and return as comma-separated string.
69
+
70
+ For dicts with a top-level 'elements' key (e.g. clash, relations output),
71
+ extracts from that flat summary list rather than the nested structure.
72
+ """
73
+ if isinstance(data, list):
74
+ ids = [str(item["id"]) for item in data if isinstance(item, dict) and "id" in item]
75
+ return ",".join(ids)
76
+ if isinstance(data, dict):
77
+ if "elements" in data and isinstance(data["elements"], list):
78
+ return _format_ids(data["elements"])
79
+ if "id" in data:
80
+ return str(data["id"])
81
+ return ""
82
+
83
+
84
+ def _format_text(data, indent: int = 0) -> str:
85
+ prefix = " " * indent
86
+ lines = []
87
+ if isinstance(data, dict):
88
+ for key, value in data.items():
89
+ if isinstance(value, (dict, list)):
90
+ lines.append(f"{prefix}{key}:")
91
+ lines.append(_format_text(value, indent + 1))
92
+ else:
93
+ lines.append(f"{prefix}{key}: {value}")
94
+ elif isinstance(data, list):
95
+ for item in data:
96
+ if isinstance(item, dict):
97
+ lines.append(_format_text(item, indent))
98
+ lines.append("")
99
+ else:
100
+ lines.append(f"{prefix}- {item}")
101
+ else:
102
+ lines.append(f"{prefix}{data}")
103
+ return "\n".join(lines)
104
+
105
+
106
+ def main():
107
+ parser = argparse.ArgumentParser(
108
+ prog="ifcquery",
109
+ description="Query and inspect IFC building models",
110
+ )
111
+ parser.add_argument("ifc_file", help="Path to the IFC file")
112
+ parser.add_argument(
113
+ "--format",
114
+ choices=["json", "text", "ids"],
115
+ default="json",
116
+ dest="output_format",
117
+ help="Output format: json (default), text (human-readable), ids (comma-separated step IDs)",
118
+ )
119
+
120
+ subparsers = parser.add_subparsers(dest="command", required=True)
121
+
122
+ subparsers.add_parser("summary", help="Model overview: schema, element counts, project info")
123
+
124
+ subparsers.add_parser("tree", help="Spatial hierarchy tree")
125
+
126
+ info_parser = subparsers.add_parser("info", help="Deep inspection of a specific element")
127
+ info_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
128
+
129
+ select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax")
130
+ select_parser.add_argument("query", help="Selector query string")
131
+
132
+ relations_parser = subparsers.add_parser("relations", help="Show relationships for an element")
133
+ relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
134
+ relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)")
135
+
136
+ clash_parser = subparsers.add_parser("clash", help="Check element placement for clashes")
137
+ clash_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
138
+ clash_parser.add_argument("--clearance", type=float, help="Minimum clearance distance")
139
+ clash_parser.add_argument("--tolerance", type=float, default=0.002, help="Intersection tolerance (default: 0.002)")
140
+ clash_parser.add_argument(
141
+ "--scope", choices=["storey", "all"], default="storey", help="Scope of elements to check (default: storey)"
142
+ )
143
+
144
+ validate_parser = subparsers.add_parser("validate", help="Schema/constraint validation")
145
+ validate_parser.add_argument(
146
+ "--rules", action="store_true", help="Also check EXPRESS rules (slower, default: false)"
147
+ )
148
+
149
+ schedule_parser = subparsers.add_parser("schedule", help="List work plans and tasks from the model")
150
+ schedule_parser.add_argument(
151
+ "--depth", type=int, default=None, metavar="N", help="Limit subtask expansion to N levels (default: unlimited)"
152
+ )
153
+
154
+ cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model")
155
+ cost_parser.add_argument(
156
+ "--depth",
157
+ type=int,
158
+ default=None,
159
+ metavar="N",
160
+ help="Limit cost item expansion to N levels (default: unlimited)",
161
+ )
162
+
163
+ subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts")
164
+
165
+ subparsers.add_parser("materials", help="List materials and material sets")
166
+
167
+ schema_parser = subparsers.add_parser("schema", help="IFC class documentation")
168
+ schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
169
+
170
+ render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image")
171
+ render_parser.add_argument(
172
+ "-o", "--output", default="", metavar="FILE", help="Output PNG path (default: <ifc_file>.png)"
173
+ )
174
+ render_parser.add_argument(
175
+ "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements"
176
+ )
177
+ render_parser.add_argument(
178
+ "--element",
179
+ default="",
180
+ metavar="ID[,ID...]",
181
+ help="Comma-separated step IDs of elements to highlight (rest rendered in grey)",
182
+ )
183
+ render_parser.add_argument(
184
+ "--view",
185
+ choices=render_mod.VIEWS,
186
+ default="iso",
187
+ help="Camera angle (default: iso)",
188
+ )
189
+
190
+ plot_parser = subparsers.add_parser(
191
+ "plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)"
192
+ )
193
+ plot_parser.add_argument(
194
+ "-o",
195
+ "--output",
196
+ default="",
197
+ metavar="FILE",
198
+ help="Output file path. Default depends on --out-format: <ifc_file>.svg/.png",
199
+ )
200
+ plot_parser.add_argument(
201
+ "--out-format",
202
+ choices=["svg", "png", "base64"],
203
+ default="png",
204
+ help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png",
205
+ )
206
+ plot_parser.add_argument(
207
+ "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict plotted elements"
208
+ )
209
+ plot_parser.add_argument(
210
+ "--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight"
211
+ )
212
+ plot_parser.add_argument(
213
+ "--view",
214
+ choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")),
215
+ default="floorplan",
216
+ help="Drawing view (default: floorplan)",
217
+ )
218
+ plot_parser.add_argument(
219
+ "--width-mm",
220
+ type=float,
221
+ default=297.0,
222
+ metavar="MM",
223
+ help="Paper width in mm (default: 297)",
224
+ )
225
+ plot_parser.add_argument(
226
+ "--height-mm",
227
+ type=float,
228
+ default=420.0,
229
+ metavar="MM",
230
+ help="Paper height in mm (default: 420)",
231
+ )
232
+ plot_parser.add_argument(
233
+ "--scale",
234
+ type=float,
235
+ default=1.0 / 100.0,
236
+ metavar="S",
237
+ help="Model-to-paper scale (default: 0.01 = 1:100)",
238
+ )
239
+ plot_parser.add_argument(
240
+ "--png-width",
241
+ type=int,
242
+ default=1024,
243
+ metavar="PX",
244
+ help="PNG width in pixels (default: 1024)",
245
+ )
246
+ plot_parser.add_argument(
247
+ "--png-height",
248
+ type=int,
249
+ default=1024,
250
+ metavar="PX",
251
+ help="PNG height in pixels (default: 1024)",
252
+ )
253
+
254
+ args = parser.parse_args()
255
+
256
+ try:
257
+ model = ifcopenshell.open(args.ifc_file)
258
+ except Exception as e:
259
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
260
+ sys.exit(1)
261
+
262
+ if args.command == "summary":
263
+ result = summary.summary(model)
264
+ elif args.command == "tree":
265
+ result = tree.tree(model)
266
+ elif args.command == "info":
267
+ try:
268
+ element_id = parse_element_id(args.element_id)
269
+ except ValueError:
270
+ print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
271
+ sys.exit(1)
272
+ try:
273
+ element = model.by_id(element_id)
274
+ except RuntimeError:
275
+ print(f"Error: Element #{element_id} not found", file=sys.stderr)
276
+ sys.exit(1)
277
+ result = info.info(model, element)
278
+ elif args.command == "select":
279
+ result = select.select(model, args.query)
280
+ elif args.command == "relations":
281
+ try:
282
+ element_id = parse_element_id(args.element_id)
283
+ except ValueError:
284
+ print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
285
+ sys.exit(1)
286
+ try:
287
+ element = model.by_id(element_id)
288
+ except RuntimeError:
289
+ print(f"Error: Element #{element_id} not found", file=sys.stderr)
290
+ sys.exit(1)
291
+ result = relations.relations(model, element, traverse=args.traverse)
292
+ elif args.command == "clash":
293
+ try:
294
+ element_id = parse_element_id(args.element_id)
295
+ except ValueError:
296
+ print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
297
+ sys.exit(1)
298
+ try:
299
+ element = model.by_id(element_id)
300
+ except RuntimeError:
301
+ print(f"Error: Element #{element_id} not found", file=sys.stderr)
302
+ sys.exit(1)
303
+ try:
304
+ result = clash_mod.clash(
305
+ model, element, clearance=args.clearance, tolerance=args.tolerance, scope=args.scope
306
+ )
307
+ except ImportError:
308
+ print("Error: ifcopenshell geometry engine not available (C++ bindings required)", file=sys.stderr)
309
+ sys.exit(1)
310
+ elif args.command == "validate":
311
+ result = validate_mod.validate(model, express_rules=args.rules)
312
+ elif args.command == "schedule":
313
+ result = schedule.schedule(model, max_depth=args.depth)
314
+ elif args.command == "cost":
315
+ result = cost_mod.cost(model, max_depth=args.depth)
316
+ elif args.command == "contexts":
317
+ result = contexts_mod.contexts(model)
318
+ elif args.command == "materials":
319
+ result = materials_mod.materials(model)
320
+ elif args.command == "schema":
321
+ result = schema.schema(model, args.entity_type)
322
+ elif args.command == "render":
323
+ element_ids = None
324
+ if args.element:
325
+ try:
326
+ element_ids = [parse_element_id(part) for part in args.element.split(",")]
327
+ except ValueError:
328
+ print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
329
+ sys.exit(1)
330
+ out_path = args.output or (os.path.splitext(args.ifc_file)[0] + ".png")
331
+ try:
332
+ png_bytes = render_mod.render(
333
+ model,
334
+ selector=args.selector or None,
335
+ element_ids=element_ids,
336
+ view=args.view,
337
+ )
338
+ except ImportError as e:
339
+ print(f"Error: {e}", file=sys.stderr)
340
+ sys.exit(1)
341
+ except ValueError as e:
342
+ print(f"Error: {e}", file=sys.stderr)
343
+ sys.exit(1)
344
+ with open(out_path, "wb") as f:
345
+ f.write(png_bytes)
346
+ print(f"Saved render to {out_path}", file=sys.stderr)
347
+ return
348
+ elif args.command == "plot":
349
+ element_ids = None
350
+ if args.element:
351
+ try:
352
+ element_ids = [parse_element_id(part) for part in args.element.split(",")]
353
+ except ValueError:
354
+ print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
355
+ sys.exit(1)
356
+
357
+ try:
358
+ result = plot.plot(
359
+ model,
360
+ selector=args.selector or None,
361
+ element_ids=element_ids,
362
+ view=args.view,
363
+ width_mm=args.width_mm,
364
+ height_mm=args.height_mm,
365
+ scale=args.scale,
366
+ output_format=args.out_format,
367
+ )
368
+ except ImportError as e:
369
+ print(f"Error: {e}", file=sys.stderr)
370
+ sys.exit(1)
371
+ except ValueError as e:
372
+ print(f"Error: {e}", file=sys.stderr)
373
+ sys.exit(1)
374
+
375
+ if args.out_format == "base64":
376
+ # result is a dict; serialise to stdout so callers can consume it
377
+ print(format_output(result, args.output_format))
378
+ return
379
+
380
+ # svg or png: write to a file
381
+ base = os.path.splitext(args.ifc_file)[0]
382
+ if args.out_format == "svg":
383
+ out_path = args.output or (base + ".svg")
384
+ else:
385
+ out_path = args.output or (base + ".png")
386
+
387
+ with open(out_path, "wb") as f:
388
+ f.write(result)
389
+
390
+ print(f"Saved drawing to {out_path}", file=sys.stderr)
391
+ return
392
+
393
+ print(format_output(result, args.output_format))
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()
ifcquery/clash.py ADDED
@@ -0,0 +1,180 @@
1
+ # This file was generated with the assistance of an AI coding tool.
2
+ # IfcQuery - IFC model interrogation CLI
3
+ # Copyright (C) 2026 Bruno Postle <bruno@postle.net>
4
+ #
5
+ # This file is part of IfcQuery.
6
+ #
7
+ # IfcQuery is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU Lesser General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+ #
12
+ # IfcQuery is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU Lesser General Public License for more details.
16
+ #
17
+ # You should have received a copy of the GNU Lesser General Public License
18
+ # along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
19
+
20
+ from __future__ import annotations
21
+
22
+ import multiprocessing
23
+ import sys
24
+ from typing import Any
25
+
26
+ import ifcopenshell
27
+ import ifcopenshell.geom
28
+ import ifcopenshell.util.element
29
+
30
+
31
+ def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
32
+ """Serialize an element to a compact reference dict."""
33
+ result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
34
+ if hasattr(element, "Name") and element.Name:
35
+ result["name"] = element.Name
36
+ return result
37
+
38
+
39
+ def _get_scope_elements(
40
+ model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str
41
+ ) -> tuple[set[ifcopenshell.entity_instance], str]:
42
+ """Return set of elements to check against and the effective scope used.
43
+
44
+ Returns (elements, effective_scope) where effective_scope may differ from
45
+ the requested scope if fallback was needed.
46
+ """
47
+ if scope == "storey":
48
+ container = ifcopenshell.util.element.get_container(element)
49
+ if container is not None:
50
+ siblings = set(ifcopenshell.util.element.get_contained(container))
51
+ siblings.discard(element)
52
+ return siblings, "storey"
53
+ else:
54
+ print(
55
+ f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all",
56
+ file=sys.stderr,
57
+ )
58
+
59
+ # scope == "all" or fallback
60
+ elements = set(model.by_type("IfcElement"))
61
+ elements -= set(model.by_type("IfcFeatureElement"))
62
+ elements.discard(element)
63
+ return elements, "all"
64
+
65
+
66
+ def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None:
67
+ """Build geometry tree for given elements using iterator.
68
+
69
+ Returns None if iterator fails to initialize (no geometry available).
70
+ """
71
+ geom_settings = ifcopenshell.geom.settings()
72
+ geom_settings.set("use-world-coords", True)
73
+ geom_tree = ifcopenshell.geom.tree()
74
+ iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements))
75
+ if not iterator.initialize():
76
+ return None
77
+ while True:
78
+ geom_tree.add_element(iterator.get())
79
+ if not iterator.next():
80
+ break
81
+ return geom_tree
82
+
83
+
84
+ def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]:
85
+ """Format a single clash result to dict."""
86
+ # clash result .a/.b are C++ wrapper entity_instances without .Name;
87
+ # look up the Python entity from the model by id for proper serialization
88
+ other = model.by_id(clash_result.b.id())
89
+ return {
90
+ "element": _ref(other),
91
+ "type": geom_tree.get_clash_type(clash_result.clash_type),
92
+ "distance": clash_result.distance,
93
+ "p1": list(clash_result.p1),
94
+ "p2": list(clash_result.p2),
95
+ }
96
+
97
+
98
+ def clash(
99
+ model: ifcopenshell.file,
100
+ element: ifcopenshell.entity_instance,
101
+ clearance: float | None = None,
102
+ tolerance: float = 0.002,
103
+ scope: str = "storey",
104
+ ) -> dict[str, Any]:
105
+ """Check element for geometric clashes against other elements.
106
+
107
+ :param model: The IFC model.
108
+ :param element: The element to check.
109
+ :param clearance: Minimum clearance distance; if provided, runs clearance check.
110
+ :param tolerance: Intersection tolerance in meters (default 0.002).
111
+ :param scope: Which elements to check against: "storey" or "all".
112
+ :return: Dict with clash results suitable for JSON serialization.
113
+ """
114
+ result: dict[str, Any] = {"element": _ref(element)}
115
+
116
+ # Get scope elements
117
+ scope_elements, effective_scope = _get_scope_elements(model, element, scope)
118
+ result["scope"] = effective_scope
119
+
120
+ if not scope_elements:
121
+ result["pass"] = True
122
+ result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}}
123
+ if clearance is not None:
124
+ result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []}
125
+ return result
126
+
127
+ # Build geometry tree for target element + scope elements
128
+ all_elements = scope_elements | {element}
129
+ geom_tree = _build_tree(model, all_elements)
130
+
131
+ if geom_tree is None:
132
+ result["pass"] = None
133
+ result["error"] = f"No geometry for element #{element.id()}"
134
+ return result
135
+
136
+ # Run intersection check
137
+ intersection_clashes = geom_tree.clash_intersection_many(
138
+ [element], list(scope_elements), tolerance=tolerance, check_all=True
139
+ )
140
+ intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes]
141
+ checks: dict[str, Any] = {
142
+ "intersection": {
143
+ "pass": len(intersection_results) == 0,
144
+ "tolerance": tolerance,
145
+ "clashes": intersection_results,
146
+ }
147
+ }
148
+
149
+ all_pass = len(intersection_results) == 0
150
+
151
+ # Run clearance check if requested
152
+ if clearance is not None:
153
+ clearance_clashes = geom_tree.clash_clearance_many(
154
+ [element], list(scope_elements), clearance=clearance, check_all=True
155
+ )
156
+ clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes]
157
+ checks["clearance"] = {
158
+ "pass": len(clearance_results) == 0,
159
+ "clearance": clearance,
160
+ "clashes": clearance_results,
161
+ }
162
+ if clearance_results:
163
+ all_pass = False
164
+
165
+ result["pass"] = all_pass
166
+ result["checks"] = checks
167
+
168
+ # Flat list of subject + all clashing elements across all checks, deduplicated.
169
+ # Allows --format ids to extract all involved IDs without jq.
170
+ seen: set[int] = {element.id()}
171
+ involved = [_ref(element)]
172
+ for check in checks.values():
173
+ for clash_item in check.get("clashes", []):
174
+ eid = clash_item["element"]["id"]
175
+ if eid not in seen:
176
+ seen.add(eid)
177
+ involved.append(clash_item["element"])
178
+ result["elements"] = involved
179
+
180
+ return result
ifcquery/contexts.py ADDED
@@ -0,0 +1,44 @@
1
+ # IfcQuery - IFC model interrogation CLI
2
+ # Copyright (C) 2026 Bruno Postle <bruno@postle.net>
3
+ #
4
+ # This file is part of IfcQuery.
5
+ #
6
+ # IfcQuery 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
+ # IfcQuery 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 IfcQuery. If not, see <http://www.gnu.org/licenses/>.
18
+
19
+ from __future__ import annotations
20
+
21
+ import ifcopenshell
22
+
23
+
24
+ def contexts(model: ifcopenshell.file) -> list[dict]:
25
+ """Return all geometric representation contexts and subcontexts.
26
+
27
+ :param model: The in-memory IFC model.
28
+ :return: List of dicts with id, type, context_type, context_identifier,
29
+ and (for subcontexts) target_view and parent_context_id.
30
+ """
31
+ results = []
32
+ for ctx in model.by_type("IfcGeometricRepresentationContext"):
33
+ entry = {
34
+ "id": ctx.id(),
35
+ "type": ctx.is_a(),
36
+ "context_type": getattr(ctx, "ContextType", None),
37
+ "context_identifier": getattr(ctx, "ContextIdentifier", None),
38
+ }
39
+ if ctx.is_a("IfcGeometricRepresentationSubContext"):
40
+ entry["target_view"] = ctx.TargetView
41
+ parent = ctx.ParentContext
42
+ entry["parent_context_id"] = parent.id() if parent else None
43
+ results.append(entry)
44
+ return results
ifcquery/cost.py ADDED
@@ -0,0 +1,45 @@
1
+ # This file was generated with the assistance of an AI coding tool.
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import ifcopenshell
7
+ import ifcopenshell.util.cost as cost_util
8
+
9
+
10
+ def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
11
+ raw_values = cost_util.get_cost_values(item)
12
+ values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values]
13
+
14
+ if max_depth is not None and depth >= max_depth:
15
+ child_count = len(cost_util.get_nested_cost_items(item))
16
+ subitems = {"truncated": True, "count": child_count} if child_count else []
17
+ else:
18
+ subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)]
19
+
20
+ return {
21
+ "id": item.id(),
22
+ "name": getattr(item, "Name", None),
23
+ "values": values,
24
+ "subitems": subitems,
25
+ }
26
+
27
+
28
+ def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
29
+ """Return a list of IfcCostSchedule entries with nested cost item trees.
30
+
31
+ max_depth limits how many levels of subitems are expanded (None = unlimited).
32
+ At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
33
+ """
34
+ result = []
35
+ for cost_schedule in model.by_type("IfcCostSchedule"):
36
+ items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)]
37
+ result.append(
38
+ {
39
+ "id": cost_schedule.id(),
40
+ "name": getattr(cost_schedule, "Name", None),
41
+ "predefined_type": getattr(cost_schedule, "PredefinedType", None),
42
+ "items": items,
43
+ }
44
+ )
45
+ return result