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
@@ -1,209 +1,227 @@
1
- // FbxScene (IR from the ufbx wasm reader) → glTF-Transform Document. Nodes/meshes/skins/materials/
2
- // textures/animations; the doctor passes and the writer then run on the Document like on any GLB.
3
-
4
- import { Document, type Node, type Texture, type Material } from "@gltf-transform/core"
5
- import { type FbxScene, type FbxTexture, f32, u16, u32, bytes } from "./read"
6
- import { resolveTextureImage, type ResolvedImage } from "../textures/resolveImage"
7
-
8
- export type ConvertWarning = { level: "warn" | "info", message: string }
9
-
10
- export type ToDocumentOptions = {
11
- /** Directory of the source .fbx — external texture references are resolved against it. */
12
- baseDir?: string
13
- /** Name used for the file's single clip when its stack name is generic (`mixamo.com`, `Take 001`, `Unreal Take`). */
14
- defaultClipName?: string
15
- /** Skip meshes/skins/materials/textures (clip-only conversion). */
16
- animationsOnly?: boolean
17
- warn?: (w: ConvertWarning) => void
18
- }
19
-
20
- const GENERIC_CLIP_NAMES = new Set([ "", "mixamo.com", "take 001", "take001", "unreal take", "default take", "unnamed", "scene" ])
21
- export const isGenericClipName = (name: string): boolean => GENERIC_CLIP_NAMES.has(name.trim().toLowerCase())
22
-
23
- const isIdentityTransform = (t: number[], r: number[], s: number[]): boolean =>
24
- Math.hypot(t[0], t[1], t[2]) < 1e-9 && Math.abs(r[3] - 1) < 1e-9 && Math.hypot(r[0], r[1], r[2]) < 1e-9 &&
25
- Math.abs(s[0] - 1) < 1e-9 && Math.abs(s[1] - 1) < 1e-9 && Math.abs(s[2] - 1) < 1e-9
26
-
27
- /** Make consecutive quaternions hemisphere-consistent so LINEAR (slerp) interpolation never takes the long way. */
28
- export const fixQuatContinuity = (q: Float32Array): void => {
29
- for (let i = 4; i < q.length; i += 4) {
30
- const dot = q[i] * q[i - 4] + q[i + 1] * q[i - 3] + q[i + 2] * q[i - 2] + q[i + 3] * q[i - 1]
31
- if (dot < 0) { q[i] = -q[i]; q[i + 1] = -q[i + 1]; q[i + 2] = -q[i + 2]; q[i + 3] = -q[i + 3] }
32
- }
33
- }
34
-
35
- export type ToDocumentResult = {
36
- doc: Document
37
- /** glTF node per FBX node index (null for the ufbx root when it was flattened away). */
38
- nodes: (Node | null)[]
39
- clipNames: string[]
40
- }
41
-
42
- export const toDocument = async (scene: FbxScene, opts: ToDocumentOptions = {}): Promise<ToDocumentResult> => {
43
- const warn = opts.warn ?? (() => {})
44
- const doc = new Document()
45
- const buffer = doc.createBuffer("buffer")
46
- const gscene = doc.createScene("Scene")
47
-
48
- // ---- nodes ----
49
- const nodes: (Node | null)[] = new Array(scene.nodes.length).fill(null)
50
- const rootIdx = scene.nodes.findIndex((n) => n.isRoot)
51
- const root = rootIdx >= 0 ? scene.nodes[rootIdx] : undefined
52
- let rootNode: Node | null = null
53
- if (root && !isIdentityTransform(root.t, root.r, root.s)) {
54
- rootNode = doc.createNode(root.name || "RootNode").setTranslation(root.t).setRotation(root.r).setScale(root.s)
55
- gscene.addChild(rootNode)
56
- nodes[rootIdx] = rootNode
57
- }
58
- for (let i = 0; i < scene.nodes.length; i++) {
59
- const n = scene.nodes[i]
60
- if (n.isRoot) continue
61
- const g = doc.createNode(n.name).setTranslation(n.t).setRotation(n.r).setScale(n.s)
62
- nodes[i] = g
63
- }
64
- for (let i = 0; i < scene.nodes.length; i++) {
65
- const n = scene.nodes[i]
66
- const g = nodes[i]
67
- if (!g || n.isRoot) continue
68
- const parent = n.parent >= 0 ? nodes[n.parent] : null
69
- if (parent) parent.addChild(g)
70
- else gscene.addChild(g)
71
- }
72
-
73
- if (!opts.animationsOnly) {
74
- // ---- textures ----
75
- const textures: (Texture | null)[] = []
76
- const byHash = new Map<string, Texture>()
77
- for (let i = 0; i < scene.textures.length; i++) {
78
- const t = scene.textures[i]
79
- const img = await resolveTextureImage(t, t.content >= 0 ? bytes(scene, t.content, t.size) : null, opts.baseDir, warn)
80
- if (!img) { textures.push(null); continue }
81
- const key = img.hash
82
- let tex = byHash.get(key)
83
- if (!tex) {
84
- tex = doc.createTexture(textureName(t, img)).setImage(img.data).setMimeType(img.mimeType)
85
- byHash.set(key, tex)
86
- }
87
- textures.push(tex)
88
- }
89
- const texAt = (i: number): Texture | null => (i >= 0 && i < textures.length ? textures[i] : null)
90
-
91
- // ---- materials ----
92
- const materials: Material[] = scene.materials.map((m) => {
93
- const mat = doc.createMaterial(m.name)
94
- mat.setBaseColorFactor([ clamp01(m.baseColor[0]), clamp01(m.baseColor[1]), clamp01(m.baseColor[2]), clamp01(m.baseColor[3]) ])
95
- const base = texAt(m.baseTex)
96
- if (base) { mat.setBaseColorTexture(base); mat.setBaseColorFactor([ 1, 1, 1, clamp01(m.baseColor[3]) ]) }
97
- mat.setMetallicFactor(clamp01(m.metallic))
98
- mat.setRoughnessFactor(clamp01(m.roughness))
99
- // glTF packs metallic (B) + roughness (G) into ONE texture; FBX keeps them apart. Only when both
100
- // point at the same image can we forward it — otherwise the factors above stand in.
101
- if (m.metallicTex >= 0 && m.metallicTex === m.roughnessTex) {
102
- const mr = texAt(m.metallicTex); if (mr) { mat.setMetallicRoughnessTexture(mr); mat.setMetallicFactor(1); mat.setRoughnessFactor(1) }
103
- } else if (m.metallicTex >= 0 || m.roughnessTex >= 0) {
104
- warn({ level: "info", message: `material '${m.name}': separate metallic/roughness maps are not packed (factors used)` })
105
- }
106
- const nrm = texAt(m.normalTex); if (nrm) mat.setNormalTexture(nrm)
107
- const occ = texAt(m.occlusionTex); if (occ) mat.setOcclusionTexture(occ)
108
- const em = texAt(m.emissiveTex)
109
- if (em) { mat.setEmissiveTexture(em); mat.setEmissiveFactor([ 1, 1, 1 ]) }
110
- else mat.setEmissiveFactor([ clamp01(m.emissive[0]), clamp01(m.emissive[1]), clamp01(m.emissive[2]) ])
111
- // alpha: an opacity map → cutout (hair cards, leaves); a factor < 1 → blend
112
- if (m.opacityTex >= 0) {
113
- mat.setAlphaMode("MASK").setAlphaCutoff(0.5)
114
- if (!base && texAt(m.opacityTex)) mat.setBaseColorTexture(texAt(m.opacityTex)!)
115
- } else if (m.opacity < 0.999) {
116
- mat.setAlphaMode("BLEND")
117
- const f = mat.getBaseColorFactor(); mat.setBaseColorFactor([ f[0], f[1], f[2], clamp01(m.opacity) ])
118
- }
119
- mat.setDoubleSided(m.doubleSided)
120
- return mat
121
- })
122
-
123
- // ---- meshes ----
124
- // An FBX geometry is often unnamed (Maya exports, "wolf" → mesh ""); name it after the node that
125
- // carries it so probe/doctor/pick-by-name have something to print.
126
- const meshNodeName: string[] = new Array(scene.meshes.length).fill("")
127
- for (const n of scene.nodes) if (n.mesh >= 0 && n.mesh < meshNodeName.length && !meshNodeName[n.mesh]) meshNodeName[n.mesh] = n.name
128
- const meshes = scene.meshes.map((m, mi) => {
129
- const mesh = doc.createMesh(m.name || meshNodeName[mi])
130
- for (const p of m.primitives) {
131
- const prim = doc.createPrimitive()
132
- prim.setAttribute("POSITION", doc.createAccessor().setType("VEC3").setArray(f32(scene, p.pos, p.vertexCount * 3).slice()).setBuffer(buffer))
133
- if (p.nrm >= 0) prim.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(f32(scene, p.nrm, p.vertexCount * 3).slice()).setBuffer(buffer))
134
- if (p.uv >= 0) prim.setAttribute("TEXCOORD_0", doc.createAccessor().setType("VEC2").setArray(f32(scene, p.uv, p.vertexCount * 2).slice()).setBuffer(buffer))
135
- if (p.col >= 0) prim.setAttribute("COLOR_0", doc.createAccessor().setType("VEC4").setArray(f32(scene, p.col, p.vertexCount * 4).slice()).setBuffer(buffer))
136
- if (p.joints >= 0 && p.weights >= 0) {
137
- prim.setAttribute("JOINTS_0", doc.createAccessor().setType("VEC4").setArray(u16(scene, p.joints, p.vertexCount * 4).slice()).setBuffer(buffer))
138
- prim.setAttribute("WEIGHTS_0", doc.createAccessor().setType("VEC4").setArray(f32(scene, p.weights, p.vertexCount * 4).slice()).setBuffer(buffer))
139
- }
140
- const idx = u32(scene, p.indices, p.indexCount)
141
- const indices = p.vertexCount <= 65535 ? Uint16Array.from(idx) : idx.slice()
142
- prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(indices).setBuffer(buffer))
143
- if (p.material >= 0 && p.material < materials.length) prim.setMaterial(materials[p.material])
144
- mesh.addPrimitive(prim)
145
- }
146
- return mesh
147
- })
148
-
149
- // ---- skins ----
150
- const skins = scene.skins.map((s) => {
151
- const skin = doc.createSkin(s.name)
152
- const ibm = f32(scene, s.ibm, s.joints.length * 16).slice()
153
- for (let j = 0; j < s.joints.length; j++) {
154
- const jn = s.joints[j] >= 0 ? nodes[s.joints[j]] : null
155
- if (jn) skin.addJoint(jn)
156
- else {
157
- // a cluster without a bone: bind it to the first available node so joint indices stay aligned
158
- const fallback = nodes.find((n) => n) ?? doc.createNode("missing-joint")
159
- skin.addJoint(fallback)
160
- warn({ level: "warn", message: `skin '${s.name}': cluster ${j} has no bone node — bound to a placeholder` })
161
- }
162
- }
163
- skin.setInverseBindMatrices(doc.createAccessor().setType("MAT4").setArray(ibm).setBuffer(buffer))
164
- return skin
165
- })
166
-
167
- for (let i = 0; i < scene.nodes.length; i++) {
168
- const n = scene.nodes[i]
169
- const g = nodes[i]
170
- if (!g) continue
171
- if (n.mesh >= 0 && n.mesh < meshes.length) g.setMesh(meshes[n.mesh])
172
- if (n.skin >= 0 && n.skin < skins.length) g.setSkin(skins[n.skin])
173
- }
174
- }
175
-
176
- // ---- animations ----
177
- const emitted = scene.animations.filter((a) => a.tracks.length > 0)
178
- const clipNames: string[] = []
179
- for (const a of emitted) {
180
- let name = a.name
181
- if (emitted.length === 1 && isGenericClipName(name) && opts.defaultClipName) name = opts.defaultClipName
182
- clipNames.push(name)
183
- const anim = doc.createAnimation(name)
184
- for (const tr of a.tracks) {
185
- const target = nodes[tr.node]
186
- if (!target) continue
187
- const times = f32(scene, tr.times, tr.count).slice()
188
- const comps = tr.path === "rotation" ? 4 : 3
189
- const values = f32(scene, tr.values, tr.count * comps).slice()
190
- if (tr.path === "rotation") fixQuatContinuity(values)
191
- const sampler = doc.createAnimationSampler()
192
- .setInput(doc.createAccessor().setType("SCALAR").setArray(times).setBuffer(buffer))
193
- .setOutput(doc.createAccessor().setType(comps === 4 ? "VEC4" : "VEC3").setArray(values).setBuffer(buffer))
194
- .setInterpolation("LINEAR")
195
- const channel = doc.createAnimationChannel().setTargetNode(target).setTargetPath(tr.path).setSampler(sampler)
196
- anim.addSampler(sampler).addChannel(channel)
197
- }
198
- }
199
-
200
- return { doc, nodes, clipNames }
201
- }
202
-
203
- const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v)
204
-
205
- const textureName = (t: FbxTexture, img: ResolvedImage): string => {
206
- const src = t.relative || t.filename || t.absolute || t.name
207
- const base = src.split(/[\\/]/).pop() ?? t.name
208
- return (base.replace(/\.[^.]+$/, "") || t.name || "texture") + (img.converted ? "" : "")
209
- }
1
+ // FbxScene (IR from the ufbx wasm reader) → glTF-Transform Document. Nodes/meshes/skins/materials/
2
+ // textures/animations; the doctor passes and the writer then run on the Document like on any GLB.
3
+
4
+ import { Document, type Node, type Texture, type Material } from "@gltf-transform/core"
5
+ import { type FbxScene, type FbxTexture, f32, u16, u32, bytes } from "./read"
6
+ import { resolveTextureImage, type ResolvedImage } from "../textures/resolveImage"
7
+
8
+ export type ConvertWarning = { level: "warn" | "info", message: string }
9
+
10
+ /** A COLOR_0 set (RGBA per vertex) that is black on EVERY vertex - a channel nobody authored, never a colour to multiply by. */
11
+ export function deadVertexColor(col: Float32Array): boolean {
12
+ for (let i = 0; i + 3 < col.length; i += 4) if (col[i] > 0 || col[i + 1] > 0 || col[i + 2] > 0) return false
13
+ return col.length > 0
14
+ }
15
+
16
+ export type ToDocumentOptions = {
17
+ /** Directory of the source .fbx — external texture references are resolved against it. */
18
+ baseDir?: string
19
+ /** Name used for the file's single clip when its stack name is generic (`mixamo.com`, `Take 001`, `Unreal Take`). */
20
+ defaultClipName?: string
21
+ /** Skip meshes/skins/materials/textures (clip-only conversion). */
22
+ animationsOnly?: boolean
23
+ /** Also write the FBX's further UV sets as TEXCOORD_1..3 (off by default: TEXCOORD_1 is the lightmap set in
24
+ * every LeCodes GLB, and a pack's extra sets are shader inputs — the Unity kind builder reads them and
25
+ * drops them again). */
26
+ extraUvSets?: boolean
27
+ warn?: (w: ConvertWarning) => void
28
+ }
29
+
30
+ const GENERIC_CLIP_NAMES = new Set([ "", "mixamo.com", "take 001", "take001", "unreal take", "default take", "unnamed", "scene" ])
31
+ export const isGenericClipName = (name: string): boolean => GENERIC_CLIP_NAMES.has(name.trim().toLowerCase())
32
+
33
+ const isIdentityTransform = (t: number[], r: number[], s: number[]): boolean =>
34
+ Math.hypot(t[0], t[1], t[2]) < 1e-9 && Math.abs(r[3] - 1) < 1e-9 && Math.hypot(r[0], r[1], r[2]) < 1e-9 &&
35
+ Math.abs(s[0] - 1) < 1e-9 && Math.abs(s[1] - 1) < 1e-9 && Math.abs(s[2] - 1) < 1e-9
36
+
37
+ /** Make consecutive quaternions hemisphere-consistent so LINEAR (slerp) interpolation never takes the long way. */
38
+ export const fixQuatContinuity = (q: Float32Array): void => {
39
+ for (let i = 4; i < q.length; i += 4) {
40
+ const dot = q[i] * q[i - 4] + q[i + 1] * q[i - 3] + q[i + 2] * q[i - 2] + q[i + 3] * q[i - 1]
41
+ if (dot < 0) { q[i] = -q[i]; q[i + 1] = -q[i + 1]; q[i + 2] = -q[i + 2]; q[i + 3] = -q[i + 3] }
42
+ }
43
+ }
44
+
45
+ export type ToDocumentResult = {
46
+ doc: Document
47
+ /** glTF node per FBX node index (null for the ufbx root when it was flattened away). */
48
+ nodes: (Node | null)[]
49
+ clipNames: string[]
50
+ }
51
+
52
+ export const toDocument = async (scene: FbxScene, opts: ToDocumentOptions = {}): Promise<ToDocumentResult> => {
53
+ const warn = opts.warn ?? (() => {})
54
+ const doc = new Document()
55
+ const buffer = doc.createBuffer("buffer")
56
+ const gscene = doc.createScene("Scene")
57
+
58
+ // ---- nodes ----
59
+ const nodes: (Node | null)[] = new Array(scene.nodes.length).fill(null)
60
+ const rootIdx = scene.nodes.findIndex((n) => n.isRoot)
61
+ const root = rootIdx >= 0 ? scene.nodes[rootIdx] : undefined
62
+ let rootNode: Node | null = null
63
+ if (root && !isIdentityTransform(root.t, root.r, root.s)) {
64
+ rootNode = doc.createNode(root.name || "RootNode").setTranslation(root.t).setRotation(root.r).setScale(root.s)
65
+ gscene.addChild(rootNode)
66
+ nodes[rootIdx] = rootNode
67
+ }
68
+ for (let i = 0; i < scene.nodes.length; i++) {
69
+ const n = scene.nodes[i]
70
+ if (n.isRoot) continue
71
+ const g = doc.createNode(n.name).setTranslation(n.t).setRotation(n.r).setScale(n.s)
72
+ nodes[i] = g
73
+ }
74
+ for (let i = 0; i < scene.nodes.length; i++) {
75
+ const n = scene.nodes[i]
76
+ const g = nodes[i]
77
+ if (!g || n.isRoot) continue
78
+ const parent = n.parent >= 0 ? nodes[n.parent] : null
79
+ if (parent) parent.addChild(g)
80
+ else gscene.addChild(g)
81
+ }
82
+
83
+ if (!opts.animationsOnly) {
84
+ // ---- textures ----
85
+ const textures: (Texture | null)[] = []
86
+ const byHash = new Map<string, Texture>()
87
+ for (let i = 0; i < scene.textures.length; i++) {
88
+ const t = scene.textures[i]
89
+ const img = await resolveTextureImage(t, t.content >= 0 ? bytes(scene, t.content, t.size) : null, opts.baseDir, warn)
90
+ if (!img) { textures.push(null); continue }
91
+ const key = img.hash
92
+ let tex = byHash.get(key)
93
+ if (!tex) {
94
+ tex = doc.createTexture(textureName(t, img)).setImage(img.data).setMimeType(img.mimeType)
95
+ byHash.set(key, tex)
96
+ }
97
+ textures.push(tex)
98
+ }
99
+ const texAt = (i: number): Texture | null => (i >= 0 && i < textures.length ? textures[i] : null)
100
+
101
+ // ---- materials ----
102
+ const materials: Material[] = scene.materials.map((m) => {
103
+ const mat = doc.createMaterial(m.name)
104
+ mat.setBaseColorFactor([ clamp01(m.baseColor[0]), clamp01(m.baseColor[1]), clamp01(m.baseColor[2]), clamp01(m.baseColor[3]) ])
105
+ const base = texAt(m.baseTex)
106
+ if (base) { mat.setBaseColorTexture(base); mat.setBaseColorFactor([ 1, 1, 1, clamp01(m.baseColor[3]) ]) }
107
+ mat.setMetallicFactor(clamp01(m.metallic))
108
+ mat.setRoughnessFactor(clamp01(m.roughness))
109
+ // glTF packs metallic (B) + roughness (G) into ONE texture; FBX keeps them apart. Only when both
110
+ // point at the same image can we forward it — otherwise the factors above stand in.
111
+ if (m.metallicTex >= 0 && m.metallicTex === m.roughnessTex) {
112
+ const mr = texAt(m.metallicTex); if (mr) { mat.setMetallicRoughnessTexture(mr); mat.setMetallicFactor(1); mat.setRoughnessFactor(1) }
113
+ } else if (m.metallicTex >= 0 || m.roughnessTex >= 0) {
114
+ warn({ level: "info", message: `material '${m.name}': separate metallic/roughness maps are not packed (factors used)` })
115
+ }
116
+ const nrm = texAt(m.normalTex); if (nrm) mat.setNormalTexture(nrm)
117
+ const occ = texAt(m.occlusionTex); if (occ) mat.setOcclusionTexture(occ)
118
+ const em = texAt(m.emissiveTex)
119
+ if (em) { mat.setEmissiveTexture(em); mat.setEmissiveFactor([ 1, 1, 1 ]) }
120
+ else mat.setEmissiveFactor([ clamp01(m.emissive[0]), clamp01(m.emissive[1]), clamp01(m.emissive[2]) ])
121
+ // alpha: an opacity map → cutout (hair cards, leaves); a factor < 1 → blend
122
+ if (m.opacityTex >= 0) {
123
+ mat.setAlphaMode("MASK").setAlphaCutoff(0.5)
124
+ if (!base && texAt(m.opacityTex)) mat.setBaseColorTexture(texAt(m.opacityTex)!)
125
+ } else if (m.opacity < 0.999) {
126
+ mat.setAlphaMode("BLEND")
127
+ const f = mat.getBaseColorFactor(); mat.setBaseColorFactor([ f[0], f[1], f[2], clamp01(m.opacity) ])
128
+ }
129
+ mat.setDoubleSided(m.doubleSided)
130
+ return mat
131
+ })
132
+
133
+ // ---- meshes ----
134
+ // An FBX geometry is often unnamed (Maya exports, "wolf" → mesh ""); name it after the node that
135
+ // carries it so probe/doctor/pick-by-name have something to print.
136
+ const meshNodeName: string[] = new Array(scene.meshes.length).fill("")
137
+ for (const n of scene.nodes) if (n.mesh >= 0 && n.mesh < meshNodeName.length && !meshNodeName[n.mesh]) meshNodeName[n.mesh] = n.name
138
+ const meshes = scene.meshes.map((m, mi) => {
139
+ const mesh = doc.createMesh(m.name || meshNodeName[mi])
140
+ for (const p of m.primitives) {
141
+ const prim = doc.createPrimitive()
142
+ prim.setAttribute("POSITION", doc.createAccessor().setType("VEC3").setArray(f32(scene, p.pos, p.vertexCount * 3).slice()).setBuffer(buffer))
143
+ if (p.nrm >= 0) prim.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(f32(scene, p.nrm, p.vertexCount * 3).slice()).setBuffer(buffer))
144
+ if (p.uv >= 0) prim.setAttribute("TEXCOORD_0", doc.createAccessor().setType("VEC2").setArray(f32(scene, p.uv, p.vertexCount * 2).slice()).setBuffer(buffer))
145
+ if (opts.extraUvSets) for (const [ k, off ] of [ p.uv1, p.uv2, p.uv3 ].entries()) if (off !== undefined && off >= 0) prim.setAttribute(`TEXCOORD_${k + 1}`, doc.createAccessor().setType("VEC2").setArray(f32(scene, off, p.vertexCount * 2).slice()).setBuffer(buffer))
146
+ if (p.col >= 0) {
147
+ const col = f32(scene, p.col, p.vertexCount * 4).slice()
148
+ // An all-black colour set is an UNUSED channel: Unity's Standard shader never reads vertex colour, so a pack
149
+ // ships whatever its DCC left there (the Military FPS hands: zeros on the skin mesh) - while every glTF
150
+ // renderer multiplies it into the base colour, and the mesh comes out black. Dropped, with a note.
151
+ if (deadVertexColor(col)) warn({ level: "info", message: `mesh '${m.name || meshNodeName[mi]}': all-black COLOR_0 dropped (it would multiply the base colour to black)` })
152
+ else prim.setAttribute("COLOR_0", doc.createAccessor().setType("VEC4").setArray(col).setBuffer(buffer))
153
+ }
154
+ if (p.joints >= 0 && p.weights >= 0) {
155
+ prim.setAttribute("JOINTS_0", doc.createAccessor().setType("VEC4").setArray(u16(scene, p.joints, p.vertexCount * 4).slice()).setBuffer(buffer))
156
+ prim.setAttribute("WEIGHTS_0", doc.createAccessor().setType("VEC4").setArray(f32(scene, p.weights, p.vertexCount * 4).slice()).setBuffer(buffer))
157
+ }
158
+ const idx = u32(scene, p.indices, p.indexCount)
159
+ const indices = p.vertexCount <= 65535 ? Uint16Array.from(idx) : idx.slice()
160
+ prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(indices).setBuffer(buffer))
161
+ if (p.material >= 0 && p.material < materials.length) prim.setMaterial(materials[p.material])
162
+ mesh.addPrimitive(prim)
163
+ }
164
+ return mesh
165
+ })
166
+
167
+ // ---- skins ----
168
+ const skins = scene.skins.map((s) => {
169
+ const skin = doc.createSkin(s.name)
170
+ const ibm = f32(scene, s.ibm, s.joints.length * 16).slice()
171
+ for (let j = 0; j < s.joints.length; j++) {
172
+ const jn = s.joints[j] >= 0 ? nodes[s.joints[j]] : null
173
+ if (jn) skin.addJoint(jn)
174
+ else {
175
+ // a cluster without a bone: bind it to the first available node so joint indices stay aligned
176
+ const fallback = nodes.find((n) => n) ?? doc.createNode("missing-joint")
177
+ skin.addJoint(fallback)
178
+ warn({ level: "warn", message: `skin '${s.name}': cluster ${j} has no bone node — bound to a placeholder` })
179
+ }
180
+ }
181
+ skin.setInverseBindMatrices(doc.createAccessor().setType("MAT4").setArray(ibm).setBuffer(buffer))
182
+ return skin
183
+ })
184
+
185
+ for (let i = 0; i < scene.nodes.length; i++) {
186
+ const n = scene.nodes[i]
187
+ const g = nodes[i]
188
+ if (!g) continue
189
+ if (n.mesh >= 0 && n.mesh < meshes.length) g.setMesh(meshes[n.mesh])
190
+ if (n.skin >= 0 && n.skin < skins.length) g.setSkin(skins[n.skin])
191
+ }
192
+ }
193
+
194
+ // ---- animations ----
195
+ const emitted = scene.animations.filter((a) => a.tracks.length > 0)
196
+ const clipNames: string[] = []
197
+ for (const a of emitted) {
198
+ let name = a.name
199
+ if (emitted.length === 1 && isGenericClipName(name) && opts.defaultClipName) name = opts.defaultClipName
200
+ clipNames.push(name)
201
+ const anim = doc.createAnimation(name)
202
+ for (const tr of a.tracks) {
203
+ const target = nodes[tr.node]
204
+ if (!target) continue
205
+ const times = f32(scene, tr.times, tr.count).slice()
206
+ const comps = tr.path === "rotation" ? 4 : 3
207
+ const values = f32(scene, tr.values, tr.count * comps).slice()
208
+ if (tr.path === "rotation") fixQuatContinuity(values)
209
+ const sampler = doc.createAnimationSampler()
210
+ .setInput(doc.createAccessor().setType("SCALAR").setArray(times).setBuffer(buffer))
211
+ .setOutput(doc.createAccessor().setType(comps === 4 ? "VEC4" : "VEC3").setArray(values).setBuffer(buffer))
212
+ .setInterpolation("LINEAR")
213
+ const channel = doc.createAnimationChannel().setTargetNode(target).setTargetPath(tr.path).setSampler(sampler)
214
+ anim.addSampler(sampler).addChannel(channel)
215
+ }
216
+ }
217
+
218
+ return { doc, nodes, clipNames }
219
+ }
220
+
221
+ const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v)
222
+
223
+ const textureName = (t: FbxTexture, img: ResolvedImage): string => {
224
+ const src = t.relative || t.filename || t.absolute || t.name
225
+ const base = src.split(/[\\/]/).pop() ?? t.name
226
+ return (base.replace(/\.[^.]+$/, "") || t.name || "texture") + (img.converted ? "" : "")
227
+ }