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.
- kinescript/__init__.py +6 -0
- kinescript/cli.py +167 -0
- kinescript/color.py +89 -0
- kinescript/components.py +272 -0
- kinescript/docs.py +345 -0
- kinescript/ease.py +143 -0
- kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
- kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
- kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
- kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
- kinescript/mcp.py +148 -0
- kinescript/media.py +220 -0
- kinescript/mesh.py +390 -0
- kinescript/models.py +339 -0
- kinescript/output.py +370 -0
- kinescript/presets.py +170 -0
- kinescript/props.py +185 -0
- kinescript/render.py +1204 -0
- kinescript/render3d.py +393 -0
- kinescript/scene.py +362 -0
- kinescript/schema.py +701 -0
- kinescript/shaders.py +79 -0
- kinescript/space.py +81 -0
- kinescript/svgpath.py +80 -0
- kinescript/timing.py +169 -0
- kinescript/verify.py +261 -0
- kinescript-1.1.0.dist-info/METADATA +192 -0
- kinescript-1.1.0.dist-info/RECORD +40 -0
- kinescript-1.1.0.dist-info/WHEEL +5 -0
- kinescript-1.1.0.dist-info/entry_points.txt +2 -0
- kinescript-1.1.0.dist-info/licenses/LICENSE +25 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- 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))
|