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.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- 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 -0
- 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 +67 -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 +103 -0
- package/src/commands/shared.ts +121 -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 +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -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/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- 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 +81 -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 +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- 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 +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- 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
|
package/src/unity/png.ts
ADDED
|
@@ -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
|
+
}
|