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 +1926 -0
- cadaclysm/ap203.exp +5331 -0
- cadaclysm/blacksmith.py +1882 -0
- cadaclysm/cadaclysm_blacksmith.dll +0 -0
- cadaclysm/cadaclysm_capi.dll +0 -0
- cadaclysm-0.4.0.dist-info/METADATA +93 -0
- cadaclysm-0.4.0.dist-info/RECORD +10 -0
- cadaclysm-0.4.0.dist-info/WHEEL +4 -0
- cadaclysm-0.4.0.dist-info/licenses/EULA.txt +37 -0
- cadaclysm_blacksmith.py +6 -0
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:]))
|