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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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))
|