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 +118 -0
- sand3d/_core.py +319 -0
- sand3d/_guard.py +181 -0
- sand3d/assembly.py +194 -0
- sand3d/edit.py +1387 -0
- sand3d/frames.py +851 -0
- sand3d/joints.py +723 -0
- sand3d/materials.py +190 -0
- sand3d/meshfile.py +72 -0
- sand3d/parts/__init__.py +5 -0
- sand3d/parts/gear.py +42 -0
- sand3d/parts/turned.py +83 -0
- sand3d/profile.py +947 -0
- sand3d/solids.py +1118 -0
- sand3d/urdf.py +457 -0
- sand3d/usd.py +274 -0
- sand3d-1.6.1.dist-info/METADATA +166 -0
- sand3d-1.6.1.dist-info/RECORD +21 -0
- sand3d-1.6.1.dist-info/WHEEL +5 -0
- sand3d-1.6.1.dist-info/licenses/LICENSE +202 -0
- sand3d-1.6.1.dist-info/top_level.txt +1 -0
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")
|