lecodes-assets 0.2.1 → 2.5.0
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.
- package/README.md +203 -26
- package/dist/cli.js +18157 -11441
- package/dist/index.js +16603 -9878
- package/package.json +3 -1
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -1039
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +79 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +108 -0
- package/src/commands/shared.ts +140 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +288 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/impostor.ts +614 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/transcodeKtx2.ts +186 -31
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +333 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +90 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +414 -0
- package/src/unity/levelTerrain.ts +125 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +316 -0
- package/src/unity/materials.ts +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +339 -304
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- 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
|
+
}
|
package/src/unity/prefabs.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
37
|
-
|
|
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(
|
|
34
|
+
else if (cls === "MeshFilter") meshes.set(go, ref(body.m_Mesh).guid)
|
|
48
35
|
else if (cls === "MeshRenderer" || cls === "SkinnedMeshRenderer") {
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
67
|
-
|
|
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
|
+
}
|