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/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
|