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
nnnotes/room.py ADDED
@@ -0,0 +1,391 @@
1
+ """Spot background prefab -> binary glTF (.glb).
2
+
3
+ A spot background is a few hundred textured cards (MeshFilter + MeshRenderer
4
+ under a Transform hierarchy). Each mesh is baked into prefab space by walking
5
+ the Transform TRS chain, converted from Unity's left-handed space to glTF's
6
+ right-handed space (negate Z, reverse winding), and written with its texture(s)
7
+ embedded. Geometry is read from the mesh's vertex/index buffers; each submesh
8
+ becomes one primitive with the renderer's material of the same index.
9
+
10
+ Materials are translated from the Unity material's shader and that shader's own
11
+ pass render state (culling, blend factors and operations, depth write and test,
12
+ colour mask, alpha to mask, queue tags) -- not from names. A render state value
13
+ that names a property (URP/Lit's `Blend [_SrcBlend] [_DstBlend]`, `Cull [_Cull]`,
14
+ `ZWrite [_ZWrite]`) takes the material's value of that property, else the
15
+ shader's default for it, else the value the shader stores (render_state). Each
16
+ glTF material records the resolved state in `extras.unityRenderState`.
17
+ Unsupported shaders and states raise instead of being approximated. A submesh
18
+ whose material slot is empty (null) is not written: nothing says what it would
19
+ be drawn with; room.json lists it (`nullMaterialSubmeshes`). A MeshFilter with
20
+ no mesh, or a mesh with no triangles, draws nothing; room.json lists the object
21
+ (`nullMeshFilters`, `meshesWithoutTriangles`).
22
+
23
+ The situation's backgroundPosition/Rotation/Scale (spot.json) is not baked in: it
24
+ goes on top of this, as the game applies it to the prefab root. Objects that
25
+ are inactive in the prefab are kept, flagged `extras.unityActive = false`.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import io
30
+ import struct
31
+ from pathlib import Path
32
+
33
+ import numpy as np
34
+ import UnityPy
35
+
36
+ from .catalog import Catalog
37
+ from .jsonio import dumps, write_json
38
+ from .unity import SceneGraph, mesh_arrays, texture_image
39
+
40
+ # UnityEngine.Rendering.BlendMode
41
+ BLEND_ONE, BLEND_ZERO, BLEND_SRC_ALPHA, BLEND_ONE_MINUS_SRC_ALPHA = 1, 0, 5, 10
42
+ BLEND_OP_ADD = 0 # UnityEngine.Rendering.BlendOp.Add
43
+ # UnityEngine.Rendering.CullMode
44
+ CULL_OFF, CULL_FRONT, CULL_BACK = 0, 1, 2
45
+ # glTF sampler enums
46
+ NEAREST, LINEAR, LINEAR_MIPMAP_LINEAR, NEAREST_MIPMAP_NEAREST = 9728, 9729, 9987, 9984
47
+ REPEAT, CLAMP, MIRROR = 10497, 33071, 33648
48
+ # UnityEngine.TextureWrapMode
49
+ WRAP = {0: REPEAT, 1: CLAMP, 2: MIRROR, 3: MIRROR}
50
+ UNLIT_SHADERS = ("Unlit/", "Universal Render Pipeline/Unlit")
51
+ # render state of a pass (SerializedShaderState) read by room: record key -> path in m_State (render target 0)
52
+ STATE_FIELDS = {
53
+ "srcBlend": ("rtBlend0", "srcBlend"), "dstBlend": ("rtBlend0", "destBlend"),
54
+ "srcBlendAlpha": ("rtBlend0", "srcBlendAlpha"), "dstBlendAlpha": ("rtBlend0", "destBlendAlpha"),
55
+ "blendOp": ("rtBlend0", "blendOp"), "blendOpAlpha": ("rtBlend0", "blendOpAlpha"),
56
+ "colorMask": ("rtBlend0", "colMask"), "cull": ("culling",), "zWrite": ("zWrite",), "zTest": ("zTest",),
57
+ "alphaToMask": ("alphaToMask",),
58
+ }
59
+ NO_PROPERTY = "<noninit>" # SerializedShaderFloatValue.name of a fixed value
60
+
61
+
62
+ # ---- material translation -------------------------------------------------
63
+ def shader_defaults(shader_tt: dict) -> dict:
64
+ """{property name: default value} of a shader's properties (m_ParsedForm.m_PropInfo, first component)."""
65
+ props = shader_tt["m_ParsedForm"].get("m_PropInfo", {}).get("m_Props", [])
66
+ return {p["m_Name"]: p["m_DefValue[0]"] for p in props if "m_DefValue[0]" in p}
67
+
68
+
69
+ def material_values(mat_tt: dict) -> dict:
70
+ """{property name: value} of a material's int and float properties."""
71
+ sp = mat_tt["m_SavedProperties"]
72
+ return {**dict(sp.get("m_Ints", [])), **dict(sp.get("m_Floats", []))}
73
+
74
+
75
+ def state_value(x, values: dict, defaults: dict):
76
+ """One pass render state value. A SerializedShaderFloatValue {val, name} whose name is a property (not
77
+ "<noninit>") takes the material's value of it (`values`), else the shader's default (`defaults`), else `val`;
78
+ a fixed value is `val`. Enum values come back as int."""
79
+ if not isinstance(x, dict):
80
+ return x
81
+ name = x.get("name")
82
+ v = x.get("val")
83
+ if name and name != NO_PROPERTY:
84
+ v = values.get(name, defaults.get(name, v))
85
+ return int(v) if isinstance(v, float) and v.is_integer() else v
86
+
87
+
88
+ def render_state(mat_tt: dict, shader_tt: dict) -> dict:
89
+ """The render state (STATE_FIELDS) of the shader's first pass for this material."""
90
+ state, _ = _pass_state(shader_tt)
91
+ values, defaults = material_values(mat_tt), shader_defaults(shader_tt)
92
+ out = {}
93
+ for key, path in STATE_FIELDS.items():
94
+ x = state
95
+ for part in path:
96
+ x = x.get(part, {}) if isinstance(x, dict) else {}
97
+ out[key] = state_value(x, values, defaults) if x != {} else None
98
+ return out
99
+
100
+
101
+ def _pass_state(shader_tt: dict) -> tuple[dict, dict]:
102
+ """(render state of the first pass, merged subshader+pass tags)."""
103
+ ss = shader_tt["m_ParsedForm"]["m_SubShaders"][0]
104
+ ps = ss["m_Passes"][0]
105
+ tags = dict(ss.get("m_Tags", {}).get("tags", []))
106
+ tags.update(dict(ps.get("m_Tags", {}).get("tags", [])))
107
+ return ps["m_State"], tags
108
+
109
+
110
+ def translate_material(mat_tt: dict, shader_tt: dict) -> dict:
111
+ """Unity material + shader -> glTF material fields (texture filled by caller)."""
112
+ shader_name = shader_tt["m_ParsedForm"]["m_Name"]
113
+ _, tags = _pass_state(shader_tt)
114
+ rs = render_state(mat_tt, shader_tt)
115
+ src, dst, cull = rs["srcBlend"], rs["dstBlend"], rs["cull"]
116
+ floats = dict(mat_tt["m_SavedProperties"]["m_Floats"])
117
+ colors = dict(mat_tt["m_SavedProperties"]["m_Colors"])
118
+
119
+ unlit = shader_name.startswith(UNLIT_SHADERS)
120
+ if not unlit and shader_name != "Universal Render Pipeline/Lit":
121
+ raise NotImplementedError(f"shader not translated: {shader_name}")
122
+
123
+ out: dict = {"name": mat_tt["m_Name"], "extras": {"unityShader": shader_name, "unityRenderState": rs}}
124
+ if (src, dst) == (BLEND_ONE, BLEND_ZERO):
125
+ cutout = tags.get("RenderType") == "TransparentCutout" or tags.get("QUEUE") == "AlphaTest"
126
+ if cutout:
127
+ out["alphaMode"] = "MASK"
128
+ out["alphaCutoff"] = floats["_Cutoff"]
129
+ elif (src, dst) == (BLEND_SRC_ALPHA, BLEND_ONE_MINUS_SRC_ALPHA):
130
+ if rs["blendOp"] not in (None, BLEND_OP_ADD):
131
+ raise NotImplementedError(f"blend op {rs['blendOp']} in {shader_name}")
132
+ out["alphaMode"] = "BLEND"
133
+ else:
134
+ raise NotImplementedError(f"blend {src}/{dst} in {shader_name}")
135
+ if cull == CULL_OFF:
136
+ out["doubleSided"] = True
137
+ elif cull != CULL_BACK:
138
+ raise NotImplementedError(f"cull mode {cull} in {shader_name}")
139
+
140
+ pbr: dict = {}
141
+ if unlit:
142
+ out["extensions"] = {"KHR_materials_unlit": {}}
143
+ pbr.update(metallicFactor=0.0, roughnessFactor=1.0)
144
+ else:
145
+ c = colors.get("_BaseColor")
146
+ if c:
147
+ pbr["baseColorFactor"] = [c["r"], c["g"], c["b"], c["a"]]
148
+ pbr["metallicFactor"] = floats.get("_Metallic", 0.0)
149
+ pbr["roughnessFactor"] = 1.0 - floats.get("_Smoothness", 0.5)
150
+ out["pbrMetallicRoughness"] = pbr
151
+ return out
152
+
153
+
154
+ def _main_tex_slot(shader_name: str) -> str:
155
+ return "_BaseMap" if shader_name.startswith("Universal Render Pipeline/") else "_MainTex"
156
+
157
+
158
+ def _sampler(tex_tt: dict) -> dict:
159
+ s = tex_tt.get("m_TextureSettings", {})
160
+ mips = tex_tt.get("m_MipCount", 1) > 1
161
+ fm = s.get("m_FilterMode", 1)
162
+ mag = NEAREST if fm == 0 else LINEAR
163
+ if fm == 0:
164
+ mn = NEAREST_MIPMAP_NEAREST if mips else NEAREST
165
+ else:
166
+ mn = LINEAR_MIPMAP_LINEAR if mips else LINEAR
167
+ return {"magFilter": mag, "minFilter": mn,
168
+ "wrapS": WRAP.get(s.get("m_WrapU", 0), REPEAT),
169
+ "wrapT": WRAP.get(s.get("m_WrapV", 0), REPEAT)}
170
+
171
+
172
+ # ---- glTF builder -------------------------------------------------------
173
+ class _Glb:
174
+ def __init__(self):
175
+ self.bin = bytearray()
176
+ self.bufferViews, self.accessors, self.meshes, self.nodes = [], [], [], []
177
+ self.materials, self.textures, self.images, self.samplers = [], [], [], []
178
+ self.ext_used: set[str] = set()
179
+
180
+ def _view(self, data: bytes, target=None) -> int:
181
+ while len(self.bin) % 4:
182
+ self.bin.append(0)
183
+ bv = {"buffer": 0, "byteOffset": len(self.bin), "byteLength": len(data)}
184
+ if target:
185
+ bv["target"] = target
186
+ self.bin.extend(data)
187
+ self.bufferViews.append(bv)
188
+ return len(self.bufferViews) - 1
189
+
190
+ def accessor_f32(self, arr: np.ndarray, kind: str, minmax=False) -> int:
191
+ a = np.ascontiguousarray(arr, dtype="<f4")
192
+ acc = {"bufferView": self._view(a.tobytes(), 34962), "componentType": 5126,
193
+ "count": int(arr.shape[0]), "type": kind}
194
+ if minmax:
195
+ acc["min"] = a.min(0).tolist()
196
+ acc["max"] = a.max(0).tolist()
197
+ self.accessors.append(acc)
198
+ return len(self.accessors) - 1
199
+
200
+ def accessor_idx(self, idx: np.ndarray) -> int:
201
+ a = np.ascontiguousarray(idx, dtype="<u4")
202
+ self.accessors.append({"bufferView": self._view(a.tobytes(), 34963), "componentType": 5125,
203
+ "count": int(idx.shape[0]), "type": "SCALAR"})
204
+ return len(self.accessors) - 1
205
+
206
+ def texture(self, png: bytes, name: str, sampler: dict) -> int:
207
+ self.images.append({"name": name, "bufferView": self._view(png), "mimeType": "image/png"})
208
+ if sampler not in self.samplers:
209
+ self.samplers.append(sampler)
210
+ self.textures.append({"source": len(self.images) - 1,
211
+ "sampler": self.samplers.index(sampler)})
212
+ return len(self.textures) - 1
213
+
214
+ def material(self, m: dict) -> int:
215
+ self.ext_used.update(m.get("extensions", {}).keys())
216
+ self.materials.append(m)
217
+ return len(self.materials) - 1
218
+
219
+ def add_mesh(self, name, pos, nrm, uv, prims, active=True) -> None:
220
+ """prims: [(triangle index array, material index)] -- one per submesh."""
221
+ attrs = {"POSITION": self.accessor_f32(pos, "VEC3", minmax=True),
222
+ "TEXCOORD_0": self.accessor_f32(uv, "VEC2")}
223
+ if len(nrm):
224
+ attrs["NORMAL"] = self.accessor_f32(nrm, "VEC3")
225
+ primitives = []
226
+ for idx, material in prims:
227
+ p = {"attributes": attrs, "indices": self.accessor_idx(idx), "mode": 4}
228
+ if material is not None:
229
+ p["material"] = material
230
+ primitives.append(p)
231
+ self.meshes.append({"name": name, "primitives": primitives})
232
+ self.nodes.append({"name": name, "mesh": len(self.meshes) - 1,
233
+ "extras": {"unityActive": active}})
234
+
235
+ def write(self, path: Path, extras: dict):
236
+ gltf = {
237
+ "asset": {"version": "2.0", "generator": "nnnotes/room", "extras": extras},
238
+ "scene": 0, "scenes": [{"nodes": list(range(len(self.nodes)))}],
239
+ "nodes": self.nodes, "meshes": self.meshes, "accessors": self.accessors,
240
+ "bufferViews": self.bufferViews, "buffers": [{"byteLength": len(self.bin)}],
241
+ "materials": self.materials, "textures": self.textures,
242
+ "images": self.images, "samplers": self.samplers,
243
+ }
244
+ if self.ext_used:
245
+ gltf["extensionsUsed"] = sorted(self.ext_used)
246
+ js = dumps(gltf, separators=(",", ":")).encode("utf-8")
247
+ js += b" " * (-len(js) % 4)
248
+ self.bin.extend(b"\0" * (-len(self.bin) % 4))
249
+ with open(path, "wb") as f:
250
+ f.write(struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(self.bin)))
251
+ f.write(struct.pack("<II", len(js), 0x4E4F534A)); f.write(js)
252
+ f.write(struct.pack("<II", len(self.bin), 0x004E4942)); f.write(self.bin)
253
+
254
+
255
+ # ---- extraction ---------------------------------------------------------
256
+ def submesh_primitives(tris: list, mats: list, material_for, on_null) -> list:
257
+ """[(glTF triangle indices, material index)] of a mesh's submeshes: `tris` (per submesh, Unity winding),
258
+ `mats` the renderer's material PPtrs of the same index. An empty submesh gives nothing; a submesh whose material
259
+ slot is empty (path id 0) gives nothing and is reported to `on_null(submesh index)`."""
260
+ prims = []
261
+ for i, t in enumerate(tris):
262
+ if not len(t):
263
+ continue
264
+ if not mats[i].path_id: # empty slot: no material to draw the submesh with
265
+ on_null(i)
266
+ continue
267
+ prims.append((t[:, [0, 2, 1]].reshape(-1).astype(np.uint32), material_for(mats[i])))
268
+ return prims
269
+
270
+
271
+ def drawn_meshes(mf_by_go: dict, mr_by_go: dict, graph, on_null_mesh):
272
+ """(transform path id, Mesh, material PPtrs) of each GameObject with a MeshFilter and an enabled MeshRenderer.
273
+ A MeshFilter whose mesh reference is empty (path id 0) draws nothing: its object path goes to
274
+ `on_null_mesh(path)`."""
275
+ for go_pid, mf in mf_by_go.items():
276
+ tf_pid = graph.tf_of_go.get(go_pid)
277
+ mr = mr_by_go.get(go_pid)
278
+ if tf_pid is None or mr is None:
279
+ continue
280
+ if not mr.read_typetree().get("m_Enabled", 1):
281
+ continue
282
+ mesh_ref = mf.read().m_Mesh
283
+ if not mesh_ref.path_id:
284
+ on_null_mesh(graph.path(tf_pid))
285
+ continue
286
+ yield tf_pid, mesh_ref.read(), mr.read().m_Materials
287
+
288
+
289
+ def extract_room(cat: Catalog, bg_key: str, out_path: Path) -> dict:
290
+ env = UnityPy.load(*[str(p) for p in cat.fetch_key(bg_key)])
291
+ graph = SceneGraph(env)
292
+ mf_by_go, mr_by_go = {}, {}
293
+ for o in env.objects:
294
+ if o.type.name == "MeshFilter":
295
+ mf_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o
296
+ elif o.type.name == "MeshRenderer":
297
+ mr_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o
298
+
299
+ glb = _Glb()
300
+ tex_cache: dict[int, int] = {}
301
+ mat_cache: dict[int, int] = {}
302
+
303
+ def material_for(pptr) -> int:
304
+ if pptr.path_id in mat_cache:
305
+ return mat_cache[pptr.path_id]
306
+ mat = pptr.read()
307
+ mat_tt = mat.object_reader.read_typetree()
308
+ shader_tt = mat.m_Shader.read().object_reader.read_typetree()
309
+ gm = translate_material(mat_tt, shader_tt)
310
+ slot = _main_tex_slot(shader_tt["m_ParsedForm"]["m_Name"])
311
+ for key, env_ in mat.m_SavedProperties.m_TexEnvs:
312
+ if key != slot or not env_.m_Texture.path_id:
313
+ continue
314
+ tid = env_.m_Texture.path_id
315
+ if tid not in tex_cache:
316
+ tex = env_.m_Texture.read()
317
+ buf = io.BytesIO(); texture_image(tex).save(buf, format="PNG")
318
+ tex_cache[tid] = glb.texture(buf.getvalue(), tex.m_Name,
319
+ _sampler(tex.object_reader.read_typetree()))
320
+ info = {"index": tex_cache[tid]}
321
+ sx, sy = env_.m_Scale.x, env_.m_Scale.y
322
+ ox, oy = env_.m_Offset.x, env_.m_Offset.y
323
+ if (sx, sy, ox, oy) != (1.0, 1.0, 0.0, 0.0):
324
+ # Unity samples (u*sx+ox, v*sy+oy) with a bottom-left origin;
325
+ # in glTF's top-left texture space that is this transform.
326
+ info["extensions"] = {"KHR_texture_transform":
327
+ {"scale": [sx, sy], "offset": [ox, 1.0 - sy - oy]}}
328
+ glb.ext_used.add("KHR_texture_transform")
329
+ gm["pbrMetallicRoughness"]["baseColorTexture"] = info
330
+ mat_cache[pptr.path_id] = glb.material(gm)
331
+ return mat_cache[pptr.path_id]
332
+
333
+ n_mesh = 0
334
+ n_inactive = 0
335
+ skipped = []
336
+ null_slots = [] # submeshes whose material slot is empty
337
+ null_meshes = [] # objects whose MeshFilter has no mesh
338
+ no_triangles = [] # meshes with vertices but no triangles
339
+ for tf_pid, mesh, mats in drawn_meshes(mf_by_go, mr_by_go, graph, null_meshes.append):
340
+ arrays = mesh_arrays(mesh)
341
+ if arrays is None:
342
+ skipped.append(mesh.m_Name) # empty mesh (no vertex data)
343
+ continue
344
+ v, vn, uv0, tris = arrays
345
+ if not any(len(t) for t in tris): # vertices only (e.g. a placeholder mesh without index data)
346
+ no_triangles.append({"mesh": mesh.m_Name, "path": graph.path(tf_pid)})
347
+ continue
348
+ if len(mats) < len(tris):
349
+ raise NotImplementedError(f"{mesh.m_Name}: {len(tris)} submeshes, {len(mats)} materials")
350
+ m = graph.world(tf_pid)
351
+ p = (np.hstack([v, np.ones((len(v), 1))]) @ m.T)[:, :3]
352
+ p[:, 2] *= -1.0 # Unity LH -> glTF RH
353
+ if len(vn):
354
+ nw = vn @ np.linalg.inv(m[:3, :3]).T
355
+ nw /= np.maximum(np.linalg.norm(nw, axis=1, keepdims=True), 1e-12)
356
+ nw[:, 2] *= -1.0
357
+ else:
358
+ nw = np.empty((0, 3))
359
+ uv = uv0.copy()
360
+ uv[:, 1] = 1.0 - uv[:, 1] # Unity UV origin bottom-left -> glTF top-left
361
+ prims = submesh_primitives(tris, mats, material_for, lambda i: null_slots.append(
362
+ {"mesh": mesh.m_Name, "path": graph.path(tf_pid), "submesh": i}))
363
+ if not prims:
364
+ continue
365
+ is_active = graph.active(tf_pid)
366
+ glb.add_mesh(mesh.m_Name, p, nw, uv, prims, active=is_active)
367
+ n_mesh += 1
368
+ n_inactive += 0 if is_active else 1
369
+
370
+ out_path = Path(out_path)
371
+ out_path.parent.mkdir(parents=True, exist_ok=True)
372
+ glb.write(out_path, {"backgroundKey": bg_key})
373
+ doc = {
374
+ "backgroundKey": bg_key, "glb": out_path.name, "meshCount": n_mesh,
375
+ "inactiveMeshes": n_inactive,
376
+ "skippedEmptyMeshes": skipped,
377
+ "nullMaterialSubmeshes": null_slots,
378
+ "nullMeshFilters": null_meshes,
379
+ "meshesWithoutTriangles": no_triangles,
380
+ "materials": [{"name": m["name"], "shader": m["extras"]["unityShader"],
381
+ "alphaMode": m.get("alphaMode", "OPAQUE"),
382
+ "alphaCutoff": m.get("alphaCutoff"),
383
+ "doubleSided": m.get("doubleSided", False),
384
+ "unlit": "KHR_materials_unlit" in m.get("extensions", {}),
385
+ "renderState": m["extras"]["unityRenderState"]}
386
+ for m in glb.materials],
387
+ "textures": [im["name"] for im in glb.images],
388
+ "samplers": glb.samplers,
389
+ }
390
+ write_json(out_path.with_suffix(".json"), doc)
391
+ return doc