sand3d 1.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sand3d/__init__.py ADDED
@@ -0,0 +1,118 @@
1
+ """sand3d — the vocabulary an LLM writes 3D assets in.
2
+
3
+ import sand3d as s3d
4
+
5
+ One flat surface: every verb is `s3d.<verb>`. Millimetres, unless the program
6
+ sets another unit with `s3d.units`; `export` scales to metres. A program records
7
+ `s3d.__version__`, `s3d.fingerprint()` and `s3d.units()` with what it built.
8
+ """
9
+ import functools
10
+ import hashlib
11
+ import pathlib
12
+ import sys
13
+
14
+ from . import _core, _guard
15
+
16
+ from .assembly import Handle, assembly, attach, empty_scene, export, group, units
17
+ from .edit import (chamfer, copy, cut, delete, dent, fillet, hole, intersect, join, merge, pocket, scale, seat, shell,
18
+ soften, split)
19
+ from .parts import gear, knurl, thread
20
+ from .frames import Frame, along, at, bbox, grid, local, ring, snap, transform
21
+ from .materials import material, resolve as _resolve
22
+ from .joints import joint, placed_again as _placed_again, placed_on as _placed_on, pose
23
+ from .profile import Path as _Path, Placed as _Placed, arc, circle, ellipse, mirror, offset, poly, polygon, rect, slot, spline, station, stroke, superellipse, tangent_arc, text, wrap
24
+ from .solids import body, box, cone, cylinder, dome, extrude, helix, loft, revolve, sphere, sweep, torus, tube, wedge, wire
25
+
26
+ # x-release-please-start-version
27
+ __version__ = "1.6.1"
28
+ # x-release-please-end
29
+
30
+ __all__ = [
31
+ # profiles
32
+ "rect", "circle", "ellipse", "slot", "polygon", "poly", "arc", "tangent_arc", "spline", "superellipse",
33
+ "station", "text", "offset", "stroke", "wrap", "mirror",
34
+ # solids, paths
35
+ "extrude", "revolve", "sweep", "loft", "body", "wire", "helix",
36
+ "box", "cylinder", "tube", "cone", "sphere", "wedge", "torus", "dome",
37
+ # edits
38
+ "cut", "join", "intersect", "split", "delete", "copy", "hole", "pocket", "merge", "scale",
39
+ "fillet", "chamfer", "soften", "shell", "dent",
40
+ # frames, placement
41
+ "Frame", "bbox", "local", "at", "transform", "grid", "ring", "along", "snap",
42
+ # parts
43
+ "gear", "knurl", "thread",
44
+ # materials
45
+ "material",
46
+ # articulation
47
+ "joint", "pose", "seat",
48
+ # assembly
49
+ "group", "assembly", "attach", "Handle", "export", "empty_scene", "units", "fingerprint",
50
+ ]
51
+
52
+
53
+ def _verb(fn):
54
+ """`fn`, fixing the program's unit the moment it is called — even if it raises
55
+ before measuring anything: the unit is set before the first verb — and checking what
56
+ the program hands it (`_guard`)."""
57
+ name = fn.__name__
58
+ positional = fn.__code__.co_varnames[:fn.__code__.co_argcount]
59
+
60
+ @functools.wraps(fn)
61
+ def verb(*args, **kwargs):
62
+ _core.settled = True
63
+ line = sys._getframe(1).f_lineno
64
+ _guard.calls.append((name, line, set(positional[:len(args)]) | set(kwargs)))
65
+ try:
66
+ _guard.begin(name, args, kwargs)
67
+ result = fn(*args, **kwargs)
68
+ finally:
69
+ _guard.calls.pop()
70
+ if _core.dressed and not _guard.calls:
71
+ _resolve() # every mesh wears its nearest material, whatever the verb rebuilt
72
+ if name in ("at", "transform", "station"):
73
+ for placed in result if isinstance(result, list) else [result]:
74
+ if isinstance(placed, (_Placed, _Path)):
75
+ _guard.watch(placed, line)
76
+ if name in ("at", "extrude"):
77
+ on = kwargs["on"] if "on" in kwargs else (args[positional.index("on")]
78
+ if len(args) > positional.index("on") else None)
79
+ _placed(result, on)
80
+ if name == "at" and on is None:
81
+ _placed_again(result)
82
+ if name == "transform":
83
+ _placed_again(result)
84
+ return result
85
+ return verb
86
+
87
+
88
+ def _placed(result, on):
89
+ """Remember, on what `at` or `extrude` placed on a frame read on a part, that part:
90
+ `pose` refuses to leave it behind."""
91
+ if on is None:
92
+ return
93
+ if isinstance(result, Handle):
94
+ result = result.root
95
+ if isinstance(result, list):
96
+ if isinstance(on, (list, tuple)) and len(on) == len(result):
97
+ for r, f in zip(result, on):
98
+ _placed(r, f)
99
+ return
100
+ _placed_on(result, on)
101
+
102
+
103
+ for _name in __all__:
104
+ if _name not in ("units", "empty_scene", "fingerprint") and not isinstance(globals()[_name], type):
105
+ globals()[_name] = _verb(globals()[_name])
106
+ del _name
107
+
108
+
109
+ def fingerprint():
110
+ """Digest of the source.
111
+
112
+ A digest of the library's source: what a program run records beside `__version__`."""
113
+ root = pathlib.Path(__file__).resolve().parent
114
+ digest = hashlib.sha256()
115
+ for path in sorted(root.rglob("*.py")):
116
+ digest.update(path.relative_to(root).as_posix().encode() + b"\0")
117
+ digest.update(path.read_bytes() + b"\0")
118
+ return digest.hexdigest()[:16]
sand3d/_core.py ADDED
@@ -0,0 +1,319 @@
1
+ """What every verb shares: making a named object from a bmesh, and reading one back.
2
+
3
+ Nothing here is public. No function reads `bpy.context.active_object` or the
4
+ selection; the scene collection is the only context a verb touches.
5
+ """
6
+ import math
7
+
8
+ import bpy # before bmesh, which the bpy module only provides once loaded
9
+ import bmesh
10
+ from mathutils import Matrix, Vector, geometry
11
+
12
+
13
+ #: Custom property holding the name a program gave an object (Blender may suffix ob.name).
14
+ NAME = "s3d_name"
15
+
16
+
17
+ # ---------------------------------------------------------------- the program's unit
18
+
19
+ #: Millimetres in one unit of a program, by the names `units` takes.
20
+ UNITS = {"mm": 1.0, "cm": 10.0, "m": 1000.0}
21
+ #: The program's unit, and its size in millimetres.
22
+ UNIT, MM = "mm", 1.0
23
+ #: Whether a verb has measured anything in the unit since the scene was emptied: it is fixed then.
24
+ settled = False
25
+ #: Whether the program has set a material since the scene was emptied (`materials`).
26
+ dressed = False
27
+
28
+
29
+ def mm(x):
30
+ """`x` millimetres in the program's unit: every tolerance is written in millimetres."""
31
+ global settled
32
+ settled = True
33
+ return x / MM
34
+
35
+
36
+ def mm2(x):
37
+ """`x` square millimetres in the program's unit squared."""
38
+ global settled
39
+ settled = True
40
+ return x / (MM * MM)
41
+
42
+
43
+ def in_mm(x):
44
+ """A length of the program in millimetres: where a decision rounds or compares."""
45
+ global settled
46
+ settled = True
47
+ return x * MM
48
+
49
+
50
+ def triangulate(points, edges, output_type, eps):
51
+ """Blender's constrained Delaunay triangulation of 2D `points`, `eps` in millimetres.
52
+ It runs in millimetres: points on one circle (a rounded corner) are told apart by
53
+ float noise, and a program in another unit must meet the same noise. The output
54
+ points come back in the program's unit."""
55
+ size = in_mm(1.0)
56
+ out = geometry.delaunay_2d_cdt([Vector((p[0] * size, p[1] * size)) for p in points], edges, [],
57
+ output_type, eps)
58
+ return ([Vector((v.x / size, v.y / size)) for v in out[0]], *out[1:])
59
+
60
+
61
+ def world(ob):
62
+ """`ob`'s placement in the scene, from its own and its parents' placements — no
63
+ depsgraph, so it is current the moment `at` has run.
64
+
65
+ Blender keeps a placement as location, Euler angles and scale, in single
66
+ precision: a quarter turn comes back with 7.5e-8 where 0 was meant, and a face
67
+ laid on another drifts off it by microns — enough for an exact boolean to see two
68
+ planes and leave a sliver. Entries within 1e-6 of a whole number are snapped to it —
69
+ a whole millimetre for the translation."""
70
+ m = ob.matrix_parent_inverse @ ob.matrix_basis
71
+ return snapped(world(ob.parent) @ m if ob.parent else Matrix(m))
72
+
73
+
74
+ def snapped(m):
75
+ """`m` with its entries within 1e-6 of a whole number snapped to it — a whole
76
+ millimetre for the translation (`world`)."""
77
+ for row in range(4):
78
+ for col in range(4):
79
+ size = in_mm(1.0) if col == 3 and row < 3 else 1.0
80
+ v = m[row][col] * size
81
+ if abs(v - round(v)) < 1e-6:
82
+ m[row][col] = round(v) / size
83
+ return m
84
+
85
+
86
+ def unknown(owner, kind, missing, known):
87
+ """The message for names `missing` among the `kind` ("edge", "face") names `known` of
88
+ `owner`, with what a program most likely meant."""
89
+ message = f"{owner} has no {kind} named {missing}; its {kind} names: {known}"
90
+ plain = {k for k in known if "." not in k} # what the edges are, not who made them
91
+ first = missing[0].split(".")[-1]
92
+ if first in ("start", "end") and {"top", "bottom"} <= plain:
93
+ return message + (". extrude names its caps top and bottom; start and end are a sweep's, a loft's or a "
94
+ "partial revolve's")
95
+ if first in ("top", "bottom") and {"start", "end"} <= plain:
96
+ return message + (". A sweep, a loft or a partial revolve names its caps start and end; top and bottom "
97
+ "are an extrude's")
98
+ if first == "side" and kind == "edge" and {"top", "bottom"} <= plain and "side" not in plain:
99
+ return message + (". Its outline turns smoothly all round (a rounded rectangle, a circle): no corner "
100
+ "makes a side edge")
101
+ import difflib
102
+ near = difflib.get_close_matches(missing[0], known, n=1, cutoff=0.6)
103
+ if near:
104
+ return message + f". Did you mean {near[0]!r}?"
105
+ if first in ("vertical", "horizontal", "outer", "inner", "edge", "edges", "corner", "corners", "upper", "lower"):
106
+ return message + (f". {kind.capitalize()}s go by the names their maker gave them, not by direction: "
107
+ + ("an extrude's walls meet at side" if kind == "edge" else "an extrude's walls are side"))
108
+ return message
109
+
110
+
111
+ def mm_tree(ob, matrix):
112
+ """A BVH of `ob`'s mesh placed by `matrix`, in millimetres: Blender's BVH pads its boxes
113
+ in its own units, which breaks ties — search it with points times `in_mm(1.0)`."""
114
+ from mathutils.bvhtree import BVHTree
115
+ size = in_mm(1.0)
116
+ bm = bmesh.new()
117
+ bm.from_mesh(ob.data)
118
+ bm.transform(matrix)
119
+ if size != 1.0:
120
+ bmesh.ops.scale(bm, vec=(size,) * 3, verts=bm.verts)
121
+ tree = BVHTree.FromBMesh(bm)
122
+ bm.free()
123
+ return tree
124
+
125
+
126
+ def given_name(ob):
127
+ return ob.get(NAME, ob.name)
128
+
129
+
130
+ def link(ob):
131
+ """Put `ob` in the scene, where the exporter and the depsgraph see it."""
132
+ bpy.context.scene.collection.objects.link(ob)
133
+ return ob
134
+
135
+
136
+ #: Face tags that name a cap. An edge bounding such a face carries its name;
137
+ #: an edge between two other surfaces carries the verb's own kind (`side` for an
138
+ #: extrude, `rim` for a revolve, `seam` for a sweep or a loft).
139
+ CAPS = ("top", "bottom", "start", "end")
140
+ #: Prefixes of the mesh attributes that hold provenance tags: one per domain,
141
+ #: because a mesh attribute's name is unique across domains.
142
+ FACE_TAG, EDGE_TAG = "s3d_face:", "s3d_edge:"
143
+ #: Internal face attribute: which smooth surface a face belongs to.
144
+ SURFACE = "s3d~surface"
145
+
146
+
147
+ class Builder:
148
+ """A solid under construction: faces carry a surface id and provenance tags.
149
+
150
+ `finish` names the edges. An edge is a real edge when its two faces belong to
151
+ different surfaces; it carries the cap names of the faces it bounds, or the
152
+ verb's `kind` when it runs between two walls. Every tag also exists qualified
153
+ by the object's name (`plate.top`), so a join keeps them apart.
154
+
155
+ Adding a bmesh layer invalidates Python references to its elements, so every
156
+ layer is made up front, and `finish` assigns by index once all exist.
157
+ """
158
+
159
+ TAGS = ("side",) + CAPS
160
+
161
+ def __init__(self, name):
162
+ self.name = name
163
+ self.bm = bmesh.new()
164
+ self.surface = self.bm.faces.layers.int.new(SURFACE)
165
+ self.layers = {t: self.bm.faces.layers.bool.new(FACE_TAG + t) for t in self.TAGS}
166
+
167
+ def vert(self, co):
168
+ return self.bm.verts.new(co)
169
+
170
+ def face(self, verts, surface, tags):
171
+ """A face through `verts` (outward by the right-hand rule), or None if degenerate."""
172
+ verts = [v for i, v in enumerate(verts) if v is not verts[i - 1]]
173
+ if len(verts) < 3 or len(set(verts)) < len(verts):
174
+ return None
175
+ try:
176
+ f = self.bm.faces.new(verts)
177
+ except ValueError: # the face exists already
178
+ return None
179
+ f[self.surface] = surface
180
+ for tag in tags:
181
+ f[self.layers[tag]] = True
182
+ f.smooth = True
183
+ return f
184
+
185
+ def dissolve(self, faces):
186
+ """Merge each cap's triangles into as few faces as its holes allow."""
187
+ faces = set(f for f in faces if f is not None and f.is_valid)
188
+ inner = [e for e in self.bm.edges if len(e.link_faces) == 2
189
+ and all(f in faces for f in e.link_faces)
190
+ and e.link_faces[0][self.surface] == e.link_faces[1][self.surface]]
191
+ if inner:
192
+ bmesh.ops.dissolve_edges(self.bm, edges=inner, use_verts=False, use_face_split=False)
193
+
194
+ def finish(self, kind, rim=False):
195
+ """The object. `rim`: every real edge also takes `kind`, save those bounding a
196
+ start or end cap (a revolve's circles are rims whatever faces they bound)."""
197
+ bm, name = self.bm, self.name
198
+ bm.faces.index_update()
199
+ bm.edges.index_update()
200
+ face_tags = [{t for t, lay in self.layers.items() if f[lay]} for f in bm.faces]
201
+ surfaces = [f[self.surface] for f in bm.faces]
202
+ edge_tags = []
203
+ for e in bm.edges:
204
+ fs = [f.index for f in e.link_faces]
205
+ if len(fs) == 2 and surfaces[fs[0]] == surfaces[fs[1]]:
206
+ edge_tags.append(None)
207
+ continue
208
+ tags = {t for i in fs for t in face_tags[i] if t in CAPS}
209
+ if not tags or (rim and not tags & {"start", "end"}):
210
+ tags.add(kind)
211
+ edge_tags.append(tags)
212
+ for t in self.TAGS: # by name each time: removing a layer invalidates the other handles
213
+ bm.faces.layers.bool.remove(bm.faces.layers.bool[FACE_TAG + t])
214
+ bm.faces.layers.int.remove(bm.faces.layers.int[SURFACE])
215
+ write_tags(bm, name, face_tags, edge_tags)
216
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
217
+ mesh = bpy.data.meshes.new(name)
218
+ bm.to_mesh(mesh)
219
+ bm.free()
220
+ ob = link(bpy.data.objects.new(name, mesh))
221
+ ob[NAME] = name
222
+ return ob
223
+
224
+
225
+ def write_tags(bm, name, face_tags, edge_tags):
226
+ """Write provenance onto `bm` by element index: tag sets per face and per edge
227
+ (None for an edge that is no real edge), each tag plain and qualified by `name`.
228
+ Real edges are marked sharp, faces smooth."""
229
+ def qualified(tags):
230
+ return set(tags) | {f"{name}.{t}" for t in tags if "." not in t} if name else set(tags)
231
+ face_tags = [qualified(t) for t in face_tags]
232
+ edge_tags = [None if t is None else qualified(t) for t in edge_tags]
233
+ for t in sorted({t for tags in face_tags for t in tags}):
234
+ if bm.faces.layers.bool.get(FACE_TAG + t) is None:
235
+ bm.faces.layers.bool.new(FACE_TAG + t)
236
+ for t in sorted({t for tags in edge_tags if tags for t in tags}):
237
+ if bm.edges.layers.bool.get(EDGE_TAG + t) is None:
238
+ bm.edges.layers.bool.new(EDGE_TAG + t)
239
+ flayers = {lay.name[len(FACE_TAG):]: lay for lay in bm.faces.layers.bool.values()
240
+ if lay.name.startswith(FACE_TAG)}
241
+ elayers = {lay.name[len(EDGE_TAG):]: lay for lay in bm.edges.layers.bool.values()
242
+ if lay.name.startswith(EDGE_TAG)}
243
+ for f, tags in zip(bm.faces, face_tags):
244
+ for t in tags:
245
+ f[flayers[t]] = True
246
+ f.smooth = True
247
+ for e, tags in zip(bm.edges, edge_tags):
248
+ for t in tags or ():
249
+ e[elayers[t]] = True
250
+ e.smooth = tags is None
251
+
252
+
253
+ def new_object(name, bm):
254
+ """A mesh object named `name` from `bm`, its normals pointing out. Frees `bm`."""
255
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
256
+ mesh = bpy.data.meshes.new(name)
257
+ bm.to_mesh(mesh)
258
+ bm.free()
259
+ return link(bpy.data.objects.new(name, mesh))
260
+
261
+
262
+ def extent(ob, axis):
263
+ """The size of `ob`'s own mesh along an axis (0, 1, 2), before any placement."""
264
+ values = [v.co[axis] for v in ob.data.vertices]
265
+ return max(values) - min(values)
266
+
267
+
268
+ def bridge(bm, a, b):
269
+ """Faces between two rings of vertices; a ring of one vertex is a pole."""
270
+ n = max(len(a), len(b))
271
+ for k in range(n):
272
+ if len(a) == 1:
273
+ bm.faces.new((a[0], b[(k + 1) % n], b[k]))
274
+ elif len(b) == 1:
275
+ bm.faces.new((a[k], a[(k + 1) % n], b[0]))
276
+ else:
277
+ bm.faces.new((a[k], a[(k + 1) % n], b[(k + 1) % n], b[k]))
278
+
279
+
280
+ #: Chord tolerance of every circle and arc, in millimetres, and the bounds on the
281
+ #: number of segments a full turn gets: a circle up to 10 mm gets 24, of 30 mm 39,
282
+ #: and anything from 84 mm the cap. A function of the radius in millimetres only, so a
283
+ #: verb's mesh depends on nothing but its arguments and the unit.
284
+ CHORD = 0.05
285
+ TURN_MIN, TURN_MAX = 24, 64
286
+
287
+
288
+ def segments(r, sweep=360.0):
289
+ """How many straight pieces an arc of radius `r` sweeping `sweep` degrees gets."""
290
+ r = abs(in_mm(r))
291
+ if r <= CHORD:
292
+ per_turn = TURN_MIN
293
+ else:
294
+ per_turn = math.ceil(math.pi / math.acos(1 - CHORD / r))
295
+ per_turn = min(max(per_turn, TURN_MIN), TURN_MAX)
296
+ return max(1, math.ceil(per_turn * abs(sweep) / 360.0 - 1e-9))
297
+
298
+
299
+ def add_tags(bm, face_add, edge_add):
300
+ """Add names to elements of `bm` by index: {face index: tags}, {edge index: tags}.
301
+ Edges that gain a name become sharp."""
302
+ for t in sorted({t for tags in face_add.values() for t in tags}):
303
+ if bm.faces.layers.bool.get(FACE_TAG + t) is None:
304
+ bm.faces.layers.bool.new(FACE_TAG + t)
305
+ for t in sorted({t for tags in edge_add.values() for t in tags}):
306
+ if bm.edges.layers.bool.get(EDGE_TAG + t) is None:
307
+ bm.edges.layers.bool.new(EDGE_TAG + t)
308
+ bm.faces.ensure_lookup_table()
309
+ bm.edges.ensure_lookup_table()
310
+ for i, tags in face_add.items():
311
+ f = bm.faces[i]
312
+ for t in tags:
313
+ f[bm.faces.layers.bool[FACE_TAG + t]] = True
314
+ for i, tags in edge_add.items():
315
+ e = bm.edges[i]
316
+ for t in tags:
317
+ e[bm.edges.layers.bool[EDGE_TAG + t]] = True
318
+ e.smooth = False
319
+ bm.normal_update()
sand3d/_guard.py ADDED
@@ -0,0 +1,181 @@
1
+ """What a program does wrong with an object, said by the next verb that meets it.
2
+
3
+ - An object a verb used up (the tools of `cut`, `join`, `intersect`; what `merge`
4
+ takes after the first), or one `delete` removed, names the verb and the program
5
+ line that did it.
6
+ - The same object passed twice to a verb that takes objects says that `at` moves an
7
+ object, it does not copy it.
8
+ - A profile or a path `at` placed and nothing used is the commonest silent mistake:
9
+ `s3d.at(p, x=100)` alone on its line. CPython frees the value at the end of the
10
+ statement; its `__del__` arms an error that the next verb raises.
11
+
12
+ Nothing here touches geometry. `begin` runs at the entry of every public verb.
13
+ """
14
+ import keyword
15
+ import weakref
16
+
17
+ import bpy
18
+
19
+ from . import _core
20
+
21
+ #: Objects removed while a program may still hold them: id of the Python object -> (it, message).
22
+ _gone = {}
23
+ #: The public verbs running: [(verb, the program line that called it, the parameters it wrote)].
24
+ calls = []
25
+ #: The error a freed, unused `at()` result armed.
26
+ pending = None
27
+ #: `at()` results not used yet.
28
+ _watched = weakref.WeakSet()
29
+
30
+ #: Why a verb uses up what it takes.
31
+ REASONS = {"cut": "a tool is consumed by the boolean", "join": "a tool is consumed by the boolean",
32
+ "intersect": "a tool is consumed by the boolean", "merge": "an object merged into the first is used up",
33
+ "at": "at with a list of frames uses the object up and returns one copy per frame: use the list it returns"}
34
+ #: Keywords 1.0.0 took away, by verb, and what to write instead.
35
+ REMOVED = {
36
+ "at": {"mirror": "at: mirror= is gone in 1.0 — the image across a plane of the parent is "
37
+ "s3d.transform(ob, mirror=\"x\"), on s3d.copy(ob) to keep the original"},
38
+ "mirror": {"axis": "mirror: axis= is gone in 1.0 — the letter is the coordinate that changes sign: "
39
+ "s3d.mirror(p, \"x\") is the old axis=\"y\""},
40
+ "hole": {"at": "hole: at= is on= in 1.0 — s3d.hole(ob, d, on=frames or points); on= is the one word for where"},
41
+ "join": {"at": "join: at= is gone in 1.0 — place one copy per frame with at, and join the list: "
42
+ "s3d.join(ob, s3d.at(tool, on=[frames]))"},
43
+ "cut": {"at": "cut: at= is gone in 1.0 — place one copy per frame with at, and cut the list: "
44
+ "s3d.cut(ob, s3d.at(tool, on=[frames]))"},
45
+ "loft": {"closed": "loft: closed= is loop= in 1.0 — loop=True joins the last section back to the first, a ring "
46
+ "with no ends; without it, the two ends of a loft are capped"},
47
+ }
48
+ #: Verbs whose objects must all be different, from which argument on.
49
+ DISTINCT = {"cut": 0, "join": 0, "intersect": 0, "merge": 0, "group": 1, "assembly": 1, "attach": 2, "at": 0, "transform": 0}
50
+
51
+
52
+ def reset():
53
+ """A new scene: nothing is used up or pending any more, and what the last program
54
+ placed and still holds can be freed without a word."""
55
+ global pending
56
+ for value in list(_watched):
57
+ value._s3d_used = True
58
+ _watched.clear()
59
+ _gone.clear()
60
+ pending = None
61
+
62
+
63
+ def removed(ob):
64
+ """Whether `ob` is a Blender object that has been removed from the file."""
65
+ if not isinstance(ob, bpy.types.ID):
66
+ return False
67
+ try:
68
+ ob.name
69
+ except ReferenceError:
70
+ return True
71
+ return False
72
+
73
+
74
+ def _code(name):
75
+ """A name as a program would write the object in a call: itself if it can be a variable."""
76
+ return name if name.isidentifier() and not keyword.iskeyword(name) else repr(name)
77
+
78
+
79
+ def given(verb, names, inside=None):
80
+ """Which of `names` the program wrote in its call to `verb`, if `verb` is the public
81
+ verb running; for a call from inside the library, `inside` (all of them by default)."""
82
+ if calls and calls[-1][0] == verb:
83
+ return set(names) & calls[-1][2]
84
+ return set(names) if inside is None else set(inside)
85
+
86
+
87
+ def using_up(obs, target):
88
+ """Record that the verb running uses `obs` up on `target` — before it does: whichever
89
+ way it goes, the program's objects are gone after it."""
90
+ if not calls or calls[0][0] not in REASONS:
91
+ return
92
+ verb, line = calls[0][:2]
93
+ for ob in _items(obs):
94
+ if isinstance(ob, bpy.types.Object) and not removed(ob):
95
+ name = _core.given_name(ob)
96
+ copy = name if _code(name) == name else "..."
97
+ keep = "" if verb == "at" else f" To keep it, pass s3d.copy({copy}) to {verb} instead."
98
+ _gone[id(ob)] = (ob, f"{name!r} was used up by {verb}({_code(_core.given_name(target))}, ...) on line "
99
+ f"{line}; {REASONS[verb]}.{keep}")
100
+
101
+
102
+ def deleting(ob):
103
+ """Record that the program deletes `ob` with `delete`."""
104
+ if calls and calls[0][0] == "delete":
105
+ name = _core.given_name(ob)
106
+ _gone[id(ob)] = (ob, f"{name!r} was deleted by delete({_code(name)}) on line {calls[0][1]}.")
107
+
108
+
109
+ def watch(value, line):
110
+ """Watch an `at()` result: if it is freed unused, the next verb says so."""
111
+ value._s3d_from = line
112
+ value._s3d_used = False
113
+ _watched.add(value)
114
+ return value
115
+
116
+
117
+ def use(value):
118
+ """`value` was used: no error if it is freed now."""
119
+ if getattr(value, "_s3d_used", True) is False:
120
+ value._s3d_used = True
121
+
122
+
123
+ def freed(value):
124
+ """Called by `__del__`: arm the error if an `at()` result goes unused."""
125
+ global pending
126
+ if getattr(value, "_s3d_used", True) is False and pending is None:
127
+ pending = _unused(value)
128
+
129
+
130
+ def _unused(value):
131
+ from .profile import Placed
132
+ kind = "profile" if isinstance(value, Placed) else "path"
133
+ return f"at() on line {value._s3d_from} placed a {kind} that nothing used; write p = s3d.at(p, ...)"
134
+
135
+
136
+ def _items(args):
137
+ """The objects, placed profiles and paths a call was given: lists opened (not points),
138
+ a handle by its root."""
139
+ from .assembly import Handle
140
+ from .profile import Path, Placed
141
+ for a in args:
142
+ if isinstance(a, (bpy.types.ID, Placed, Path)):
143
+ yield a
144
+ elif isinstance(a, Handle):
145
+ yield a.root
146
+ elif isinstance(a, (list, tuple)) and not (a and isinstance(a[0], (int, float))):
147
+ yield from _items(a)
148
+
149
+
150
+ def begin(verb, args, kwargs):
151
+ """At the entry of a public verb: raise what is pending, refuse what is gone or passed
152
+ twice, and mark what it is given as used."""
153
+ global pending
154
+ for keyword, message in REMOVED.get(verb, {}).items():
155
+ if keyword in kwargs:
156
+ raise TypeError(message)
157
+ if pending is not None:
158
+ message, pending = pending, None
159
+ raise ValueError(f"{verb}: {message}")
160
+ for item in _items(list(args) + list(kwargs.values())):
161
+ if isinstance(item, bpy.types.ID):
162
+ if verb != "delete" and removed(item):
163
+ gone = _gone.get(id(item))
164
+ raise ValueError(f"{verb}: {gone[1]}" if gone else f"{verb}: an object given was removed from the scene")
165
+ else:
166
+ use(item)
167
+ if verb == "export":
168
+ unused = sorted((v._s3d_from, _unused(v)) for v in list(_watched) if v._s3d_used is False)
169
+ if unused:
170
+ raise ValueError(f"{verb}: {unused[0][1]}")
171
+ if verb in DISTINCT:
172
+ obs = [o for o in _items(args[DISTINCT[verb]:]) if isinstance(o, bpy.types.Object)]
173
+ seen = {}
174
+ for o in obs:
175
+ seen[o.as_pointer()] = seen.get(o.as_pointer(), 0) + 1
176
+ for o in obs:
177
+ if seen[o.as_pointer()] > 1:
178
+ name = _core.given_name(o)
179
+ at = name if _code(name) == name else "..."
180
+ raise ValueError(f"{verb}: {name!r} is passed {seen[o.as_pointer()]} times; at moves an object, it "
181
+ f"does not copy it: s3d.at({at}, on=s3d.ring(...)) places one copy per frame")