cadaclysm 0.4.0__py3-none-win_amd64.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.
cadaclysm/__init__.py ADDED
@@ -0,0 +1,1926 @@
1
+ """The cadaclysm C ABI, as Python objects: this file is the whole binding.
2
+
3
+ import cadaclysm
4
+
5
+ with cadaclysm.open("part.stp") as scene:
6
+ print(scene.version, scene.schema, scene.metres_per_unit)
7
+ for node in scene.roots:
8
+ walk(node)
9
+
10
+ def walk(node, depth=0):
11
+ print(" " * depth, node.name, node.kind)
12
+ for child in node.children:
13
+ walk(child, depth + 1)
14
+
15
+ It uses `ctypes` and the published header, the way any Python program would —
16
+ no generated bindings, no Rust, no build system. Drop it beside your own
17
+ script and point `CADACLYSM_LIBRARY` at the shared library if it is not in the
18
+ place this looks by default.
19
+
20
+ `numpy` is imported only when triangles or polylines are actually asked for. A
21
+ script that walks the tree and reads attributes needs nothing but the standard
22
+ library.
23
+
24
+ ## Everything borrows from the scene
25
+
26
+ Every pointer this ABI hands back — names, ids, attribute text, vertex and
27
+ index arrays — points into the open document and dies with it. Nothing here
28
+ copies by default, so nothing here is safe after `Scene.close()`, which is
29
+ what leaving a `with` block does.
30
+
31
+ Strings are the easy half: `ctypes` decodes `char *` into a Python `str` on the
32
+ way out, so `node.name` is already a copy and outlives anything.
33
+
34
+ Arrays are the sharp half, and **`node.mesh` hands back read-only numpy views
35
+ into the library's own memory** rather than copies. That is the deliberate
36
+ choice: `ufi.stp` is 90.5M triangles, and copying every mesh to be safe would
37
+ cost gigabytes and seconds to hand back arrays most callers upload to the GPU
38
+ and drop. Two things make the unsafe use hard to reach by accident:
39
+
40
+ * The arrays are read-only, so a stray write cannot corrupt the document.
41
+ * Each array keeps the `Scene` alive through its `.base`, so a view cannot
42
+ outlive the scene by having merely dropped the last reference to it.
43
+
44
+ That leaves exactly one way to dangle: keeping a view past an explicit
45
+ `close()`. Call `mesh.copy()` for arrays that must outlive the scene, or
46
+ finish with them inside the `with`.
47
+ """
48
+
49
+ import ctypes
50
+ import decimal
51
+ import enum
52
+ import os
53
+ import platform
54
+ import re
55
+ import sys
56
+ from ctypes import POINTER, c_bool, c_char_p, c_double, c_float, c_size_t, c_uint32, c_uint64, c_void_p
57
+ from pathlib import Path
58
+
59
+ __all__ = [
60
+ "Attribute",
61
+ "Bounds",
62
+ "Brep",
63
+ "CadaclysmError",
64
+ "Convention",
65
+ "FILE_UNITS",
66
+ "Manifold",
67
+ "Mesh",
68
+ "NONE",
69
+ "Node",
70
+ "Placement",
71
+ "Polylines",
72
+ "Scene",
73
+ "UV_WORLD",
74
+ "ValueKind",
75
+ "build_date",
76
+ "declared_schema",
77
+ "library_path",
78
+ "license",
79
+ "license_info",
80
+ "license_notice_count",
81
+ "mesh_formats",
82
+ "open",
83
+ "open_memory",
84
+ "pick_file",
85
+ "resolve_schema",
86
+ "version",
87
+ ]
88
+
89
+ #: What the ABI returns for "no such node": a parent that is a root, an
90
+ #: `instance_of` that is not an instance, an index past the end. Spelled
91
+ #: `CADACLYSM_NONE` in the header, and `UINT32_MAX` underneath.
92
+ NONE = 0xFFFFFFFF
93
+
94
+
95
+ class CadaclysmError(Exception):
96
+ """A call into the library failed, carrying what it said about it."""
97
+
98
+
99
+ class Convention(enum.IntEnum):
100
+ """The coordinate space to open a file into — `CadaclysmConvention`.
101
+
102
+ The library converts on the way out, so nothing here rotates anything: a
103
+ caller names the space it draws in and reads geometry already in it.
104
+ `NATIVE` keeps the file's own axes and units, which is what every caller
105
+ got before the parameter existed and what this module still defaults to.
106
+ """
107
+
108
+ #: The file's own axes and its own units.
109
+ NATIVE = 0
110
+ #: Z up, left-handed, centimetres.
111
+ UNREAL = 1
112
+ #: Y up, left-handed, metres.
113
+ UNITY = 2
114
+ #: Y up, right-handed, metres — glTF, three.js, Bevy, wgpu.
115
+ Y_UP = 3
116
+ #: Z up, right-handed, metres. `NATIVE`'s axes at Blender's unit, which is
117
+ #: the only difference between the two.
118
+ BLENDER = 4
119
+
120
+ @classmethod
121
+ def parse(cls, text: str) -> int:
122
+ """A packed `uint32` from a name a user typed, as `viewer.py` takes it.
123
+
124
+ `"unreal"`, or `"unreal+file-units"` to keep the file's own units under
125
+ the preset's axes. Raises `ValueError` naming what was accepted, since
126
+ an unrecognised name silently read as `NATIVE` is the one outcome that
127
+ looks like success and draws the wrong space.
128
+ """
129
+ preset, _, rest = text.strip().lower().partition("+")
130
+ packed = {
131
+ "native": cls.NATIVE, "unreal": cls.UNREAL, "unity": cls.UNITY,
132
+ "y-up": cls.Y_UP, "blender": cls.BLENDER,
133
+ }.get(preset)
134
+ if packed is None:
135
+ raise ValueError(f"no convention called {preset!r}: "
136
+ "native, unreal, unity, y-up or blender")
137
+ packed = int(packed)
138
+ for flag in filter(None, rest.split("+")):
139
+ if flag != "file-units":
140
+ raise ValueError(f"no convention flag called {flag!r}: file-units")
141
+ packed |= FILE_UNITS
142
+ return packed
143
+
144
+
145
+ #: OR into a convention: keep the preset's axes but the file's own units.
146
+ #:
147
+ #: **A packing of this module's own now, not the ABI's.** The library takes
148
+ #: `file_units` as a field of `CadaclysmOpenOptions`, and `open` unpacks this
149
+ #: bit into it. The bit survives here because `Convention.parse` returns one
150
+ #: integer and every caller passing `unreal+file-units` expects that to keep
151
+ #: working.
152
+ FILE_UNITS = 0x100
153
+
154
+ #: OR into a convention: ask for `Mesh.uvs`, at one world unit per unit of `u`.
155
+ #: `CADACLYSM_UV_WORLD`. Off by default in the library and so here — a `(u, v)`
156
+ #: is eight bytes a vertex, which is not a cost to impose on a caller who never
157
+ #: asked. Even with it, `Mesh.uvs` is None for a node whose reader produces
158
+ #: none.
159
+ #:
160
+ #: What it turns on is *generating* coordinates from a surface's own parameters.
161
+ #: A format that stores them is a separate matter and is not gated by it:
162
+ #: `PartDesignExample-Body.step` yields UVs on 0 nodes without this flag and 1
163
+ #: with it, while `extrusion.3dm` yields them on the same 1 node either way,
164
+ #: its meshes carrying coordinates the file itself wrote.
165
+ #: **Also this module's own packing**, unpacked into the struct's `uvs` field.
166
+ UV_WORLD = 0x200
167
+
168
+
169
+ def _options(convention, schema=None, colors=False, source_meters_per_unit=0.0):
170
+ """A `CadaclysmOpenOptions` built from this module's arguments.
171
+
172
+ Returns the struct *and the objects it points into*: ctypes will happily let
173
+ the schema array be collected while the struct still holds its address, so a
174
+ caller has to keep the second value alive across the call.
175
+ """
176
+ options = _OpenOptions()
177
+ _lib().cadaclysm_open_options_init(ctypes.byref(options))
178
+ packed = int(convention)
179
+ options.convention = packed & ~(FILE_UNITS | UV_WORLD)
180
+ options.file_units = bool(packed & FILE_UNITS)
181
+ options.uvs = 1 if packed & UV_WORLD else 0
182
+ options.colors = 1 if colors else 0
183
+ options.source_meters_per_unit = float(source_meters_per_unit)
184
+ held = []
185
+ if schema is not None:
186
+ encoded = str(schema).encode()
187
+ array = (c_char_p * 1)(encoded)
188
+ options.schemas = array
189
+ options.schema_count = 1
190
+ held = [encoded, array]
191
+ return options, held
192
+
193
+
194
+ class ValueKind(enum.IntEnum):
195
+ """Which field of an attribute holds its value.
196
+
197
+ **One-based, with zero meaning the attribute was not there** — see
198
+ `include/cadaclysm.h`. A zero-based reading of this enum is off by one for
199
+ every kind, which shows up as every text attribute printing an integer.
200
+ """
201
+
202
+ NONE = 0
203
+ TEXT = 1
204
+ INTEGER = 2
205
+ REAL = 3
206
+ BOOLEAN = 4
207
+ #: The flat C struct cannot hold a list's elements, so `text` carries a
208
+ #: `[a, b, c]` rendering of them.
209
+ LIST = 5
210
+ #: Another entity, with the id the file gave (`#4`) in `text`. Its own kind
211
+ #: rather than TEXT so a consumer can follow it instead of showing it as
212
+ #: prose.
213
+ REFERENCE = 6
214
+
215
+
216
+ # ---- the structs the ABI returns by value ---------------------------------
217
+
218
+
219
+ class _Bounds(ctypes.Structure):
220
+ _fields_ = [("min", c_float * 3), ("max", c_float * 3)]
221
+
222
+
223
+ class _Attribute(ctypes.Structure):
224
+ _fields_ = [
225
+ ("name", c_char_p),
226
+ ("kind", ctypes.c_int),
227
+ ("text", c_char_p),
228
+ ("integer", ctypes.c_int64),
229
+ ("real", c_double),
230
+ ("boolean", c_bool),
231
+ ]
232
+
233
+
234
+ class _Mesh(ctypes.Structure):
235
+ #: Field order must match `CadaclysmMesh` in `include/cadaclysm.h` exactly.
236
+ #: `uvs` sits between `normals` and `indices`, which is where the header
237
+ #: puts it; a copy that leaves it out still loads and still runs, and reads
238
+ #: `uvs` as `indices` and the two halves of the real `indices` pointer as
239
+ #: `vertex_count` and `index_count`. Measured on `extrusion.3dm` before this
240
+ #: field was added: every drawn node came back with a null `indices`, a
241
+ #: `vertex_count` of 995677168 and an `index_count` of 430 — the low and
242
+ #: high words of a heap address — so the viewer drew nothing at all and
243
+ #: raised nothing.
244
+ #:
245
+ #: `cadaclysm-capi/tests/bindings.rs` now pins this against the header, by
246
+ #: field order and by whether each field is a pointer. It does not pin the
247
+ #: exact ctypes type, so `c_float` becoming `c_double` is still yours to get
248
+ #: right.
249
+ _fields_ = [
250
+ ("positions", POINTER(c_float)),
251
+ ("normals", POINTER(c_float)),
252
+ ("uvs", POINTER(c_float)),
253
+ ("colors", POINTER(c_float)),
254
+ ("indices", POINTER(c_uint32)),
255
+ ("vertex_count", c_uint32),
256
+ ("index_count", c_uint32),
257
+ ]
258
+
259
+
260
+ class _OpenOptions(ctypes.Structure):
261
+ #: `CadaclysmOpenOptions`. Field order and `size` are the whole contract:
262
+ #: `cadaclysm_open_options_init` fills the library's whole struct, so this
263
+ #: list must match the header field for field -- `tests/bindings.rs` pins
264
+ #: it -- and it may never reorder.
265
+ _fields_ = [
266
+ ("size", c_size_t),
267
+ ("convention", c_uint32),
268
+ ("spec", c_void_p),
269
+ ("file_units", ctypes.c_bool),
270
+ ("uvs", c_uint32),
271
+ ("colors", c_uint32),
272
+ ("source_meters_per_unit", c_double),
273
+ ("schemas", POINTER(c_char_p)),
274
+ ("schema_count", c_size_t),
275
+ ("schema_text", POINTER(ctypes.c_uint8)),
276
+ ("schema_length", c_size_t),
277
+ # The pick hook is not exposed here -- passing null takes the
278
+ # default (shallowest member, ties to archive order). `init` fills
279
+ # the whole struct, so this list must match the header's field
280
+ # order and length.
281
+ ("pick", c_void_p),
282
+ ("pick_user", c_void_p),
283
+ ]
284
+
285
+
286
+ class _Polylines(ctypes.Structure):
287
+ _fields_ = [
288
+ ("positions", POINTER(c_float)),
289
+ ("counts", POINTER(c_uint32)),
290
+ ("polyline_count", c_uint32),
291
+ ("vertex_count", c_uint32),
292
+ ]
293
+
294
+
295
+
296
+
297
+
298
+ class _Face(ctypes.Structure):
299
+ #: `CadaclysmFace`. Field order and the fixed array widths are the contract; a
300
+ #: mismatch here reads one face's frame as the next one's domain. Pinned against the
301
+ #: header by `cadaclysm-capi/tests/bindings.rs`.
302
+ _fields_ = [
303
+ ("kind", c_uint32),
304
+ ("reversed", c_uint32),
305
+ ("transposed", c_uint32),
306
+ ("reserved", c_uint32),
307
+ ("origin", c_float * 4),
308
+ ("ax", c_float * 4),
309
+ ("ay", c_float * 4),
310
+ ("az", c_float * 4),
311
+ ("domain", c_float * 4),
312
+ ("scalars", c_float * 4),
313
+ ("loop_start", c_uint32),
314
+ ("loop_count", c_uint32),
315
+ ("profile_start", c_uint32),
316
+ ("profile_count", c_uint32),
317
+ ("profile2_start", c_uint32),
318
+ ("profile2_count", c_uint32),
319
+ ("nurbs_start", c_uint32),
320
+ ("nurbs_count", c_uint32),
321
+ ]
322
+
323
+
324
+ class _Surfaces(ctypes.Structure):
325
+ #: `CadaclysmSurfaces`. The counts are in elements, not floats: `point_count` counts
326
+ #: (u, v) pairs and `profile_count` counts four-float samples, so each array is that
327
+ #: many times its stride.
328
+ _fields_ = [
329
+ ("faces", POINTER(_Face)),
330
+ ("face_count", c_uint32),
331
+ ("loops", POINTER(c_uint32)),
332
+ ("loop_count", c_uint32),
333
+ ("points", POINTER(c_float)),
334
+ ("point_count", c_uint32),
335
+ ("profiles", POINTER(c_float)),
336
+ ("profile_count", c_uint32),
337
+ ("nurbs", POINTER(c_float)),
338
+ ("nurbs_count", c_uint32),
339
+ ]
340
+
341
+
342
+ # ---- loading the library --------------------------------------------------
343
+
344
+ #: Every entry point in `include/cadaclysm.h`, as `(name, restype, argtypes)`.
345
+ #:
346
+ #: Declared in full, and all of them rather than the few any one caller uses:
347
+ #: without a `restype` ctypes assumes `int`, which truncates every pointer
348
+ #: these return on a 64-bit build, and a handle truncated to 32 bits is a
349
+ #: crash somewhere else entirely.
350
+ _ENTRY_POINTS = [
351
+ ("cadaclysm_last_error", c_char_p, []),
352
+ ("cadaclysm_version", c_char_p, []),
353
+ ("cadaclysm_license_set", ctypes.c_bool, [c_char_p]),
354
+ ("cadaclysm_license_info", c_char_p, []),
355
+ ("cadaclysm_license_notice_count", c_uint64, []),
356
+ ("cadaclysm_build_date", c_char_p, []),
357
+ ("cadaclysm_open", c_void_p, [c_char_p, POINTER(_OpenOptions)]),
358
+ ("cadaclysm_open_memory", c_void_p,
359
+ [POINTER(ctypes.c_uint8), c_size_t, c_char_p, POINTER(_OpenOptions)]),
360
+ ("cadaclysm_open_options_init", None, [POINTER(_OpenOptions)]),
361
+ ("cadaclysm_close", None, [c_void_p]),
362
+ ("cadaclysm_source_name", c_char_p, [c_void_p]),
363
+ ("cadaclysm_node_count", c_uint32, [c_void_p]),
364
+ ("cadaclysm_root_count", c_uint32, [c_void_p]),
365
+ ("cadaclysm_root", c_uint32, [c_void_p, c_uint32]),
366
+ ("cadaclysm_schema", c_char_p, [c_void_p]),
367
+ ("cadaclysm_schema_read", c_char_p, [c_void_p]),
368
+ ("cadaclysm_metres_per_unit", c_double, [c_void_p]),
369
+ ("cadaclysm_bounds", _Bounds, [c_void_p]),
370
+ ("cadaclysm_node_parent", c_uint32, [c_void_p, c_uint32]),
371
+ ("cadaclysm_node_child_count", c_uint32, [c_void_p, c_uint32]),
372
+ ("cadaclysm_node_child", c_uint32, [c_void_p, c_uint32, c_uint32]),
373
+ ("cadaclysm_node_depth", c_uint32, [c_void_p, c_uint32]),
374
+ ("cadaclysm_node_name", c_char_p, [c_void_p, c_uint32]),
375
+ ("cadaclysm_node_kind", c_char_p, [c_void_p, c_uint32]),
376
+ ("cadaclysm_node_visible", c_bool, [c_void_p, c_uint32]),
377
+ ("cadaclysm_node_save_mesh", c_bool, [c_void_p, c_uint32, c_char_p, c_char_p]),
378
+ ("cadaclysm_scene_save", c_bool, [c_void_p, c_char_p, c_char_p]),
379
+ ("cadaclysm_mesh_format_count", c_uint32, []),
380
+ ("cadaclysm_mesh_format", c_char_p, [c_uint32]),
381
+ ("cadaclysm_mesh_format_extension", c_char_p, [c_uint32]),
382
+ ("cadaclysm_query", c_uint32,
383
+ [c_void_p, c_char_p, POINTER(c_uint32), c_uint32]),
384
+ # `NULL` for the parent, which is a `const CadaclysmWindow *`. A viewer with
385
+ # a window of its own should pass one; this binding does not, because pyglet
386
+ # hands out a window handle only through platform-specific attributes and a
387
+ # wrong pointer here reaches a platform API.
388
+ ("cadaclysm_pick_file", c_char_p, [c_void_p]),
389
+ ("cadaclysm_node_id", c_char_p, [c_void_p, c_uint32]),
390
+ ("cadaclysm_node_color", c_bool, [c_void_p, c_uint32, POINTER(c_float)]),
391
+ ("cadaclysm_node_transform", None, [c_void_p, c_uint32, POINTER(c_double)]),
392
+ ("cadaclysm_node_attribute_count", c_uint32, [c_void_p, c_uint32]),
393
+ ("cadaclysm_node_attribute", _Attribute, [c_void_p, c_uint32, c_uint32]),
394
+ ("cadaclysm_placement_count", c_uint32, [c_void_p]),
395
+ ("cadaclysm_placement_geometry", c_uint32, [c_void_p, c_uint32]),
396
+ ("cadaclysm_placement_select", c_uint32, [c_void_p, c_uint32]),
397
+ ("cadaclysm_placement_transform", None, [c_void_p, c_uint32, POINTER(c_double)]),
398
+ ("cadaclysm_node_can_mesh", c_bool, [c_void_p, c_uint32]),
399
+ ("cadaclysm_node_mesh", _Mesh, [c_void_p, c_uint32]),
400
+ ("cadaclysm_node_surfaces", _Surfaces, [c_void_p, c_uint32]),
401
+ ("cadaclysm_node_brep", c_void_p, [c_void_p, c_uint32]),
402
+ ("cadaclysm_brep_release", None, [c_void_p]),
403
+ ("cadaclysm_brep_manifold", c_bool, [c_void_p, POINTER(c_uint32)]),
404
+ ("cadaclysm_brep_layout_id", c_char_p, []),
405
+ ("cadaclysm_surface_matrix", None, [c_void_p, POINTER(c_float)]),
406
+ ("cadaclysm_node_bounds", _Bounds, [c_void_p, c_uint32]),
407
+ ("cadaclysm_node_instance_of", c_uint32, [c_void_p, c_uint32]),
408
+ ("cadaclysm_node_select_as", c_uint32, [c_void_p, c_uint32]),
409
+ ("cadaclysm_node_generator", c_char_p, [c_void_p, c_uint32]),
410
+ ("cadaclysm_diagnostic_count", c_uint32, [c_void_p]),
411
+ ("cadaclysm_diagnostic", c_char_p, [c_void_p, c_uint32]),
412
+ ("cadaclysm_node_edges", _Polylines, [c_void_p, c_uint32]),
413
+ ("cadaclysm_node_curves", _Polylines, [c_void_p, c_uint32]),
414
+ ("cadaclysm_node_isocurves", _Polylines, [c_void_p, c_uint32]),
415
+ ("cadaclysm_realize_all", c_uint32, [c_void_p]),
416
+ ("cadaclysm_realized", c_uint32, [c_void_p]),
417
+ ("cadaclysm_realize_total", c_uint32, [c_void_p]),
418
+ ("cadaclysm_cancel", None, [c_void_p]),
419
+ ]
420
+
421
+
422
+ def _library_name() -> str:
423
+ suffix = {"Windows": ".dll", "Darwin": ".dylib"}.get(platform.system(), ".so")
424
+ return "cadaclysm_capi.dll" if suffix == ".dll" else "libcadaclysm_capi" + suffix
425
+
426
+
427
+ def library_path() -> Path:
428
+ """Where the shared library is, preferring a release build over a debug one.
429
+
430
+ `CADACLYSM_LIBRARY` first, so this file works dropped beside a script
431
+ anywhere; then next to this file; then a `lib/` directory in any ancestor
432
+ (the SDK layout); then a `target/release` (or `target/debug`) in any ancestor
433
+ (this repository's layout).
434
+ """
435
+ name = _library_name()
436
+ override = os.environ.get("CADACLYSM_LIBRARY")
437
+ if override:
438
+ candidate = Path(override)
439
+ # A directory or the library itself, since both are things to point at.
440
+ candidate = candidate / name if candidate.is_dir() else candidate
441
+ if candidate.exists():
442
+ return candidate
443
+ raise CadaclysmError(f"CADACLYSM_LIBRARY={override} names nothing that exists")
444
+
445
+ here = Path(__file__).resolve().parent
446
+ searched = [here / name]
447
+ # Walking up from this file: an SDK checkout keeps the library in `lib/` beside
448
+ # the wrappers; the repository this example ships in keeps it in `target/release`
449
+ # (or `target/debug`, a fallback for a machine that only built that).
450
+ for ancestor in [here, *here.parents]:
451
+ searched.append(ancestor / "lib" / name)
452
+ for ancestor in [here, *here.parents]:
453
+ searched += [ancestor / "target" / profile / name for profile in ("release", "debug")]
454
+ for candidate in searched:
455
+ if candidate.exists():
456
+ return candidate
457
+ raise CadaclysmError(
458
+ f"{name} not found. Looked in:\n"
459
+ + "".join(f" {c}\n" for c in searched)
460
+ + "Build it with:\n cargo build --release -p cadaclysm-capi\n"
461
+ "or run fetch.py in an SDK checkout, or point CADACLYSM_LIBRARY at it."
462
+ )
463
+
464
+
465
+ _library = None
466
+
467
+
468
+ def _lib() -> ctypes.CDLL:
469
+ """The loaded library, declared and cached.
470
+
471
+ Cached because `CDLL` on an already-loaded library is cheap but the
472
+ `argtypes` assignment below is not free, and because two `CDLL` objects
473
+ over one library would each re-declare the same function objects.
474
+ """
475
+ global _library
476
+ if _library is None:
477
+ path = library_path()
478
+ library = ctypes.CDLL(str(path))
479
+ for name, restype, argtypes in _ENTRY_POINTS:
480
+ # A library older than this file is the likeliest reason a symbol is
481
+ # missing, and `getattr` on a CDLL reports it as a bare AttributeError
482
+ # naming only the symbol -- which reads as a bug in this module rather
483
+ # than as a stale build. Measured: a library built before
484
+ # `cadaclysm_node_isocurves` was added dies here on the *first* ABI
485
+ # call of any kind, because every entry point is bound up front.
486
+ try:
487
+ function = getattr(library, name)
488
+ except AttributeError:
489
+ raise CadaclysmError(
490
+ f"{path} has no {name}: the library is older than this copy of "
491
+ f"cadaclysm.py, which declares {len(_ENTRY_POINTS)} entry points. "
492
+ "Rebuild it with `cargo build --release -p cadaclysm-capi`."
493
+ ) from None
494
+ function.restype, function.argtypes = restype, argtypes
495
+ _library = library
496
+ return _library
497
+
498
+
499
+ _numpy_module = None
500
+
501
+
502
+ def _numpy():
503
+ """`numpy`, imported on first use.
504
+
505
+ Deferred so that reading a file's tree, ids and attributes — which is most
506
+ of what an evaluation does — needs nothing outside the standard library.
507
+ Only `Mesh` and `Polylines` reach this.
508
+ """
509
+ global _numpy_module
510
+ if _numpy_module is None:
511
+ import numpy
512
+
513
+ _numpy_module = numpy
514
+ return _numpy_module
515
+
516
+
517
+ def _text(raw) -> str:
518
+ """A borrowed `char *` as a `str`. Null and empty both come back as `""`."""
519
+ return raw.decode("utf-8", "replace") if raw else ""
520
+
521
+
522
+ def _last_error() -> str:
523
+ return _text(_lib().cadaclysm_last_error())
524
+
525
+
526
+ def version() -> str:
527
+ """The version of the library actually loaded, which is the one worth reporting."""
528
+ return _text(_lib().cadaclysm_version())
529
+
530
+
531
+ def license(text_or_path) -> None:
532
+ """Load a license: the certificate text, or the path of a file holding it.
533
+
534
+ Without this the library looks in ``CADACLYSM_LICENSE``, then for
535
+ ``cadaclysm.lic`` beside the running executable and in the working
536
+ directory. Raises with the library's reason when the text does not verify;
537
+ the previous license, if any, stays in use.
538
+ """
539
+ text = os.fspath(text_or_path) if not isinstance(text_or_path, str) else text_or_path
540
+ if not _lib().cadaclysm_license_set(text.encode("utf-8")):
541
+ raise CadaclysmError(_last_error() or "license refused")
542
+
543
+
544
+ def license_info() -> str:
545
+ """One line about the license the library is running under.
546
+
547
+ Never null: the license line, e.g. ``"customer=Acme Ltd
548
+ expiry=2027-09-15 entitlements=import,kernel"``, or, without one,
549
+ ``"unlicensed"`` (``"unlicensed -- <reason>"`` when a license was found
550
+ but did not verify).
551
+ """
552
+ return _text(_lib().cadaclysm_license_info())
553
+
554
+
555
+ def license_notice_count() -> int:
556
+ """How many unlicensed notices this library has printed to stderr in this
557
+ process. An application without a stderr to watch (a GUI, a game) can
558
+ show its own banner by polling this instead."""
559
+ return int(_lib().cadaclysm_license_notice_count())
560
+
561
+
562
+ def build_date() -> str:
563
+ """When the loaded library was built, ``YYYY-MM-DD``; a paid license covers every build dated on or before its expiry."""
564
+ return _text(_lib().cadaclysm_build_date())
565
+
566
+
567
+ def mesh_formats() -> "list[tuple[str, str]]":
568
+ """Every format `Node.save_mesh` writes, as `(name, extension)`.
569
+
570
+ Ask rather than hard-code: a format added to the library turns up in a menu
571
+ built from this without the client being touched, which is the whole reason
572
+ the ABI enumerates them. The extension is carried because it is not
573
+ derivable -- `stl-ascii` writes a `.stl`.
574
+ """
575
+ library = _lib()
576
+ return [
577
+ (_text(library.cadaclysm_mesh_format(i)),
578
+ _text(library.cadaclysm_mesh_format_extension(i)))
579
+ for i in range(library.cadaclysm_mesh_format_count())
580
+ ]
581
+
582
+
583
+ def pick_file() -> "Path | None":
584
+ """Ask the user for a file to open, through the library's own dialog.
585
+
586
+ `None` if they cancelled — or if no dialog was available, which on Linux
587
+ means neither an XDG portal nor `zenity`. The ABI cannot tell those two
588
+ apart and neither can this, so a caller treats both as "no file", which is
589
+ the right answer either way.
590
+
591
+ The filters come from what this build can read, so a reader added to the
592
+ library turns up in the dialog without anything here being touched. That is
593
+ the same reason `mesh_formats` exists.
594
+
595
+ Blocks until the user acts. On macOS it must be called from the main thread.
596
+ """
597
+ raw = _lib().cadaclysm_pick_file(None)
598
+ if not raw:
599
+ return None
600
+ # Borrowed, and only until the next picker call on this thread — so it is
601
+ # copied into a `Path` here rather than held.
602
+ return Path(_text(raw))
603
+
604
+
605
+ # ---- borrowed memory, seen as numpy ---------------------------------------
606
+
607
+
608
+ class _Borrowed:
609
+ """One block of the scene's memory, exposed through the array interface.
610
+
611
+ Two properties come out of building views this way rather than with
612
+ `numpy.ctypeslib.as_array`, and both are load-bearing:
613
+
614
+ * `data` carries the read-only flag, so `numpy` marks the array
615
+ unwriteable and a stray assignment raises instead of scribbling on the
616
+ document's own vertex buffer.
617
+ * The array `numpy` builds holds this object as its `.base`, and this
618
+ object holds the scene — so no view can outlive the scene by having
619
+ merely dropped the last reference to it. An explicit `close()` still
620
+ invalidates every view, which is the documented sharp edge.
621
+ """
622
+
623
+ __slots__ = ("_scene", "__array_interface__")
624
+
625
+ def __init__(self, scene, pointer, shape, typestr):
626
+ self._scene = scene
627
+ self.__array_interface__ = {
628
+ "version": 3,
629
+ "data": (ctypes.cast(pointer, c_void_p).value, True),
630
+ "shape": shape,
631
+ "typestr": typestr,
632
+ }
633
+
634
+
635
+ def _view(scene, pointer, shape, dtype):
636
+ """A read-only numpy view of `shape` over borrowed memory, or None if null."""
637
+ if not pointer:
638
+ return None
639
+ numpy = _numpy()
640
+ return numpy.asarray(_Borrowed(scene, pointer, shape, numpy.dtype(dtype).str))
641
+
642
+
643
+ # ---- the values the ABI hands over ----------------------------------------
644
+
645
+
646
+ class Bounds:
647
+ """An axis-aligned box, or all zeros where there was nothing to bound."""
648
+
649
+ __slots__ = ("min", "max")
650
+
651
+ def __init__(self, low, high):
652
+ self.min = tuple(float(v) for v in low)
653
+ self.max = tuple(float(v) for v in high)
654
+
655
+ @property
656
+ def is_empty(self) -> bool:
657
+ """Whether this is the all-zero box the ABI uses for "nothing here"."""
658
+ return not any(self.min) and not any(self.max)
659
+
660
+ @property
661
+ def size(self):
662
+ return tuple(b - a for a, b in zip(self.min, self.max))
663
+
664
+ @property
665
+ def centre(self):
666
+ return tuple((a + b) / 2.0 for a, b in zip(self.min, self.max))
667
+
668
+ def __iter__(self):
669
+ """Unpacks as `low, high`, so `low, high = node.bounds` works."""
670
+ return iter((self.min, self.max))
671
+
672
+ def __repr__(self):
673
+ return f"Bounds(min={self.min}, max={self.max})"
674
+
675
+
676
+ def _decimal_text(value: float) -> str:
677
+ """A float written out the way cadaclysm's own Rust `Display` writes it.
678
+
679
+ Rust's `Display for f64` never switches to exponent notation, and prints
680
+ the shortest decimal that round-trips. Python's `repr` gives the same
681
+ digits but does switch — `1e16` and `1e-05` — so the digits are taken from
682
+ `repr` and spelled out through `Decimal`, which for a finite value is
683
+ exactly what the Go client's `strconv.FormatFloat(v, 'f', -1, 64)` does.
684
+ Without this a thickness of 1e-05 metres prints in a form no other client
685
+ shows. The infinities are where Go and Rust part company; this follows
686
+ Rust.
687
+ """
688
+ if value != value or value in (float("inf"), float("-inf")):
689
+ # Decimal("inf") formats as "Infinity"; Rust prints "inf" and "NaN".
690
+ return "NaN" if value != value else ("inf" if value > 0 else "-inf")
691
+ return format(decimal.Decimal(repr(value)).normalize(), "f")
692
+
693
+
694
+ class Attribute:
695
+ """One thing the file said about a node.
696
+
697
+ `value` is already the Python type the kind names: `str` for TEXT, LIST and
698
+ REFERENCE, `int` for INTEGER, `float` for REAL, `bool` for BOOLEAN, and
699
+ `None` for NONE. `kind` says which, for a caller that wants to tell a
700
+ reference from prose or total the numbers rather than print them.
701
+ """
702
+
703
+ __slots__ = ("name", "kind", "value")
704
+
705
+ def __init__(self, name, kind, value):
706
+ self.name = name
707
+ self.kind = kind
708
+ self.value = value
709
+
710
+ @property
711
+ def text(self) -> str:
712
+ """The value rendered for display, as cadaclysm's own Rust `Display` does.
713
+
714
+ Agrees exactly with what the Go, C# and Java clients print for every
715
+ finite value. The one divergence is the infinities: this prints `inf`
716
+ and `-inf`, which is what Rust writes, where Go's `FormatFloat` writes
717
+ `+Inf` and `-Inf`. Rust is the library's own rendering, so it wins.
718
+ """
719
+ if self.value is None:
720
+ return ""
721
+ if self.kind is ValueKind.REAL:
722
+ return _decimal_text(self.value)
723
+ if self.kind is ValueKind.BOOLEAN:
724
+ # Go's "%t" and C#'s bool.ToString() lowercased: "true"/"false",
725
+ # not Python's "True"/"False".
726
+ return "true" if self.value else "false"
727
+ return str(self.value)
728
+
729
+ def __repr__(self):
730
+ return f"Attribute(name={self.name!r}, kind={self.kind.name}, value={self.value!r})"
731
+
732
+
733
+ def _attribute(raw) -> "Attribute | None":
734
+ """A `CadaclysmAttribute` as an `Attribute`, or None for one past the end.
735
+
736
+ **The kind picks exactly one field to read.** The others are zero, so
737
+ reading the wrong one is silent: a text attribute read as `integer` gives
738
+ 0 for every node in the file and looks like data.
739
+ """
740
+ # Null specifically, as the Go client's `a.name == nil` tests. The header
741
+ # promises only "an all-zero one past the end", so an attribute the file
742
+ # genuinely named `""` is a real attribute and is kept — `not raw.name`
743
+ # would have thrown it away with the terminator.
744
+ if raw.name is None:
745
+ return None
746
+ kind = ValueKind(raw.kind) if raw.kind in _KINDS else ValueKind.NONE
747
+ if kind in (ValueKind.TEXT, ValueKind.LIST, ValueKind.REFERENCE):
748
+ value = _text(raw.text)
749
+ elif kind is ValueKind.INTEGER:
750
+ value = int(raw.integer)
751
+ elif kind is ValueKind.REAL:
752
+ value = float(raw.real)
753
+ elif kind is ValueKind.BOOLEAN:
754
+ value = bool(raw.boolean)
755
+ else:
756
+ value = None
757
+ return Attribute(_text(raw.name), kind, value)
758
+
759
+
760
+ _KINDS = frozenset(int(k) for k in ValueKind)
761
+
762
+
763
+ class Mesh:
764
+ """A node's triangles, in the node's own frame.
765
+
766
+ `positions` and `normals` are `(vertex_count, 3)` float32, `uvs` is
767
+ `(vertex_count, 2)` float32 and `indices` is `(index_count,)` uint32, three
768
+ to a triangle. All four are read-only views into the scene — see the module
769
+ docstring — and `normals` is None for a mesh that carries none.
770
+
771
+ `uvs` is None for a node whose reader produced none — which is most of
772
+ them unless the scene was opened with `UV_WORLD`; see that constant for the
773
+ one case that does not need it. One unit of `u` or `v` is one world unit,
774
+ so faces do not share an origin and their charts overlap: a tiling
775
+ material, not a lightmap.
776
+ """
777
+
778
+ __slots__ = ("positions", "normals", "uvs", "colors", "indices",
779
+ "vertex_count", "index_count")
780
+
781
+ def __init__(self, positions, normals, uvs, colors, indices, vertex_count, index_count):
782
+ self.positions = positions
783
+ self.normals = normals
784
+ self.uvs = uvs
785
+ #: `(vertex_count, 4)` float32 RGBA, or None -- which is the common
786
+ #: case. Only a body the file painted in more than one colour, opened
787
+ #: with `colors=True`, carries them; otherwise the node's own colour
788
+ #: says everything there is to say.
789
+ self.colors = colors
790
+ self.indices = indices
791
+ self.vertex_count = vertex_count
792
+ self.index_count = index_count
793
+
794
+ @property
795
+ def triangle_count(self) -> int:
796
+ return self.index_count // 3
797
+
798
+ def __bool__(self) -> bool:
799
+ """False for a node with no triangles, so `if node.mesh:` reads right.
800
+
801
+ A node drawn as a *curve* answers `can_mesh` and has an empty mesh,
802
+ having no surface to triangulate.
803
+ """
804
+ return self.index_count > 0 and self.positions is not None
805
+
806
+ def copy(self) -> "Mesh":
807
+ """The same triangles in memory of our own, safe to outlive the scene.
808
+
809
+ Expensive on purpose to be visible: this is where the gigabytes go on a
810
+ large assembly, and it should be a line a reader can point at.
811
+ """
812
+ return Mesh(
813
+ None if self.positions is None else self.positions.copy(),
814
+ None if self.normals is None else self.normals.copy(),
815
+ None if self.uvs is None else self.uvs.copy(),
816
+ None if self.colors is None else self.colors.copy(),
817
+ None if self.indices is None else self.indices.copy(),
818
+ self.vertex_count,
819
+ self.index_count,
820
+ )
821
+
822
+ def __repr__(self):
823
+ return f"Mesh(vertices={self.vertex_count}, triangles={self.triangle_count})"
824
+
825
+
826
+ class Polylines:
827
+ """A node's feature edges or free curves, already flattened to points.
828
+
829
+ `positions` is `(vertex_count, 3)` float32 with the runs end to end, and
830
+ `counts` is `(polyline_count,)` uint32 saying where each run stops. Both
831
+ are read-only views into the scene.
832
+ """
833
+
834
+ __slots__ = ("positions", "counts", "polyline_count", "vertex_count")
835
+
836
+ def __init__(self, positions, counts, polyline_count, vertex_count):
837
+ self.positions = positions
838
+ self.counts = counts
839
+ self.polyline_count = polyline_count
840
+ self.vertex_count = vertex_count
841
+
842
+ def __bool__(self) -> bool:
843
+ return self.polyline_count > 0 and self.positions is not None
844
+
845
+ def segment_indices(self):
846
+ """Indices into `positions` making line-segment endpoint pairs.
847
+
848
+ `GL_LINES` and every other pair-taking API want two endpoints per
849
+ segment, while the ABI hands over runs: a polyline of n points is n - 1
850
+ segments, so each interior point is named twice. Handing back indices
851
+ rather than points lets a caller transform the `vertex_count` positions
852
+ once and expand afterwards, instead of transforming the roughly twice
853
+ as many expanded endpoints.
854
+
855
+ Vectorised, because a real assembly has millions of these: `ufi.stp`
856
+ alone carries 4.7M segments, and a Python loop over them costs more
857
+ than reading the 224 MB file did.
858
+ """
859
+ numpy = _numpy()
860
+ empty = numpy.empty(0, numpy.int64)
861
+ if not self:
862
+ return empty
863
+ counts = self.counts
864
+ # Where each run starts, and which runs are long enough to have a
865
+ # segment at all: a one-point run is a point, not a line.
866
+ starts = numpy.concatenate(([0], numpy.cumsum(counts[:-1], dtype=numpy.int64)))
867
+ keep = counts >= 2
868
+ if not keep.any():
869
+ return empty
870
+ lengths = counts[keep].astype(numpy.int64) - 1
871
+ # Within each kept run emit 0,1 1,2 2,3 ...: `first` repeats the run's
872
+ # start once per segment and `step` counts 0..n-2 within the run,
873
+ # built for the whole set at once rather than per polyline.
874
+ first = numpy.repeat(starts[keep], lengths)
875
+ step = numpy.arange(len(first)) - numpy.repeat(
876
+ numpy.concatenate(([0], numpy.cumsum(lengths[:-1]))), lengths
877
+ )
878
+ a = first + step
879
+ pairs = numpy.empty(len(a) * 2, numpy.int64)
880
+ pairs[0::2], pairs[1::2] = a, a + 1
881
+ return pairs
882
+
883
+ def segments(self):
884
+ """The endpoint pairs themselves, `(2 * segment_count, 3)` in the node's own frame."""
885
+ return self.positions[self.segment_indices()]
886
+
887
+ def __repr__(self):
888
+ return f"Polylines(polylines={self.polyline_count}, vertices={self.vertex_count})"
889
+
890
+
891
+ # ---- placements -----------------------------------------------------------
892
+
893
+
894
+
895
+
896
+ class Face:
897
+ """One trimmed face: the surface itself, plus the loops that cut it.
898
+
899
+ `kind` is 0 plane, 1 cylinder, 2 cone, 3 sphere, 4 torus, 5 revolution, 6 extrusion,
900
+ 7 NURBS, 8 sum. `origin`, `ax`, `ay`, `az` are the frame; `scalars` is kind-dependent;
901
+ `domain` is `(u_min, v_min, u_max, v_max)`. `loops` is a list of `(N, 2)` arrays of
902
+ `(u, v)`, each closing implicitly, and `profile` and `nurbs` carry what a swept or
903
+ NURBS surface needs. See `CadaclysmFace` in the header for the whole story.
904
+ """
905
+
906
+ __slots__ = ("kind", "reversed", "transposed", "origin", "ax", "ay", "az",
907
+ "domain", "scalars", "loops", "profile", "profile2", "nurbs")
908
+
909
+ def __init__(self, **fields):
910
+ for name, value in fields.items():
911
+ setattr(self, name, value)
912
+
913
+ def __repr__(self):
914
+ names = ("plane", "cylinder", "cone", "sphere", "torus", "revolution",
915
+ "extrusion", "nurbs", "sum")
916
+ kind = names[self.kind] if self.kind < len(names) else self.kind
917
+ return f"Face({kind}, {len(self.loops)} loops)"
918
+
919
+
920
+ class Surfaces:
921
+ """A part's faces as surfaces and trims, and the arrays they share.
922
+
923
+ Iterate it for `Face` objects. Everything here is **in the file's own frame**, unlike
924
+ every other product this module hands back -- see `Scene.surface_matrix`.
925
+ """
926
+
927
+ __slots__ = ("faces",)
928
+
929
+ def __init__(self, faces):
930
+ self.faces = faces
931
+
932
+ def __bool__(self) -> bool:
933
+ return bool(self.faces)
934
+
935
+ def __len__(self) -> int:
936
+ return len(self.faces)
937
+
938
+ def __iter__(self):
939
+ return iter(self.faces)
940
+
941
+ def __repr__(self):
942
+ return f"Surfaces({len(self.faces)} faces)"
943
+
944
+
945
+ class Placement:
946
+ """One drawing of one node's geometry, at one place.
947
+
948
+ **A node is not a drawing, and the difference is a bug this library shipped.**
949
+ Most nodes are structure and draw nothing; a node that places a block draws
950
+ everything inside that block; and a block's members draw once per placement of
951
+ it rather than once on their own account. A viewer that walks nodes and asks
952
+ each for a mesh draws a Rhino block's contents once, at the definition's own
953
+ frame, and every placement of it not at all -- which is what `instances.3dm`
954
+ looked like here: one tube where the file has six.
955
+
956
+ So iterate `scene.placements` to draw, and nodes to build a tree. A handle
957
+ rather than a snapshot, like `Node`, so nothing here goes stale.
958
+ """
959
+
960
+ __slots__ = ("scene", "index")
961
+
962
+ def __init__(self, scene: "Scene", index: int):
963
+ self.scene = scene
964
+ self.index = index
965
+
966
+ @property
967
+ def geometry(self) -> "Node":
968
+ """The node whose mesh, edges and curves this draws.
969
+
970
+ Two drawings of one shape name the same node and so hand back the same
971
+ arrays -- which is what lets a caller upload it once and draw it twice.
972
+ """
973
+ return Node(self.scene,
974
+ _lib().cadaclysm_placement_geometry(self.scene._handle, self.index))
975
+
976
+ @property
977
+ def select(self) -> "Node":
978
+ """What a click on this drawing should select.
979
+
980
+ The placement rather than the shape it draws: the shape is somewhere else
981
+ and is shared with every sibling copy, so selecting it would light them
982
+ all up.
983
+ """
984
+ return Node(self.scene,
985
+ _lib().cadaclysm_placement_select(self.scene._handle, self.index))
986
+
987
+ @property
988
+ def transform(self):
989
+ """Where to draw it, as a 4x4 float64 numpy array.
990
+
991
+ Already composed through every frame between the document's root and this
992
+ drawing, so nothing is multiplied here. Transposed into numpy's row-major
993
+ convention for the reason `Node.transform` gives, and `raw_transform`
994
+ keeps the ABI's own order.
995
+ """
996
+ numpy = _numpy()
997
+ return numpy.array(self.raw_transform, numpy.float64).reshape(4, 4).T
998
+
999
+ @property
1000
+ def raw_transform(self):
1001
+ """The same matrix in the ABI's own column-major order, as 16 floats."""
1002
+ out = (c_double * 16)()
1003
+ _lib().cadaclysm_placement_transform(self.scene._handle, self.index, out)
1004
+ return tuple(out)
1005
+
1006
+ def __repr__(self):
1007
+ return f"Placement(index={self.index}, geometry={self.geometry.index})"
1008
+
1009
+
1010
+ # ---- nodes ----------------------------------------------------------------
1011
+
1012
+
1013
+ class Brep:
1014
+ """A body's exact B-rep -- the trimmed surfaces its mesh is cut from --
1015
+ shared with the scene rather than copied: a reference of this object's own,
1016
+ given back by `release()` (or leaving a `with` block, or the collector).
1017
+
1018
+ It is for the blacksmith library, which operates on it without a copy
1019
+ (`cadaclysm_blacksmith.Solid.from_node`) -- `pointer` and `layout_id` are
1020
+ what that hands across -- and for asking whether it is a manifold
1021
+ (`manifold`). The brep outlives the scene it came from for as long as
1022
+ anything holds it.
1023
+
1024
+ In the node's own frame and **the file's own units and axes**, whatever
1025
+ convention the scene was opened with. The blacksmith library must come
1026
+ from the same release as this one; it checks `layout_id` and refuses
1027
+ otherwise.
1028
+ """
1029
+
1030
+ __slots__ = ("_pointer", "__weakref__")
1031
+
1032
+ def __init__(self, pointer: int):
1033
+ self._pointer = pointer
1034
+
1035
+ def __enter__(self):
1036
+ return self
1037
+
1038
+ def __exit__(self, *_):
1039
+ self.release()
1040
+
1041
+ def __del__(self):
1042
+ self.release()
1043
+
1044
+ @property
1045
+ def pointer(self) -> int:
1046
+ if not self._pointer:
1047
+ raise CadaclysmError("brep: released")
1048
+ return self._pointer
1049
+
1050
+ @staticmethod
1051
+ def layout_id() -> str:
1052
+ """How this library lays a brep out in memory: its compiler, target and
1053
+ source. The blacksmith library shares a brep only with a library whose
1054
+ id equals its own."""
1055
+ return _text(_lib().cadaclysm_brep_layout_id())
1056
+
1057
+ @property
1058
+ def manifold(self) -> "Manifold":
1059
+ """Whether its faces make a manifold -- every edge bordered by one face
1060
+ or two, the faces round every vertex one fan -- and whether it is
1061
+ closed, as a `Manifold` record. Read off the topology the file wrote,
1062
+ not a mesh: faces that name no shared edge (IGES, each surface its own
1063
+ sheet; an IFC face written as one polygon) read as open however well
1064
+ they meet in space."""
1065
+ out = (c_uint32 * 8)()
1066
+ if not _lib().cadaclysm_brep_manifold(self.pointer, out):
1067
+ raise CadaclysmError(_text(_lib().cadaclysm_last_error()) or "manifold")
1068
+ return Manifold(tuple(out))
1069
+
1070
+ def release(self) -> None:
1071
+ pointer, self._pointer = getattr(self, "_pointer", None), None
1072
+ if pointer and _library is not None:
1073
+ _library.cadaclysm_brep_release(pointer)
1074
+
1075
+
1076
+ class Manifold:
1077
+ """Whether a brep's faces make a manifold, as plain data (`Brep.manifold`):
1078
+ its faces, edges and vertices; the edges one face borders (a sheet's rim),
1079
+ the edges three or more do, and the vertices whose faces make more than one
1080
+ fan (two solids touching at a corner); `is_manifold` where there are none of
1081
+ the last two, and `is_closed` where there is no boundary edge either -- it
1082
+ encloses a solid."""
1083
+
1084
+ __slots__ = ("faces", "edges", "vertices", "boundary_edges", "non_manifold_edges", "non_manifold_vertices",
1085
+ "is_manifold", "is_closed")
1086
+
1087
+ def __init__(self, row):
1088
+ (self.faces, self.edges, self.vertices, self.boundary_edges, self.non_manifold_edges,
1089
+ self.non_manifold_vertices) = (int(v) for v in row[:6])
1090
+ self.is_manifold, self.is_closed = bool(row[6]), bool(row[7])
1091
+
1092
+ def __repr__(self):
1093
+ return (f"Manifold(faces={self.faces}, edges={self.edges}, vertices={self.vertices}, "
1094
+ f"boundary_edges={self.boundary_edges}, non_manifold_edges={self.non_manifold_edges}, "
1095
+ f"non_manifold_vertices={self.non_manifold_vertices}, is_manifold={self.is_manifold}, "
1096
+ f"is_closed={self.is_closed})")
1097
+
1098
+
1099
+ class Node:
1100
+ """One node of the document: an assembly, a shape, a placement.
1101
+
1102
+ A handle rather than a snapshot — every property below asks the scene when
1103
+ you ask it, so nothing here goes stale and nothing is read that a caller
1104
+ never looks at. That matters: `bounds` and `mesh` *build* the geometry, and
1105
+ a tree of ten thousand nodes should cost ten thousand names, not ten
1106
+ thousand tessellations.
1107
+ """
1108
+
1109
+ __slots__ = ("scene", "index")
1110
+
1111
+ def __init__(self, scene: "Scene", index: int):
1112
+ self.scene = scene
1113
+ self.index = index
1114
+
1115
+ # Identity is the pair, so a node from one lookup equals the same node from
1116
+ # another and can key a dict of, say, what the viewer has uploaded.
1117
+ def __eq__(self, other):
1118
+ return (
1119
+ isinstance(other, Node)
1120
+ and other.index == self.index
1121
+ and other.scene is self.scene
1122
+ )
1123
+
1124
+ def __hash__(self):
1125
+ return hash((id(self.scene), self.index))
1126
+
1127
+ def __repr__(self):
1128
+ return f"<Node {self.index} {self.name or self.kind or '?'}>"
1129
+
1130
+ @property
1131
+ def name(self) -> str:
1132
+ return _text(_lib().cadaclysm_node_name(self.scene._handle, self.index))
1133
+
1134
+ @property
1135
+ def id(self) -> str:
1136
+ """What the file calls it — a STEP `#N`, an IFC GlobalId, a Rhino UUID.
1137
+
1138
+ Text rather than a number because that is what the formats carry: a
1139
+ 22-character GlobalId does not fit in an integer.
1140
+ """
1141
+ return _text(_lib().cadaclysm_node_id(self.scene._handle, self.index))
1142
+
1143
+ @property
1144
+ def kind(self) -> str:
1145
+ """What the file calls it — an IFC type, an openNURBS class, a shape kind."""
1146
+ return _text(_lib().cadaclysm_node_kind(self.scene._handle, self.index))
1147
+
1148
+ @property
1149
+ def visible(self) -> bool:
1150
+ """Whether the file says to show this when it is opened.
1151
+
1152
+ **The file's opening state, and not inherited.** A Rhino layer is a node of
1153
+ its own carrying its own switch, and its members carry theirs; hiding a
1154
+ subtree means walking it. `visible_now` does that walk.
1155
+
1156
+ `True` where the format says nothing, which is most of them -- so a `False`
1157
+ is always something the file actually said.
1158
+ """
1159
+ return bool(_lib().cadaclysm_node_visible(self.scene._handle, self.index))
1160
+
1161
+ @property
1162
+ def visible_now(self) -> bool:
1163
+ """`visible`, but with every ancestor consulted.
1164
+
1165
+ A layer switched off hides what hangs under it however the members' own
1166
+ switches are set, which is what Rhino shows and what `visible` alone does
1167
+ not say.
1168
+ """
1169
+ node = self
1170
+ while node is not None:
1171
+ if not node.visible:
1172
+ return False
1173
+ node = node.parent
1174
+ return True
1175
+
1176
+ @property
1177
+ def locked(self) -> bool:
1178
+ """Whether the file says this cannot be selected or edited.
1179
+
1180
+ Rhino's idea, so it rides as a property rather than a field: an object is
1181
+ locked by its own flag or by its layer's, and the reader has already
1182
+ combined the two. Formats without the concept answer `False`.
1183
+
1184
+ Locking is not hiding. A locked thing is drawn exactly as any other and only
1185
+ refuses to be picked.
1186
+ """
1187
+ for attribute in self.attributes:
1188
+ if attribute.name == "Locked":
1189
+ return bool(attribute.value)
1190
+ return False
1191
+
1192
+ @property
1193
+ def label(self) -> str:
1194
+ """Something to put in a tree row: the name, else the kind, else `#index`."""
1195
+ return self.name or self.kind or f"#{self.index}"
1196
+
1197
+ @property
1198
+ def depth(self) -> int:
1199
+ """How far down the tree it sits, a root being zero. For indenting."""
1200
+ return _lib().cadaclysm_node_depth(self.scene._handle, self.index)
1201
+
1202
+ @property
1203
+ def generator(self) -> str:
1204
+ """What its geometry was before it was triangles — `brep`, `mesh`, `csg`.
1205
+
1206
+ Empty for a node that draws nothing, there being no geometry to have
1207
+ come from anything.
1208
+ """
1209
+ return _text(_lib().cadaclysm_node_generator(self.scene._handle, self.index))
1210
+
1211
+ @property
1212
+ def parent(self) -> "Node | None":
1213
+ """The node containing this one, or None for a root."""
1214
+ return self.scene._node_or_none(
1215
+ _lib().cadaclysm_node_parent(self.scene._handle, self.index)
1216
+ )
1217
+
1218
+ @property
1219
+ def children(self) -> "list[Node]":
1220
+ library, handle = _lib(), self.scene._handle
1221
+ count = library.cadaclysm_node_child_count(handle, self.index)
1222
+ return [
1223
+ Node(self.scene, library.cadaclysm_node_child(handle, self.index, i))
1224
+ for i in range(count)
1225
+ ]
1226
+
1227
+ @property
1228
+ def instance_of(self) -> "Node | None":
1229
+ """The node whose geometry this one is a placement of, or None.
1230
+
1231
+ The point of meshes coming over in their own frame: a shell placed
1232
+ seventy-four times is one mesh and seventy-four transforms, and this is
1233
+ how a caller knows to upload the buffer once.
1234
+ """
1235
+ return self.scene._node_or_none(
1236
+ _lib().cadaclysm_node_instance_of(self.scene._handle, self.index)
1237
+ )
1238
+
1239
+ @property
1240
+ def select_as(self) -> "Node":
1241
+ """What a click on this node's geometry should select — itself, usually.
1242
+
1243
+ A format that hangs geometry on a child of the object it belongs to
1244
+ (IFC: a representation item under its product) points the child back at
1245
+ the object.
1246
+ """
1247
+ chosen = _lib().cadaclysm_node_select_as(self.scene._handle, self.index)
1248
+ return self if chosen == NONE else Node(self.scene, chosen)
1249
+
1250
+ @property
1251
+ def attributes(self) -> "list[Attribute]":
1252
+ """Everything the file said about this node."""
1253
+ library, handle = _lib(), self.scene._handle
1254
+ count = library.cadaclysm_node_attribute_count(handle, self.index)
1255
+ out = []
1256
+ for i in range(count):
1257
+ attribute = _attribute(library.cadaclysm_node_attribute(handle, self.index, i))
1258
+ if attribute is not None:
1259
+ out.append(attribute)
1260
+ return out
1261
+
1262
+ @property
1263
+ def can_mesh(self) -> bool:
1264
+ """Whether this node is drawn — whether it has geometry of its own to show.
1265
+
1266
+ Asks for nothing to be built. Most nodes of a model are structure — an
1267
+ assembly, a storey, a layer — and answer False.
1268
+ """
1269
+ return _lib().cadaclysm_node_can_mesh(self.scene._handle, self.index)
1270
+
1271
+ def save_mesh(self, path, fmt: str = "stl") -> None:
1272
+ """Write this node's mesh to `path` in `fmt`.
1273
+
1274
+ `fmt` is one of `mesh_formats()`. Raises `CadaclysmError` if the node
1275
+ draws nothing, which most of them do -- an assembly, a storey, a layer
1276
+ -- or if the format is not one the library writes. Ask `can_mesh` first
1277
+ if a menu should grey the row out rather than let the click fail.
1278
+
1279
+ The mesh written is this node's own, where it is defined and without its
1280
+ placement, so a node instanced six times writes one file wherever it is
1281
+ asked from.
1282
+ """
1283
+ ok = _lib().cadaclysm_node_save_mesh(
1284
+ self.scene._handle, self.index, str(path).encode(), fmt.encode()
1285
+ )
1286
+ if not ok:
1287
+ raise CadaclysmError(_last_error() or f"could not write {path}")
1288
+
1289
+ @property
1290
+ def colour(self):
1291
+ """`(r, g, b, a)` if the file gave one, else None.
1292
+
1293
+ None rather than a default: most STEP files carry no colour at all, and
1294
+ the honest answer lets the caller use its own.
1295
+ """
1296
+ rgba = (c_float * 4)()
1297
+ if not _lib().cadaclysm_node_color(self.scene._handle, self.index, rgba):
1298
+ return None
1299
+ return tuple(float(v) for v in rgba)
1300
+
1301
+ @property
1302
+ def transform(self):
1303
+ """Where this node's geometry sits, as a 4x4 float64 numpy array.
1304
+
1305
+ The ABI writes it column-major, as OpenGL and every engine do; this
1306
+ transposes it into the row-major convention numpy and the textbooks
1307
+ use, so `M[:3, :3]` is the rotation and scale block and `M[:3, 3]` is
1308
+ the offset. Feeding it back to a GL uniform therefore wants
1309
+ `M.T.astype("f4")` — or the ABI's own order, which `raw_transform`
1310
+ keeps.
1311
+
1312
+ Doubles, while the mesh is floats, on purpose: a building at UTM
1313
+ coordinates baked into f32 world positions loses millimetres, where an
1314
+ f32 mesh about its own origin under an f64 transform does not.
1315
+ """
1316
+ numpy = _numpy()
1317
+ return numpy.array(self.raw_transform, numpy.float64).reshape(4, 4).T
1318
+
1319
+ @property
1320
+ def raw_transform(self):
1321
+ """The same matrix in the ABI's own column-major order, as 16 floats."""
1322
+ out = (c_double * 16)()
1323
+ _lib().cadaclysm_node_transform(self.scene._handle, self.index, out)
1324
+ return tuple(out)
1325
+
1326
+ @property
1327
+ def bounds(self) -> Bounds:
1328
+ """The extent of the geometry this node draws, **in that geometry's own frame**.
1329
+
1330
+ Builds the geometry if it has not been built. Carry it through
1331
+ `transform` for world coordinates, exactly as with the mesh it bounds.
1332
+ """
1333
+ raw = _lib().cadaclysm_node_bounds(self.scene._handle, self.index)
1334
+ return Bounds(raw.min, raw.max)
1335
+
1336
+ @property
1337
+ def mesh(self) -> Mesh:
1338
+ """Its triangles, in their own frame, built now if they have not been.
1339
+
1340
+ Where the node instances another these are the instanced node's
1341
+ triangles in the instanced node's frame, so two occurrences of one
1342
+ shape hand back the *same* arrays and two different transforms. Read
1343
+ the module docstring on what these views may not outlive.
1344
+ """
1345
+ raw = _lib().cadaclysm_node_mesh(self.scene._handle, self.index)
1346
+ n = raw.vertex_count
1347
+ return Mesh(
1348
+ _view(self.scene, raw.positions, (n, 3), "float32"),
1349
+ _view(self.scene, raw.normals, (n, 3), "float32"),
1350
+ # Two floats a vertex, not three: `uvs` holds `vertex_count * 2`.
1351
+ _view(self.scene, raw.uvs, (n, 2), "float32"),
1352
+ # Four floats a vertex: `colors` holds `vertex_count * 4`, RGBA.
1353
+ _view(self.scene, raw.colors, (n, 4), "float32"),
1354
+ _view(self.scene, raw.indices, (raw.index_count,), "uint32"),
1355
+ n,
1356
+ raw.index_count,
1357
+ )
1358
+
1359
+ @property
1360
+ def surfaces(self) -> Surfaces:
1361
+ """Its faces as surfaces and trim loops, where the reader built them.
1362
+
1363
+ The parametric product: each face is the surface it sits on plus the loops that
1364
+ cut it, both in that surface's own (u, v). Nothing here was meshed, and nothing
1365
+ here costs `Node.mesh` anything -- a body carries both descriptions and builds
1366
+ whichever is asked for, so a document can be looked at either way, or both, with
1367
+ no decision taken when it was opened. Empty where the reader has no parametric
1368
+ read of this body (a tessellated face set, a boolean) or of this format.
1369
+ """
1370
+ import numpy as np
1371
+
1372
+ raw = _lib().cadaclysm_node_surfaces(self.scene._handle, self.index)
1373
+ if not raw.face_count:
1374
+ return Surfaces([])
1375
+ loops = _view(self.scene, raw.loops, (raw.loop_count, 2), "uint32")
1376
+ points = _view(self.scene, raw.points, (raw.point_count, 2), "float32")
1377
+ profiles = _view(self.scene, raw.profiles, (raw.profile_count, 4), "float32")
1378
+ nurbs = _view(self.scene, raw.nurbs, (raw.nurbs_count,), "float32")
1379
+
1380
+ out = []
1381
+ for i in range(raw.face_count):
1382
+ f = raw.faces[i]
1383
+ rings = []
1384
+ for k in range(f.loop_count):
1385
+ start, length = loops[f.loop_start + k]
1386
+ rings.append(points[start:start + length])
1387
+ out.append(Face(
1388
+ kind=f.kind,
1389
+ reversed=bool(f.reversed),
1390
+ transposed=bool(f.transposed),
1391
+ origin=np.array(f.origin[:3]),
1392
+ ax=np.array(f.ax[:3]),
1393
+ ay=np.array(f.ay[:3]),
1394
+ az=np.array(f.az[:3]),
1395
+ domain=np.array(f.domain[:]),
1396
+ scalars=np.array(f.scalars[:]),
1397
+ loops=rings,
1398
+ profile=profiles[f.profile_start:f.profile_start + f.profile_count],
1399
+ profile2=profiles[f.profile2_start:f.profile2_start + f.profile2_count],
1400
+ nurbs=nurbs[f.nurbs_start:f.nurbs_start + f.nurbs_count],
1401
+ ))
1402
+ return Surfaces(out)
1403
+
1404
+ @property
1405
+ def brep(self) -> "Brep | None":
1406
+ """Its exact B-rep, for `cadaclysm_blacksmith.Solid.from_node` to operate on,
1407
+ or `None` where it has none (a mesh, a curve, a CSG body, a JT or OpenSCAD
1408
+ part). Shared with the scene, not copied; see `Brep`."""
1409
+ pointer = _lib().cadaclysm_node_brep(self.scene._handle, self.index)
1410
+ return Brep(pointer) if pointer else None
1411
+
1412
+ @property
1413
+ def edges(self) -> Polylines:
1414
+ """Its feature edges, as polylines to draw an overlay from."""
1415
+ return self._polylines(_lib().cadaclysm_node_edges)
1416
+
1417
+ @property
1418
+ def curves(self) -> Polylines:
1419
+ """Its free curves, as polylines. A 2D drawing is all of these."""
1420
+ return self._polylines(_lib().cadaclysm_node_curves)
1421
+
1422
+ @property
1423
+ def isocurves(self) -> Polylines:
1424
+ """Its interior surface lines, as polylines.
1425
+
1426
+ Distinct from `edges`: those bound the faces, these rule across them, so
1427
+ a curved face reads as curved rather than as a flat patch. A flat face
1428
+ still yields its outline here rather than nothing, which is why the two
1429
+ can overlap.
1430
+ """
1431
+ return self._polylines(_lib().cadaclysm_node_isocurves)
1432
+
1433
+ def _polylines(self, function) -> Polylines:
1434
+ raw = function(self.scene._handle, self.index)
1435
+ return Polylines(
1436
+ _view(self.scene, raw.positions, (raw.vertex_count, 3), "float32"),
1437
+ _view(self.scene, raw.counts, (raw.polyline_count,), "uint32"),
1438
+ raw.polyline_count,
1439
+ raw.vertex_count,
1440
+ )
1441
+
1442
+ def walk(self):
1443
+ """This node and every node under it, parents before children."""
1444
+ stack = [self]
1445
+ while stack:
1446
+ node = stack.pop()
1447
+ yield node
1448
+ stack.extend(reversed(node.children))
1449
+
1450
+
1451
+ # ---- the scene ------------------------------------------------------------
1452
+
1453
+
1454
+ class Scene:
1455
+ """An open document. Close it when done, or use it as a context manager.
1456
+
1457
+ Everything it hands back borrows from it — see the module docstring.
1458
+ """
1459
+
1460
+ __slots__ = ("_pointer", "path", "schema_path", "convention", "__weakref__")
1461
+
1462
+ def __init__(self, pointer: int, path: Path, schema_path, convention=0):
1463
+ self._pointer = pointer
1464
+ #: The file this was read from.
1465
+ self.path = path
1466
+ #: The `.exp` actually used, or None. Worth reporting when `open` was
1467
+ #: given a directory and chose from it.
1468
+ self.schema_path = schema_path
1469
+ #: The packed `uint32` this was opened with — a `Convention` OR'd with
1470
+ #: `FILE_UNITS` and `UV_WORLD`. Kept because nothing the ABI hands back
1471
+ #: says what space it is in, and every array out of this scene is in
1472
+ #: this one.
1473
+ self.convention = convention
1474
+
1475
+ # -- lifetime --
1476
+
1477
+ @property
1478
+ def _handle(self) -> int:
1479
+ """The raw handle, refusing to hand over a closed one.
1480
+
1481
+ Every call in this module goes through here rather than touching
1482
+ `_pointer`, so a use-after-close raises a Python exception at the call
1483
+ site instead of passing a dangling pointer into the library.
1484
+ """
1485
+ if self._pointer is None:
1486
+ raise CadaclysmError(f"{self.path.name}: the scene is closed")
1487
+ return self._pointer
1488
+
1489
+ @property
1490
+ def surface_matrix(self):
1491
+ """The 4x4 that puts `Node.surfaces` in the space everything else is already in.
1492
+
1493
+ Only the surfaces need it. Meshes, polylines and Bezier curves arrive in the
1494
+ convention the document was opened with; a surface does not, because converting
1495
+ one means converting its parameter space too -- a cylinder's `v` is a length and
1496
+ scales, a sphere's is an angle and does not -- and getting that wrong slides the
1497
+ trim loops off the face they trim. For a document opened NATIVE at the file's own
1498
+ units this is the identity.
1499
+ """
1500
+ import numpy as np
1501
+
1502
+ out = (c_float * 16)()
1503
+ _lib().cadaclysm_surface_matrix(self._handle, out)
1504
+ # Column-major from the library, as OpenGL and the header both say.
1505
+ return np.array(out, dtype="f8").reshape(4, 4, order="F")
1506
+
1507
+ @property
1508
+ def closed(self) -> bool:
1509
+ return self._pointer is None
1510
+
1511
+ def close(self) -> None:
1512
+ """Give the scene back. Idempotent.
1513
+
1514
+ Every borrowed array — every `Mesh` and `Polylines` view still in
1515
+ Python's hands — is reading freed memory afterwards.
1516
+ """
1517
+ if self._pointer is not None:
1518
+ pointer, self._pointer = self._pointer, None
1519
+ _lib().cadaclysm_close(pointer)
1520
+
1521
+ def __enter__(self) -> "Scene":
1522
+ return self
1523
+
1524
+ def __exit__(self, *exception) -> None:
1525
+ self.close()
1526
+
1527
+ def __del__(self):
1528
+ # Only reached once nothing refers to the scene, and a borrowed view
1529
+ # refers to it through its `.base` — so this cannot pull memory out
1530
+ # from under an array that is still alive.
1531
+ try:
1532
+ self.close()
1533
+ except Exception:
1534
+ pass
1535
+
1536
+ def __repr__(self):
1537
+ state = "closed" if self.closed else f"{len(self)} nodes"
1538
+ return f"<Scene {self.path.name} ({state})>"
1539
+
1540
+ # -- the file --
1541
+
1542
+ @property
1543
+ def version(self) -> str:
1544
+ """The version of the library that read it."""
1545
+ return version()
1546
+
1547
+ @property
1548
+ def schema(self) -> str:
1549
+ """The schema the file named, or `""` for a format that names none."""
1550
+ return _text(_lib().cadaclysm_schema(self._handle))
1551
+
1552
+ @property
1553
+ def schema_read(self) -> str:
1554
+ """The schema that actually read it, which is not always the one it named.
1555
+
1556
+ A file declaring `IFC4X3_RC2` reads under `IFC4X3_ADD2` where that is what is
1557
+ registered -- a release candidate and the finished schema of the same version
1558
+ are the same schema. A file whose declared schema nobody registered reads
1559
+ under whichever registered one defines the entity types it contains.
1560
+
1561
+ `schema` keeps saying what the file said, so the two differ exactly when a
1562
+ substitution happened -- see `substituted`.
1563
+ """
1564
+ return _text(_lib().cadaclysm_schema_read(self._handle))
1565
+
1566
+ @property
1567
+ def substituted(self) -> bool:
1568
+ """Whether something other than the file's own schema read it.
1569
+
1570
+ Compared on the *bare* names. A `FILE_SCHEMA` entry may carry a formal
1571
+ identifier -- `AUTOMOTIVE_DESIGN { 1 2 10303 214 0 1 1 1 }` -- and the library
1572
+ matches on the text before the braces, so comparing the whole entry calls
1573
+ every AP214 file substituted when nothing was substituted at all.
1574
+ """
1575
+ read = self.schema_read
1576
+ if not read:
1577
+ return False
1578
+ bare = lambda entry: entry.split("{")[0].strip().strip(".").casefold()
1579
+ return bare(read) not in (bare(part) for part in self.schema.split(","))
1580
+
1581
+ @property
1582
+ def metres_per_unit(self) -> float:
1583
+ """What one length in the file is worth in metres, or 1 where it did not say."""
1584
+ return _lib().cadaclysm_metres_per_unit(self._handle)
1585
+
1586
+ @property
1587
+ def bounds(self) -> Bounds:
1588
+ """Everything the model covers, **in world coordinates**.
1589
+
1590
+ The one figure here not in a node's own frame. **This meshes all of
1591
+ it**, being the only way to know how far it reaches; a caller that has
1592
+ not the time should frame from the nodes it has built.
1593
+ """
1594
+ raw = _lib().cadaclysm_bounds(self._handle)
1595
+ return Bounds(raw.min, raw.max)
1596
+
1597
+ @property
1598
+ def diagnostics(self) -> "list[str]":
1599
+ """What this file held that the reader could not build."""
1600
+ library, handle = _lib(), self._handle
1601
+ return [
1602
+ _text(library.cadaclysm_diagnostic(handle, i))
1603
+ for i in range(library.cadaclysm_diagnostic_count(handle))
1604
+ ]
1605
+
1606
+ @property
1607
+ def source_name(self) -> "str | None":
1608
+ """The archive member this was read from, or None for a plain file.
1609
+
1610
+ `open` on a `.zip` chose one member -- the shallowest it could read --
1611
+ and this is the only way to learn which.
1612
+ """
1613
+ raw = _lib().cadaclysm_source_name(self._handle)
1614
+ return _text(raw) if raw else None
1615
+
1616
+ # -- nodes --
1617
+
1618
+ def __len__(self) -> int:
1619
+ """How many nodes it has, geometry or not."""
1620
+ return _lib().cadaclysm_node_count(self._handle)
1621
+
1622
+ def __getitem__(self, index: int) -> Node:
1623
+ count = len(self)
1624
+ if index < 0:
1625
+ index += count
1626
+ if not 0 <= index < count:
1627
+ raise IndexError(f"node {index} of {count}")
1628
+ return Node(self, index)
1629
+
1630
+ def __iter__(self):
1631
+ """Every node in index order, without building a list of them."""
1632
+ return (Node(self, i) for i in range(len(self)))
1633
+
1634
+ @property
1635
+ def nodes(self) -> "list[Node]":
1636
+ """Every node, in index order.
1637
+
1638
+ A list, so it can be indexed and measured; iterate the scene itself to
1639
+ avoid materialising one object per node on a very large file.
1640
+ """
1641
+ return list(self)
1642
+
1643
+ def query(self, filter: str) -> "list[int]": # noqa: A002 - the ABI's own word
1644
+ """The indices of the nodes a filter matches, in document order.
1645
+
1646
+ The filter is one boolean expression over a node --
1647
+ `class == ON_Brep and within(class == ON_Layer and name == Walls)`.
1648
+
1649
+ Indices rather than `Node`s because that is what the ABI hands back and
1650
+ what a caller filtering a tree wants: a set to test membership against,
1651
+ not a thousand freshly built objects.
1652
+
1653
+ Raises [`CadaclysmError`] carrying the parser's own message and byte
1654
+ offset if the filter will not parse. An empty result is not an error --
1655
+ a filter that matches nothing is a perfectly good answer, and the ABI
1656
+ distinguishes the two by whether it left a reason behind.
1657
+ """
1658
+ library, handle = _lib(), self._handle
1659
+ encoded = filter.encode()
1660
+ # Sized first, then filled: the ABI cannot hand back an allocation this
1661
+ # side would have to free, so it counts on request and writes on demand.
1662
+ total = library.cadaclysm_query(handle, encoded, None, 0)
1663
+ if total == 0:
1664
+ reason = _last_error()
1665
+ if reason:
1666
+ raise CadaclysmError(f"{self.path.name}: {reason}")
1667
+ return []
1668
+ out = (c_uint32 * total)()
1669
+ written = library.cadaclysm_query(handle, encoded, out, total)
1670
+ # A second call could in principle see a different document; it cannot
1671
+ # here, since nothing between the two calls can mutate the scene.
1672
+ return list(out[:min(written, total)])
1673
+
1674
+ @property
1675
+ def placements(self) -> "list[Placement]":
1676
+ """What this document draws and where -- see [`Placement`].
1677
+
1678
+ **Not the nodes, and the difference is the point.** A node walk draws a
1679
+ Rhino block once at its definition's frame and every placement of it not
1680
+ at all. This is the list to iterate to draw.
1681
+ """
1682
+ return [Placement(self, i)
1683
+ for i in range(_lib().cadaclysm_placement_count(self._handle))]
1684
+
1685
+ @property
1686
+ def roots(self) -> "list[Node]":
1687
+ """The nodes nothing else contains."""
1688
+ library, handle = _lib(), self._handle
1689
+ found = []
1690
+ for i in range(library.cadaclysm_root_count(handle)):
1691
+ index = library.cadaclysm_root(handle, i)
1692
+ if index != NONE:
1693
+ found.append(Node(self, index))
1694
+ return found
1695
+
1696
+ def _node_or_none(self, index: int) -> "Node | None":
1697
+ """A node index as a `Node`, or None for `CADACLYSM_NONE`."""
1698
+ return None if index == NONE else Node(self, index)
1699
+
1700
+ def walk(self):
1701
+ """Every node reachable from the roots, parents before children."""
1702
+ for root in self.roots:
1703
+ yield from root.walk()
1704
+
1705
+ # -- building geometry --
1706
+
1707
+ def realize_all(self) -> int:
1708
+ """Build every mesh now, across threads, and say how many were built.
1709
+
1710
+ Reading is lazy so a caller can put the tree on screen while the shapes
1711
+ are still to come. Asking node by node instead meshes them one at a
1712
+ time on one core; this does the same work over every core. On a large
1713
+ STEP file that is the difference between a demo and a wait.
1714
+
1715
+ Watch it from another thread with `realized` and `realize_total`, or
1716
+ stop it with `cancel`.
1717
+ """
1718
+ return _lib().cadaclysm_realize_all(self._handle)
1719
+
1720
+ @property
1721
+ def realized(self) -> int:
1722
+ """How many nodes `realize_all` has finished with. Safe to read from another thread."""
1723
+ return _lib().cadaclysm_realized(self._handle)
1724
+
1725
+ @property
1726
+ def realize_total(self) -> int:
1727
+ """How many there will be in all — zero until `realize_all` starts."""
1728
+ return _lib().cadaclysm_realize_total(self._handle)
1729
+
1730
+ def cancel(self) -> None:
1731
+ """Ask a running `realize_all` to stop.
1732
+
1733
+ **One-way, and for the life of the scene.** Nothing clears the flag, so
1734
+ every later `realize_all` on this scene returns 0 at once; a UI that
1735
+ offers "Cancel" and then "Load anyway" must reopen the file. Meshes
1736
+ stay available one node at a time either way.
1737
+ """
1738
+ _lib().cadaclysm_cancel(self._handle)
1739
+
1740
+ # -- writing --
1741
+
1742
+ def save(self, path, fmt: str = "glb") -> None:
1743
+ """Write the whole scene to `path`: `"glb"` (binary glTF), `"gltf"`
1744
+ (text glTF, one file either way) or `"obj"` (Wavefront, every
1745
+ placement baked to its own named object, a `.mtl` beside it under the
1746
+ same stem when anything has a colour).
1747
+
1748
+ Every placement of every shape, named and placed as the tree is, with a
1749
+ material per colour -- where `Node.save_mesh` writes one node's mesh
1750
+ on its own (the same names in `mesh_formats()` are those one-mesh forms).
1751
+ Coordinates are the scene's own, in the convention it was opened with
1752
+ (`Y_UP` for the Y-up metres glTF specifies); the winding is turned for
1753
+ a clockwise convention so the file reads right-side out everywhere.
1754
+ Raises `CadaclysmError` on any other format or a failed write.
1755
+ """
1756
+ ok = _lib().cadaclysm_scene_save(self._handle, str(path).encode(), fmt.encode())
1757
+ if not ok:
1758
+ raise CadaclysmError(_last_error() or f"could not write {path}")
1759
+
1760
+
1761
+ # ---- opening --------------------------------------------------------------
1762
+
1763
+
1764
+ def declared_schema(model: Path) -> str:
1765
+ """The schema a STEP or IFC file says it speaks, from its own header.
1766
+
1767
+ `FILE_SCHEMA(('IFC2X3'))` sits near the top of the file, so a few kilobytes
1768
+ is plenty and a 300 MB IFC costs nothing to ask.
1769
+ """
1770
+ with Path(model).open("rb") as f:
1771
+ head = f.read(8192).decode("latin-1")
1772
+ found = re.search(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", head, re.IGNORECASE)
1773
+ return found.group(1) if found else ""
1774
+
1775
+
1776
+ def _plain(name: str) -> str:
1777
+ return "".join(c for c in name.upper() if c.isalnum())
1778
+
1779
+
1780
+ def resolve_schema(model: Path, schema):
1781
+ """`schema` resolved to `(chosen, fallbacks)` — one `.exp`, or a list to try.
1782
+
1783
+ A file is taken as given. A directory is matched against what the model
1784
+ says it speaks: the ABI registers exactly one schema per open, so something
1785
+ has to choose, and the file itself is the one that knows. Where the
1786
+ declared name resembles no filename the whole directory comes back as
1787
+ fallbacks to try in turn — AP203 calls itself CONFIG_CONTROL_DESIGN, and
1788
+ there will be others.
1789
+ """
1790
+ if schema is None:
1791
+ return None, []
1792
+ schema = Path(schema)
1793
+ if schema.is_file():
1794
+ return schema, []
1795
+ if not schema.is_dir():
1796
+ raise CadaclysmError(f"schema {schema} is neither a file nor a directory")
1797
+ available = sorted(schema.glob("*.exp"))
1798
+ if not available:
1799
+ raise CadaclysmError(f"no .exp schemas in {schema}")
1800
+
1801
+ declared = _plain(declared_schema(model))
1802
+ matches = [
1803
+ exp
1804
+ for exp in available
1805
+ if declared
1806
+ and (declared.startswith(_plain(exp.stem)) or _plain(exp.stem).startswith(declared))
1807
+ ]
1808
+ if matches:
1809
+ # The longest name that still matches is the most specific one.
1810
+ return max(matches, key=lambda exp: len(_plain(exp.stem))), []
1811
+ return None, available
1812
+
1813
+
1814
+ def open(path, schema=None, convention=Convention.NATIVE, # noqa: A001 - the verb this module is for
1815
+ colors=False) -> Scene:
1816
+ """Open a CAD file.
1817
+
1818
+ `schema` names an EXPRESS schema (`.exp`) beyond the ones built into the
1819
+ library — every schema the project ships is compiled in, so a STEP or IFC
1820
+ file opens with None, and one is passed only for a schema the library does
1821
+ not carry (it replaces a built-in of the same name). A directory is allowed
1822
+ and is matched against what the file says it speaks.
1823
+
1824
+ `convention` is the space to read the file into — a `Convention`, optionally
1825
+ OR'd with `FILE_UNITS` and `UV_WORLD`. The library does the converting, so
1826
+ every array a caller reads out is already in it; there is nothing left for
1827
+ the caller to rotate or scale. The default keeps the file's own axes and
1828
+ units, so a script written before this parameter existed is unaffected.
1829
+
1830
+ There is no `surfaces` argument, and there used to be. Every body now carries
1831
+ both descriptions -- its triangles and its trimmed surfaces -- and builds
1832
+ whichever is asked for, so `Node.mesh` and `Node.surfaces` are both there to
1833
+ read on any document, and reading one costs nothing towards the other. A file
1834
+ no longer has to be opened twice, or opened again, to be looked at the other
1835
+ way.
1836
+
1837
+ A `.zip` opens its first readable member; `Scene.source_name` says which.
1838
+
1839
+ Raises `CadaclysmError` on failure, carrying what the library said. An
1840
+ unrecognised `convention` is one of the failures it raises on, so a typo
1841
+ cannot be mistaken for `NATIVE`. It never returns None, so a null handle
1842
+ cannot reach a later call.
1843
+ """
1844
+ path = Path(path)
1845
+ if not path.exists():
1846
+ raise CadaclysmError(f"{path}: no such file")
1847
+
1848
+ library = _lib()
1849
+
1850
+ # A directory goes over whole rather than being narrowed to one file here.
1851
+ # The library walks it and keys each schema under the name that schema
1852
+ # itself *declares*, which is the only authority on the matter: `ap203.exp`
1853
+ # declares `config_control_design`, so choosing by filename hands it to a
1854
+ # file whose FILE_SCHEMA says AP203 -- and, having matched, leaves nothing
1855
+ # to fall back to. `123Block_Color.stp` is refused that way and opens fine
1856
+ # when the directory is passed through, its real schema being the one in
1857
+ # `ap203e2_mim_lf.exp`.
1858
+ if schema is not None and Path(schema).is_dir():
1859
+ options, _held = _options(convention, schema, colors)
1860
+ pointer = library.cadaclysm_open(str(path).encode(), ctypes.byref(options))
1861
+ if pointer:
1862
+ return Scene(pointer, path, Path(schema), int(convention))
1863
+ raise CadaclysmError(f"{path.name}: {_last_error()}")
1864
+
1865
+ chosen, fallbacks = resolve_schema(path, schema)
1866
+ for candidate in [chosen] if chosen is not None or not fallbacks else fallbacks:
1867
+ options, _held = _options(convention, candidate, colors)
1868
+ pointer = library.cadaclysm_open(str(path).encode(), ctypes.byref(options))
1869
+ if pointer:
1870
+ return Scene(pointer, path, candidate, int(convention))
1871
+ raise CadaclysmError(f"{path.name}: {_last_error()}")
1872
+
1873
+
1874
+ def open_memory(data, format, schema=None, name="<memory>", # noqa: A002
1875
+ convention=Convention.NATIVE, colors=False) -> Scene:
1876
+ """Open a CAD file already in bytes.
1877
+
1878
+ `format` names the kind as an extension would — `"step"`, `"ifc"`, `"igs"`,
1879
+ `"brep"`, `"3dm"`, `"scad"` — since there is no file name to take it from.
1880
+ A leading dot is allowed and ignored. `schema` must be a path here: there
1881
+ is no file on disk to read a `FILE_SCHEMA` line out of. `convention` is as
1882
+ `open` takes it.
1883
+ """
1884
+ buffer = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
1885
+ options, _held = _options(convention, schema, colors)
1886
+ pointer = _lib().cadaclysm_open_memory(
1887
+ buffer,
1888
+ len(data),
1889
+ str(format).encode(),
1890
+ ctypes.byref(options),
1891
+ )
1892
+ if not pointer:
1893
+ raise CadaclysmError(f"{name}: {_last_error()}")
1894
+ return Scene(pointer, Path(name), Path(schema) if schema is not None else None,
1895
+ int(convention))
1896
+
1897
+
1898
+ # ---- a look at a file, when run directly ----------------------------------
1899
+
1900
+
1901
+ def _main(argv) -> int:
1902
+ """`python cadaclysm.py model.stp [schema]` — the tree and the totals.
1903
+
1904
+ Here so the module can be run against a file without a window, a GPU or
1905
+ anything installed beyond the standard library.
1906
+ """
1907
+ if not argv:
1908
+ print(__doc__.strip().splitlines()[0])
1909
+ print(f"usage: python {Path(__file__).name} MODEL [SCHEMA]")
1910
+ return 2
1911
+ with open(argv[0], argv[1] if len(argv) > 1 else None) as scene:
1912
+ print(f"cadaclysm {scene.version} - {scene.path.name}")
1913
+ if scene.schema_path:
1914
+ print(f" schema: {scene.schema_path.name}")
1915
+ print(f" {scene.schema or '(no schema)'}, {scene.metres_per_unit} m/unit, "
1916
+ f"{len(scene)} nodes, {len(scene.roots)} roots")
1917
+ for node in scene.walk():
1918
+ drawn = " *" if node.can_mesh else ""
1919
+ print(f" {' ' * node.depth}{node.label} [{node.kind}]{drawn}")
1920
+ for note in scene.diagnostics:
1921
+ print(f" diagnostic: {note}")
1922
+ return 0
1923
+
1924
+
1925
+ if __name__ == "__main__":
1926
+ sys.exit(_main(sys.argv[1:]))