kinescript 1.1.0__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.
Files changed (40) hide show
  1. kinescript/__init__.py +6 -0
  2. kinescript/cli.py +167 -0
  3. kinescript/color.py +89 -0
  4. kinescript/components.py +272 -0
  5. kinescript/docs.py +345 -0
  6. kinescript/ease.py +143 -0
  7. kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
  8. kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
  9. kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
  10. kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
  11. kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  12. kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
  13. kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
  14. kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
  15. kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
  16. kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
  17. kinescript/mcp.py +148 -0
  18. kinescript/media.py +220 -0
  19. kinescript/mesh.py +390 -0
  20. kinescript/models.py +339 -0
  21. kinescript/output.py +370 -0
  22. kinescript/presets.py +170 -0
  23. kinescript/props.py +185 -0
  24. kinescript/render.py +1204 -0
  25. kinescript/render3d.py +393 -0
  26. kinescript/scene.py +362 -0
  27. kinescript/schema.py +701 -0
  28. kinescript/shaders.py +79 -0
  29. kinescript/space.py +81 -0
  30. kinescript/svgpath.py +80 -0
  31. kinescript/timing.py +169 -0
  32. kinescript/verify.py +261 -0
  33. kinescript-1.1.0.dist-info/METADATA +192 -0
  34. kinescript-1.1.0.dist-info/RECORD +40 -0
  35. kinescript-1.1.0.dist-info/WHEEL +5 -0
  36. kinescript-1.1.0.dist-info/entry_points.txt +2 -0
  37. kinescript-1.1.0.dist-info/licenses/LICENSE +25 -0
  38. kinescript-1.1.0.dist-info/licenses/kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
  39. kinescript-1.1.0.dist-info/licenses/kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
  40. kinescript-1.1.0.dist-info/top_level.txt +1 -0
