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/models.py ADDED
@@ -0,0 +1,339 @@
1
+ """Imported models: glTF 2.0 (.glb, .gltf) and Wavefront .obj files read with numpy into a Mesh and its
2
+ materials. The geometry is static: node transforms apply, animation, skins and morph targets do not, and a
3
+ textured material takes its texture's mean colour. Files are y up; the mesh comes out y down like space.py."""
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ import struct
9
+ from pathlib import Path
10
+ from typing import NamedTuple
11
+ from urllib.parse import unquote
12
+
13
+ import numpy as np
14
+ import skia
15
+
16
+ from .mesh import Mesh, _refine
17
+
18
+ EXTENSIONS = (".glb", ".gltf", ".obj")
19
+ _TYPES = {5120: "<i1", 5121: "<u1", 5122: "<i2", 5123: "<u2", 5125: "<u4", 5126: "<f4"}
20
+ _WIDTH = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT2": 4, "MAT3": 9, "MAT4": 16}
21
+ _COMPRESSED = ("KHR_draco_mesh_compression", "EXT_meshopt_compression")
22
+ EDGE = 1 / 24 # longest triangle edge kept, as a share of the model's longest side
23
+ _CACHE: dict = {}
24
+
25
+
26
+ class Imported(NamedTuple):
27
+ mesh: Mesh # centred on its bounding box, in file units; M indexes `materials`, -1 takes the layer's own
28
+ materials: list # {"fill": (r, g, b), "shine": s, "metal": m}
29
+ size: np.ndarray # (3,) bounding box
30
+
31
+
32
+ def load(path: str) -> Imported:
33
+ """The model in `path`, parsed once per file version (path, modification time, size)."""
34
+ p = Path(path)
35
+ st = p.stat()
36
+ key = (str(p.resolve()), st.st_mtime_ns, st.st_size)
37
+ hit = _CACHE.get(key)
38
+ if hit is None:
39
+ ext = p.suffix.lower()
40
+ try:
41
+ if ext not in EXTENSIONS:
42
+ raise ValueError(f"models are read from {', '.join(EXTENSIONS)} files")
43
+ hit = _finish(*(_obj(p) if ext == ".obj" else _gltf(p)))
44
+ except Exception as e: # noqa: BLE001 - a malformed file fails in many ways; all of them name the file
45
+ why = e if isinstance(e, ValueError) else f"could not be read ({type(e).__name__}: {e})"
46
+ raise ValueError(f"{p.name}: {why}") from e
47
+ if len(_CACHE) > 16:
48
+ _CACHE.clear()
49
+ _CACHE[key] = hit
50
+ return hit
51
+
52
+
53
+ def slots(imp: Imported, n: int) -> tuple[np.ndarray, list]:
54
+ """Material of each vertex and the materials, at most n of them. When a file has more, the ones on the
55
+ most vertices stay and every other one takes the kept colour nearest its own."""
56
+ M, mats = imp.mesh.M, imp.materials
57
+ if len(mats) <= n:
58
+ return M, mats
59
+ if n <= 0:
60
+ return np.full_like(M, -1), []
61
+ keep = np.argsort(-np.bincount(M[M >= 0], minlength=len(mats)), kind="stable")[:n]
62
+ cols = np.array([m["fill"] for m in mats])
63
+ near = keep[np.argmin(((cols[:, None] - cols[keep][None]) ** 2).sum(-1), axis=1)]
64
+ near[keep] = keep
65
+ lut = np.full(len(mats), -1)
66
+ lut[keep] = np.arange(n)
67
+ return np.where(M >= 0, lut[near][np.maximum(M, 0)], -1), [mats[k] for k in keep]
68
+
69
+
70
+ def _flat(P: np.ndarray, T: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
71
+ """Each triangle its own three vertices with its face normal (files without normals)."""
72
+ P = P[T].reshape(-1, 3)
73
+ fn = np.cross(P[1::3] - P[0::3], P[2::3] - P[0::3])
74
+ return P, np.repeat(fn, 3, axis=0), np.arange(len(P)).reshape(-1, 3)
75
+
76
+
77
+ def _finish(pieces: list, mats: list) -> Imported:
78
+ """One mesh from (P, N, T, M) pieces in file space: turned y down, cleaned of degenerate triangles and
79
+ unused vertices, centred, and with repeated materials merged."""
80
+ if not pieces:
81
+ raise ValueError("no triangle meshes found")
82
+ base = np.cumsum([0] + [len(p[0]) for p in pieces[:-1]])
83
+ P = np.concatenate([p[0] for p in pieces]) * (1.0, -1.0, 1.0)
84
+ N = np.concatenate([p[1] for p in pieces]) * (1.0, -1.0, 1.0)
85
+ T = np.concatenate([p[2] + b for p, b in zip(pieces, base)])[:, [0, 2, 1]] # y flipped: wind back
86
+ M = np.concatenate([p[3] for p in pieces])
87
+ a, b, c = P[T[:, 0]], P[T[:, 1]], P[T[:, 2]]
88
+ area = np.linalg.norm(np.cross(b - a, c - a), axis=1)
89
+ T = T[area > 1e-12 * max(float(np.ptp(P, axis=0).max()), 1e-9) ** 2]
90
+ if not len(T):
91
+ raise ValueError("no triangle meshes found")
92
+ used, inv = np.unique(T, return_inverse=True)
93
+ P, N, M, T = P[used], N[used], M[used], inv.reshape(-1, 3)
94
+ lo, hi = P.min(0), P.max(0)
95
+ # Long triangles sort by their middle and can be drawn over parts in front of them (the painter's
96
+ # algorithm): cut every edge down to 1/24 of the model's size. Already fine meshes are unchanged.
97
+ P, T, N, M = _refine(P, T, float((hi - lo).max()) * EDGE, outline=True, extra=(N, M))
98
+ N = N / np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
99
+ T = T.astype(np.int32)
100
+ seen: dict = {}
101
+ remap = np.array([seen.setdefault((tuple(np.round(m["fill"], 3)), m["shine"], m["metal"]), len(seen))
102
+ for m in mats] + [-1], int)
103
+ M = remap[np.where(M >= 0, M, len(mats))]
104
+ kept = np.unique(M[M >= 0]) # materials no remaining triangle uses take no slot
105
+ lut = np.full(len(seen) + 1, -1)
106
+ lut[kept] = np.arange(len(kept))
107
+ keys = list(seen)
108
+ merged = [dict(fill=tuple(map(float, keys[k][0])), shine=keys[k][1], metal=keys[k][2]) for k in kept]
109
+ return Imported(Mesh(P - (lo + hi) / 2, N, T, lut[M].astype(np.int32)), merged, hi - lo)
110
+
111
+
112
+ # ---- glTF -----------------------------------------------------------------------------------------------
113
+
114
+ def _node(nd: dict) -> np.ndarray:
115
+ if "matrix" in nd:
116
+ return np.array(nd["matrix"], float).reshape(4, 4).T # stored column by column
117
+ x, y, z, w = nd.get("rotation", (0.0, 0.0, 0.0, 1.0))
118
+ R = np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
119
+ [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
120
+ [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])
121
+ m = np.eye(4)
122
+ m[:3, :3] = R * np.asarray(nd.get("scale", (1.0, 1.0, 1.0)), float)
123
+ m[:3, 3] = nd.get("translation", (0.0, 0.0, 0.0))
124
+ return m
125
+
126
+
127
+ def _gltf(p: Path) -> tuple[list, list]:
128
+ data, chunks = p.read_bytes(), []
129
+ if data[:4] == b"glTF":
130
+ version, total = struct.unpack_from("<II", data, 4)
131
+ if version != 2:
132
+ raise ValueError(f"glTF version {version}; only glTF 2.0 is read")
133
+ off, doc = 12, None
134
+ while off + 8 <= min(total, len(data)):
135
+ n, kind = struct.unpack_from("<II", data, off)
136
+ if kind == 0x4E4F534A:
137
+ doc = json.loads(data[off + 8:off + 8 + n])
138
+ elif kind == 0x004E4942:
139
+ chunks.append(data[off + 8:off + 8 + n])
140
+ off += 8 + n
141
+ if doc is None:
142
+ raise ValueError("no JSON chunk; not a glTF binary")
143
+ else:
144
+ doc = json.loads(data)
145
+ if not str(doc.get("asset", {}).get("version", "2")).startswith("2"):
146
+ raise ValueError(f"glTF version {doc['asset']['version']}; only glTF 2.0 is read")
147
+ squeezed = [e for e in doc.get("extensionsRequired", []) if e in _COMPRESSED]
148
+ if squeezed:
149
+ raise ValueError(f"meshes are compressed with {squeezed[0]}, which Kinescript does not read; "
150
+ "export it again without mesh compression")
151
+ bufs: dict = {}
152
+
153
+ def uri(u: str) -> bytes:
154
+ if u.startswith("data:"):
155
+ return base64.b64decode(u.split(",", 1)[1])
156
+ return (p.parent / unquote(u)).read_bytes()
157
+
158
+ def buffer(i: int) -> bytes:
159
+ if i not in bufs:
160
+ b = doc["buffers"][i]
161
+ bufs[i] = uri(b["uri"]) if "uri" in b else chunks[0]
162
+ return bufs[i]
163
+
164
+ def view(i: int) -> tuple[bytes, int, int | None]:
165
+ v = doc["bufferViews"][i]
166
+ return buffer(v["buffer"]), v.get("byteOffset", 0), v.get("byteStride")
167
+
168
+ def accessor(i: int) -> np.ndarray:
169
+ a = doc["accessors"][i]
170
+ if "sparse" in a:
171
+ raise ValueError("sparse accessors are not read; export it again without them")
172
+ dt, k, n = np.dtype(_TYPES[a["componentType"]]), _WIDTH[a["type"]], a["count"]
173
+ if "bufferView" not in a:
174
+ return np.zeros((n, k))
175
+ buf, off, stride = view(a["bufferView"])
176
+ arr = np.ndarray((n, k), dt, buf, off + a.get("byteOffset", 0), (stride or k * dt.itemsize, dt.itemsize))
177
+ out = arr.astype(float)
178
+ if a.get("normalized") and dt.kind in "iu":
179
+ out = np.maximum(out / np.iinfo(dt).max, -1.0)
180
+ return out
181
+
182
+ means: dict = {}
183
+
184
+ def texture(t: dict | None) -> np.ndarray | None:
185
+ """Mean colour (linear) of a texture's image, or None when it cannot be read."""
186
+ try:
187
+ src = doc["textures"][t["index"]]["source"]
188
+ if src not in means:
189
+ im = doc["images"][src]
190
+ if "bufferView" in im:
191
+ buf, off, _ = view(im["bufferView"])
192
+ raw = buf[off:off + doc["bufferViews"][im["bufferView"]]["byteLength"]]
193
+ else:
194
+ raw = uri(im["uri"])
195
+ img = skia.Image.MakeFromEncoded(skia.Data.MakeWithCopy(raw))
196
+ px = img.toarray(colorType=skia.kRGBA_8888_ColorType)
197
+ px = px[::max(1, px.shape[0] // 64), ::max(1, px.shape[1] // 64), :3] / 255.0
198
+ means[src] = (px.reshape(-1, 3) ** 2.2).mean(0)
199
+ return means[src]
200
+ except (KeyError, IndexError, TypeError, AttributeError, OSError):
201
+ return None
202
+
203
+ mats = []
204
+ for mt in doc.get("materials", []):
205
+ pbr = mt.get("pbrMetallicRoughness", {})
206
+ col = np.array(pbr.get("baseColorFactor", (1.0, 1.0, 1.0, 1.0))[:3], float)
207
+ mean = texture(pbr.get("baseColorTexture"))
208
+ col = col if mean is None else col * mean
209
+ metal, rough = float(pbr.get("metallicFactor", 1.0)), float(pbr.get("roughnessFactor", 1.0))
210
+ # Rough metal looks like plain paint here; only smooth metal gets the tinted highlight.
211
+ mats.append({"fill": tuple(np.clip(col, 0, 1) ** (1 / 2.2)), "shine": round(1 - rough, 3),
212
+ "metal": round(metal * (1 - rough), 3)})
213
+ pieces = []
214
+
215
+ def emit(mi: int, world: np.ndarray):
216
+ A = world[:3, :3]
217
+ for prim in doc["meshes"][mi].get("primitives", []):
218
+ mode, at = prim.get("mode", 4), prim.get("attributes", {})
219
+ if mode not in (4, 5, 6) or "POSITION" not in at:
220
+ continue # points and lines have no surface
221
+ P = accessor(at["POSITION"])[:, :3]
222
+ idx = accessor(prim["indices"]).ravel().astype(np.int64) if "indices" in prim else np.arange(len(P))
223
+ if mode == 4:
224
+ T = idx[:len(idx) // 3 * 3].reshape(-1, 3)
225
+ else:
226
+ i = np.arange(max(0, len(idx) - 2))
227
+ T = (np.stack([idx[i], idx[i + 1 + i % 2], idx[i + 2 - i % 2]], 1) if mode == 5
228
+ else np.stack([np.full(len(i), idx[0]), idx[i + 1], idx[i + 2]], 1))
229
+ if np.linalg.det(A) < 0:
230
+ T = T[:, [0, 2, 1]] # a mirroring node turns its faces inside out; turn them back
231
+ P = P @ A.T + world[:3, 3]
232
+ if "NORMAL" in at:
233
+ N = accessor(at["NORMAL"])[:, :3] @ np.linalg.inv(A)
234
+ else:
235
+ P, N, T = _flat(P, T)
236
+ mat = prim.get("material", -1)
237
+ if mat >= 0 and doc["materials"][mat].get("doubleSided"):
238
+ P, N, T = np.r_[P, P], np.r_[N, -N], np.r_[T, T[:, [0, 2, 1]] + len(P)]
239
+ pieces.append((P, N, T, np.full(len(P), mat)))
240
+
241
+ nodes = doc.get("nodes", [])
242
+ if doc.get("scenes"):
243
+ roots = doc["scenes"][doc.get("scene", 0)].get("nodes", [])
244
+ else:
245
+ kids = {k for nd in nodes for k in nd.get("children", [])}
246
+ roots = [i for i in range(len(nodes)) if i not in kids]
247
+ stack = [(i, np.eye(4)) for i in roots]
248
+ while stack:
249
+ i, parent = stack.pop()
250
+ world = parent @ _node(nodes[i])
251
+ if "mesh" in nodes[i]:
252
+ emit(nodes[i]["mesh"], world)
253
+ stack.extend((k, world) for k in nodes[i].get("children", []))
254
+ if not nodes:
255
+ for mi in range(len(doc.get("meshes", []))):
256
+ emit(mi, np.eye(4))
257
+ return pieces, mats
258
+
259
+
260
+ # ---- OBJ ------------------------------------------------------------------------------------------------
261
+
262
+ def _mtl(path: Path) -> dict:
263
+ """Materials of an .mtl file: Kd is the colour, Ns (0..1000) the shine."""
264
+ out: dict = {}
265
+ cur = None
266
+ if not path.is_file():
267
+ return out
268
+ for line in path.read_text(errors="replace").splitlines():
269
+ t = line.split()
270
+ if not t:
271
+ continue
272
+ if t[0] == "newmtl":
273
+ cur = out.setdefault(" ".join(t[1:]), {"fill": (0.8, 0.8, 0.8), "shine": 0.0, "metal": 0.0})
274
+ elif cur is not None and t[0] == "Kd" and len(t) >= 4:
275
+ cur["fill"] = tuple(min(1.0, max(0.0, float(x))) for x in t[1:4])
276
+ elif cur is not None and t[0] == "Ns" and len(t) >= 2:
277
+ cur["shine"] = round(min(1.0, max(0.0, float(t[1]) / 1000)) ** 0.5, 3)
278
+ return out
279
+
280
+
281
+ def _obj(p: Path) -> tuple[list, list]:
282
+ V, VN, tris, nrm, fm, fg = [], [], [], [], [], []
283
+ lib: dict = {}
284
+ names: list = []
285
+ mat, grp = -1, 0
286
+
287
+ def ix(s: str, n: int) -> int:
288
+ i = int(s)
289
+ return i - 1 if i > 0 else n + i
290
+
291
+ for line in p.read_text(errors="replace").splitlines():
292
+ t = line.split("#", 1)[0].split()
293
+ if not t:
294
+ continue
295
+ if t[0] == "v":
296
+ V.append(t[1:4])
297
+ elif t[0] == "vn":
298
+ VN.append(t[1:4])
299
+ elif t[0] == "f" and len(t) >= 4:
300
+ c = [tok.split("/") for tok in t[1:]]
301
+ vi = [ix(x[0], len(V)) for x in c]
302
+ ni = [ix(x[2], len(VN)) if len(x) > 2 and x[2] else -1 for x in c]
303
+ for k in range(1, len(c) - 1): # a polygon as a fan
304
+ tris.append((vi[0], vi[k], vi[k + 1]))
305
+ nrm.append((ni[0], ni[k], ni[k + 1]))
306
+ fm.append(mat)
307
+ fg.append(grp)
308
+ elif t[0] == "usemtl":
309
+ name = " ".join(t[1:])
310
+ if name not in names:
311
+ names.append(name)
312
+ mat = names.index(name)
313
+ elif t[0] == "mtllib":
314
+ lib.update(_mtl(p.parent / " ".join(t[1:])))
315
+ elif t[0] == "s" and len(t) > 1:
316
+ grp = 0 if t[1].lower() in ("off", "0") else int(t[1]) if t[1].isdigit() else 1
317
+ if not tris:
318
+ return [], []
319
+ V = np.array(V, float)
320
+ cv, cn = np.array(tris).ravel(), np.array(nrm).ravel()
321
+ known = np.array([n in lib for n in names] + [False])
322
+ fm = np.array(fm)
323
+ fm = np.where(known[fm], fm, -1) # usemtl of a material no .mtl defines: the layer's own
324
+ cm, ct = np.repeat(fm, 3), np.repeat(np.arange(len(tris)), 3)
325
+ if len(VN) and (cn >= 0).all():
326
+ key = np.stack([cv, cn, cm], 1)
327
+ _, first, inv = np.unique(key, axis=0, return_index=True, return_inverse=True)
328
+ N = np.array(VN, float)[cn[first]]
329
+ else:
330
+ # No normals in the file: flat faces, smooth across the vertices of each smoothing group ("s 1").
331
+ cg = np.repeat(np.array(fg), 3)
332
+ key = np.stack([cv, np.where(cg > 0, cg, -1 - ct), cm], 1)
333
+ _, first, inv = np.unique(key, axis=0, return_index=True, return_inverse=True)
334
+ a, b, c = V[cv[0::3]], V[cv[1::3]], V[cv[2::3]]
335
+ N = np.zeros((len(first), 3))
336
+ np.add.at(N, inv.ravel(), np.repeat(np.cross(b - a, c - a), 3, axis=0))
337
+ inv = inv.ravel()
338
+ mats = [lib[n] if n in lib else {"fill": (0.8, 0.8, 0.8), "shine": 0.0, "metal": 0.0} for n in names]
339
+ return [(V[cv[first]], N, inv.reshape(-1, 3), cm[first])], mats