nnnotes 0.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 (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,151 @@
1
+ """Atoms: the pure functions the export stages are built from.
2
+
3
+ An atom takes values and returns values: no file or network IO, no threads, no state kept between calls, and the
4
+ same inputs give the same output. Each atom has exactly one reference implementation, registered in `ATOMS` under
5
+ the atom's name as an `Impl(id, fn, cost)`:
6
+
7
+ - `id` names the implementation and the versions of the libraries its output depends on
8
+ (`unitypy-1.25.0+pillow-12.1.1/1`: the libraries, then the revision of the nnnotes code). A task's key includes
9
+ the ids of the atoms it uses, so a library upgrade or a revision bump re-runs exactly the tasks that use the atom.
10
+ - `fn` is the function.
11
+ - `cost(facts)` estimates the work of one call from facts known before the call (sizes, counts, formats):
12
+ `{"cpuSeconds": float, "peakBytes": int}`. The estimates schedule work; they never change an output.
13
+
14
+ `NNNOTES_ATOM_<NAME>` (the name upper-cased, `.` as `_`, e.g. `NNNOTES_ATOM_PNG_ENCODE`) replaces an atom's
15
+ implementation for evaluation: the value is `module:attribute`, an importable `Impl` (or a callable returning
16
+ one). The replacement's id differs from the reference's, so keys and provenance record it.
17
+
18
+ An input an atom documents as out of its scope raises `Unsupported(code, message)`; `code` is one of the reason
19
+ codes listed in docs/stages.md.
20
+
21
+ Heavy libraries (UnityPy, Pillow's codecs) are imported by the atom functions when first called, so importing this
22
+ package or listing the registry does not load them.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import importlib
27
+ import os
28
+ import re
29
+ from collections.abc import Mapping
30
+ from dataclasses import dataclass
31
+ from functools import lru_cache
32
+ from importlib import metadata
33
+ from typing import Callable
34
+
35
+ __all__ = ["ATOMS", "Impl", "Unsupported", "atom_id", "ids", "impl_id", "resolve"]
36
+
37
+
38
+ class Unsupported(Exception):
39
+ """An input outside an atom's documented scope. `code` is a reason code (docs/stages.md), `message` names the
40
+ object or the value that is out of scope."""
41
+
42
+ def __init__(self, code: str, message: str):
43
+ super().__init__(f"{code}: {message}")
44
+ self.code = code
45
+ self.message = message
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Impl:
50
+ id: str
51
+ fn: Callable
52
+ cost: Callable[[dict], dict]
53
+
54
+
55
+ @lru_cache(maxsize=None)
56
+ def _version(dist: str) -> str:
57
+ try:
58
+ return metadata.version(dist)
59
+ except metadata.PackageNotFoundError:
60
+ return "absent"
61
+
62
+
63
+ def impl_id(libraries: tuple[str, ...], revision: int) -> str:
64
+ """`lib-version+lib-version/revision` for the installed versions of `libraries` (distribution names), or
65
+ `nnnotes/revision` for an implementation that depends on no library's behaviour."""
66
+ if not libraries:
67
+ return f"nnnotes/{revision}"
68
+ return "+".join(f"{d.lower()}-{_version(d)}" for d in libraries) + f"/{revision}"
69
+
70
+
71
+ # name -> (module, function, cost function, libraries whose behaviour the output depends on, revision)
72
+ _REFERENCE: dict[str, tuple[str, str, str, tuple[str, ...], int]] = {
73
+ "reader": ("reader", "open_bundle", "cost", ("UnityPy",), 1),
74
+ "texture.decode": ("texture", "decode", "cost", ("UnityPy", "texture2ddecoder", "astc-encoder-py", "Pillow"), 1),
75
+ "png.encode": ("png", "encode", "cost", ("Pillow",), 1),
76
+ "astc.container": ("astc", "container", "cost", (), 1),
77
+ "sprite.crop": ("sprite", "crop", "cost", ("Pillow", "numpy"), 1),
78
+ "mesh.arrays": ("mesh", "arrays", "cost", ("numpy",), 1),
79
+ "gltf.write": ("gltf", "write", "cost", ("numpy",), 1),
80
+ "sniff": ("sniff", "sniff", "cost", (), 1),
81
+ }
82
+
83
+
84
+ def env_name(name: str) -> str:
85
+ """The environment variable that replaces atom `name` for evaluation."""
86
+ return "NNNOTES_ATOM_" + re.sub(r"[^A-Z0-9]", "_", name.upper())
87
+
88
+
89
+ @lru_cache(maxsize=None)
90
+ def _reference(name: str) -> Impl:
91
+ module, fn, cost, libraries, revision = _REFERENCE[name]
92
+ mod = importlib.import_module(f"{__name__}.{module}")
93
+ return Impl(impl_id(libraries, revision), getattr(mod, fn), getattr(mod, cost))
94
+
95
+
96
+ def _replacement(name: str, spec: str) -> Impl:
97
+ module, sep, attr = spec.partition(":")
98
+ if not sep or not module or not attr:
99
+ raise ValueError(f"{env_name(name)}={spec!r}: expected module:attribute")
100
+ obj = getattr(importlib.import_module(module), attr)
101
+ impl = obj if isinstance(obj, Impl) else obj()
102
+ if not isinstance(impl, Impl):
103
+ raise ValueError(f"{env_name(name)}={spec!r}: not an Impl")
104
+ if impl.id == _reference(name).id:
105
+ raise ValueError(f"{env_name(name)}={spec!r}: the replacement has the reference's id {impl.id!r}")
106
+ return impl
107
+
108
+
109
+ def resolve(name: str) -> Impl:
110
+ """The implementation of atom `name`: the reference, or the replacement named by NNNOTES_ATOM_<NAME>."""
111
+ if name not in _REFERENCE:
112
+ raise KeyError(f"unknown atom {name!r}")
113
+ spec = os.environ.get(env_name(name))
114
+ return _replacement(name, spec) if spec else _reference(name)
115
+
116
+
117
+ class _Registry(Mapping):
118
+ """ATOMS[name] -> Impl (see `resolve`)."""
119
+
120
+ def __getitem__(self, name: str) -> Impl:
121
+ return resolve(name)
122
+
123
+ def __iter__(self):
124
+ return iter(_REFERENCE)
125
+
126
+ def __len__(self) -> int:
127
+ return len(_REFERENCE)
128
+
129
+
130
+ ATOMS: Mapping[str, Impl] = _Registry()
131
+
132
+
133
+ def atom_id(name: str) -> str:
134
+ """The implementation id of atom `name` (resolve(name).id); the reference's id is computed without importing
135
+ the atom's module."""
136
+ if name not in _REFERENCE:
137
+ raise KeyError(f"unknown atom {name!r}")
138
+ if os.environ.get(env_name(name)):
139
+ return resolve(name).id
140
+ _module, _fn, _cost, libraries, revision = _REFERENCE[name]
141
+ return impl_id(libraries, revision)
142
+
143
+
144
+ def ids(*names: str) -> dict[str, str]:
145
+ """{atom name: implementation id} of `names`: the `atoms` part of a task (contract.Task.atoms)."""
146
+ return {n: atom_id(n) for n in sorted(names)}
147
+
148
+
149
+ def estimate(cpu_seconds: float, peak_bytes: float) -> dict:
150
+ """A cost estimate as the atoms' cost functions return it."""
151
+ return {"cpuSeconds": round(float(cpu_seconds), 6), "peakBytes": int(peak_bytes)}
nnnotes/atoms/astc.py ADDED
@@ -0,0 +1,52 @@
1
+ """Atom `astc.container`: ASTC blocks in the standard `.astc` file layout.
2
+
3
+ A 16-byte header (magic 0x5CA1AB13, block width, height and depth, then the image width, height and depth as
4
+ 24-bit little-endian integers) followed by the blocks of the first mip level, 16 bytes each, in the order they are
5
+ stored (rows of blocks from the first stored row). Texture data holding further mip levels is cut after the first.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+
11
+ from . import estimate
12
+
13
+ MAGIC = 0x5CA1AB13
14
+ BLOCK_SIZES = frozenset({(4, 4), (5, 4), (5, 5), (6, 5), (6, 6), (8, 5), (8, 6), (8, 8), (10, 5), (10, 6), (10, 8),
15
+ (10, 10), (12, 10), (12, 12)})
16
+
17
+
18
+ def level0_size(width: int, height: int, block: tuple[int, int]) -> int:
19
+ bx, by = block
20
+ return -(-width // bx) * -(-height // by) * 16
21
+
22
+
23
+ def container(blocks: bytes, width: int, height: int, block: tuple[int, int]) -> bytes:
24
+ """The .astc file of a `width` x `height` 2D image whose blocks (of `block` texels) start `blocks`."""
25
+ block = tuple(block)
26
+ if block not in BLOCK_SIZES:
27
+ raise ValueError(f"ASTC block {block}: not a 2D ASTC block size")
28
+ if not (0 < width < 1 << 24 and 0 < height < 1 << 24):
29
+ raise ValueError(f"ASTC image {width}x{height}: sizes must be 1 .. 2^24 - 1")
30
+ need = level0_size(width, height, block)
31
+ if len(blocks) < need:
32
+ raise ValueError(f"ASTC {width}x{height} {block[0]}x{block[1]}: {len(blocks)} bytes, {need} needed")
33
+ head = struct.pack("<I3B", MAGIC, block[0], block[1], 1)
34
+ dims = b"".join(v.to_bytes(3, "little") for v in (width, height, 1))
35
+ return head + dims + bytes(blocks[:need])
36
+
37
+
38
+ def parse_header(data: bytes) -> dict:
39
+ """{block: (x, y, z), size: (w, h, d)} of a .astc file's header."""
40
+ if len(data) < 16:
41
+ raise ValueError("ASTC header: fewer than 16 bytes")
42
+ magic, bx, by, bz = struct.unpack_from("<I3B", data)
43
+ if magic != MAGIC:
44
+ raise ValueError(f"ASTC header: magic {magic:#010x}")
45
+ size = tuple(int.from_bytes(data[7 + 3 * i:10 + 3 * i], "little") for i in range(3))
46
+ return {"block": (bx, by, bz), "size": size}
47
+
48
+
49
+ def cost(facts: dict) -> dict:
50
+ """facts: size (bytes of the blocks)."""
51
+ n = int(facts.get("size", 0))
52
+ return estimate(1e-5 + n * 2e-10, n * 2)
nnnotes/atoms/gltf.py ADDED
@@ -0,0 +1,290 @@
1
+ """Atom `gltf.write`: binary glTF 2.0 (.glb) of meshes in their local space.
2
+
3
+ Each mesh becomes one node (no transform) and one glTF mesh with one primitive per non-empty submesh (a mesh without
4
+ an index buffer: one POINTS primitive over every vertex). Unity's left-handed space becomes glTF's right-handed one
5
+ as in the room export: positions, normals and tangents have z negated (tangent w too, so the bitangent follows),
6
+ triangle winding is reversed, texture coordinates have v flipped (1 - v; a normalized integer: max - v).
7
+
8
+ Attributes: POSITION (float, with min / max), NORMAL, TANGENT, COLOR_0, TEXCOORD_0..7, JOINTS_0 / WEIGHTS_0 (the
9
+ skin's four joints and weights). Component types glTF allows are kept (float; normalized unsigned byte / short for
10
+ colours, texture coordinates and weights); other stored types are converted to float (half floats exactly;
11
+ normalized integers dequantized). Components beyond the ones a semantic takes (a normal's w, a texture coordinate's
12
+ z and w) are written as the custom attribute `_<SEMANTIC>_EXTRA` unless they are all zero; a channel no glTF
13
+ semantic can hold (BlendWeight / BlendIndices of a skin that is not converted, a tangent with fewer than four
14
+ components) as `_<CHANNEL>`. Float attributes carry min / max (NaN components ignored).
15
+
16
+ Mesh extras (`extras.unity`): the submeshes as stored (topology, firstByte, indexCount, baseVertex, firstVertex,
17
+ vertexCount), the stored vertex channel table (channel, stream, offset, format, dimension byte), submeshes left out
18
+ because they are empty, and for skinned meshes the bind poses (row-major 4x4, Unity
19
+ space), the bone name hashes and the root bone name hash. glTF's skin needs the skeleton's nodes, which a mesh does
20
+ not have. The JSON is compact, NaN in extras as {"$float": "nan"}, non-finite floats as 1e999 / -1e999.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import struct
26
+
27
+ import numpy as np
28
+
29
+ from . import Unsupported, estimate
30
+ from .mesh import Channel, MeshArrays, require_vertices
31
+ from .. import jsonio
32
+
33
+ GLB_MAGIC, JSON_CHUNK, BIN_CHUNK = 0x46546C67, 0x4E4F534A, 0x004E4942
34
+ ARRAY_BUFFER, ELEMENT_ARRAY_BUFFER = 34962, 34963
35
+ COMPONENT = {np.dtype("int8"): 5120, np.dtype("uint8"): 5121, np.dtype("int16"): 5122, np.dtype("uint16"): 5123,
36
+ np.dtype("uint32"): 5125, np.dtype("float32"): 5126}
37
+ TYPES = {1: "SCALAR", 2: "VEC2", 3: "VEC3", 4: "VEC4"}
38
+ MODES = {"points": 0, "lines": 1, "line_strip": 3, "triangles": 4}
39
+
40
+
41
+ def _tag_nan(v):
42
+ if isinstance(v, float) and v != v:
43
+ return {"$float": "nan"}
44
+ if isinstance(v, dict):
45
+ return {k: _tag_nan(x) for k, x in v.items()}
46
+ if isinstance(v, (list, tuple)):
47
+ return [_tag_nan(x) for x in v]
48
+ return v
49
+
50
+
51
+ def _floats(a: np.ndarray) -> list:
52
+ return [float(x) for x in a]
53
+
54
+
55
+ class _Builder:
56
+ def __init__(self):
57
+ self.bin = bytearray()
58
+ self.views: list = []
59
+ self.accessors: list = []
60
+
61
+ def _view(self, data: bytes, target: int, stride: int | None = None) -> int:
62
+ self.bin.extend(b"\0" * (-len(self.bin) % 4))
63
+ v = {"buffer": 0, "byteOffset": len(self.bin), "byteLength": len(data)}
64
+ if stride:
65
+ v["byteStride"] = stride
66
+ v["target"] = target
67
+ self.bin.extend(data)
68
+ self.views.append(v)
69
+ return len(self.views) - 1
70
+
71
+ def vertex(self, a: np.ndarray, normalized: bool = False, minmax: bool | None = None) -> int:
72
+ """Accessor of per-vertex values (vertices, components); elements padded to 4 bytes (glTF alignment)."""
73
+ a = np.ascontiguousarray(a)
74
+ a = a.astype(a.dtype.newbyteorder("<"))
75
+ count, n = a.shape
76
+ elem = a.dtype.itemsize * n
77
+ stride = -(-elem // 4) * 4
78
+ if stride == elem:
79
+ data = a.tobytes()
80
+ view = self._view(data, ARRAY_BUFFER)
81
+ else:
82
+ padded = np.zeros((count, stride), np.uint8)
83
+ padded[:, :elem] = a.view(np.uint8).reshape(count, elem)
84
+ view = self._view(padded.tobytes(), ARRAY_BUFFER, stride)
85
+ acc = {"bufferView": view, "componentType": COMPONENT[a.dtype.newbyteorder("=")], "count": count,
86
+ "type": TYPES[n]}
87
+ if normalized:
88
+ acc["normalized"] = True
89
+ if minmax if minmax is not None else a.dtype.kind == "f":
90
+ nan = np.isnan(a)
91
+ lo = np.where(nan, np.inf, a).min(0).astype(np.float64)
92
+ hi = np.where(nan, -np.inf, a).max(0).astype(np.float64)
93
+ lo[nan.all(0)] = hi[nan.all(0)] = 0.0
94
+ acc["min"], acc["max"] = _floats(lo), _floats(hi)
95
+ self.accessors.append(acc)
96
+ return len(self.accessors) - 1
97
+
98
+ def indices(self, idx: np.ndarray) -> int:
99
+ flat = np.ascontiguousarray(idx, np.int64).reshape(-1)
100
+ dt = "<u2" if flat.max(initial=0) < 0xFFFF else "<u4"
101
+ view = self._view(flat.astype(dt).tobytes(), ELEMENT_ARRAY_BUFFER)
102
+ self.accessors.append({"bufferView": view, "componentType": 5123 if dt == "<u2" else 5125,
103
+ "count": int(flat.size), "type": "SCALAR"})
104
+ return len(self.accessors) - 1
105
+
106
+
107
+ def _allowed(ch: Channel, unorm_ok: bool) -> tuple[np.ndarray, bool]:
108
+ """(values, normalized) in a component type glTF allows for the semantic: float32, or (when `unorm_ok`) the
109
+ stored normalized unsigned byte / short."""
110
+ a = ch.data
111
+ if unorm_ok and ch.normalized and a.dtype in (np.dtype("uint8"), np.dtype("uint16")):
112
+ return a, True
113
+ return ch.as_float(), False
114
+
115
+
116
+ def _custom(ch: Channel) -> tuple[np.ndarray, bool]:
117
+ """Values of a custom attribute: byte / short types as stored, 32-bit integers as unsigned short when they fit,
118
+ anything else as float32."""
119
+ a = ch.data
120
+ if a.dtype in (np.dtype("int8"), np.dtype("uint8"), np.dtype("int16"), np.dtype("uint16")):
121
+ return a, ch.normalized
122
+ if a.dtype.kind in "iu" and (not a.size or (a.min() >= 0 and a.max() <= 0xFFFF)):
123
+ return a.astype(np.uint16), False
124
+ return ch.as_float(), False
125
+
126
+
127
+ def _extra(b: _Builder, attrs: dict, name: str, ch: Channel, take: int) -> None:
128
+ rest = ch.data[:, take:]
129
+ if rest.size and np.any(rest != 0):
130
+ a, norm = _custom(Channel(rest, ch.normalized))
131
+ attrs[f"_{name}_EXTRA"] = b.vertex(a, norm)
132
+
133
+
134
+ def _flip_v(a: np.ndarray, normalized: bool) -> np.ndarray:
135
+ a = a.copy()
136
+ if normalized:
137
+ a[:, 1] = np.iinfo(a.dtype).max - a[:, 1]
138
+ else:
139
+ a[:, 1] = (1.0 - a[:, 1].astype(np.float64)).astype(np.float32)
140
+ return a
141
+
142
+
143
+ def _attributes(b: _Builder, m: MeshArrays) -> dict:
144
+ attrs: dict = {}
145
+ ch = m.channels["position"]
146
+ pos = ch.as_float()
147
+ if pos.shape[1] < 3:
148
+ pos = np.hstack([pos, np.zeros((len(pos), 3 - pos.shape[1]), np.float32)])
149
+ pos = pos[:, :3].copy()
150
+ pos[:, 2] *= -1
151
+ attrs["POSITION"] = b.vertex(pos, minmax=True)
152
+ _extra(b, attrs, "POSITION", ch, 3)
153
+ ch = m.channels.get("normal")
154
+ if ch is not None:
155
+ if ch.data.shape[1] >= 3:
156
+ n = ch.as_float()[:, :3].copy()
157
+ n[:, 2] *= -1
158
+ attrs["NORMAL"] = b.vertex(n)
159
+ _extra(b, attrs, "NORMAL", ch, 3)
160
+ else:
161
+ attrs["_NORMAL"] = b.vertex(*_custom(ch))
162
+ ch = m.channels.get("tangent")
163
+ if ch is not None:
164
+ t = ch.as_float()
165
+ if t.shape[1] >= 3:
166
+ t = t.copy()
167
+ t[:, 2] *= -1
168
+ if t.shape[1] >= 4:
169
+ t[:, 3] *= -1
170
+ if t.shape[1] == 4:
171
+ attrs["TANGENT"] = b.vertex(t)
172
+ else:
173
+ attrs["_TANGENT"] = b.vertex(t)
174
+ ch = m.channels.get("color")
175
+ if ch is not None:
176
+ if ch.data.shape[1] in (3, 4):
177
+ attrs["COLOR_0"] = b.vertex(*_allowed(ch, True))
178
+ else:
179
+ attrs["_COLOR"] = b.vertex(*_custom(ch))
180
+ for i in range(8):
181
+ ch = m.channels.get(f"uv{i}")
182
+ if ch is None:
183
+ continue
184
+ if ch.data.shape[1] >= 2:
185
+ a, norm = _allowed(Channel(ch.data[:, :2], ch.normalized), True)
186
+ attrs[f"TEXCOORD_{i}"] = b.vertex(_flip_v(a, norm), norm)
187
+ _extra(b, attrs, f"TEXCOORD_{i}", ch, 2)
188
+ else:
189
+ attrs[f"_TEXCOORD_{i}"] = b.vertex(*_custom(ch))
190
+ if m.skin is not None:
191
+ attrs["JOINTS_0"] = b.vertex(m.skin.joints.astype(np.uint16), minmax=False)
192
+ attrs["WEIGHTS_0"] = b.vertex(*_allowed(m.skin.weights, True))
193
+ else:
194
+ for name, key in (("blendWeight", "_BLENDWEIGHT"), ("blendIndices", "_BLENDINDICES")):
195
+ if name in m.channels:
196
+ attrs[key] = b.vertex(*_custom(m.channels[name]))
197
+ return attrs
198
+
199
+
200
+ def _extras(m: MeshArrays, empty: list) -> dict:
201
+ unity: dict = {"submeshes": [{"topology": int(s.topology or 0), "firstByte": int(s.first_byte),
202
+ "indexCount": int(s.index_count), "baseVertex": int(s.base_vertex or 0),
203
+ "firstVertex": int(s.first_vertex), "vertexCount": int(s.vertex_count)}
204
+ for s in m.submeshes]}
205
+ if m.layout:
206
+ unity["channels"] = m.layout
207
+ if empty:
208
+ unity["emptySubmeshes"] = empty
209
+ if len(m.bind_poses):
210
+ unity["bindPoses"] = [[[float(x) for x in row] for row in mat] for mat in m.bind_poses]
211
+ unity["boneNameHashes"] = [int(h) for h in m.bone_name_hashes]
212
+ unity["rootBoneNameHash"] = None if m.root_bone_name_hash is None else int(m.root_bone_name_hash)
213
+ return {"unity": unity}
214
+
215
+
216
+ def write(meshes, *, extras: dict | None = None, generator: str = "nnnotes") -> bytes:
217
+ """GLB bytes of `meshes` (MeshArrays, one node each). `extras` goes into the asset's extras."""
218
+ b = _Builder()
219
+ gl_meshes, nodes = [], []
220
+ for m in meshes:
221
+ require_vertices(m)
222
+ attrs = _attributes(b, m)
223
+ prims, empty = [], []
224
+ for p in m.primitives:
225
+ if p.indices is None:
226
+ prims.append({"attributes": attrs, "mode": MODES["points"]})
227
+ continue
228
+ if not p.indices.size:
229
+ empty.append(p.submesh)
230
+ continue
231
+ idx = p.indices[:, [0, 2, 1]] if p.topology == "triangles" else p.indices
232
+ prims.append({"attributes": attrs, "indices": b.indices(idx), "mode": MODES[p.topology]})
233
+ if not prims:
234
+ raise Unsupported("empty.mesh", f"mesh {m.name!r}: every submesh is empty")
235
+ gl_meshes.append({"name": m.name, "primitives": prims, "extras": _extras(m, empty)})
236
+ nodes.append({"name": m.name, "mesh": len(gl_meshes) - 1})
237
+ asset: dict = {"version": "2.0", "generator": generator}
238
+ if extras:
239
+ asset["extras"] = extras
240
+ doc = {"asset": asset, "scene": 0, "scenes": [{"nodes": list(range(len(nodes)))}], "nodes": nodes,
241
+ "meshes": gl_meshes, "accessors": b.accessors, "bufferViews": b.views,
242
+ "buffers": [{"byteLength": len(b.bin)}]}
243
+ js = jsonio.dumps(_tag_nan(doc), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
244
+ js += b" " * (-len(js) % 4)
245
+ body = bytes(b.bin) + b"\0" * (-len(b.bin) % 4)
246
+ total = 12 + 8 + len(js) + 8 + len(body)
247
+ return (struct.pack("<III", GLB_MAGIC, 2, total) + struct.pack("<II", len(js), JSON_CHUNK) + js
248
+ + struct.pack("<II", len(body), BIN_CHUNK) + body)
249
+
250
+
251
+ def read(glb: bytes) -> tuple[dict, bytes]:
252
+ """(JSON document, binary chunk) of GLB bytes."""
253
+ import json
254
+ magic, version, total = struct.unpack_from("<III", glb)
255
+ if magic != GLB_MAGIC or version != 2 or total != len(glb):
256
+ raise ValueError("not a glTF 2.0 binary")
257
+ n, kind = struct.unpack_from("<II", glb, 12)
258
+ if kind != JSON_CHUNK:
259
+ raise ValueError("GLB: first chunk is not JSON")
260
+ doc = json.loads(glb[20:20 + n])
261
+ pos = 20 + n
262
+ body = b""
263
+ if pos < len(glb):
264
+ m, kind = struct.unpack_from("<II", glb, pos)
265
+ if kind != BIN_CHUNK:
266
+ raise ValueError("GLB: second chunk is not BIN")
267
+ body = glb[pos + 8:pos + 8 + m]
268
+ return doc, body
269
+
270
+
271
+ def accessor_array(doc: dict, body: bytes, index: int) -> np.ndarray:
272
+ """The values of accessor `index` as an array (count, components) (flat for SCALAR)."""
273
+ acc = doc["accessors"][index]
274
+ view = doc["bufferViews"][acc["bufferView"]]
275
+ dt = np.dtype({v: k for k, v in COMPONENT.items()}[acc["componentType"]]).newbyteorder("<")
276
+ n = {v: k for k, v in TYPES.items()}[acc["type"]]
277
+ count, elem = acc["count"], dt.itemsize * n
278
+ stride = view.get("byteStride", elem)
279
+ start = view["byteOffset"] + acc.get("byteOffset", 0)
280
+ raw = np.frombuffer(body, np.uint8, count=stride * (count - 1) + elem if count else 0, offset=start)
281
+ rows = np.lib.stride_tricks.as_strided(raw, (count, elem), (stride, 1)) if count else raw.reshape(0, elem)
282
+ a = np.ascontiguousarray(rows).view(dt).reshape(count, n)
283
+ return a[:, 0] if n == 1 else a
284
+
285
+
286
+ def cost(facts: dict) -> dict:
287
+ """facts: vertexCount, indexCount, attributeBytes (per vertex, default 48)."""
288
+ v, i = int(facts.get("vertexCount", 0)), int(facts.get("indexCount", 0))
289
+ size = v * int(facts.get("attributeBytes", 48)) + i * 4
290
+ return estimate(5e-5 + v * 5e-8 + i * 1e-8, size * 3 + math.ceil(size / 4))