lecodes-assets 0.2.1 → 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 (97) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +17948 -11911
  3. package/dist/index.js +17525 -11479
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +67 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +103 -0
  25. package/src/commands/shared.ts +121 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +286 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/merge/accessors.ts +30 -0
  34. package/src/doctor/merge/atlasLayout.ts +94 -0
  35. package/src/doctor/merge/atlasPixels.ts +114 -0
  36. package/src/doctor/merge/bindSpace.ts +59 -0
  37. package/src/doctor/merge/classify.ts +136 -0
  38. package/src/doctor/merge/geometry.ts +167 -0
  39. package/src/doctor/merge/index.ts +98 -0
  40. package/src/doctor/merge/matrix.ts +60 -0
  41. package/src/doctor/merge/plan.ts +88 -0
  42. package/src/doctor/merge/textures.ts +60 -0
  43. package/src/doctor/merge/types.ts +77 -0
  44. package/src/doctor/transcodeKtx2.ts +179 -28
  45. package/src/doctor/validateGlb.ts +2 -2
  46. package/src/doctor/vertexAo.ts +321 -0
  47. package/src/fbx/read.ts +2 -0
  48. package/src/fbx/toDocument.ts +227 -209
  49. package/src/geometry.ts +222 -0
  50. package/src/index.ts +2 -0
  51. package/src/retarget/heading.ts +238 -171
  52. package/src/retarget/humanoid.ts +572 -561
  53. package/src/retarget/retarget.ts +2375 -2272
  54. package/src/retarget/rig.ts +4 -1
  55. package/src/retarget/writeMarks.ts +134 -132
  56. package/src/sky/cmgen.ts +3 -3
  57. package/src/sky/index.ts +57 -8
  58. package/src/terrainPack.ts +61 -22
  59. package/src/unity/colliderFit.ts +287 -0
  60. package/src/unity/fbxModel.ts +194 -0
  61. package/src/unity/foliage.ts +63 -0
  62. package/src/unity/gameObjects.ts +81 -0
  63. package/src/unity/guid.ts +104 -0
  64. package/src/unity/image.ts +58 -0
  65. package/src/unity/kindGlb.ts +364 -0
  66. package/src/unity/lecodesLevel.ts +381 -0
  67. package/src/unity/levelTerrain.ts +116 -0
  68. package/src/unity/matFile.ts +179 -0
  69. package/src/unity/materialSlots.ts +53 -0
  70. package/src/unity/materialWriter.ts +306 -0
  71. package/src/unity/materials.ts +34 -429
  72. package/src/unity/math.ts +82 -0
  73. package/src/unity/prefabTree.ts +285 -0
  74. package/src/unity/prefabs.ts +10 -36
  75. package/src/unity/propertyPath.ts +43 -0
  76. package/src/unity/scene.ts +325 -304
  77. package/src/unity/serialized.ts +242 -0
  78. package/src/unity/terrain.ts +221 -0
  79. package/src/unity/triBudget.ts +77 -0
  80. package/src/unity/triplanar.ts +122 -0
  81. package/src/unity/unityMesh.ts +151 -0
  82. package/src/unity/unpack.ts +24 -5
  83. package/src/unity/yaml.ts +131 -0
  84. package/src/unreal/index.ts +6 -5
  85. package/src/unreal/materialGraph.ts +224 -0
  86. package/src/unreal/materials.ts +117 -9
  87. package/src/unreal/package.ts +30 -12
  88. package/src/unreal/resolve.ts +6 -4
  89. package/src/unreal/sound.ts +128 -0
  90. package/src/unreal/texture.ts +70 -3
  91. package/src/unreal/toDocument.ts +7 -0
  92. package/src/unreal/versions.ts +8 -0
  93. package/src/unwrap/index.ts +167 -0
  94. package/src/unwrap/lightmapUv.ts +761 -0
  95. package/src/wasm/fbxReader.js +1 -1
  96. package/src/wasm/lightmapUv.js +1 -1
  97. package/src/doctor/lightmapUv.ts +0 -452
@@ -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
+ }
@@ -6,9 +6,9 @@
6
6
  // renders a mesh, which FBX mesh it shows and which material GUIDs its renderer carries, so the
7
7
  // converter can re-material the matching glTF node (`applyUnityMaterials`).
8
8
 
9
- import { existsSync, readFileSync, readdirSync } from "node:fs"
10
- import { join } from "node:path"
11
- import { parseUnityYaml } from "./scene"
9
+ import { readFileSync } from "node:fs"
10
+ import { walkFiles } from "./guid"
11
+ import { isMap, readUnityObjects, ref } from "./yaml"
12
12
 