kinescript/mesh.py ADDED
@@ -0,0 +1,390 @@
1
+ """Meshes for model layers. Each primitive is built once at unit size with numpy and scaled per frame,
2
+ so sizes animate for free. Coordinates follow space.py: x right, y down, z toward the viewer."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from functools import lru_cache
7
+ from typing import NamedTuple
8
+
9
+ import numpy as np
10
+ import skia
11
+
12
+ from . import space
13
+
14
+ SHAPES = ("cube", "sphere", "cylinder", "cone", "torus", "plane")
15
+ EXTRUDED = ("text", "path")
16
+ DETAIL = {"low": 0.5, "medium": 1.0, "high": 2.0}
17
+
18
+
19
+ class Mesh(NamedTuple):
20
+ P: np.ndarray # (n, 3) positions
21
+ N: np.ndarray # (n, 3) unit normals
22
+ T: np.ndarray # (m, 3) triangles, wound so a face toward the camera is clockwise on screen (y down)
23
+ M: np.ndarray # (n,) material index of each vertex
24
+
25
+
26
+ def _grid(nu: int, nv: int, base: int = 0) -> np.ndarray:
27
+ """Two triangles per cell of a (nu + 1) x (nv + 1) vertex grid stored row by row in u."""
28
+ i, j = np.meshgrid(np.arange(nu), np.arange(nv), indexing="ij")
29
+ a = base + i * (nv + 1) + j
30
+ b, c, d = a + nv + 1, a + nv + 2, a + 1
31
+ return np.concatenate([np.stack([a, b, c], -1).reshape(-1, 3), np.stack([a, c, d], -1).reshape(-1, 3)])
32
+
33
+
34
+ def _fan(n: int, centre: int, ring: int) -> np.ndarray:
35
+ k = np.arange(n)
36
+ return np.stack([np.full(n, centre), ring + k, ring + k + 1], -1)
37
+
38
+
39
+ def _orient(P, N, T) -> Mesh:
40
+ """Wind every triangle so its face normal agrees with its vertex normals; drop degenerate ones."""
41
+ a, b, c = P[T[:, 0]], P[T[:, 1]], P[T[:, 2]]
42
+ fn = np.cross(b - a, c - a)
43
+ keep = np.linalg.norm(fn, axis=1) > 1e-9
44
+ T, fn = T[keep], fn[keep]
45
+ flip = (fn * (N[T[:, 0]] + N[T[:, 1]] + N[T[:, 2]])).sum(1) < 0
46
+ T[flip] = T[flip][:, [0, 2, 1]]
47
+ return Mesh(P.astype(float), N / np.linalg.norm(N, axis=1, keepdims=True), T.astype(np.int32),
48
+ np.zeros(len(P), np.int32))
49
+
50
+
51
+ def cat(meshes) -> Mesh:
52
+ base = np.cumsum([0] + [len(m.P) for m in meshes[:-1]])
53
+ return Mesh(np.concatenate([m.P for m in meshes]), np.concatenate([m.N for m in meshes]),
54
+ np.concatenate([m.T + b for m, b in zip(meshes, base)]), np.concatenate([m.M for m in meshes]))
55
+
56
+
57
+ def _surface(fn, nu: int, nv: int):
58
+ """Vertices of a parametric surface over u, v in [0, 1] (both ends included)."""
59
+ u, v = np.meshgrid(np.linspace(0, 1, nu + 1), np.linspace(0, 1, nv + 1), indexing="ij")
60
+ P, N = fn(u.ravel(), v.ravel())
61
+ return P, N, _grid(nu, nv)
62
+
63
+
64
+ def _disc(y: float, n: int, normal_y: float):
65
+ a = np.linspace(0, 2 * np.pi, n + 1)
66
+ ring = np.stack([np.cos(a), np.full(n + 1, y), np.sin(a)], -1)
67
+ P = np.concatenate([[[0, y, 0]], ring])
68
+ return P, np.tile([0, normal_y, 0], (n + 2, 1)), _fan(n, 0, 1)
69
+
70
+
71
+ @lru_cache(maxsize=64)
72
+ def unit(shape: str, detail: str = "medium", tube: float = 0.35) -> Mesh:
73
+ """`shape` at unit size: cube and plane span -0.5..0.5, round shapes have radius 1 and height 1."""
74
+ k = DETAIL.get(detail, 1.0)
75
+ seg = max(6, round(24 * k))
76
+ if shape in ("cube", "plane"):
77
+ n = max(1, round(6 * k))
78
+ faces = []
79
+ axes = [(2, 0, 1), (2, 1, 0)] if shape == "plane" else [(a, b, c) for a in range(3) for b, c in
80
+ [((a + 1) % 3, (a + 2) % 3), ((a + 2) % 3, (a + 1) % 3)]]
81
+ for i, (a, b, c) in enumerate(axes):
82
+ sign = 1.0 if i % 2 == 0 else -1.0
83
+ u, v = np.meshgrid(np.linspace(-0.5, 0.5, n + 1), np.linspace(-0.5, 0.5, n + 1), indexing="ij")
84
+ P = np.zeros((u.size, 3))
85
+ P[:, a], P[:, b], P[:, c] = (0.0 if shape == "plane" else 0.5 * sign), u.ravel(), v.ravel()
86
+ N = np.zeros_like(P)
87
+ N[:, a] = sign
88
+ faces.append(_orient(P, N, _grid(n, n)))
89
+ return cat(faces)
90
+ if shape == "sphere":
91
+ def fn(u, v):
92
+ th, ph = u * 2 * np.pi, v * np.pi
93
+ P = np.stack([np.sin(ph) * np.cos(th), -np.cos(ph), np.sin(ph) * np.sin(th)], -1)
94
+ return P, P
95
+ return _orient(*_surface(fn, 2 * seg, seg))
96
+ if shape == "torus":
97
+ def fn(u, v):
98
+ th, ph = u * 2 * np.pi, v * 2 * np.pi
99
+ N = np.stack([np.cos(ph) * np.cos(th), np.cos(ph) * np.sin(th), np.sin(ph)], -1)
100
+ P = np.stack([np.cos(th), np.sin(th), np.zeros_like(th)], -1) + tube * N
101
+ return P, N
102
+ return _orient(*_surface(fn, round(2.6 * seg), seg))
103
+ if shape in ("cylinder", "cone"):
104
+ cone = shape == "cone"
105
+
106
+ def fn(u, v):
107
+ th = u * 2 * np.pi
108
+ r = v if cone else np.ones_like(v)
109
+ P = np.stack([r * np.cos(th), v - 0.5, r * np.sin(th)], -1)
110
+ N = np.stack([np.cos(th), np.full_like(th, -1.0 if cone else 0.0), np.sin(th)], -1)
111
+ return P, N
112
+ side = _orient(*_surface(fn, 2 * seg, max(1, seg // 6)))
113
+ caps = [_orient(*_disc(0.5, 2 * seg, 1.0))] + ([] if cone else [_orient(*_disc(-0.5, 2 * seg, -1.0))])
114
+ return cat([side] + caps)
115
+ raise ValueError(f"unknown shape '{shape}'. Known: {', '.join(SHAPES)}")
116
+
117
+
118
+ def size(shape: str, get) -> tuple[tuple[float, float, float], float]:
119
+ """(x, y, z) scale of the unit shape and the torus tube ratio, from w/h/depth/r/tube via get(key, default)."""
120
+ if shape in ("cube", "plane"):
121
+ w = float(get("w", 200))
122
+ return (w, float(get("h", w)), float(get("depth", w)) if shape == "cube" else 1.0), 0.35
123
+ r = float(get("r", {"sphere": 100, "torus": 110, "cylinder": 80, "cone": 90}[shape]))
124
+ if shape == "torus":
125
+ return (r, r, r), round(min(0.95, max(0.02, float(get("tube", r * 0.35)) / (r or 1))), 2)
126
+ return ((r, r, r) if shape == "sphere" else (r, float(get("h", 200)), r)), 0.35
127
+
128
+
129
+ def placed(m: Mesh, scale3, matrix: np.ndarray | None = None) -> Mesh:
130
+ """`m` scaled by scale3, then moved by a 4x4 matrix (normals by its inverse transpose)."""
131
+ s = np.asarray(scale3, float)
132
+ P, N = m.P * s, m.N / s
133
+ if matrix is not None:
134
+ P = P @ matrix[:3, :3].T + matrix[:3, 3]
135
+ N = N @ np.linalg.inv(matrix[:3, :3])
136
+ return m._replace(P=P, N=N / np.linalg.norm(N, axis=1, keepdims=True))
137
+
138
+
139
+ def part_matrix(p: dict) -> np.ndarray:
140
+ """A part's placement inside its model, in the same order as a layer transform."""
141
+ f = lambda k, d=0.0: float(p.get(k, d)) # noqa: E731
142
+ sc = f("scale", 1.0)
143
+ return (space.translate(f("x"), f("y")) @ space.tilt(f("z"), f("rotateX"), f("rotateY"))
144
+ @ space.rotate_z(f("rotate")) @ np.diag([sc, sc, sc, 1.0]))
145
+
146
+
147
+ def merge(meshes: list[Mesh], materials: list[int]) -> Mesh:
148
+ """One mesh from several, each vertex tagged with its mesh's material slot."""
149
+ return cat([m._replace(M=np.full(len(m.P), k, np.int32)) for m, k in zip(meshes, materials)])
150
+
151
+
152
+ def build_order(m: Mesh) -> np.ndarray:
153
+ """Triangle indices bottom to top, the order `draw` and the build motion reveal them in."""
154
+ return np.argsort(-m.P[m.T, 1].mean(1), kind="stable")
155
+
156
+
157
+ # ---- extruded outlines (text and SVG paths) -----------------------------------------------------------
158
+
159
+ def _flatten(verb, p: np.ndarray, seg: float, w: float) -> np.ndarray:
160
+ """Points along one path segment, after its start point. Curves are cut into pieces about `seg` long,
161
+ lines into pieces up to 3 * seg, so walls stay short enough for the painter's sort."""
162
+ if verb == skia.Path.kLine_Verb:
163
+ n = max(1, math.ceil(float(np.hypot(*(p[1] - p[0]))) / (3 * seg)))
164
+ else:
165
+ n = max(1, min(64, math.ceil(float(np.hypot(*np.diff(p, axis=0).T).sum()) / seg)))
166
+ t = np.linspace(0, 1, n + 1)[1:, None]
167
+ s = 1 - t
168
+ if verb == skia.Path.kLine_Verb:
169
+ return p[0] + t * (p[1] - p[0])
170
+ if verb == skia.Path.kQuad_Verb:
171
+ return s * s * p[0] + 2 * s * t * p[1] + t * t * p[2]
172
+ if verb == skia.Path.kConic_Verb:
173
+ return (s * s * p[0] + 2 * w * s * t * p[1] + t * t * p[2]) / (s * s + 2 * w * s * t + t * t)
174
+ return s ** 3 * p[0] + 3 * s * s * t * p[1] + 3 * s * t * t * p[2] + t ** 3 * p[3]
175
+
176
+
177
+ def _area(q: np.ndarray) -> float:
178
+ """Signed area; positive when the outline runs clockwise on screen (y down)."""
179
+ return 0.5 * float((q[:, 0] * np.roll(q[:, 1], -1) - np.roll(q[:, 0], -1) * q[:, 1]).sum())
180
+
181
+
182
+ def _ring(pts) -> np.ndarray | None:
183
+ q = np.asarray(pts, float)
184
+ q = q[np.any(np.abs(q - np.roll(q, 1, 0)) > 1e-6, axis=1)] # drop repeated points, the closing one too
185
+ return q if len(q) >= 3 and abs(_area(q)) > 1e-6 else None
186
+
187
+
188
+ def contours(path: skia.Path, seg: float) -> list[np.ndarray]:
189
+ """The closed outlines of `path` as (n, 2) arrays, with overlaps removed."""
190
+ try:
191
+ path = skia.Simplify(path)
192
+ except Exception: # Simplify gives up on some degenerate paths; use the path as given
193
+ pass
194
+ out, cur = [], []
195
+ it = skia.Path.Iter(path, True)
196
+ while True:
197
+ verb, pts = it.next()
198
+ if verb in (skia.Path.kMove_Verb, skia.Path.kClose_Verb, skia.Path.kDone_Verb):
199
+ q = _ring(cur) if len(cur) > 2 else None
200
+ if q is not None:
201
+ out.append(q)
202
+ cur = [(pts[0].fX, pts[0].fY)] if verb == skia.Path.kMove_Verb else []
203
+ if verb == skia.Path.kDone_Verb:
204
+ return out
205
+ continue
206
+ cur.extend(_flatten(verb, np.array([(q.fX, q.fY) for q in pts]), seg, it.conicWeight()))
207
+
208
+
209
+ def _contains(q: np.ndarray, p) -> bool:
210
+ """Whether point p is inside the closed outline q (even-odd)."""
211
+ x, y = q[:, 0], q[:, 1]
212
+ x2, y2 = np.roll(x, -1), np.roll(y, -1)
213
+ with np.errstate(divide="ignore", invalid="ignore"):
214
+ hit = ((y > p[1]) != (y2 > p[1])) & (p[0] < x + (p[1] - y) * (x2 - x) / (y2 - y))
215
+ return bool(hit.sum() % 2)
216
+
217
+
218
+ def _in_tri(p: np.ndarray, a, b, c) -> np.ndarray:
219
+ """Which points p (k, 2) lie inside or on triangle abc, either winding."""
220
+ d1 = (b[0] - a[0]) * (p[:, 1] - a[1]) - (b[1] - a[1]) * (p[:, 0] - a[0])
221
+ d2 = (c[0] - b[0]) * (p[:, 1] - b[1]) - (c[1] - b[1]) * (p[:, 0] - b[0])
222
+ d3 = (a[0] - c[0]) * (p[:, 1] - c[1]) - (a[1] - c[1]) * (p[:, 0] - c[0])
223
+ return ~(((d1 < 0) | (d2 < 0) | (d3 < 0)) & ((d1 > 0) | (d2 > 0) | (d3 > 0)))
224
+
225
+
226
+ def _bridge(Q: np.ndarray, ring: np.ndarray, hole: np.ndarray) -> np.ndarray:
227
+ """Join `hole` into `ring` (index arrays into Q) with a cut from the hole's rightmost point to a ring point
228
+ it can see (Eberly, "Triangulation by ear clipping"). The ring runs clockwise on screen, the hole the other way."""
229
+ m = hole[np.argmax(Q[hole, 0])]
230
+ mx, my = Q[m]
231
+ a, b = Q[ring], Q[np.roll(ring, -1)]
232
+ with np.errstate(divide="ignore", invalid="ignore"):
233
+ x = a[:, 0] + (my - a[:, 1]) * (b[:, 0] - a[:, 0]) / (b[:, 1] - a[:, 1])
234
+ ok = ((a[:, 1] > my) != (b[:, 1] > my)) & (x >= mx)
235
+ if not ok.any():
236
+ return ring
237
+ e = np.flatnonzero(ok)[np.argmin(x[ok])]
238
+ k = e if a[e, 0] > b[e, 0] else (e + 1) % len(ring) # the end of the hit edge further along the ray
239
+ prv, nxt = Q[np.roll(ring, 1)], Q[np.roll(ring, -1)]
240
+ reflex = (a[:, 0] - prv[:, 0]) * (nxt[:, 1] - prv[:, 1]) - (a[:, 1] - prv[:, 1]) * (nxt[:, 0] - prv[:, 0]) < 0
241
+ block = reflex & _in_tri(a, (mx, my), (x[e], my), Q[ring[k]])
242
+ block[k] = False
243
+ if block.any(): # a reflex corner hides that point: cut to the corner closest in angle to the ray instead
244
+ cand = np.flatnonzero(block)
245
+ d = a[cand] - (mx, my)
246
+ dist = np.hypot(d[:, 0], d[:, 1])
247
+ k = cand[np.lexsort((dist, np.abs(d[:, 1]) / np.maximum(dist, 1e-12)))[0]]
248
+ j = int(np.flatnonzero(hole == m)[0])
249
+ return np.concatenate([ring[:k + 1], hole[j:], hole[:j], [m, ring[k]], ring[k + 1:]])
250
+
251
+
252
+ def _earclip(Q: np.ndarray) -> np.ndarray:
253
+ """Triangles filling the simple polygon Q (n, 2), which runs clockwise on screen. (m, 3) indices into Q."""
254
+ n = len(Q)
255
+ prv, nxt = np.roll(np.arange(n), 1), np.roll(np.arange(n), -1)
256
+ alive = np.ones(n, bool)
257
+ tiny = 1e-9 * float(np.ptp(Q, axis=0).max()) ** 2
258
+
259
+ def cross(a, b, c):
260
+ return (Q[b, 0] - Q[a, 0]) * (Q[c, 1] - Q[a, 1]) - (Q[b, 1] - Q[a, 1]) * (Q[c, 0] - Q[a, 0])
261
+
262
+ def blocked(a, b, c):
263
+ p = Q[alive]
264
+ same = (p == Q[a]).all(1) | (p == Q[b]).all(1) | (p == Q[c]).all(1) # the corners, and bridge copies
265
+ return bool((_in_tri(p, Q[a], Q[b], Q[c]) & ~same).any())
266
+
267
+ out, i, left, miss = [], 0, n, 0
268
+ while left > 3:
269
+ a, c = prv[i], nxt[i]
270
+ if cross(a, i, c) > tiny and not blocked(a, i, c):
271
+ out.append((a, i, c))
272
+ elif miss < left:
273
+ i, miss = c, miss + 1
274
+ continue
275
+ else: # no clean ear in a whole lap (degenerate outline): drop a flat corner, else the most convex one
276
+ ids = np.flatnonzero(alive)
277
+ cr = cross(prv[ids], ids, nxt[ids])
278
+ flat = np.abs(cr) <= tiny
279
+ i = int(ids[np.argmax(flat)] if flat.any() else ids[np.argmax(cr)])
280
+ a, c = prv[i], nxt[i]
281
+ if not flat.any():
282
+ out.append((a, i, c))
283
+ alive[i] = False
284
+ nxt[a], prv[c] = c, a
285
+ i, left, miss = c, left - 1, 0
286
+ i = int(np.flatnonzero(alive)[0])
287
+ out.append((prv[i], i, nxt[i]))
288
+ return np.array(out, np.int32).reshape(-1, 3)
289
+
290
+
291
+ def shape2d(rings: list[np.ndarray]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
292
+ """Outlines as one point array Q, wound for extrusion (outer outlines clockwise on screen, holes the other
293
+ way), the index where each outline starts, and the triangles (into Q) that fill them."""
294
+ lo, hi = [q.min(0) for q in rings], [q.max(0) for q in rings]
295
+ within = [[j for j in range(len(rings)) if j != i and (lo[j] <= q[0]).all() and (q[0] <= hi[j]).all()
296
+ and _contains(rings[j], q[0])] for i, q in enumerate(rings)]
297
+ rings = [q if (len(w) % 2 == 0) == (_area(q) > 0) else q[::-1] for q, w in zip(rings, within)]
298
+ starts = np.cumsum([0] + [len(q) for q in rings[:-1]]).astype(np.int64)
299
+ Q = np.concatenate(rings) if rings else np.zeros((0, 2))
300
+ tris = []
301
+ for i, w in enumerate(within):
302
+ if len(w) % 2:
303
+ continue
304
+ ring = np.arange(starts[i], starts[i] + len(rings[i]))
305
+ holes = [np.arange(starts[h], starts[h] + len(rings[h])) for h, wh in enumerate(within)
306
+ if len(wh) == len(w) + 1 and i in wh]
307
+ for h in sorted(holes, key=lambda h: -Q[h, 0].max()):
308
+ ring = _bridge(Q, ring, h)
309
+ tris.append(ring[_earclip(Q[ring])])
310
+ return Q, starts, (np.concatenate(tris) if tris else np.zeros((0, 3), np.int64))
311
+
312
+
313
+ def _refine(P: np.ndarray, T: np.ndarray, most: float, outline: bool = False, extra: tuple = ()) -> tuple:
314
+ """Split edges longer than `most` at their midpoints until none is left, so a surface is made of small
315
+ triangles the painter's sort can order against nearby walls and parts. Returns P, T and the per-vertex
316
+ arrays in `extra` grown to match (a new vertex takes the mean of its edge's ends, or the first end's
317
+ integer). `outline=False` never splits edges on the outline, so a cap still meets its walls."""
318
+ extra = list(extra)
319
+ for _ in range(12):
320
+ e = np.sort(T[:, [[0, 1], [1, 2], [2, 0]]].reshape(-1, 2), axis=1) # edges ab, bc, ca of each triangle
321
+ edges, inv, count = np.unique(e, axis=0, return_inverse=True, return_counts=True)
322
+ cut = ((count == 2) | outline) & (np.linalg.norm(P[edges[:, 0]] - P[edges[:, 1]], axis=1) > most)
323
+ if not cut.any():
324
+ break
325
+ mid = np.full(len(edges), -1)
326
+ mid[cut] = len(P) + np.arange(cut.sum())
327
+ a, b = edges[cut, 0], edges[cut, 1]
328
+ P = np.concatenate([P, (P[a] + P[b]) / 2])
329
+ extra = [np.concatenate([x, x[a] if x.dtype.kind in "iu" else (x[a] + x[b]) / 2]) for x in extra]
330
+ M = mid[inv.ravel()].reshape(-1, 3)
331
+ n = (M >= 0).sum(1)
332
+ # Turn each triangle so one cut is on ab, or two cuts are on ab and bc; winding is unchanged.
333
+ r = np.where(n == 1, np.argmax(M >= 0, 1), np.where(n == 2, (np.argmin(M >= 0, 1) + 1) % 3, 0))
334
+ turn = (np.arange(3)[None] + r[:, None]) % 3
335
+ (a, b, c), (ab, bc, ca) = np.take_along_axis(T, turn, 1).T, np.take_along_axis(M, turn, 1).T
336
+ tri = lambda *v, k: np.stack(v, 1)[n == k] # noqa: E731
337
+ T = np.concatenate([tri(a, b, c, k=0), tri(a, ab, c, k=1), tri(ab, b, c, k=1),
338
+ tri(ab, b, bc, k=2), tri(a, ab, bc, k=2), tri(a, bc, c, k=2),
339
+ tri(a, ab, ca, k=3), tri(ab, b, bc, k=3), tri(ca, bc, c, k=3), tri(ab, bc, ca, k=3)])
340
+ return (P, T, *extra)
341
+
342
+
343
+ def extrude(Q: np.ndarray, starts: np.ndarray, caps: np.ndarray, depth: float, bevel: float, rows: int,
344
+ edge: float) -> Mesh:
345
+ """A solid from shape2d's outlines, spanning z = -depth/2..depth/2. The walls are cut into `rows` bands
346
+ and shade smoothly where outline pieces meet at under about 45 degrees; `bevel` chamfers the edges at
347
+ 45 degrees; no cap edge inside the outline is longer than `edge`."""
348
+ n = len(Q)
349
+ size = np.diff(np.append(starts, n))
350
+ first, count = np.repeat(starts, size), np.repeat(size, size)
351
+ idx = np.arange(n)
352
+ nxt, prv = first + (idx - first + 1) % count, first + (idx - first - 1) % count
353
+ d = Q[nxt] - Q
354
+ en = np.stack([d[:, 1], -d[:, 0]], 1)
355
+ en /= np.linalg.norm(en, axis=1, keepdims=True) # outward normal of the edge from point i to the next
356
+ ep = en[prv]
357
+ dot = (en * ep).sum(1)
358
+ vn = en + ep
359
+ vn /= np.maximum(np.linalg.norm(vn, axis=1, keepdims=True), 1e-12)
360
+ smooth = (dot > 0.7)[:, None]
361
+ n0, n1 = np.where(smooth, vn, en), np.where(smooth[nxt], vn[nxt], en) # normal at each end of each edge
362
+ b = max(0.0, min(bevel, depth * 0.45))
363
+ zf = depth / 2 - b
364
+ pieces = []
365
+
366
+ def band(rings, z, nz):
367
+ """A strip of quads along every edge, through one ring of points per height in z."""
368
+ P = np.empty((n, 2, len(z), 3))
369
+ N = np.empty_like(P)
370
+ for j, r in enumerate(rings):
371
+ P[:, 0, j, :2], P[:, 1, j, :2] = r, r[nxt]
372
+ P[..., 2] = z
373
+ N[:, 0, :, :2], N[:, 1, :, :2] = n0[:, None], n1[:, None]
374
+ N[..., 2] = nz
375
+ T = _grid(1, len(z) - 1)[None] + (np.arange(n) * 2 * len(z))[:, None, None]
376
+ pieces.append((P.reshape(-1, 3), N.reshape(-1, 3), T.reshape(-1, 3)))
377
+
378
+ band([Q] * (rows + 1), np.linspace(zf, -zf, rows + 1), 0.0)
379
+ face = Q
380
+ if b > 0:
381
+ face = Q - b * (en + ep) / np.maximum(1 + dot, 0.5)[:, None] # outline moved in along its mitred normals
382
+ for s in (1.0, -1.0):
383
+ band([face, Q], np.array([s * depth / 2, s * zf]), s)
384
+ face, caps = _refine(face, caps, edge)
385
+ for s in (1.0, -1.0):
386
+ P = np.concatenate([face, np.full((len(face), 1), s * depth / 2)], 1)
387
+ pieces.append((P, np.tile([0.0, 0.0, s], (len(face), 1)), caps))
388
+ base = np.cumsum([0] + [len(p[0]) for p in pieces[:-1]])
389
+ return _orient(np.concatenate([p[0] for p in pieces]), np.concatenate([p[1] for p in pieces]),
390
+ np.concatenate([p[2] + o for p, o in zip(pieces, base)]).astype(np.int64))