lecodes-assets 0.2.0 → 2.4.10

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 (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,198 @@
1
+ // Unity materials as the texture source of truth for an FBX conversion.
2
+ //
3
+ // An FBX exported for Unity usually references only a base colour (often a legacy file name) and a
4
+ // normal map; the real shading inputs live in the Unity `.mat` next to it — metallic, roughness /
5
+ // smoothness, occlusion (separate maps or an ORM / MaskMap / MetallicGloss pack), emission, and the
6
+ // alpha mode. Textures are referenced by GUID, resolved through a `unity-index.txt` (written by
7
+ // `lecodes assets unpack`) or the `.meta` sidecars of a real Unity project (guid.ts).
8
+ //
9
+ // What this does: for every FBX material, find the `.mat` of the same name (modulo M_/MI_/MAT_
10
+ // prefixes; matFile.ts reads it, a BINARY one through the pack's texture naming convention), and
11
+ // rewrite the glTF material from it (materialWriter.ts). `.mat` files are also looked up across the
12
+ // whole unpacked package through `unity-index.txt`: packs keep shared materials (asphalt, concrete)
13
+ // in a Textures/ tree far from the FBX that uses them. Then the material overrides of the `.prefab`
14
+ // files around the FBX are honoured (prefabs.ts).
15
+
16
+ import { existsSync } from "node:fs"
17
+ import { basename, dirname, resolve } from "node:path"
18
+ import type { Document, Material, Mesh, Node } from "@gltf-transform/core"
19
+ import { findUnityIndex, guidOfPath, indexedMaterialFiles, resolverFromIndex, resolverFromMeta, type GuidResolver } from "./guid"
20
+ import { collectUnityMaterials, materialKey, type UnityMaterial } from "./matFile"
21
+ import { SLOTS, pick, unityMaterialWriter, type Warn } from "./materialWriter"
22
+ import { collectPrefabOverrides, type PrefabOverride } from "./prefabs"
23
+
24
+ export type ApplyUnityOptions = {
25
+ /** Folder(s) to look for `.mat` files in (default: the FBX's folder and its parent, recursively). */
26
+ materialDirs?: string[]
27
+ /** `unity-index.txt` to resolve GUIDs with (default: found walking up from the FBX; else `.meta` sidecars). */
28
+ indexFile?: string
29
+ /** Honour the material overrides of the `.prefab` files around the FBX (default true) — packs assign
30
+ * the real material on the prefab's MeshRenderer and leave the FBX with placeholders. */
31
+ prefabs?: boolean
32
+ /** Folders to read `.prefab` files from (default: the FBX's folder and its parent, 3 deep). */
33
+ prefabDirs?: string[]
34
+ warn?: Warn
35
+ }
36
+
37
+ export type ApplyUnityReport = {
38
+ /** glTF material name → what was applied (slots[0] notes a non-exact match). */
39
+ applied: { material: string, mat: string, slots: string[] }[]
40
+ unmatched: string[]
41
+ binary: string[]
42
+ /** Binary .mat files whose slots were inferred from their sibling textures. */
43
+ inferred: string[]
44
+ /** Nodes re-materialed from a prefab's MeshRenderer (slots listed once per distinct .mat). */
45
+ overrides: { node: string, mat: string, slots: string[] }[]
46
+ resolver: string | null
47
+ }
48
+
49
+ /** Rewrite the Document's materials from the Unity `.mat` files that match them by name. */
50
+ export const applyUnityMaterials = async (doc: Document, fbxPath: string, opts: ApplyUnityOptions = {}): Promise<ApplyUnityReport> => {
51
+ const warn = opts.warn ?? (() => {})
52
+ const fbxDir = dirname(resolve(fbxPath))
53
+ const report: ApplyUnityReport = { applied: [], unmatched: [], binary: [], inferred: [], overrides: [], resolver: null }
54
+ // Only a Unity CONTEXT makes the .mat scan worthwhile: an unpacked package (unity-index.txt above
55
+ // the FBX) or a real Unity project (the FBX's own .meta sidecar). Without one the scan of the parent
56
+ // folder only finds unrelated projects' materials (a Mixamo folder next to a Unity export) and the
57
+ // .meta resolver would crawl a whole home directory looking for `Assets`.
58
+ const indexFile = opts.indexFile ?? findUnityIndex(fbxDir)
59
+ const inProject = existsSync(fbxPath + ".meta")
60
+ if (!indexFile && !inProject && !opts.materialDirs) return report
61
+ const dirs = opts.materialDirs ?? [ fbxDir, dirname(fbxDir) ]
62
+ // the FBX's neighbourhood first (wins on a name clash), then every .mat the package index lists
63
+ const { byKey, byFile, binary, inferred } = collectUnityMaterials(dirs, 6, indexFile ? indexedMaterialFiles(indexFile) : [])
64
+ report.binary = binary
65
+ report.inferred = inferred
66
+ const mats = doc.getRoot().listMaterials()
67
+ if (byKey.size === 0) return report
68
+
69
+ let resolver: GuidResolver | null = indexFile ? resolverFromIndex(indexFile) : null
70
+ if (!resolver && (inProject || opts.materialDirs)) resolver = resolverFromMeta(fbxDir)
71
+ // inferred materials reference files directly — they need no GUID resolver
72
+ if (!resolver && inferred.length) resolver = { source: "inferred from sibling textures", resolve: () => null }
73
+ if (!resolver) {
74
+ warn({ level: "warn", message: `Unity materials found but no way to resolve their texture GUIDs — need a unity-index.txt (lecodes assets unpack) above the FBX or the .meta sidecars of a Unity project` })
75
+ return report
76
+ }
77
+ report.resolver = resolver.source
78
+
79
+ // name matching: exact (modulo prefixes) → one key contains the other (MI_Tshirt ↔ M_Shirt) → the
80
+ // FBX material's base texture and a .mat's base slot point at the same file (MI_Skin ↔ M_Body via
81
+ // TX_Body_BaseColor) — packs rename materials freely but rarely duplicate a texture
82
+ const allMats = [ ...new Set(byKey.values()) ]
83
+ const stemOf = (f: string): string => basename(f).replace(/\.[^.]+$/, "").toLowerCase()
84
+ const matByStem = new Map<string, UnityMaterial>()
85
+ for (const um of allMats) {
86
+ const g = pick(um, SLOTS.base)
87
+ const f = g ? resolver.resolve(g) : null
88
+ if (f && !matByStem.has(stemOf(f))) matByStem.set(stemOf(f), um)
89
+ }
90
+ const matchMaterial = (gm: Material): { um: UnityMaterial, how: string } | null => {
91
+ const k = materialKey(gm.getName())
92
+ const exact = byKey.get(k)
93
+ if (exact) return { um: exact, how: "name" }
94
+ if (k.length >= 4) {
95
+ const part = allMats.find((um) => { const mk = materialKey(um.name); return mk.length >= 4 && (mk.includes(k) || k.includes(mk)) })
96
+ if (part) return { um: part, how: `name ~ ${part.name}` }
97
+ }
98
+ const base = gm.getBaseColorTexture()
99
+ const stem = base ? stemOf(base.getName() || base.getURI() || "") : ""
100
+ const byTex = stem ? matByStem.get(stem) : undefined
101
+ if (byTex) return { um: byTex, how: `base texture ${stem}` }
102
+ return null
103
+ }
104
+
105
+ const { applyTo } = unityMaterialWriter(doc, resolver, warn)
106
+
107
+ const appliedFrom = new Map<Material, string>()
108
+ for (const gm of mats) {
109
+ const hit = matchMaterial(gm)
110
+ if (!hit) { report.unmatched.push(gm.getName()); continue }
111
+ const slots = await applyTo(gm, hit.um, hit.how)
112
+ appliedFrom.set(gm, hit.um.file)
113
+ report.applied.push({ material: gm.getName(), mat: basename(hit.um.file), slots })
114
+ }
115
+
116
+ // prefab overrides: a prefab's MeshRenderer names the real material for a node whose FBX material
117
+ // is a placeholder ("17 - Default"). Only prefabs showing THIS FBX's mesh count (by GUID, when the
118
+ // index knows the FBX). The override is applied per primitive; a mesh shared by several nodes
119
+ // takes the first node's override.
120
+ if (opts.prefabs !== false && resolver.source !== "inferred from sibling textures") {
121
+ const fbxGuid = indexFile ? guidOfPath(indexFile, fbxPath) : null
122
+ const overrides = collectPrefabOverrides(opts.prefabDirs ?? [ fbxDir, dirname(fbxDir) ])
123
+ .filter((o) => !fbxGuid || !o.meshGuid || o.meshGuid === fbxGuid)
124
+ const byNode = new Map<string, PrefabOverride>()
125
+ for (const o of overrides) if (!byNode.has(o.node)) byNode.set(o.node, o)
126
+ const matCache = new Map<string, Material>()
127
+ // the FBX importer hangs the mesh on an unnamed child of the named node — take the subtree
128
+ const meshesUnder = (n: Node): Mesh[] => {
129
+ const out: Mesh[] = []
130
+ const walk = (x: Node): void => { const m = x.getMesh(); if (m) out.push(m); x.listChildren().forEach(walk) }
131
+ walk(n)
132
+ return out
133
+ }
134
+ const targets: [ Node, PrefabOverride ][] = []
135
+ for (const node of doc.getRoot().listNodes()) { const o = byNode.get(node.getName()); if (o) targets.push([ node, o ]) }
136
+ // a SINGLE-mesh prop whose prefab GameObject is named after the prefab, not the FBX node
137
+ // (`P_stone_fence` showing `stone_fence_LP.014`): no name matches, but the prefab names THIS FBX
138
+ // by GUID and there is only one mesh it could mean. The prefab named like the FBX wins over
139
+ // its variants (`P_Tent_1` over `P_Tent_1B`).
140
+ if (!targets.length && fbxGuid) {
141
+ const meshNodes = doc.getRoot().listNodes().filter((n) => n.getMesh())
142
+ const mine = overrides.filter((o) => o.meshGuid === fbxGuid)
143
+ if (meshNodes.length === 1 && mine.length) {
144
+ const stem = basename(fbxPath).replace(/\.fbx$/i, "").toLowerCase()
145
+ const o = mine.find((x) => basename(x.prefab, ".prefab").toLowerCase().replace(/^p_/, "") === stem) ?? mine[0]!
146
+ targets.push([ meshNodes[0]!, o ])
147
+ }
148
+ }
149
+ for (const [ node, o ] of targets) {
150
+ const prims = meshesUnder(node).flatMap((m) => m.listPrimitives())
151
+ for (let i = 0; i < prims.length; i++) {
152
+ const g = o.materials[i]
153
+ if (!g) continue
154
+ const matFile = resolver.resolve(g)
155
+ if (!matFile) continue
156
+ // by PATH first — the GUID named one exact .mat, and the name key cannot tell
157
+ // `Materials/M_VP_01.mat` from the FBX's placeholder `Assets/Materials/MI_VP_01.mat`
158
+ const um = byFile.get(resolve(matFile)) ?? byKey.get(materialKey(basename(matFile, ".mat")))
159
+ if (!um) continue
160
+ const current = prims[i].getMaterial()
161
+ if (current && appliedFrom.get(current) === um.file) continue // the FBX material already matched this .mat
162
+ let gm = matCache.get(um.file)
163
+ if (!gm) {
164
+ gm = doc.createMaterial(um.name)
165
+ const slots = await applyTo(gm, um, "name")
166
+ matCache.set(um.file, gm)
167
+ appliedFrom.set(gm, um.file)
168
+ report.overrides.push({ node: node.getName(), mat: basename(um.file), slots })
169
+ } else report.overrides.push({ node: node.getName(), mat: basename(um.file), slots: [] })
170
+ prims[i].setMaterial(gm)
171
+ }
172
+ }
173
+ }
174
+ return report
175
+ }
176
+
177
+ /** Drop textures no material references any more (the FBX's legacy ones after a .mat rewrite). */
178
+ export const pruneUnusedTextures = (doc: Document): number => {
179
+ let n = 0
180
+ for (const t of doc.getRoot().listTextures()) {
181
+ if (t.listParents().every((p) => p.propertyType === "Root")) { t.dispose(); n++ }
182
+ }
183
+ return n
184
+ }
185
+
186
+ export const describeUnityReport = (r: ApplyUnityReport): string[] => {
187
+ const lines: string[] = []
188
+ for (const a of r.applied) lines.push(`🎨 ${a.material} ← ${a.mat}: ${a.slots.join(", ") || "(no changes)"}`)
189
+ const byMat = new Map<string, { nodes: string[], slots: string[] }>()
190
+ for (const o of r.overrides) { const e = byMat.get(o.mat) ?? { nodes: [], slots: [] }; e.nodes.push(o.node); if (o.slots.length) e.slots = o.slots; byMat.set(o.mat, e) }
191
+ for (const [ mat, e ] of byMat) lines.push(`🎨 prefab override ← ${mat}: ${e.slots.join(", ") || "(shared)"} — ${e.nodes.length} node${e.nodes.length === 1 ? "" : "s"} (${e.nodes.slice(0, 4).join(", ")}${e.nodes.length > 4 ? ", …" : ""})`)
192
+ if (r.unmatched.length && r.applied.length) lines.push(`ℹ no Unity material for: ${r.unmatched.join(", ")} (FBX references kept)`)
193
+ const skipped = r.binary.filter((b) => !r.inferred.includes(b))
194
+ if (r.inferred.length) lines.push(`ℹ ${r.inferred.length} binary-serialised .mat file(s) read by texture naming convention (slots inferred from the files next to them)`)
195
+ if (skipped.length) lines.push(`ℹ ${skipped.length} binary-serialised .mat file(s) skipped — no textures named after them (Unity: Edit ▸ Project Settings ▸ Editor ▸ Asset Serialization = Force Text) — e.g. ${basename(skipped[0])}`)
196
+ return lines
197
+ }
198
+
@@ -0,0 +1,82 @@
1
+ // The Unity importer's linear algebra: column-major 4×4 matrices, (x, y, z, w) quaternions, and the
2
+ // ONE frame conversion everything here shares.
3
+ //
4
+ // Frame: Unity is left-handed (+Z forward); glTF / LeCodes are right-handed. Unity's FBX importer
5
+ // mirrors X, so undoing it is a conjugation by S = diag(−1, 1, 1): position (−x, y, z), quaternion
6
+ // (x, −y, −z, w), scale unchanged. It is applied to placements AND inside kinds (part matrices), so a
7
+ // kind's meshes — the FBX's own geometry, which is Unity's mesh mirrored back — land where Unity
8
+ // drew them.
9
+
10
+ import type { Quat, Vec3 } from "./yaml"
11
+
12
+ export type Mat4 = number[] // column-major
13
+ export type TRS = { t: Vec3, r: Quat, s: Vec3 }
14
+
15
+ export const I4 = (): Mat4 => [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
16
+
17
+ export const mmul = (a: Mat4, b: Mat4): Mat4 => {
18
+ const o = new Array(16).fill(0)
19
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
20
+ return o
21
+ }
22
+
23
+ export const scaleMatrix = (s: number): Mat4 => [ s, 0, 0, 0, 0, s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1 ]
24
+
25
+ export const trsMatrix = (t: Vec3, q: Quat, s: Vec3): Mat4 => {
26
+ const [ x, y, z, w ] = q
27
+ const xx = x * x, yy = y * y, zz = z * z, xy = x * y, xz = x * z, yz = y * z, wx = w * x, wy = w * y, wz = w * z
28
+ return [
29
+ (1 - 2 * (yy + zz)) * s[0], 2 * (xy + wz) * s[0], 2 * (xz - wy) * s[0], 0,
30
+ 2 * (xy - wz) * s[1], (1 - 2 * (xx + zz)) * s[1], 2 * (yz + wx) * s[1], 0,
31
+ 2 * (xz + wy) * s[2], 2 * (yz - wx) * s[2], (1 - 2 * (xx + yy)) * s[2], 0,
32
+ t[0], t[1], t[2], 1,
33
+ ]
34
+ }
35
+
36
+ /** Conjugate by the X mirror: S·M·S with S = diag(−1, 1, 1). */
37
+ export const mirrorX = (m: Mat4): Mat4 => m.map((v, i) => ((Math.floor(i / 4) === 0) !== (i % 4 === 0) ? -v : v))
38
+
39
+ /** The same conjugation on a quaternion and on a position. */
40
+ export const mirrorQuat = (q: number[]): Quat => [ q[0], -q[1], -q[2], q[3] ]
41
+ export const mirrorPos = (p: number[]): Vec3 => [ -p[0], p[1], p[2] ]
42
+
43
+ export const decompose = (m: Mat4): TRS => {
44
+ const sx = Math.hypot(m[0], m[1], m[2]), sy = Math.hypot(m[4], m[5], m[6]), sz0 = Math.hypot(m[8], m[9], m[10])
45
+ const det = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5])
46
+ const sz = det < 0 ? -sz0 : sz0
47
+ const r00 = m[0] / sx, r10 = m[1] / sx, r20 = m[2] / sx, r01 = m[4] / sy, r11 = m[5] / sy, r21 = m[6] / sy, r02 = m[8] / sz, r12 = m[9] / sz, r22 = m[10] / sz
48
+ const tr = r00 + r11 + r22
49
+ let q: Quat
50
+ if (tr > 0) { const s = Math.sqrt(tr + 1) * 2; q = [ (r21 - r12) / s, (r02 - r20) / s, (r10 - r01) / s, s / 4 ] }
51
+ else if (r00 > r11 && r00 > r22) { const s = Math.sqrt(1 + r00 - r11 - r22) * 2; q = [ s / 4, (r01 + r10) / s, (r02 + r20) / s, (r21 - r12) / s ] }
52
+ else if (r11 > r22) { const s = Math.sqrt(1 + r11 - r00 - r22) * 2; q = [ (r01 + r10) / s, s / 4, (r12 + r21) / s, (r02 - r20) / s ] }
53
+ else { const s = Math.sqrt(1 + r22 - r00 - r11) * 2; q = [ (r02 + r20) / s, (r12 + r21) / s, s / 4, (r10 - r01) / s ] }
54
+ const l = Math.hypot(...q) || 1
55
+ return { t: [ m[12], m[13], m[14] ], r: q.map((v) => v / l) as Quat, s: [ sx, sy, sz ] }
56
+ }
57
+
58
+ /** Hamilton product a · b (apply b, then a). */
59
+ export const qmul = (a: number[], b: number[]): Quat => [
60
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
61
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
62
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
63
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
64
+ ]
65
+
66
+ const DEG = 180 / Math.PI
67
+ /** YXZ euler angles (DEGREES) from a unit quaternion — the SDK's `Node.eulerAngles` convention. */
68
+ export const eulerYXZ = (q: number[]): [number, number, number] => {
69
+ const [ x, y, z, w ] = q
70
+ const x2 = x + x, y2 = y + y, z2 = z + z
71
+ const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2
72
+ const m13 = xz + wy, m21 = xy + wz, m22 = 1 - (xx + zz), m23 = yz - wx, m31 = xz - wy, m33 = 1 - (xx + yy), m11 = 1 - (yy + zz)
73
+ const ex = Math.asin(-Math.max(-1, Math.min(1, m23)))
74
+ let ey: number, ez: number
75
+ if (Math.abs(m23) < 0.9999999) { ey = Math.atan2(m13, m33); ez = Math.atan2(m21, m22) } else { ey = Math.atan2(-m31, m11); ez = 0 }
76
+ return [ ex * DEG, ey * DEG, ez * DEG ]
77
+ }
78
+
79
+ /** Rounded for output (and `+ 0` so a −0 never reaches a file). */
80
+ export const r6 = (v: number): number => Math.round(v * 1e6) / 1e6 + 0
81
+ export const r3 = (v: number): number => Math.round(v * 1000) / 1000 + 0
82
+ export const r2 = (v: number): number => Math.round(v * 100) / 100 + 0
@@ -0,0 +1,75 @@
1
+ // Minimal PNG decoder — 8-bit (and 16-bit, truncated) grey / grey+alpha / RGB / RGBA / palette,
2
+ // non-interlaced, all five scanline filters. Enough for every texture a game asset pack ships, so
3
+ // ORM packing does not need `sharp` (an optional native dependency). Interlaced or <8-bit images
4
+ // return null and the caller falls back to sharp / a warning.
5
+
6
+ import { inflateSync } from "node:zlib"
7
+ import type { Rgba } from "../textures/resolveImage"
8
+
9
+ const u32 = (b: Uint8Array, o: number): number => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0
10
+
11
+ export const decodePng = (b: Uint8Array): Rgba | null => {
12
+ if (b.length < 33 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47) return null
13
+ let o = 8
14
+ let width = 0, height = 0, depth = 0, color = 0, interlace = 0
15
+ const idat: Uint8Array[] = []
16
+ let palette: Uint8Array | null = null
17
+ let trns: Uint8Array | null = null
18
+ while (o + 8 <= b.length) {
19
+ const len = u32(b, o)
20
+ const type = String.fromCharCode(b[o + 4], b[o + 5], b[o + 6], b[o + 7])
21
+ const data = b.subarray(o + 8, o + 8 + len)
22
+ if (type === "IHDR") { width = u32(data, 0); height = u32(data, 4); depth = data[8]; color = data[9]; interlace = data[12] }
23
+ else if (type === "PLTE") palette = data
24
+ else if (type === "tRNS") trns = data
25
+ else if (type === "IDAT") idat.push(data)
26
+ else if (type === "IEND") break
27
+ o += 12 + len
28
+ }
29
+ if (!width || !height || interlace !== 0 || (depth !== 8 && depth !== 16)) return null
30
+ const channels = color === 0 ? 1 : color === 2 ? 3 : color === 3 ? 1 : color === 4 ? 2 : color === 6 ? 4 : 0
31
+ if (!channels) return null
32
+ const bps = depth / 8
33
+ const bpp = channels * bps // bytes per pixel (filter unit)
34
+ const stride = width * bpp
35
+ let raw: Uint8Array
36
+ try {
37
+ const total = idat.reduce((n, c) => n + c.length, 0)
38
+ const joined = new Uint8Array(total)
39
+ let p = 0
40
+ for (const c of idat) { joined.set(c, p); p += c.length }
41
+ raw = new Uint8Array(inflateSync(joined))
42
+ } catch { return null }
43
+ if (raw.length < (stride + 1) * height) return null
44
+ const out = new Uint8Array(width * height * 4)
45
+ const prev = new Uint8Array(stride)
46
+ const cur = new Uint8Array(stride)
47
+ for (let y = 0; y < height; y++) {
48
+ const f = raw[y * (stride + 1)]
49
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
50
+ for (let x = 0; x < stride; x++) {
51
+ const a = x >= bpp ? cur[x - bpp] : 0
52
+ const bb = prev[x]
53
+ const c = x >= bpp ? prev[x - bpp] : 0
54
+ let v = line[x]
55
+ if (f === 1) v += a
56
+ else if (f === 2) v += bb
57
+ else if (f === 3) v += (a + bb) >> 1
58
+ else if (f === 4) { const p = a + bb - c; const pa = Math.abs(p - a), pb = Math.abs(p - bb), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? bb : c }
59
+ cur[x] = v & 0xff
60
+ }
61
+ for (let x = 0; x < width; x++) {
62
+ const s = x * bpp, d = (y * width + x) * 4
63
+ if (color === 3) {
64
+ const i = cur[s]
65
+ out[d] = palette ? palette[i * 3] : i; out[d + 1] = palette ? palette[i * 3 + 1] : i; out[d + 2] = palette ? palette[i * 3 + 2] : i
66
+ out[d + 3] = trns && i < trns.length ? trns[i] : 255
67
+ } else if (channels === 1) { out[d] = out[d + 1] = out[d + 2] = cur[s]; out[d + 3] = 255 }
68
+ else if (channels === 2) { out[d] = out[d + 1] = out[d + 2] = cur[s]; out[d + 3] = cur[s + bps] }
69
+ else if (channels === 3) { out[d] = cur[s]; out[d + 1] = cur[s + bps]; out[d + 2] = cur[s + 2 * bps]; out[d + 3] = 255 }
70
+ else { out[d] = cur[s]; out[d + 1] = cur[s + bps]; out[d + 2] = cur[s + 2 * bps]; out[d + 3] = cur[s + 3 * bps] }
71
+ }
72
+ prev.set(cur)
73
+ }
74
+ return { width, height, data: out }
75
+ }
@@ -0,0 +1,285 @@
1
+ // Unity GameObject trees out of text-serialised `.prefab` / `.unity` files, with NESTED prefab
2
+ // instances expanded and their modifications applied — what the Unity editor shows in its hierarchy.
3
+ //
4
+ // A file's objects are GameObjects + components (Transform, MeshFilter, MeshRenderer, LODGroup,
5
+ // colliders, Light …) wired by fileID, plus `PrefabInstance` objects that stand for a whole other
6
+ // prefab. Expanding an instance = a CLONE of the source prefab's tree, its `m_Modifications` applied
7
+ // to the cloned objects (a property path on the object the target fileID names in the source
8
+ // prefab), parented under `m_TransformParent`. Objects of the outer file that hang on the instance
9
+ // (a child GameObject parented to one of its transforms, a component added to one of its objects)
10
+ // reference it through STRIPPED stubs: `m_PrefabInstance` + `m_CorrespondingSourceObject` name the
11
+ // object inside the expansion.
12
+ //
13
+ // Ids: inside a prefab ASSET the objects of a nested instance are also addressable under
14
+ // `(instanceFileID XOR sourceFileID) & 0x7FFF…` — how an outer file's modifications reach an object
15
+ // two levels down. Scenes use their own stub ids, resolved through the stub fields.
16
+ //
17
+ // A model file is a prefab too (fbxModel.ts builds its tree).
18
+
19
+ import { readFileSync } from "node:fs"
20
+ import { MODEL_EXT, modelTree, readModel, type FbxMeta, type ModelInfo } from "./fbxModel"
21
+ import { cloneTree, type Tree, type UComp, type UGO } from "./gameObjects"
22
+ import type { GuidResolver } from "./guid"
23
+ import { readPath, setPath } from "./propertyPath"
24
+ import { isMap, readUnityObjects, ref, str, type Y, type YMap } from "./yaml"
25
+
26
+ const MASK = (1n << 63n) - 1n
27
+ const xorId = (a: string, b: string): string => ((BigInt(a) ^ BigInt(b)) & MASK).toString()
28
+
29
+ export type TreeStats = { instances: number, unresolvedMods: number, sceneLocalRefs: number, guessedTransforms: number, missingPrefabs: Set<string>, binaryPrefabs: Set<string> }
30
+ export const newStats = (): TreeStats => ({ instances: 0, unresolvedMods: 0, sceneLocalRefs: 0, guessedTransforms: 0, missingPrefabs: new Set(), binaryPrefabs: new Set() })
31
+
32
+ const isComp = (o: UGO | UComp): o is UComp => "cls" in o
33
+ const materialSlot = (path: string): number | null => { const m = /^m_Materials\.Array\.data\[(\d+)\]$/.exec(path); return m ? Number(m[1]) : null }
34
+
35
+ /**
36
+ * The renderers of an instance whose ids nobody published.
37
+ *
38
+ * A MODEL prefab whose `.meta` ships an empty `internalIDToNameTable` publishes no ids for anything
39
+ * below its root, so our synthesised components get placeholder ones and a prefab that assigns the
40
+ * MATERIAL — which is how a pack with material-less FBX files does it — cannot address them. Everton
41
+ * High School is that pack: 56 of its 97 kinds imported with no material at all, silently, because
42
+ * these modifications found no target.
43
+ *
44
+ * The choice is made by SLOT COUNT first: a door's four renderers want 2, 3, 3 and 1 material, and
45
+ * the two that want 3 carry the same list, so the only choice that matters is unambiguous. Without
46
+ * a fit, a lone free renderer is taken (one renderer, one target); anything else stays unresolved.
47
+ */
48
+ class RendererGuess {
49
+ private guessed = new Map<string, UComp>()
50
+ /** How many material slots each target wants: the highest `m_Materials.Array.data[N]` it carries, plus one. */
51
+ private slotsWanted = new Map<string, number>()
52
+ constructor(private tree: Tree, mods: Y[]) {
53
+ for (const m of mods) {
54
+ if (!isMap(m)) continue
55
+ const slot = materialSlot(str(m.propertyPath))
56
+ if (slot === null) continue
57
+ const id = ref(m.target).fileID
58
+ this.slotsWanted.set(id, Math.max(this.slotsWanted.get(id) ?? 0, slot + 1))
59
+ }
60
+ }
61
+
62
+ /** The renderer chosen for this unresolvable target — the same one for all its slots. */
63
+ pick(targetId: string): UComp | undefined {
64
+ const already = this.guessed.get(targetId)
65
+ if (already) return already
66
+ const taken = new Set(this.guessed.values())
67
+ const free: UComp[] = []
68
+ for (const v of this.tree.ids.values()) if (isComp(v) && v.cls === "MeshRenderer" && !taken.has(v)) free.push(v)
69
+ const want = this.slotsWanted.get(targetId) ?? 0
70
+ const fit = free.filter((r) => (Array.isArray(r.body.m_Materials) ? r.body.m_Materials.length : 0) === want)
71
+ const pick = fit[0] ?? (free.length === 1 ? free[0] : undefined)
72
+ if (pick) this.guessed.set(targetId, pick)
73
+ return pick
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The transforms of an instance whose ids nobody published — matched BY POSITION.
79
+ *
80
+ * The same gap as RendererGuess, on the other component: a MODEL prefab whose `.meta` ships an empty
81
+ * `internalIDToNameTable` publishes no ids below its root, so a placement that nudges one of the
82
+ * model's CHILDREN (`m_LocalPosition.z` on a node inside the FBX) names an id we cannot address.
83
+ * Peremena's door frames are one FBX of eight frames, seven of them moved by 5 to 16 cm in the scene
84
+ * to meet the doors, and without the moves every sideways door stood 15 cm out of its opening.
85
+ *
86
+ * An override writes the NEW value of the components it changes, and a nudge keeps the object close
87
+ * to where the file put it — so each unresolved target is matched to the free Transform whose file
88
+ * position is nearest over exactly those components. Only when it is unambiguous: within MAX_M, clearly
89
+ * nearer than the runner-up, and one target per transform. Rotation / scale overrides ride along on a
90
+ * target the position matched; a target with no position component is left unresolved.
91
+ */
92
+ export class TransformGuess {
93
+ private static MAX_M = 0.5
94
+ private matched = new Map<string, UComp>()
95
+ constructor(tree: Tree, mods: Y[], resolvable: (id: string, guid: string) => boolean) {
96
+ const wanted = new Map<string, Record<string, number>>()
97
+ for (const m of mods) {
98
+ if (!isMap(m)) continue
99
+ const t = ref(m.target), hit = /^m_LocalPosition\.([xyz])$/.exec(str(m.propertyPath))
100
+ if (!hit || resolvable(t.fileID, t.guid ?? "")) continue
101
+ const v = Number(str(m.value))
102
+ if (!Number.isFinite(v)) continue
103
+ const w = wanted.get(t.fileID) ?? {}
104
+ w[hit[1]] = v
105
+ wanted.set(t.fileID, w)
106
+ }
107
+ if (!wanted.size) return
108
+ const roots = new Set(tree.roots)
109
+ const free: { c: UComp, p: Record<string, number> }[] = []
110
+ for (const v of tree.ids.values()) {
111
+ if (!isComp(v) || v.cls !== "Transform" || roots.has(v.go)) continue
112
+ const lp = isMap(v.body.m_LocalPosition) ? v.body.m_LocalPosition : {}
113
+ free.push({ c: v, p: { x: Number(str(lp.x)) || 0, y: Number(str(lp.y)) || 0, z: Number(str(lp.z)) || 0 } })
114
+ }
115
+ const cands: { id: string, c: UComp, d: number }[] = []
116
+ for (const [ id, w ] of wanted) {
117
+ const ds = free.map((f) => ({ c: f.c, d: Math.hypot(...Object.entries(w).map(([ k, v ]) => v - f.p[k])) })).sort((a, b) => a.d - b.d)
118
+ const [ best, next ] = ds
119
+ if (best && best.d <= TransformGuess.MAX_M && (!next || next.d > best.d * 2 + 0.05)) cands.push({ id, c: best.c, d: best.d })
120
+ }
121
+ const taken = new Set<UComp>()
122
+ for (const k of cands.sort((a, b) => a.d - b.d)) if (!taken.has(k.c)) { taken.add(k.c); this.matched.set(k.id, k.c) }
123
+ }
124
+ pick(targetId: string): UComp | undefined { return this.matched.get(targetId) }
125
+ get count(): number { return this.matched.size }
126
+ }
127
+
128
+ export class PrefabLoader {
129
+ private cache = new Map<string, Tree | null>()
130
+ private models = new Map<string, ModelInfo | null>()
131
+ constructor(readonly resolver: GuidResolver, readonly stats: TreeStats = newStats()) {}
132
+
133
+ /** Read every model file the texts instance (transitively through the prefabs they instance) —
134
+ * FBX parsing is async, tree building is not. */
135
+ async preload(texts: string[], guids: string[] = []): Promise<void> {
136
+ const seen = new Set<string>()
137
+ const queue = [ ...guids ]
138
+ const scan = (t: string): void => { for (const m of t.matchAll(/m_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})/g)) queue.push(m[1]) }
139
+ texts.forEach(scan)
140
+ while (queue.length) {
141
+ const g = queue.pop()!
142
+ if (seen.has(g)) continue
143
+ seen.add(g)
144
+ const path = this.resolver.resolve(g)
145
+ if (!path) continue
146
+ if (MODEL_EXT.test(path)) { if (!this.models.has(g)) this.models.set(g, await readModel(path)); continue }
147
+ if (/\.prefab$/i.test(path)) { const t = readFileSync(path, "utf8"); if (t.startsWith("%YAML")) scan(t) }
148
+ }
149
+ }
150
+
151
+ modelMeta(guid: string): FbxMeta | null { return this.models.get(guid)?.meta ?? null }
152
+
153
+ /** A fresh (cloned) tree of the prefab asset, or null when it cannot be read. */
154
+ prefab(guid: string, depth = 0): Tree | null {
155
+ if (depth > 16) return null
156
+ let t = this.cache.get(guid)
157
+ if (t === undefined) {
158
+ t = this.read(guid, depth)
159
+ this.cache.set(guid, t)
160
+ }
161
+ return t ? cloneTree(t) : null
162
+ }
163
+
164
+ private read(guid: string, depth: number): Tree | null {
165
+ const path = this.resolver.resolve(guid)
166
+ if (!path) { this.stats.missingPrefabs.add(guid); return null }
167
+ if (MODEL_EXT.test(path)) {
168
+ const info = this.models.get(guid)
169
+ if (!info) { this.stats.missingPrefabs.add(guid); return null }
170
+ return modelTree(info, guid)
171
+ }
172
+ const text = readFileSync(path, "utf8")
173
+ if (!text.startsWith("%YAML")) { this.stats.binaryPrefabs.add(path); return null }
174
+ return this.buildText(text, guid, depth + 1)
175
+ }
176
+
177
+ /** One `m_Modifications` entry onto the cloned tree of the instance it belongs to. */
178
+ private applyModification(tree: Tree, m: YMap, guess: RendererGuess, placed: TransformGuess): void {
179
+ const tgt = ref(m.target)
180
+ const path = str(m.propertyPath)
181
+ // An override names its target as (guid, fileID) - "object N of THAT asset". Once a prefab is
182
+ // nested the tree's own ids are XOR-composed, so an override reaching PAST one prefab into
183
+ // another's object misses by id: a kit whose prefabs are bare wrappers around an FBX puts
184
+ // EVERY placement's transform there, and the whole level then collapses onto the origin.
185
+ const target = tree.ids.get(tgt.fileID)
186
+ ?? (tgt.guid ? tree.bySource.get(`${tgt.guid}:${tgt.fileID}`) : undefined)
187
+ ?? (path.startsWith("m_Materials") ? guess.pick(tgt.fileID) : undefined)
188
+ ?? (path.startsWith("m_Local") ? placed.pick(tgt.fileID) : undefined)
189
+ if (!target) { this.stats.unresolvedMods++; return }
190
+ // AN OVERRIDE POINTING AT AN OBJECT INSIDE THE SCENE, not at an asset, has nothing an
191
+ // importer can follow: no GUID, so no file. Mesh painters write exactly that — Polybrush
192
+ // stores the painted copy of a mesh as a scene-local `!u!43` (`PolybrushMesh…`) and rewrites
193
+ // the MeshFilter's `m_Mesh` to it. Taking that at face value replaced a real mesh reference
194
+ // with a dangling one and the part was then dropped in silence: the shooting range's walls
195
+ // lost 257 of them and nine wall kinds came out of the import with no geometry at all.
196
+ // The asset reference underneath is the same geometry minus the painted vertex colours, so
197
+ // it is kept and the loss is counted rather than the wall disappearing.
198
+ const objRef = m.objectReference && ref(m.objectReference).fileID !== "0" ? m.objectReference : undefined
199
+ if (objRef && !ref(objRef).guid && ref(readPath(target.body, path)).guid) { this.stats.sceneLocalRefs++; return }
200
+ setPath(target.body, path, objRef ?? m.value)
201
+ }
202
+
203
+ /** A `PrefabInstance` object → the source prefab's tree, cloned, with this instance's overrides on it. */
204
+ private expandInstance(body: YMap, depth: number): { tree: Tree, source: string, parent: string } | null {
205
+ const source = ref(body.m_SourcePrefab).guid
206
+ const tree = source ? this.prefab(source, depth) : null
207
+ if (!source || !tree) return null
208
+ this.stats.instances++
209
+ const mod = isMap(body.m_Modification) ? body.m_Modification : {}
210
+ const mods = Array.isArray(mod.m_Modifications) ? mod.m_Modifications : []
211
+ const guess = new RendererGuess(tree, mods)
212
+ const placed = new TransformGuess(tree, mods, (id, guid) => tree.ids.has(id) || (!!guid && tree.bySource.has(`${guid}:${id}`)))
213
+ this.stats.guessedTransforms += placed.count
214
+ for (const m of mods) if (isMap(m)) this.applyModification(tree, m, guess, placed)
215
+ for (const rc of Array.isArray(mod.m_RemovedComponents) ? mod.m_RemovedComponents : []) {
216
+ const c = tree.ids.get(ref(rc).fileID)
217
+ if (c && isComp(c)) c.go.comps = c.go.comps.filter((x) => x !== c)
218
+ }
219
+ for (const r of tree.roots) r.prefab = source
220
+ return { tree, source, parent: ref(mod.m_TransformParent).fileID }
221
+ }
222
+
223
+ /** Build the trees of a text-serialised file (a prefab asset or a scene). */
224
+ buildText(text: string, fileGuid: string | null, depth = 0): Tree {
225
+ const objs = readUnityObjects(text)
226
+ const ids = new Map<string, UGO | UComp>()
227
+ const bySource = new Map<string, UGO | UComp>()
228
+ /** This file's own objects, to be published under its own GUID once it is known to be an asset. */
229
+ const own = new Map<string, UGO | UComp>()
230
+ const reg = (id: string, o: UGO | UComp): void => { ids.set(id, o); own.set(id, o) }
231
+ const instances = new Map<string, Tree>()
232
+ const instanceParent = new Map<string, string>()
233
+
234
+ // 1. nested prefab instances: clone the source, apply the modifications, expose its objects
235
+ for (const o of objs) {
236
+ if (o.cls !== "PrefabInstance" || o.stripped) continue
237
+ const inst = this.expandInstance(o.body, depth)
238
+ if (!inst) continue
239
+ instances.set(o.id, inst.tree)
240
+ instanceParent.set(o.id, inst.parent)
241
+ // this file publishes a nested object under Unity's composed id, and an override that
242
+ // reaches it from further out names exactly that (with THIS file's guid)
243
+ for (const [ k, v ] of inst.tree.ids) reg(xorId(o.id, k), v)
244
+ // ...while by SOURCE it stays whatever asset defined it, however deep it now sits
245
+ for (const [ k, v ] of inst.tree.bySource) if (!bySource.has(k)) bySource.set(k, v)
246
+ }
247
+
248
+ // 2. stripped stubs → the object inside the expansion
249
+ for (const o of objs) {
250
+ if (!o.stripped) continue
251
+ const tree = instances.get(ref(o.body.m_PrefabInstance).fileID)
252
+ const hit = tree?.ids.get(ref(o.body.m_CorrespondingSourceObject).fileID)
253
+ if (hit) reg(o.id, hit)
254
+ }
255
+
256
+ // 3. the file's own GameObjects and components
257
+ for (const o of objs) {
258
+ if (o.stripped || o.cls !== "GameObject") continue
259
+ reg(o.id, { id: o.id, body: o.body, comps: [], children: [], parent: null })
260
+ }
261
+ const transforms: UComp[] = []
262
+ for (const o of objs) {
263
+ if (o.stripped || o.cls === "GameObject" || o.cls === "PrefabInstance") continue
264
+ const owner = ids.get(ref(o.body.m_GameObject).fileID)
265
+ if (!owner || isComp(owner)) continue
266
+ const c: UComp = { cls: o.cls, id: o.id, body: o.body, go: owner }
267
+ owner.comps.push(c)
268
+ reg(o.id, c)
269
+ if (o.cls === "Transform" || o.cls === "RectTransform") transforms.push(c)
270
+ }
271
+
272
+ // 4. parenting: own transforms by m_Father, instance roots by m_TransformParent
273
+ const roots: UGO[] = []
274
+ const attach = (child: UGO, parentTransformId: string): void => {
275
+ const p = parentTransformId !== "0" ? ids.get(parentTransformId) : undefined
276
+ const pgo = p ? (isComp(p) ? p.go : p) : null
277
+ if (pgo) { child.parent = pgo; pgo.children.push(child) } else roots.push(child)
278
+ }
279
+ for (const t of transforms) attach(t.go, ref(t.body.m_Father).fileID)
280
+ for (const [ id, tree ] of instances) for (const r of tree.roots) attach(r, instanceParent.get(id) ?? "0")
281
+ // Unity's child order (m_Children) is cosmetic here; file order is stable enough
282
+ if (fileGuid) for (const [ k, v ] of own) bySource.set(`${fileGuid}:${k}`, v)
283
+ return { roots, ids, bySource }
284
+ }
285
+ }