13
13
  export type PrefabOverride = {
14
14
  /** The .prefab file. */
@@ -21,11 +21,6 @@ export type PrefabOverride = {
21
21
  materials: (string | undefined)[]
22
22
  }
23
23
 
24
- type Y = string | Y[] | { [k: string]: Y }
25
- const isMap = (v: Y | undefined): v is { [k: string]: Y } => typeof v === "object" && v !== null && !Array.isArray(v)
26
- const guidOf = (v: Y | undefined): string | undefined => (isMap(v) && typeof v.guid === "string" ? v.guid : undefined)
27
- const idOf = (v: Y | undefined): string => (isMap(v) ? String(v.fileID ?? "0") : "0")
28
-
29
24
  /** Material overrides declared by one text-serialised prefab (empty for binary / non-prefab files). */
30
25
  export const readPrefabOverrides = (file: string): PrefabOverride[] => {
31
26
  const text = readFileSync(file, "utf8")
@@ -33,22 +28,13 @@ export const readPrefabOverrides = (file: string): PrefabOverride[] => {
33
28
  const names = new Map<string, string>() // GameObject id → name
34
29
  const meshes = new Map<string, string | undefined>() // GameObject id → mesh guid
35
30
  const renderers = new Map<string, (string | undefined)[]>() // GameObject id → material guids
36
- const re = /^--- !u!(\d+) &(-?\d+)( stripped)?\s*$/gm
37
- const starts: { idx: number, end: number }[] = []
38
- for (let m = re.exec(text); m; m = re.exec(text)) starts.push({ idx: m.index, end: m.index + m[0].length })
39
- for (let i = 0; i < starts.length; i++) {
40
- const chunk = text.slice(starts[i].end, i + 1 < starts.length ? starts[i + 1].idx : text.length)
41
- const doc = parseUnityYaml(chunk)
42
- const cls = Object.keys(doc)[0] ?? ""
43
- const body = doc[cls]
44
- if (!isMap(body)) continue
45
- const id = /&(-?\d+)/.exec(text.slice(starts[i].idx, starts[i].end))?.[1] ?? ""
31
+ for (const { cls, id, body } of readUnityObjects(text)) {
32
+ const go = ref(body.m_GameObject).fileID
46
33
  if (cls === "GameObject" && typeof body.m_Name === "string") names.set(id, body.m_Name)
47
- else if (cls === "MeshFilter") meshes.set(idOf(body.m_GameObject), guidOf(body.m_Mesh))
34
+ else if (cls === "MeshFilter") meshes.set(go, ref(body.m_Mesh).guid)
48
35
  else if (cls === "MeshRenderer" || cls === "SkinnedMeshRenderer") {
49
- const mats = Array.isArray(body.m_Materials) ? body.m_Materials.map((m) => guidOf(m)) : []
50
- renderers.set(idOf(body.m_GameObject), mats)
51
- if (cls === "SkinnedMeshRenderer") meshes.set(idOf(body.m_GameObject), guidOf(body.m_Mesh))
36
+ renderers.set(go, Array.isArray(body.m_Materials) ? body.m_Materials.map((m) => (isMap(m) && typeof m.guid === "string" ? m.guid : undefined)) : [])
37
+ if (cls === "SkinnedMeshRenderer") meshes.set(go, ref(body.m_Mesh).guid)
52
38
  }
53
39
  }
54
40
  const out: PrefabOverride[] = []
@@ -63,18 +49,6 @@ export const readPrefabOverrides = (file: string): PrefabOverride[] => {
63
49
  /** Every `.prefab` under `dirs` (recursive to `depth`), read for overrides. */
64
50
  export const collectPrefabOverrides = (dirs: string[], depth = 3): PrefabOverride[] => {
65
51
  const files: string[] = []
66
- const walk = (dir: string, d: number): void => {
67
- if (d < 0 || !existsSync(dir)) return
68
- let entries
69
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
70
- for (const e of entries) {
71
- const p = join(dir, e.name)
72
- if (e.isDirectory()) { if (!e.name.startsWith(".")) walk(p, d - 1) }
73
- else if (/\.prefab$/i.test(e.name)) files.push(p)
74
- }
75
- }
76
- for (const d of dirs) walk(d, depth)
77
- const out: PrefabOverride[] = []
78
- for (const f of [ ...new Set(files) ]) out.push(...readPrefabOverrides(f))
79
- return out
52
+ for (const d of dirs) walkFiles(d, depth, files, (n) => /\.prefab$/i.test(n))
53
+ return [ ...new Set(files) ].flatMap(readPrefabOverrides)
80
54
  }
@@ -0,0 +1,43 @@
1
+ // Unity's serialized property paths (`m_LocalPosition.x`, `m_Materials.Array.data[1]`) on a raw
2
+ // YAML body — what a PrefabInstance's `m_Modifications` are written in.
3
+
4
+ import { isMap, num, type Y, type YMap } from "./yaml"
5
+
6
+ /** Read what a dotted property path currently holds (the mirror of `setPath`), so an override can be
7
+ * compared with what it would replace. Undefined when any step is missing. */
8
+ export const readPath = (body: YMap, path: string): Y | undefined => {
9
+ let cur: Y | undefined = body
10
+ for (const p of path.split(".")) {
11
+ if (!isMap(cur)) return undefined
12
+ cur = cur[p]
13
+ }
14
+ return cur
15
+ }
16
+
17
+ /** `m_LocalPosition.x`, `m_Materials.Array.data[1]`, `m_Materials.Array.size`, `m_Size.y` … */
18
+ export const setPath = (body: YMap, path: string, value: Y | undefined): void => {
19
+ if (!path || value === undefined) return
20
+ const parts = path.split(".")
21
+ let cur: Y = body
22
+ for (let i = 0; i < parts.length; i++) {
23
+ const p = parts[i]
24
+ const last = i === parts.length - 1
25
+ if (p === "Array" && Array.isArray(cur)) continue
26
+ const arr = /^data\[(\d+)\]$/.exec(p)
27
+ if (arr && Array.isArray(cur)) {
28
+ const k = +arr[1]
29
+ if (last) { cur[k] = value; return }
30
+ if (cur[k] === undefined || typeof cur[k] !== "object") cur[k] = {}
31
+ cur = cur[k]
32
+ continue
33
+ }
34
+ if (p === "size" && Array.isArray(cur) && last) { cur.length = Math.max(0, num(value as Y, cur.length)); return }
35
+ if (!isMap(cur)) return
36
+ if (last) { cur[p] = value; return }
37
+ let next: Y | undefined = cur[p]
38
+ // an array field addressed as `field.Array.…` — make sure it is one
39
+ if (parts[i + 1] === "Array" && !Array.isArray(next)) { next = []; cur[p] = next }
40
+ else if (next === undefined || typeof next !== "object") { next = {}; cur[p] = next }
41
+ cur = next
42
+ }
43
+ }