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 +20 -0
- ifcquery/__main__.py +397 -0
- ifcquery/clash.py +180 -0
- ifcquery/contexts.py +44 -0
- ifcquery/cost.py +45 -0
- ifcquery/info.py +262 -0
- ifcquery/materials.py +100 -0
- ifcquery/plot.py +284 -0
- ifcquery/relations.py +197 -0
- ifcquery/render.py +465 -0
- ifcquery/schedule.py +56 -0
- ifcquery/schema.py +19 -0
- ifcquery/select.py +41 -0
- ifcquery/summary.py +52 -0
- ifcquery/tree.py +68 -0
- ifcquery/validate.py +15 -0
- ifcquery-0.8.5.dist-info/METADATA +514 -0
- ifcquery-0.8.5.dist-info/RECORD +21 -0
- ifcquery-0.8.5.dist-info/WHEEL +5 -0
- ifcquery-0.8.5.dist-info/entry_points.txt +2 -0
- ifcquery-0.8.5.dist-info/top_level.txt +1 -0
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
|