From 5e2d5307b49d35c687b4501121b663845ef1ecf5 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 15 Oct 2026 01:16:28 +0800 Subject: [PATCH] feat(export)!: give every object a json document and list its references Every object a converter takes has a `json` artifact or is contained in another object's. A Texture2D, Mesh, TextAsset or Font adds its typetree JSON without the bytes its other artifact holds (the pixels, the vertex, index and compressed mesh data, the stored text, the font file). A sprite's document and a prefab's are their `json`. A sprite's json holds its decoded geometry (positions, triangles, stored texture coordinates) and the render data its image was cut from: the sprite's own or its atlas' entry, with texture, rects, uvTransform, downscale and settings. Each item lists in `refs` the ids of the objects its documents reference, other bundles' objects included and the objects of its own prefab left out, so the objects reachable from an object follow from the items alone. The original layout writes a secondary artifact whose role is its extension at `.` (a texture's json at x.png.json). BREAKING CHANGE: the `meta` and `prefab` roles are `json`; the converters are tex.png/2, sprite.png/2, mesh.glb/2, prefab.json/2, data/2 and font/2. Co-Authored-By: Claude Opus 5.5 --- docs/assets.md | 3 +- docs/contracts.md | 5 +- docs/stages.md | 26 ++++++--- src/nnnotes/pipeline/contract.py | 15 ++++- src/nnnotes/pipeline/layout.py | 6 +- src/nnnotes/pipeline/objexport.py | 19 +++++-- src/nnnotes/schemas/result.schema.json | 9 +++ src/nnnotes/unity/convert/__init__.py | 19 ++++--- src/nnnotes/unity/convert/files.py | 31 ++++++++--- src/nnnotes/unity/convert/objects.py | 23 +++++++- src/nnnotes/unity/convert/prefab.py | 4 +- src/nnnotes/unity/convert/textures.py | 65 +++++++++++++++------- tests/golden/unity.export.json | 74 ++++++++++++------------- tests/pipeline/test_layout.py | 7 ++- tests/pipeline/test_objexport.py | 77 ++++++++++++++++++++++---- 15 files changed, 273 insertions(+), 110 deletions(-) diff --git a/docs/assets.md b/docs/assets.md index 1eeb842..7060085 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -147,7 +147,8 @@ no longer wanted, never touches other files, and continues an interrupted write - other objects under the same container path: `stem[].`, the Addressables sub-object form (`…/member_full[square].png`); - objects without a container path: `_bundles///~.`; -- secondary artifacts beside the primary one: `..` (`….meta.json`, `….blob.m_Data.moc3`); +- secondary artifacts beside the primary one: `..` (`….blob.m_Data.moc3`), `.` for a + role that is its extension (`….png.json`, the `json` of a texture or sprite); - a container path present in two bundles: `stem@.ext` for each; - names Windows refuses, reserved device names and trailing dots are escaped as `%XX`; paths that coincide ignoring case get `~<8 hex>` and are listed in the layout report; diff --git a/docs/contracts.md b/docs/contracts.md index affcad8..389a28b 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -109,7 +109,8 @@ One output file ([artifact.schema.json](https://github.com/MetaSekaiLab/nnnotes/ `ext` is lower-case letters and digits. The bytes are stored once, by content id, whatever their names. Roles: an object's primary artifact is the one whose role comes first in `image`, `mesh`, `audio`, `video`, `font`, -`data`, `prefab`, `json` (then by role name); its other artifacts (`meta`, `blob:`, …) are secondary. +`data`, `json` (then by role name); its other artifacts (`json` beside an image, `blob:`, …) are +secondary. ## Result: nnnotes.result/1 @@ -120,7 +121,7 @@ The result of a task, written after the bytes of all its artifacts are stored: i {"schema": "nnnotes.result/1", "key": "…", "keyParts": {…}, "status": "ok", "artifacts": [], "items": [{"object": "CAB-…:1", "class": "Texture2D", "status": "exported", "artifacts": ["CAB-…:1#image"]}, - {"object": "CAB-…:2", "class": "Transform", "status": "contained", "in": "CAB-…:9#prefab"}, + {"object": "CAB-…:2", "class": "Transform", "status": "contained", "in": "CAB-…:9#json"}, {"object": "CAB-…:3", "class": "Texture2D", "status": "unsupported", "reason": {"code": "empty.texture", "message": "…"}}]} ``` diff --git a/docs/stages.md b/docs/stages.md index bcd1f01..f0473c6 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -48,21 +48,33 @@ with its reason code; any other error of a converter gives it with `generic.erro | Class | Converter | Artifacts | Atoms | Notes | |---|---|---|---|---| -| Texture2D | `tex.png/1` | `image`: PNG of the first mip level (HDR ASTC: `.astc`) | `texture.decode`, `png.encode`, `astc.container` | further mip levels as facts; a texture without pixels: `empty.texture` | -| Sprite | `sprite.png/1` | `image`: PNG on the sprite's rect (packing undone, tight mask applied), `meta`: JSON | `texture.decode`, `sprite.crop`, `png.encode`, `mesh.arrays` | the texture is decoded once per task; a sprite whose SpriteAtlas is in another bundle gets its `meta` here (with its mesh and object facts) and its `image` from `sprite.crop` | -| Mesh | `mesh.glb/1` | `mesh`: binary glTF in the mesh's local space | `mesh.arrays`, `gltf.write` | see `gltf.write` below; limits of the skin and blend shapes as issues of the artifact | -| GameObject (a root) | `prefab.json/1` | `prefab`: the hierarchy's nodes (path, path ids, transform) with their components' fields | | the objects below it are `contained`; a root of a scene's file names its scene | +| Texture2D | `tex.png/2` | `image`: PNG of the first mip level (HDR ASTC: `.astc`), `json`: the typetree without `image data` (settings, colour space, mip count, stream data) | `texture.decode`, `png.encode`, `astc.container` | further mip levels as facts; a texture without pixels: `empty.texture` | +| Sprite | `sprite.png/2` | `image`: PNG on the sprite's rect (packing undone, tight mask applied), `json`: the typetree, geometry, render data and image (see below) | `texture.decode`, `sprite.crop`, `png.encode`, `mesh.arrays` | the texture is decoded once per task; a sprite whose SpriteAtlas is in another bundle gets its `json` here (with its geometry and object facts) and its `image` from `sprite.crop` | +| Mesh | `mesh.glb/2` | `mesh`: binary glTF in the mesh's local space, `json`: the typetree without the vertex, index and compressed mesh data | `mesh.arrays`, `gltf.write` | see `gltf.write` below; limits of the skin and blend shapes as issues of the artifact | +| GameObject (a root) | `prefab.json/2` | `json`: the hierarchy's nodes (path, path ids, transform) with their components' fields | | the objects below it are `contained`; a root of a scene's file names its scene | | Material | `material.json/1` | `json`: shader, keywords, textures, floats, colours | | | | Shader | `shader.json/1` | `json`: the parsed form (the bytes of `nnnotes export shader`'s `.json`), `program:/sp

__`: each compiled sub-program as stored (`.glsl`, `.metal`, `.vkprog`), `index`: the variants (platform, subshader, pass, stage, GPU program type, keywords, program role), `typetree`: the typetree JSON | | a shader without a parsed form: its typetree with `generic.error` | | AnimationClip | `clip.json/2` | `json`: the Mecanim clip data and its bindings as stored (see below) | | a legacy or compressed clip: `generic.clip.legacy` | | AnimatorController | `controller.json/2` | `json`: name, clips (`$ref`), layers, state machines, parameters, default values | | an AnimatorOverrideController: `generic.controller.override` | | MonoBehaviour (not in a hierarchy) | `mono.json/2`, `cri.usm/1` | `json`: `$script` (assembly, namespace, class) and the typetree; a cue sheet asset also `acb`, a movie asset holding its USM also `usm` (see below) | `sniff` | the class from the task's script table | -| TextAsset | `data/1` | `data`: the stored bytes | `sniff` | extension from the container path, else from the content | -| Font | `font/1` | `font`: the font file | `sniff` | | +| TextAsset | `data/2` | `data`: the stored bytes, `json`: the typetree without `m_Script` | `sniff` | extension from the container path, else from the content | +| Font | `font/2` | `font`: the font file, `json`: the typetree without `m_FontData` | `sniff` | | | MonoScript | `scripts.json/1` | `#scripts.json`: every MonoScript of the bundle | | | | AssetBundle | `bundle.json/1` | `#bundle.json`: serialized files, externals, the AssetBundle objects | | | | any other class | `generic.json/1` | `json`: the typetree | `sniff` | | +Every object a converter takes has a `json` artifact or is contained in another object's. Each item lists in +`refs` the objects its documents reference (by object id, other bundles' objects included, the objects of its own +prefab left out): the objects reachable from an object follow from the items alone. + +A sprite's `json` holds `sprite` (its typetree), `mesh` (its geometry decoded: `positions` in the sprite's units, +`submeshes` of triangles, `uv0` when stored), `renderData` (`source`: `renderData` for the sprite's own render +data, `atlas` for its SpriteAtlas' entry; `atlas`; and that render data's `texture`, `alphaTexture`, +`secondaryTextures`, `textureRect`, `textureRectOffset`, `atlasRectOffset`, `uvTransform`, `downscaleMultiplier`, +`settingsRaw`) and `image` (`packed`, `packingMode`, `packingRotation`, `width`, `height`). The texture coordinate +of a position `p` is `((p.x * uvTransform.x + uvTransform.y) / width, (p.y * uvTransform.z + uvTransform.w) / +height)` of the render data's texture. + A task's key includes the atoms `reader` and `sniff`, the converter `generic.json`, and the converters (with their atoms) of the classes the bundle's census lists, so a converter's version bump runs again exactly the bundles holding its classes. `classes` (a list of class names) limits a task to the objects of those classes and the objects their @@ -110,7 +122,7 @@ the same way: its `data.data` bytes, whatever their size, are the artifact `:`). Its inputs are the atlas' JSON, the `meta` of each such sprite and the images of the +subject>:`). Its inputs are the atlas' JSON, the `json` of each such sprite and the images of the atlas textures they use; it writes `#image` with the same bytes `unity.export` writes for a sprite whose atlas is in its own bundle. diff --git a/src/nnnotes/pipeline/contract.py b/src/nnnotes/pipeline/contract.py index 69eb785..26fa343 100644 --- a/src/nnnotes/pipeline/contract.py +++ b/src/nnnotes/pipeline/contract.py @@ -63,7 +63,7 @@ # locator kinds of a task input: where the bytes can be read (never part of the key) LOCATOR_KINDS = ("depot", "download", "file", "catalog") # an object's primary artifact is the one whose role comes first here; its other artifacts are secondary -PRIMARY_ROLES = ("image", "mesh", "audio", "video", "font", "data", "prefab", "json") +PRIMARY_ROLES = ("image", "mesh", "audio", "video", "font", "data", "json") _EXT = re.compile(r"[0-9a-z]+") _BUNDLE_SUFFIX = re.compile(r"(_[0-9a-f]{32})?\.bundle$") @@ -324,11 +324,18 @@ def reason(code: str, message: str = "") -> dict: def item( - obj: str, status: str, *, artifacts=(), within: str | None = None, why: dict | None = None, cls: str | None = None + obj: str, + status: str, + *, + artifacts=(), + within: str | None = None, + why: dict | None = None, + cls: str | None = None, + refs=(), ) -> dict: """The outcome for one object of a task. exported: its artifacts; contained: `within` (the artifact it is part of); generic: its artifacts and why the specialized form was not made; unsupported: why (and artifacts, if - any); failed: why.""" + any); failed: why. `refs`: the ids of the other objects its documents reference.""" if status not in ITEM_STATUSES: raise ValueError(f"item {obj}: unknown status {status!r}") need_art = status in ("exported", "generic") @@ -348,6 +355,8 @@ def item( d["artifacts"] = sorted(artifacts) if within is not None: d["in"] = within + if refs: + d["refs"] = sorted(set(refs)) if why is not None: d["reason"] = why return d diff --git a/src/nnnotes/pipeline/layout.py b/src/nnnotes/pipeline/layout.py index cc9a3c2..3fbb13d 100644 --- a/src/nnnotes/pipeline/layout.py +++ b/src/nnnotes/pipeline/layout.py @@ -11,7 +11,8 @@ `~` (stem[name]~123.png) - an object's primary artifact (contract.PRIMARY_ROLES) is written at that path, its extension appended unless the path already ends with it (x.prefab -> x.prefab.json, y.png -> y.png); its other artifacts at - `..` (':' and '/' of the role as '.') + `..` (':' and '/' of the role as '.'), `.` for a role that is its extension + (y.png -> y.png.json) - objects without a container: `//~.`, root = the root of the object's serialized file when one is given (`object_roots`: the export command roots every object of a bundle at `_bundles/`, whichever task made the artifact), else the source's root; under a root whose @@ -301,7 +302,8 @@ def _object_files(base: list[str], records: list[dict], append_ext: bool) -> lis segs = base[:-1] + [f"{base[-1]}.{ext}"] if append_ext else _with_ext(base, ext) else: role = contract.parse_artifact_id(r["id"])[1] - segs = base[:-1] + [f"{base[-1]}.{_role_segment(role)}.{ext}"] + name = f"{base[-1]}.{ext}" if role == ext else f"{base[-1]}.{_role_segment(role)}.{ext}" + segs = base[:-1] + [name] out.append((segs, _entry("", r))) return out diff --git a/src/nnnotes/pipeline/objexport.py b/src/nnnotes/pipeline/objexport.py index f17904d..182e638 100644 --- a/src/nnnotes/pipeline/objexport.py +++ b/src/nnnotes/pipeline/objexport.py @@ -130,6 +130,7 @@ def _emit( def _begin(self, conv: str) -> None: self.staged, self.conv = [], conv + self.ctx.seen.clear() def _publish(self) -> list[str]: """Keep the staged artifacts; returns their ids.""" @@ -139,10 +140,15 @@ def _publish(self) -> list[str]: self.staged = [] return ids + def _refs(self, info) -> list[str]: + """The objects the documents of `info` made since the last _begin reference.""" + return sorted(self.ctx.seen - {info.id}) + def _commit(self, info, status: str, why: dict | None = None) -> None: - """The item of `info`: the artifacts its conversion staged.""" + """The item of `info`: the artifacts its conversion staged and the objects they reference.""" + refs = self._refs(info) ids = self._publish() - self.items[info.id] = contract.item(info.id, status, artifacts=ids, why=why, cls=info.class_name) + self.items[info.id] = contract.item(info.id, status, artifacts=ids, why=why, cls=info.class_name, refs=refs) def _failed(self, info, e: Exception, prefix: str = "") -> None: self._begin("generic.json") @@ -270,7 +276,7 @@ def _prefabs(self) -> None: self._fallback(info_of[k], "generic.error", f"hierarchy at transform {root.transform}: {why}") continue if self._convert(info, "prefab.json", prefab.prefab, h, root): - within = contract.artifact_id(info.id, "prefab") + within = contract.artifact_id(info.id, "json") for k in root.members: i = info_of[k] if i.id != info.id: @@ -293,9 +299,10 @@ def _loose(self, cls: str, conv: str, kind: str) -> None: for i in infos: mark = len(self.staged) key = (i.file, i.path_id) + self.ctx.seen.clear() try: entries[i.id] = self.ctx.tree(self.ctx.readers[key], self.ctx.tt(key)) - done.append(i) + done.append((i, self._refs(i))) except Exception as e: del self.staged[mark:] failed.append((i, e)) @@ -303,8 +310,8 @@ def _loose(self, cls: str, conv: str, kind: str) -> None: doc = encode_object(generic.loose_doc(self.ctx, conv, entries)) aid = self._emit(None, conv, doc, "json", kind, "json", {"objects": len(done)}, owner=self.task.id)[0] self._publish() - for i in done: - self.items[i.id] = contract.item(i.id, "contained", within=aid, cls=i.class_name) + for i, refs in done: + self.items[i.id] = contract.item(i.id, "contained", within=aid, cls=i.class_name, refs=refs) for i, e in failed: if isinstance(e, ReadError): self._failed(i, e) diff --git a/src/nnnotes/schemas/result.schema.json b/src/nnnotes/schemas/result.schema.json index 96557fa..541d034 100644 --- a/src/nnnotes/schemas/result.schema.json +++ b/src/nnnotes/schemas/result.schema.json @@ -68,6 +68,15 @@ "in": { "$ref": "urn:nnnotes:schema:common#/$defs/artifactId" }, + "refs": { + "description": "The ids of the other objects the object's documents reference, other bundles' objects included.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, "reason": { "$ref": "urn:nnnotes:schema:common#/$defs/reason" } diff --git a/src/nnnotes/unity/convert/__init__.py b/src/nnnotes/unity/convert/__init__.py index 1020360..759d8ab 100644 --- a/src/nnnotes/unity/convert/__init__.py +++ b/src/nnnotes/unity/convert/__init__.py @@ -6,9 +6,9 @@ bundle holds, with the atoms they use (CONVERTER_ATOMS). Texture2D tex.png `image`: PNG of the first mip level (HDR ASTC: the .astc file) - Sprite sprite.png `image`: PNG on the sprite's rect; `meta`: JSON + Sprite sprite.png `image`: PNG on the sprite's rect; `json`: typetree, geometry, render data Mesh mesh.glb `mesh`: binary glTF in the mesh's local space - GameObject (a root) prefab.json `prefab`: the hierarchy below it with its components + GameObject (a root) prefab.json `json`: the hierarchy below it with its components Material material.json `json` Shader shader.json `json`: the parsed form as `nnnotes export shader` writes it; `program:/sp

__`: each compiled sub-program as @@ -25,6 +25,9 @@ AssetBundle bundle.json "#bundle.json": serialized files, externals, AssetBundle objects any other class generic.json `json`: the object's typetree +Every object a converter takes has a `json` artifact (or is contained in one): the converter's document, or the +typetree JSON (a Texture2D, Mesh, TextAsset or Font without the bytes its other artifact holds). + Object JSON (canon/1 in the object's own field order): - a reference is {"$ref": {"file", "pathId"}}, with "class" and "name" when the target is in the bundle; a null reference is null. Inside a prefab, a GameObject, Transform or component of the same prefab is named by its path: @@ -60,16 +63,16 @@ # converter -> version CONVERTERS = { - "tex.png": 1, - "sprite.png": 1, - "mesh.glb": 1, - "prefab.json": 1, + "tex.png": 2, + "sprite.png": 2, + "mesh.glb": 2, + "prefab.json": 2, "material.json": 1, "clip.json": 2, "controller.json": 2, "mono.json": 2, - "data": 1, - "font": 1, + "data": 2, + "font": 2, "scripts.json": 1, "bundle.json": 1, "shader.json": 1, diff --git a/src/nnnotes/unity/convert/files.py b/src/nnnotes/unity/convert/files.py index 3c0269e..fe1509b 100644 --- a/src/nnnotes/unity/convert/files.py +++ b/src/nnnotes/unity/convert/files.py @@ -1,17 +1,26 @@ -"""Mesh (mesh.glb), TextAsset (data) and Font (font).""" +"""Mesh (mesh.glb), TextAsset (data) and Font (font). Each also has `json`: its typetree JSON without the bytes its +other artifact holds.""" from __future__ import annotations from ..atoms import Unsupported -from .objects import BundleObjects, container_ext +from .objects import BundleObjects, container_ext, encode_object, omit -__all__ = ["FONT_FORMATS", "font", "mesh", "text"] +__all__ = ["FONT_FORMATS", "MESH_DATA", "font", "mesh", "text"] FONT_FORMATS = ("ttf", "otf", "ttc", "woff", "woff2") +# the fields of a Mesh whose data its glTF holds decoded: vertices, indices, the compressed form +MESH_DATA = ("m_VertexData/m_DataSize", "m_IndexBuffer", "m_CompressedMesh") + + +def _json(ctx: BundleObjects, key: tuple, tt: dict, *data: str) -> None: + doc = ctx.tree(ctx.readers[key], omit(tt, *data)) + ctx.emit(key, "json", encode_object(doc), "json", "unity.object", "json", {}) def mesh(ctx: BundleObjects, info) -> None: - """`mesh`: binary glTF in the mesh's local space; the limits of its conversion as issues.""" + """`mesh`: binary glTF in the mesh's local space, the limits of its conversion as issues; `json` without the + vertex, index and compressed mesh data (MESH_DATA).""" key = (info.file, info.path_id) m = ctx.fns["mesh.arrays"](ctx.view.mesh_input(info)) glb = ctx.fns["gltf.write"]([m]) @@ -22,25 +31,31 @@ def mesh(ctx: BundleObjects, info) -> None: "skinned": m.skin is not None, } ctx.emit(key, "mesh", glb, "glb", "mesh.gltf", "glb", facts, issues=m.issues) + _json(ctx, key, ctx.tt(key), *MESH_DATA) def text(ctx: BundleObjects, info) -> None: - """`data`: the stored bytes, the extension from the container path, else from the content.""" + """`data`: the stored bytes, the extension from the container path, else from the content; `json` without + them (`m_Script`).""" key = (info.file, info.path_id) - s = ctx.tt(key).get("m_Script", "") + tt = ctx.tt(key) + s = tt.get("m_Script", "") data = s.encode("utf-8", "surrogateescape") if isinstance(s, str) else bytes(s or b"") container = ctx.containers.get(key) sn = ctx.fns["sniff"](data, container) ext = container_ext(container) or sn.ext ctx.emit(key, "data", data, ext, "text", sn.format, {"size": len(data)}) + _json(ctx, key, tt, "m_Script") def font(ctx: BundleObjects, info) -> None: - """`font`: the font file.""" + """`font`: the font file; `json` without it (`m_FontData`).""" key = (info.file, info.path_id) - data = bytes(ctx.tt(key).get("m_FontData") or b"") + tt = ctx.tt(key) + data = bytes(tt.get("m_FontData") or b"") if not data: raise Unsupported("no_data.font", "the font has no font data") sn = ctx.fns["sniff"](data) ext = sn.ext if sn.format in FONT_FORMATS else (container_ext(ctx.containers.get(key)) or sn.ext) ctx.emit(key, "font", data, ext, "font", sn.format, {"size": len(data)}) + _json(ctx, key, tt, "m_FontData") diff --git a/src/nnnotes/unity/convert/objects.py b/src/nnnotes/unity/convert/objects.py index e92c2ce..b75a0d1 100644 --- a/src/nnnotes/unity/convert/objects.py +++ b/src/nnnotes/unity/convert/objects.py @@ -4,6 +4,9 @@ Artifacts go out through `emit(key, role, data, ext, kind, format, facts, *, issues=(), refs=(), owner=None)`, which the caller gives: it stores the bytes and records the artifact "#" (owner: the object `key`, else the task-level name `owner`) and returns (artifact id, content record). + +Every object a document names by {"$ref"} is recorded in `seen` (by object id, other bundles' objects included): +the objects an object's documents reference, which the caller clears before converting an object. """ from __future__ import annotations @@ -25,6 +28,7 @@ "encode_object", "label", "message", + "omit", "ref_object_id", ] @@ -76,6 +80,21 @@ def encode_object(doc) -> bytes: return encode(_stored(doc), sort_keys=False) +def omit(tt: dict, *paths: str) -> dict: + """`tt` without the fields at `paths` (field names joined by "/"); the dictionaries on the way are copied.""" + out = dict(tt) + for path in paths: + head, _, rest = path.partition("/") + if head not in out: + continue + if rest: + if isinstance(out[head], dict): + out[head] = omit(out[head], rest) + else: + del out[head] + return out + + def ref_object_id(v) -> str | None: """The object id of a {"$ref": {...}} value (None for null or an unresolved file).""" if not isinstance(v, dict) or "$ref" not in v or "file" not in v["$ref"]: @@ -160,6 +179,7 @@ def __init__(self, view, fns: dict, *, scripts: dict, blob_min: int, png_level: self._heads: dict[int, tuple] = {} self._captures: dict[str, Capture] = {} self._local: Callable[[tuple], dict | None] | None = None + self.seen: set[str] = set() # ---------------------------------------------------------------- identities and names @staticmethod @@ -272,8 +292,9 @@ def script_class(self, key: tuple) -> str | None: # ---------------------------------------------------------------- references and values def dref(self, key: tuple) -> dict: - """{"$ref"} of an object, with its class and name when it is in the bundle.""" + """{"$ref"} of an object, with its class and name when it is in the bundle (recorded in `seen`).""" file, pid = key + self.seen.add(object_id(file, pid)) d = {"file": label(file), "pathId": pid} r = self.readers.get(key) if r is not None: diff --git a/src/nnnotes/unity/convert/prefab.py b/src/nnnotes/unity/convert/prefab.py index 0dd4e1c..845719e 100644 --- a/src/nnnotes/unity/convert/prefab.py +++ b/src/nnnotes/unity/convert/prefab.py @@ -145,7 +145,7 @@ def _component(ctx: BundleObjects, h: Hierarchies, o, root: tuple, unresolved: l def prefab(ctx: BundleObjects, info, h: Hierarchies, root: Root) -> None: - """`prefab`: {"name", "scene" (a root of a scene's file), "nodes"}: every node of the hierarchy in hierarchy + """`json`: {"name", "scene" (a root of a scene's file), "nodes"}: every node of the hierarchy in hierarchy order (path, name, path ids, active, layer, tag, local transform, rect, components with their fields).""" key = (info.file, info.path_id) g = h.graphs[info.file] @@ -190,4 +190,4 @@ def prefab(ctx: BundleObjects, info, h: Hierarchies, root: Root) -> None: else [] ) facts = {"nodes": len(nodes), "objects": len(root.members)} - ctx.emit(key, "prefab", encode_object(doc), "json", "prefab", "json", facts, issues=issues) + ctx.emit(key, "json", encode_object(doc), "json", "prefab", "json", facts, issues=issues) diff --git a/src/nnnotes/unity/convert/textures.py b/src/nnnotes/unity/convert/textures.py index 2cb7ec3..ed05194 100644 --- a/src/nnnotes/unity/convert/textures.py +++ b/src/nnnotes/unity/convert/textures.py @@ -2,6 +2,12 @@ A texture's image is decoded once per bundle: `texture` (or `decode_into`, when the task leaves textures out) puts it in a holder that the sprites cut from it read. Where a sprite's image comes from is its plan (`sprite_plan`). + +A sprite's `json` holds its typetree JSON (`sprite`), its decoded geometry (`mesh`: positions in the sprite's units, +triangles per submesh, texture coordinates when stored; a texture coordinate of a position p is +((p.x * uvTransform.x + uvTransform.y) / width, (p.y * uvTransform.z + uvTransform.w) / height) of the render data's +texture), the render data its image was cut from (`renderData`: the sprite's own or its atlas' entry for it) and +the image (`image`). """ from __future__ import annotations @@ -10,7 +16,7 @@ from ..atoms import Unsupported from ..ids import object_id -from .objects import BundleObjects, encode_object, message, ref_object_id +from .objects import BundleObjects, encode_object, message, omit, ref_object_id __all__ = [ "atlas_entries", @@ -24,13 +30,27 @@ ] +# the fields of a sprite's render data (SpriteRenderData, or a SpriteAtlas entry) that say how it is drawn +RENDER_FIELDS = ( + "texture", + "alphaTexture", + "secondaryTextures", + "textureRect", + "textureRectOffset", + "atlasRectOffset", + "uvTransform", + "downscaleMultiplier", + "settingsRaw", +) + + def _canon(v) -> str: return json.dumps(v, sort_keys=True, ensure_ascii=True) def texture(ctx: BundleObjects, info, holder: dict) -> None: - """`image`: PNG of the first mip level (HDR ASTC: the .astc file). The decoded image (or the error) goes into - `holder` for the texture's sprites.""" + """`image`: PNG of the first mip level (HDR ASTC: the .astc file); `json`: the typetree JSON without the pixels + (`image data`). The decoded image (or the error) goes into `holder` for the texture's sprites.""" from ..atoms.texture import ASTC_BLOCKS, format_name, is_hdr_astc key = (info.file, info.path_id) @@ -54,6 +74,7 @@ def texture(ctx: BundleObjects, info, holder: dict) -> None: ) data = ctx.fns["astc.container"](inp.data, w, h, ASTC_BLOCKS[fmt]) ctx.emit(key, "image", data, "astc", "texture.image", "astc", facts) + _texture_json(ctx, key, tt) return try: img = ctx.fns["texture.decode"](inp) @@ -64,6 +85,12 @@ def texture(ctx: BundleObjects, info, holder: dict) -> None: facts["mode"] = img.mode data = ctx.fns["png.encode"](img, ctx.png_level) ctx.emit(key, "image", data, "png", "texture.image", "png", facts) + _texture_json(ctx, key, tt) + + +def _texture_json(ctx: BundleObjects, key: tuple, tt: dict) -> None: + doc = ctx.tree(ctx.readers[key], omit(tt, "image data")) + ctx.emit(key, "json", encode_object(doc), "json", "unity.object", "json", {}) def decode_into(ctx: BundleObjects, info, holder: dict) -> None: @@ -145,8 +172,8 @@ def sprite_plan(ctx: BundleObjects, key: tuple, atlases: dict) -> dict: def sprite(ctx: BundleObjects, info, plan: dict, holder: dict) -> None: - """`image`: PNG on the sprite's rect cut from its texture's image in `holder`; `meta`: the sprite's JSON and - where its image came from.""" + """`image`: PNG on the sprite's rect cut from its texture's image in `holder`; `json`: the sprite's typetree + JSON, geometry, render data and image (see the module documentation).""" from ..atoms import sprite as sprite_atom err = holder.get("error") @@ -161,7 +188,8 @@ def sprite(ctx: BundleObjects, info, plan: dict, holder: dict) -> None: alpha = ctx.fns["texture.decode"](ctx.view.texture_input(ctx.info[plan["alpha"]])) if plan["alpha"] else None raw = int(src["settingsRaw"]) st = sprite_atom.settings(raw) - mesh = ctx.fns["mesh.arrays"](ctx.view.mesh_input(info)) if st.packing_mode == sprite_atom.TIGHT else None + m = ctx.fns["mesh.arrays"](ctx.view.mesh_input(info)) + mesh = m if st.packing_mode == sprite_atom.TIGHT else None downscale = float(src.get("downscaleMultiplier", 1.0)) img = ctx.fns["sprite.crop"]( holder["image"], @@ -179,30 +207,29 @@ def sprite(ctx: BundleObjects, info, plan: dict, holder: dict) -> None: refs.append({"rel": "atlas", "object": object_id(*plan["atlasKey"])}) facts = {"width": img.width, "height": img.height, "mode": img.mode} ctx.emit(key, "image", data, "png", "sprite.image", "png", facts, refs=refs) - meta = { + owner = plan["owner"] + doc = { "sprite": ctx.tree(r, tt), - "image": { + "mesh": sprite_atom.mesh_values(m) if "position" in m.channels else None, + "renderData": { "source": "atlas" if plan["atlasKey"] is not None else "renderData", "atlas": None if plan["atlasKey"] is None else ctx.dref(plan["atlasKey"]), - "texture": ctx.dref(plan["texture"]), - "alphaTexture": None if plan["alpha"] is None else ctx.dref(plan["alpha"]), - "textureRect": src["textureRect"], - "textureRectOffset": src["textureRectOffset"], - "settingsRaw": raw, + **{k: ctx.value(owner, src[k]) for k in RENDER_FIELDS if k in src}, + }, + "image": { "packed": st.packed, "packingMode": st.packing_mode, "packingRotation": st.packing_rotation, - "downscaleMultiplier": downscale, "width": img.width, "height": img.height, }, } - ctx.emit(key, "meta", encode_object(meta), "json", "sprite.meta", "json", {}, refs=refs) + ctx.emit(key, "json", encode_object(doc), "json", "sprite.meta", "json", {}, refs=refs) def sprite_external(ctx: BundleObjects, info, plan: dict, crop: str) -> None: - """`meta` of a sprite whose SpriteAtlas is in another bundle: its JSON, mesh and object facts, for the stage - `crop` that cuts its image from the atlas texture.""" + """`json` of a sprite whose SpriteAtlas is in another bundle: its typetree JSON, geometry and object facts, for + the stage `crop` that cuts its image from the atlas texture.""" from ..atoms import sprite as sprite_atom key = (info.file, info.path_id) @@ -217,7 +244,7 @@ def sprite_external(ctx: BundleObjects, info, plan: dict, crop: str) -> None: } facts = {"crop": crop, "atlas": atlas_oid} refs = [{"rel": "atlas", "object": atlas_oid}] - ctx.emit(key, "meta", encode_object(meta), "json", "sprite.meta", "json", facts, refs=refs) + ctx.emit(key, "json", encode_object(meta), "json", "sprite.meta", "json", facts, refs=refs) # ---------------------------------------------------------------- sprites cut from an atlas of another bundle @@ -233,7 +260,7 @@ def atlas_entry(entries: list, key): def crop_meta(meta: dict, entries: list, image, crop): - """The image of a sprite whose atlas is in another bundle, from its `meta` (sprite_external), its atlas' + """The image of a sprite whose atlas is in another bundle, from its `json` (sprite_external), its atlas' render data entries and `image(object id)`, the decoded image of an atlas texture; `crop`: atom sprite.crop.""" from ..atoms import sprite as sprite_atom diff --git a/tests/golden/unity.export.json b/tests/golden/unity.export.json index 96e8707..d310ee3 100644 --- a/tests/golden/unity.export.json +++ b/tests/golden/unity.export.json @@ -5,89 +5,89 @@ "clip.json": "clip.json/2", "controller.json": "controller.json/2", "cri.usm": "cri.usm/1", - "data": "data/1", - "font": "font/1", + "data": "data/2", + "font": "font/2", "generic.json": "generic.json/1", "gltf.write": "numpy-2.4.6/1", "material.json": "material.json/1", "mesh.arrays": "numpy-2.4.6/1", - "mesh.glb": "mesh.glb/1", + "mesh.glb": "mesh.glb/2", "mono.json": "mono.json/2", "png.encode": "pillow-12.3.0/1", - "prefab.json": "prefab.json/1", + "prefab.json": "prefab.json/2", "reader": "fakeunity/1", "scripts.json": "scripts.json/1", "shader.json": "shader.json/1", "sniff": "nnnotes/1", "sprite.crop": "pillow-12.3.0+numpy-2.4.6/1", - "sprite.png": "sprite.png/1", - "tex.png": "tex.png/1", + "sprite.png": "sprite.png/2", + "tex.png": "tex.png/2", "texture.decode": "unitypy-1.25.3+texture2ddecoder-1.0.6+astc-encoder-py-0.1.12+pillow-12.3.0/1" }, "fixtures": { "all": { - "key": "d3be85097dda7b39fb780c7b8d4560dbf88e85901372cc2256c2478b9d2ba1ef", - "result": "13f069ec2e00b913f537b8434f235b1232b5f875aebe1c3f40c39ff4e3274bdc" + "key": "10585d61586a4362e7e5a3c8857a9b583fac363abb5874e760d767fde9cb7d30", + "result": "3bd7b57ee922de132411cb8d027a00421fed5c5a595cdb3eef3e3cb5982b7374" }, "bundle.json": { - "key": "c81a9154d13d953e581bbd4cffe0590ad296a296ff6eeda9398aa8d394b44325", - "result": "cbaaf45561972f2004fa74352dc3adcc96c2baabadb56d85a49add792921fe9c" + "key": "fb99b80d44ff61bf4b90ea87e40a632c0f34372ddebd60dd00f74368c5f6b94d", + "result": "169b9bab48a8cb6423de878430b6291bf56999a5d640e9893dc2a1eeda9b93f9" }, "clip.json": { - "key": "07f85633bae3127fe11950188284b9325066133b4d86eaf61c3414b3c622fa77", - "result": "f133a83bce862cef316e2ea2907790b073eb7928911150bed8225529534df177" + "key": "535034c7ce7875a3ea19ebadbb4d12bca912621d38982cd47097cb1c66ff62c5", + "result": "fad3849534a5ad3892f968f567c88efa426088232df6e26a8f646757592fa135" }, "controller.json": { - "key": "0488d21f6759dc95639a3d1e643980472452fbd4b04c3d5623942a1095b5e4a5", - "result": "c1ca4ec4b97bb18b24a8effc3a0faa4f5dc5a6e3724eeba8368dd0505f59235a" + "key": "522f6cefe4102041b197c0feda95de6668a7e88ab548b0d682ef26b492079fd1", + "result": "f17bc2432ad36b87f17e3fffec59eab429a803d23f0bc63db018ba2d99a847db" }, "cri.usm": { "key": "8d1d45652a6a631253b3bad7f096af988fcc3894831a8fe5e7ec90b939bc3427", - "result": "ca67e2f4878b562b24cb6ca374c469ee2085a2c0259245adcae05cba4f9095b1" + "result": "9a0ca28ce190fc8b5c03fc108b657825c1361a05b416894d2f9c55402cae62fd" }, "data": { - "key": "62b78ee012b053bfb1ee11245b07cc58428f13bd0fb1b5de221b7b0a22372654", - "result": "7ca1098d52be75092183689a236f8d0b876edd68684e32b691364bad93ca89bd" + "key": "98f3c28baedd007e88d9a48adccdad2a1970e63df6e487917cf1c6823feb651d", + "result": "a2c0972bc1c388038dedb9b70f4a8a6d67b58dfca0faac3c438610df125c7234" }, "font": { - "key": "7b6638e13d5a7771ca82970c82ff10e8e815e9ae11e022f608829ca7e6ff2a11", - "result": "bdbbe3b89be967d95eb2cfb1e116773b149d4ba521551807030627e8d57c5cca" + "key": "c1a50c8ae58a104313d93a7296464812f98c82d8c9aa516589b9d6e5cb89bffa", + "result": "d18d119de09ed000abca438357e72951a18d576c2b26b76932adf9621f67d2bd" }, "generic.json": { - "key": "56d22e25eec789a5b67a0e73ccd1db2edc9a1b50f23e7bc367ae26e7c1410509", - "result": "f404a37e961ab0a92b773317a65eff12caa0a2a8816d5f954a329bb967d1d638" + "key": "3f111bf015cc23af61fb457ad94be8c3479259d2e8ec2388390580161179afde", + "result": "a79ea2404c179705d54adb9c08f917acedd070bffccc01ba72da3f68af522cd4" }, "material.json": { - "key": "413ce336e41f3966b370858050aa0ba522b35862338b6762d78747b9d36940c5", - "result": "246599600a2de4bae74b7b13dbc83ebf7856e1d13a1e1ee35b8274706eae2068" + "key": "fd0603efb48c4582fa8664efcc44be7e238dc6168846dd5528ac6ec350732903", + "result": "373545644b49d9047bae346a78186d7bd0b1b5158d17a393b42050e505209e32" }, "mesh.glb": { - "key": "d73226629446825538e9f52f7139470cc2ee3d06480cbf480d8cb8876b98e491", - "result": "bb4ce49483164483c3729736c92f82e5b48db83f4ed67c57890a30b976ec1ed0" + "key": "d14c16a5c18145cc039a4568b4c8561b0120644c2de1e1abd1dd1be1f17917f0", + "result": "650489a1a9d4e52ae66bc44a101ad5c127e9aa836c8d69e4c08c662e70c73b47" }, "mono.json": { - "key": "3da938e46ad6126b00d2b4efd67416bbc3b7983dfc39ec18a92da5dae1cbd5d6", - "result": "c20cf16802c538e51cd327facc43a3401da0ca67bf5f1adaa7a3554ff1d56954" + "key": "5820ec6ca51a697d9ad2f3ed1ee5c8a849fd8f813b053f335b86b1b95082abd8", + "result": "cdabce4d56148f552c87738a9208fd36d0cc581590d7e5eb92f8e5b522de2266" }, "prefab.json": { - "key": "c0e4016cd8ccad7ef8870c61a8a4be8271c2997683b4d868aa99a9763a5bdee9", - "result": "7512a5b00635e586c8a0e8777897b7f2287a293f39552af78a6ab8d8d6c65b69" + "key": "8994f1ef92388d443797f11c65a61a6346acf01ff0dc8bfbd36dc2fb2d2d23d2", + "result": "368986bcb1df65ea7fb906e01fe49b814c35e41212a079cdfcc5b70d5ed06559" }, "scripts.json": { - "key": "82609d6edbc52764a5f491b6abe2e8bf1f1383336549058d588dbed6ca72f1fe", - "result": "48743bb2322d8e81d31607cab08986e7e6b7b921543b25067fac0deb875d0478" + "key": "aafda4c0fe111458f3b48e85b8f60d0e9234dd250590591966a8ac3305d99ef9", + "result": "a2644c8fe5f8198b3771374f10ad01b91a9b111d74aff79b85a9b9b2e071e2c5" }, "shader.json": { - "key": "355da22724a98e77ccf12c9f7d532fd6e3c8cd23fd34ea4d3c536136a36c2bca", - "result": "5404f4143a35b8efd83ac2f09f9ab2c4c52782db76c4c9c3637afa6b7d4bc2fb" + "key": "a71aa982ce0da9929eaf721a1db903ac0565e1232c94a1d451cdf818f2c59f31", + "result": "fef75615b20f6dc056a4934606d1f094159e219b87482b5efae95c407d0f6d55" }, "sprite.png": { - "key": "0ed6598b9a398b409e57f47465f5b7f5106a4ea7f6327771c6272fa4e3df4617", - "result": "50a216c09f9a3479eacd619bc857b27ee84345ac251d5b73c0a0493246646aae" + "key": "d42d38c45711b2de94fc4509b0dd8d919d01d123091f09d54e177df7e4cf751d", + "result": "5c6d4968cbc56a1eefb9c8c94b380487f9876f591e33acbef40bb735edbb70b9" }, "tex.png": { - "key": "03e26506c9a6187554a6047f78e9ecded3c470632550fee0067cc992f6823077", - "result": "9ef852eca9d54b96138aab99e835507d7c27f25d921b2741610d0cd0f18f9d8c" + "key": "a5af40387bb2c9fe6ba31415af4e4028163eab23bd4114ad4384b57159ae1318", + "result": "54d91ffc00400f162f677f9e1525e34698b33dc439560b4372dffcd09a8dd73b" } }, "libraries": { diff --git a/tests/pipeline/test_layout.py b/tests/pipeline/test_layout.py index b4a8655..7ef6086 100644 --- a/tests/pipeline/test_layout.py +++ b/tests/pipeline/test_layout.py @@ -72,7 +72,7 @@ def test_objects_of_one_container(tmp_path): "Sprite", "square", "assets/x/member_full.png", - {"image": png("s1"), "meta": (b"{}", "json")}, + {"image": png("s1"), "json": (b"{}", "json"), "meta": (b"[]", "json")}, ), ("CAB-1", 12, "Sprite", "member_full", "assets/x/member_full.png", {"image": png("s2")}), ("CAB-1", 21, "Sprite", "dup", "assets/x/member_full.png", {"image": png("d1")}), @@ -83,7 +83,7 @@ def test_objects_of_one_container(tmp_path): "GameObject", "foo", "assets/p/foo.prefab", - {"prefab": (b"{}\n", "json"), "blob:m_Data/Array": (b"MOC3", "moc3")}, + {"json": (b"{}\n", "json"), "blob:m_Data/Array": (b"MOC3", "moc3")}, ), ("CAB-1", 40, "Texture2D", "hdr", "assets/x/hdr.png", {"image": (b"\x13\xab\xa1\x5c", "astc")}), ], @@ -92,11 +92,12 @@ def test_objects_of_one_container(tmp_path): assert p == { "CAB-1:10#image": "assets/x/member_full.png", "CAB-1:11#image": "assets/x/member_full[square].png", + "CAB-1:11#json": "assets/x/member_full[square].png.json", "CAB-1:11#meta": "assets/x/member_full[square].png.meta.json", "CAB-1:12#image": "assets/x/member_full[member_full].png", "CAB-1:21#image": "assets/x/member_full[dup]~21.png", "CAB-1:22#image": "assets/x/member_full[dup]~22.png", - "CAB-1:30#prefab": "assets/p/foo.prefab.json", + "CAB-1:30#json": "assets/p/foo.prefab.json", "CAB-1:30#blob:m_Data/Array": "assets/p/foo.prefab.blob.m_Data.Array.moc3", "CAB-1:40#image": "assets/x/hdr.png.astc", } diff --git a/tests/pipeline/test_objexport.py b/tests/pipeline/test_objexport.py index 79cab58..ddee38c 100644 --- a/tests/pipeline/test_objexport.py +++ b/tests/pipeline/test_objexport.py @@ -669,7 +669,7 @@ def test_every_object_has_exactly_one_item(exported): oid(74): ("exported", None), oid(51): ("exported", None), } - assert items[oid(61)]["in"] == f"{oid(60)}#prefab" and items[oid(80)]["in"] == "unity.export:all#scripts.json" + assert items[oid(61)]["in"] == f"{oid(60)}#json" and items[oid(80)]["in"] == "unity.export:all#scripts.json" assert items[oid(1)]["in"] == "unity.export:all#bundle.json" assert items[oid(94)]["artifacts"] == [f"{oid(94)}#json"] assert doc["status"] == "ok" and all(i["class"] for i in doc["items"]) @@ -702,7 +702,7 @@ def test_provenance_facts_and_atoms(exported): assert a["provenance"]["inputs"] == [{"role": "bundle", "sha256": task.inputs[0].sha256}] g = art(doc, f"{oid(95)}#json") assert set(g["provenance"]["atoms"]) == {"reader", "sniff", "generic.json"} - assert task.atoms["tex.png"] == "tex.png/1" and task.atoms["reader"] == "fakeunity/1" + assert task.atoms["tex.png"] == "tex.png/2" and task.atoms["reader"] == "fakeunity/1" # ---------------------------------------------------------------- converters @@ -756,11 +756,27 @@ def test_sprites_same_bundle(exported): canvas={"rect": tt["m_Rect"], "offset": src["textureRectOffset"], "pivot": tt["m_Pivot"]}, ) assert load(depot, doc, f"{oid(pid)}#image") == png.encode(want) - meta = obj(depot, doc, f"{oid(pid)}#meta") - assert meta["image"]["texture"] == {"$ref": {"file": CAB, "pathId": 10, "class": "Texture2D", "name": "tex"}} + meta = obj(depot, doc, f"{oid(pid)}#json") + rd = meta["renderData"] + assert rd["texture"] == {"$ref": {"file": CAB, "pathId": 10, "class": "Texture2D", "name": "tex"}} + assert rd["textureRect"] == src["textureRect"] and rd["uvTransform"] == src["uvTransform"] + assert rd["settingsRaw"] == src["settingsRaw"] and rd["alphaTexture"] is None assert meta["sprite"]["m_Name"] == tt["m_Name"] - meta = obj(depot, doc, f"{oid(12)}#meta") - assert meta["image"]["source"] == "atlas" and meta["image"]["atlas"]["$ref"]["pathId"] == 20 + pos, idx = _sprite_mesh(tt["m_Rect"]) + assert meta["mesh"]["positions"] == np.asarray(pos, np.float32).astype(float).tolist() + assert meta["mesh"]["submeshes"] == [np.asarray(idx).reshape(-1, 3).tolist()] + assert meta["image"] == { + "packed": bool(src["settingsRaw"] & 1), + "packingMode": (src["settingsRaw"] >> 1) & 1, + "packingRotation": (src["settingsRaw"] >> 2) & 0xF, + "width": int(tt["m_Rect"]["width"]), + "height": int(tt["m_Rect"]["height"]), + } + assert by_object(doc)[oid(pid)]["refs"] == [oid(10)] + ([oid(20)] if pid == 12 else []) + meta = obj(depot, doc, f"{oid(11)}#json") + assert meta["renderData"]["source"] == "renderData" and meta["renderData"]["atlas"] is None + meta = obj(depot, doc, f"{oid(12)}#json") + assert meta["renderData"]["source"] == "atlas" and meta["renderData"]["atlas"]["$ref"]["pathId"] == 20 assert meta["sprite"]["m_RD"]["m_VertexData"]["m_DataSize"]["$hex"] # small: inline refs = art(doc, f"{oid(12)}#image")["semantics"]["refs"] assert refs == [{"rel": "texture", "object": oid(10)}, {"rel": "atlas", "object": oid(20)}] @@ -768,8 +784,8 @@ def test_sprites_same_bundle(exported): def test_sprite_with_its_atlas_elsewhere_gets_a_meta_only(exported): depot, _task, doc = exported - assert by_object(doc)[oid(13)]["artifacts"] == [f"{oid(13)}#meta"] - a = art(doc, f"{oid(13)}#meta") + assert by_object(doc)[oid(13)]["artifacts"] == [f"{oid(13)}#json"] + a = art(doc, f"{oid(13)}#json") assert a["semantics"]["facts"] == {"crop": CROP, "atlas": f"{ATLAS_CAB}:99"} meta = obj(depot, doc, a["id"]) assert meta["image"] == {"source": "atlas", "atlas": {"$ref": {"file": ATLAS_CAB, "pathId": 99}}, "stage": CROP} @@ -801,9 +817,48 @@ def test_text_and_fonts(exported): assert load(depot, doc, f["id"]) == _ttf() and f["content"]["ext"] == "ttf" +def test_every_converted_object_has_its_json(exported): + depot, _task, doc = exported + tex = obj(depot, doc, f"{oid(10)}#json") + assert "image data" not in tex and tex["m_Width"] == W and tex["m_TextureFormat"] == 4 + assert art(doc, f"{oid(10)}#json")["semantics"] == {"kind": "unity.object", "format": "json", "facts": {}} + assert obj(depot, doc, f"{oid(15)}#json")["m_MipCount"] == 2 # HDR ASTC: the .astc image and the json + mesh = obj(depot, doc, f"{oid(30)}#json") + assert "m_DataSize" not in mesh["m_VertexData"] and "m_IndexBuffer" not in mesh + assert mesh["m_VertexData"]["m_VertexCount"] == 3 and mesh["m_Name"] == "tri" + text = obj(depot, doc, f"{oid(40)}#json") + assert text == {"m_Name": "readme"} + font = obj(depot, doc, f"{oid(42)}#json") + assert font == {"m_Name": "font"} + items = by_object(doc) + for pid in (10, 11, 12, 15, 30, 40, 41, 42, 44, 45, 50, 60, 70, 73, 74, 90, 92, 95, 96): + assert f"{oid(pid)}#json" in items[oid(pid)]["artifacts"], pid + assert all( + i["status"] != "exported" or any(a.endswith("#json") for a in i["artifacts"]) or i["object"] == oid(13) + for i in doc["items"] + if i["class"] not in ("AssetBundle", "MonoScript") + ) + + +def test_items_name_the_objects_they_reference(exported): + _depot, _task, doc = exported + items = by_object(doc) + assert items[oid(50)]["refs"] == [oid(10), f"{ATLAS_CAB}:5"] # the material's shader and texture + # a prefab: the objects its components reference outside it, other bundles' included; its own objects are named + # by their paths + assert items[oid(60)]["refs"] == [oid(10), "CAB-scripts:3", "CAB-scripts:7", "CAB-scripts:8"] + assert "refs" not in items[oid(61)] and "refs" not in items[oid(63)] # contained in the prefab + assert items[oid(1)]["refs"] == [oid(10), oid(11), oid(40), oid(60)] # the AssetBundle's container + assert items[oid(92)]["refs"] == [oid(90)] # the controller's clip + assert items[oid(96)]["refs"] == ["CAB-scripts:12"] # a clip binding's script + assert items[oid(13)]["refs"] == [f"{ATLAS_CAB}:99"] # a sprite's atlas in another bundle + assert items[oid(70)]["refs"] == [oid(80)] # its script + assert "refs" not in items[oid(30)] and "refs" not in items[oid(40)] + + def test_prefab(exported): depot, _task, doc = exported - p = obj(depot, doc, f"{oid(60)}#prefab") + p = obj(depot, doc, f"{oid(60)}#json") assert p["name"] == "root" and [n["path"] for n in p["nodes"]] == ["root", "root/child"] root, child = p["nodes"] assert root["pathId"] == 60 and root["transformPathId"] == 61 and root["layer"] == 5 @@ -814,9 +869,9 @@ def test_prefab(exported): assert fields["tex"] == {"$ref": {"file": CAB, "pathId": 10, "class": "Texture2D", "name": "tex"}} assert fields["m_Script"] == {"$ref": {"file": "CAB-scripts", "pathId": 7}} assert math.isnan(fields["value"]) and fields["small"] == {"$hex": "616263"} - assert b'{"$float": "nan"}' in load(depot, doc, f"{oid(60)}#prefab").replace(b"\n", b"").replace( + assert b'{"$float": "nan"}' in load(depot, doc, f"{oid(60)}#json").replace(b"\n", b"").replace( b" ", b"" - ) or b'"$float"' in load(depot, doc, f"{oid(60)}#prefab") + ) or b'"$float"' in load(depot, doc, f"{oid(60)}#json") (c,) = child["components"] assert c["fields"]["up"] == {"transform": "root"} assert c["fields"]["comp"] == {"component": "MonoBehaviour", "gameObject": "root", "class": "Behaviour"}