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
@@ -6,26 +6,27 @@
6
6
  // unbound joints / re-encoding webp→png) vs LOSSY fixes (bound-joint removal,
7
7
  // webp→jpg, downscaling, heavy-png→jpg).
8
8
  // Fix : applies everything; the caller writes <name>.fixed.glb and re-validates.
9
- // KTX2 : textures → KTX2 (ETC1S; --ktx2-hq keeps normals UASTC) via KTX-Software's toktx.
9
+ // KTX2 : textures → KTX2 (ETC1S; --ktx2-hq keeps normals UASTC) via KTX-Software (toktx, or `ktx create` where toktx is gone).
10
10
  import { gatherSkinPrims, planSkin, applySkinPlan, DEFAULT_TARGET, type SkinPlan } from './reduceGlbBones'
11
11
  import { planTexture, applyTexturePlan, getTextureRoles, DEFAULT_MAX_SIZE, type TexturePlan } from './transcodeTextures'
12
- import { planKtx2, applyKtx2, findToktx, checkToktx, type Ktx2Plan } from './transcodeKtx2'
12
+ import { planKtx2, applyKtx2, findKtxEncoder, KTX_ENCODER_MISSING, type Ktx2Plan, type KtxEncoder } from './transcodeKtx2'
13
13
  import { measureScale, planScale, applyScale, type ScaleInfo, type ScalePlan, type ScaleOptions } from './normalizeScale'
14
14
  import { analyzeShading, applyShading, type ShadingPlan, type ShadingOptions } from './shading'
15
- import { analyzeLightmapUv, applyLightmapUv, type LightmapUvPlan, type LightmapUvOptions, type LightmapUvResult } from './lightmapUv'
15
+ import { planCardTrim, applyCardTrim, type CardTrimPlan, type CardTrimOptions, type CardTrimResult } from './cardTrim'
16
16
  import { planLod, applyLod, type LodPlan, type LodOptions, type LodResult } from './lod'
17
+ import { planMerge, applyMerge, describeScale, type MergePlan, type MergeOptions, type MergeResult } from './merge'
17
18
  import { duplicateNodeNames } from '../mergeSkins'
18
19
  import { KHRTextureBasisu } from '@gltf-transform/extensions'
19
20
  import type { Document, Material } from '@gltf-transform/core'
20
21
  import { mkdirSync, existsSync, statSync, writeFileSync } from 'node:fs'
21
22
  import { join } from 'node:path'
22
23
 
23
- export { DEFAULT_TARGET, DEFAULT_MAX_SIZE, findToktx, checkToktx }
24
+ export { DEFAULT_TARGET, DEFAULT_MAX_SIZE, findKtxEncoder, KTX_ENCODER_MISSING }
24
25
  export { sceneBounds, measureScale, applyScale } from './normalizeScale'
25
26
 
26
27
  interface MetallicWarn { mat: Material; name: string; metal: number }
27
- export interface DoctorPlan { skinPlans: SkinPlan[]; texPlans: TexturePlan[]; ktx2Plans: Ktx2Plan[]; metallicWarn: MetallicWarn[]; shadingPlan: ShadingPlan; lightmapPlan: LightmapUvPlan; scaleInfo: ScaleInfo | null; scalePlan: ScalePlan | null; lodPlan: LodPlan | null; conflicts: number; target: number; dupNames: { name: string; count: number; shadowsJoint: boolean }[] }
28
- export interface DoctorOptions extends ScaleOptions, ShadingOptions, LightmapUvOptions, LodOptions { target?: number; maxSize?: number; ktx2?: boolean; ktx2Hq?: boolean; ktx2Uastc?: boolean }
28
+ export interface DoctorPlan { skinPlans: SkinPlan[]; texPlans: TexturePlan[]; ktx2Plans: Ktx2Plan[]; metallicWarn: MetallicWarn[]; bareCount: number; shadingPlan: ShadingPlan; scaleInfo: ScaleInfo | null; scalePlan: ScalePlan | null; lodPlan: LodPlan | null; cardTrimPlan: CardTrimPlan; mergePlan: MergePlan; conflicts: number; target: number; dupNames: { name: string; count: number; shadowsJoint: boolean }[] }
29
+ export interface DoctorOptions extends ScaleOptions, ShadingOptions, LodOptions, CardTrimOptions, MergeOptions { target?: number; maxSize?: number; ktx2?: boolean; ktx2Hq?: boolean; ktx2Uastc?: boolean }
29
30
 
30
31
  export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promise<DoctorPlan> {
31
32
  const target = opts.target ?? DEFAULT_TARGET
@@ -50,9 +51,12 @@ export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promi
50
51
  const metallicWarn: MetallicWarn[] = root.listMaterials()
51
52
  .filter((m) => m.getMetallicFactor() > 0.01 && !m.getMetallicRoughnessTexture())
52
53
  .map((m) => ({ mat: m, name: m.getName() || '?', metal: m.getMetallicFactor() }))
54
+ // Primitives with NO material at all: glTF's default material is metallic 1 / roughness 1 — a white ROUGH METAL,
55
+ // which under an environment (or a lightmap's reflection probe) is a dark featureless blob (the school's
56
+ // cabinets and sinks, 2026-09-17). --fix-metallic gives them a plain white dielectric.
57
+ let bareCount = 0
58
+ for (const mesh of root.listMeshes()) for (const prim of mesh.listPrimitives()) if (!prim.getMaterial()) bareCount++
53
59
  const shadingPlan = analyzeShading(doc, opts)
54
- // Lightmap UVs are only ever ADDED on request (--lightmap-uv); the analysis just says what would be unwrapped.
55
- const lightmapPlan = analyzeLightmapUv(doc, opts)
56
60
  // Scale is always MEASURED (probe/report it even with no flag); it is only APPLIED when asked.
57
61
  const scaleInfo = measureScale(doc)
58
62
  const scalePlan = planScale(scaleInfo, opts)
@@ -61,7 +65,11 @@ export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promi
61
65
  const dupNames = duplicateNodeNames(doc)
62
66
  // LOD levels are only ever ADDED on request (--lod); see doctor/lod.ts.
63
67
  const lodPlan = planLod(doc, opts)
64
- return { skinPlans, texPlans, ktx2Plans, metallicWarn, shadingPlan, lightmapPlan, scaleInfo, scalePlan, lodPlan, conflicts, target, dupNames }
68
+ // Card trimming only on request (--trim-cards); the analysis lists the alpha-MASK primitives it would cut.
69
+ const cardTrimPlan = planCardTrim(doc, opts)
70
+ // Mesh merge + atlas only on request (--merge); the analysis says how many draw calls it would fold.
71
+ const mergePlan = await planMerge(doc, opts)
72
+ return { skinPlans, texPlans, ktx2Plans, metallicWarn, bareCount, shadingPlan, scaleInfo, scalePlan, lodPlan, cardTrimPlan, mergePlan, conflicts, target, dupNames }
65
73
  }
66
74
 
67
75
  export const kb = (n?: number) => (n === undefined ? '?' : `${Math.round(n / 1024)}KB`)
@@ -137,15 +145,16 @@ export function reportPlan(plan: DoctorPlan): { text: string; lossless: number;
137
145
  if (lossless.length) { lines.push(' ── LOSSLESS (no quality loss) ──'); lossless.forEach((l) => lines.push(' • ' + l)) }
138
146
  if (lossy.length) { lines.push(' ── LOSSY (some degradation) ──'); lossy.forEach((l) => lines.push(' • ' + l)) }
139
147
  if (blocking.length) { lines.push(' ── BLOCKING / manual ──'); blocking.forEach((l) => lines.push(' ✗ ' + l)) }
148
+ if (plan.metallicWarn.length || plan.bareCount) lines.push(` ── MATERIALS ──`)
140
149
  if (plan.metallicWarn.length) {
141
- lines.push(` ── MATERIALS ──`)
142
150
  lines.push(` ℹ ${plan.metallicWarn.length} material(s) have metallic without an MR texture → may render BLACK without IBL (--fix-metallic zeroes them):`)
143
151
  lines.push(` ${plan.metallicWarn.map((w) => `${w.name}(${w.metal.toFixed(2)})`).join(', ')}`)
144
152
  }
153
+ if (plan.bareCount) lines.push(` ℹ ${plan.bareCount} primitive(s) have NO material → glTF's default is a white ROUGH METAL (a dark blob under any environment; --fix-metallic gives them a white dielectric)`)
145
154
  const selfLit = plan.shadingPlan.issues.filter((i) => i.selfLit)
146
155
  const boosted = plan.shadingPlan.issues.filter((i) => i.specularBoost > 0)
147
156
  if (selfLit.length || boosted.length) {
148
- if (!plan.metallicWarn.length) lines.push(` ── MATERIALS ──`)
157
+ if (!plan.metallicWarn.length && !plan.bareCount) lines.push(` ── MATERIALS ──`)
149
158
  if (selfLit.length) {
150
159
  lines.push(` ⚠ ${selfLit.length} material(s) emit their own base colour (emissive = albedo) → render ~2× too bright, washed out (--fix-emissive drops it, --unlit goes flat):`)
151
160
  lines.push(` ${selfLit.map((i) => i.name).join(', ')}`)
@@ -163,14 +172,24 @@ export function reportPlan(plan: DoctorPlan): { text: string; lossless: number;
163
172
  else if (sc && sc.suggested !== 1) lines.push(` --fix-scale applies ×${fmtFactor(sc.suggested)} → ${dims3(sc.dims.map((d) => d * sc.suggested))}; --scale-to <m> / --scale <n> to decide yourself`)
164
173
  else lines.push(` --scale-to <m> sets the largest dimension explicitly`)
165
174
  }
166
- const lm = plan.lightmapPlan
167
- if (lm.enabled || lm.prims.some((p) => p.hasUv1)) {
168
- lines.push(` ── LIGHTMAP ──`)
169
- const tris = lm.prims.reduce((n, p) => n + p.triangles, 0)
170
- const existing = lm.prims.filter((p) => p.hasUv1).length
171
- if (lm.enabled) lines.push(` • lightmap UV (TEXCOORD_1) for ${lm.prims.length} primitive(s), ${tris} triangles — xatlas, padding ${lm.padding}${existing ? `; ${existing} existing set(s) replaced` : ''}`)
172
- else lines.push(` ℹ ${existing} primitive(s) already carry TEXCOORD_1 (lightmap UV); --lightmap-uv re-unwraps all ${lm.prims.length}`)
173
- if (lm.skipped.length) lines.push(` skipped: ${lm.skipped.slice(0, 6).map((s) => `${s.name} (${s.reason})`).join(', ')}${lm.skipped.length > 6 ? '…' : ''}`)
175
+ const ct = plan.cardTrimPlan
176
+ if (ct.prims.length) {
177
+ lines.push(` ── CARDS ──`)
178
+ const tris = ct.prims.reduce((n, p) => n + p.triangles, 0)
179
+ const ready = ct.prims.filter((p) => p.texture === 'ok').length
180
+ if (ct.enabled) lines.push(` • trim ${ready} alpha-MASK primitive(s) (${tris} triangles) to their opaque texels, margin ${ct.margin} texel(s)${ready < ct.prims.length ? `; ${ct.prims.length - ready} skipped (${ct.prims.filter((p) => p.texture !== 'ok').map((p) => `${p.name}: ${p.texture}`).slice(0, 4).join(', ')})` : ''}`)
181
+ else lines.push(` ℹ ${ct.prims.length} alpha-MASK primitive(s), ${tris} triangles — foliage cards? --trim-cards cuts each quad to its opaque texels (less discarded fill)${ready < ct.prims.length ? `; ${ct.prims.length - ready} carry KTX2 / transformed / no textures and would be skipped` : ''}`)
182
+ }
183
+ const mg = plan.mergePlan
184
+ if (mg.groups.length || mg.skipped.length) {
185
+ lines.push(` ── MERGE ──`)
186
+ const fold = `${mg.primitivesBefore} primitive(s) over ${mg.materialsBefore} material(s) → ${mg.primitivesAfter} draw call(s)`
187
+ if (mg.enabled) {
188
+ lines.push(` • merge ${fold}`)
189
+ for (const g of mg.groups) lines.push(` ${g.label}: ${g.sources.length} primitives, ${g.materials.length} material(s)${g.atlas ? ` → 1 (atlas ${g.atlas.size}² ${g.atlas.maps.join('+')}, ${g.atlas.tiles.length} tiles${describeScale(g.atlas)}, pad ${g.atlas.pad})` : g.materials.length === 1 ? ` (kept: ${g.materials[0].getName() || 'material'}${g.note ? ` — ${g.note}` : ''})` : ''}${g.family === 'skinned' ? `, ${g.joints} joints in one skin` : ', transforms baked'}`)
190
+ if (mg.joints > plan.target) blocking.push(`bones merged skin has ${mg.joints} joints > ${plan.target} — exclude parts (--merge-exclude) or raise --target`)
191
+ } else if (mg.groups.length) lines.push(` ℹ ${fold} with --merge (one atlas material per alpha mode / sidedness)`)
192
+ if (mg.skipped.length) lines.push(` ${mg.enabled ? 'skipped' : 'would skip'}: ${mg.skipped.slice(0, 6).map((s) => `${s.name} (${s.reason})`).join(', ')}${mg.skipped.length > 6 ? '…' : ''}`)
174
193
  }
175
194
  const plainDups = plan.dupNames.filter((d) => !d.shadowsJoint)
176
195
  if (plainDups.length) {
@@ -181,10 +200,10 @@ export function reportPlan(plan: DoctorPlan): { text: string; lossless: number;
181
200
  return { text: lines.join('\n'), lossless: lossless.length, lossy: lossy.length, blocking: blocking.length }
182
201
  }
183
202
 
184
- export interface ApplyOptions extends DoctorOptions { fixMetallic?: boolean; toktx?: string; progress?: (done: number, total: number) => void }
185
- export interface ApplyResult { metallicZeroed: number; ktx2Encoded: number; scaled: number; unlit: number; emissiveCleared: number; lightmapUv: LightmapUvResult | null; lod: LodResult | null }
203
+ export interface ApplyOptions extends DoctorOptions { fixMetallic?: boolean; ktxEncoder?: KtxEncoder; progress?: (done: number, total: number) => void }
204
+ export interface ApplyResult { metallicZeroed: number; bareFixed: number; ktx2Encoded: number; scaled: number; unlit: number; emissiveCleared: number; lod: LodResult | null; cards: CardTrimResult | null; merge: MergeResult | null; emptied: number }
186
205
 
187
- /** Apply a plan to the document (mutates). Resolves toktx itself when `ktx2` and no path is given. */
206
+ /** Apply a plan to the document (mutates). Resolves the KTX encoder itself when `ktx2` and none is given. */
188
207
  export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOptions = {}): Promise<ApplyResult> {
189
208
  const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
190
209
  for (const s of plan.skinPlans) applySkinPlan(doc, s)
@@ -196,19 +215,48 @@ export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOpti
196
215
  for (const w of plan.metallicWarn) w.mat.setMetallicFactor(0)
197
216
  metallicZeroed = plan.metallicWarn.length
198
217
  }
218
+ let bareFixed = 0
219
+ if (opts.fixMetallic && plan.bareCount) {
220
+ const plain = doc.createMaterial('Default').setBaseColorFactor([1, 1, 1, 1]).setMetallicFactor(0).setRoughnessFactor(1)
221
+ for (const mesh of doc.getRoot().listMeshes()) for (const prim of mesh.listPrimitives()) if (!prim.getMaterial()) { prim.setMaterial(plain); bareFixed++ }
222
+ }
199
223
  const shading = applyShading(doc, plan.shadingPlan)
200
- // After scale (the unwrap reads world-ish sizes for chart proportions) and before textures (geometry only).
201
- const lightmapUv = plan.lightmapPlan.enabled ? await applyLightmapUv(doc, plan.lightmapPlan) : null
224
+ // Merge + atlas before every other geometry pass (they see the merged primitive) and before the texture
225
+ // passes: the atlases are new PNG textures, planned below like any other — capped by --atlas-size, not
226
+ // --max-size — while the plans of the textures the atlases replaced are dropped with them.
227
+ const merge = plan.mergePlan.enabled ? await applyMerge(doc, plan.mergePlan) : null
228
+ if (merge) {
229
+ const root = doc.getRoot()
230
+ const alive = new Set(root.listTextures())
231
+ plan.ktx2Plans = plan.ktx2Plans.filter((t) => alive.has(t.tex))
232
+ plan.texPlans = plan.texPlans.filter((t) => alive.has(t.tex))
233
+ const codecPolicy = opts.ktx2Uastc ? 'uastc' : opts.ktx2Hq ? 'hq' : 'default'
234
+ for (const tex of merge.textures) {
235
+ if (opts.ktx2) { const p = await planKtx2(tex, root, plan.mergePlan.atlasSize, codecPolicy); if (p) plan.ktx2Plans.push(p) }
236
+ else { const p = await planTexture(tex, root, plan.mergePlan.atlasSize); if (p) plan.texPlans.push(p) }
237
+ }
238
+ // …and the geometry passes are planned AGAIN: their plans were made in `analyzeDoc`, against primitives
239
+ // the merge has just disposed (a plan that kept one died on it: `pos.getCount` of null).
240
+ plan.lodPlan = planLod(doc, opts)
241
+ plan.cardTrimPlan = planCardTrim(doc, opts)
242
+ }
202
243
  // LOD levels after every geometry pass (they copy the final base mesh) and before textures.
203
244
  const lod = plan.lodPlan ? await applyLod(doc, plan.lodPlan) : null
245
+ // Card trimming before the texture passes: it decodes the base colour (PNG / JPEG), which a KTX2
246
+ // encode below would take away.
247
+ const cards = plan.cardTrimPlan.enabled ? await applyCardTrim(doc, plan.cardTrimPlan) : null
204
248
  let ktx2Encoded = 0
205
249
  if (opts.ktx2) {
206
- const toktx = opts.toktx ?? findToktx()
207
- if (!checkToktx(toktx)) throw new Error('toktx not found (looked at $KTX_TOKTX, %LOCALAPPDATA%\\Programs\\KTX-Software, PATH). Install KTX-Software or set KTX_TOKTX=<path to toktx>.')
208
- for (const t of plan.ktx2Plans) {
209
- await applyKtx2(t, toktx, maxSize)
250
+ const enc = opts.ktxEncoder ?? findKtxEncoder()
251
+ if (!enc) throw new Error(KTX_ENCODER_MISSING)
252
+ // Launched together, not one after another: `applyKtx2` bounds the real concurrency with its own
253
+ // pool of encoder processes (LECODES_KTX_JOBS, default one per core less one). Encoding used to be
254
+ // strictly sequential — a `spawnSync` per texture blocked the event loop — so a project's whole
255
+ // texture set went through one core however many were idle.
256
+ await Promise.all(plan.ktx2Plans.map(async (t) => {
257
+ await applyKtx2(t, enc, maxSize)
210
258
  opts.progress?.(++ktx2Encoded, plan.ktx2Plans.length)
211
- }
259
+ }))
212
260
  // KTX2 textures must be written via KHR_texture_basisu (core texture.source allows only PNG/JPEG);
213
261
  // gltf-transform only emits that form when the extension is attached to the document.
214
262
  if (plan.ktx2Plans.length && !doc.getRoot().listExtensionsUsed().some((e) => e.extensionName === 'KHR_texture_basisu')) {
@@ -224,7 +272,23 @@ export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOpti
224
272
  if (ext.extensionName === 'EXT_texture_webp') ext.dispose()
225
273
  }
226
274
  }
227
- return { metallicZeroed, ktx2Encoded, scaled: plan.scalePlan?.factor ?? 1, unlit: shading.unlit, emissiveCleared: shading.emissiveCleared, lightmapUv, lod }
275
+ // LAST, after every pass that can eat geometry: a primitive with no vertices left is not a
276
+ // primitive. Card trimming produces them — it cuts an alpha-MASK quad down to its opaque texels,
277
+ // and the Modular Shooting Range's floor stripes have LOD levels whose texels are all clear, so
278
+ // the level came out carrying five empty meshes. That is not a cosmetic problem: filament's
279
+ // gltfio walks the primitive in `ResourceLoader::loadResources` and takes the host down with an
280
+ // access violation before the first frame.
281
+ let emptied = 0
282
+ for (const mesh of doc.getRoot().listMeshes()) {
283
+ for (const prim of mesh.listPrimitives()) {
284
+ if ((prim.getAttribute("POSITION")?.getCount() ?? 0) > 0) continue
285
+ mesh.removePrimitive(prim)
286
+ prim.dispose()
287
+ emptied++
288
+ }
289
+ if (!mesh.listPrimitives().length) mesh.dispose()
290
+ }
291
+ return { metallicZeroed, bareFixed, ktx2Encoded, scaled: plan.scalePlan?.factor ?? 1, unlit: shading.unlit, emissiveCleared: shading.emissiveCleared, lod, cards, merge, emptied }
228
292
  }
229
293
 
230
294
  /** Output path for a doctored file: single file + -o → exact path; -o as dir (or batch) → derived name inside it. */
@@ -0,0 +1,30 @@
1
+ // Accessor reads as flat typed arrays.
2
+
3
+ import type { Accessor, Primitive } from '@gltf-transform/core'
4
+
5
+ export const readAttr = (acc: Accessor): { size: number; data: Float32Array } => {
6
+ const size = acc.getElementSize(), n = acc.getCount()
7
+ const data = new Float32Array(n * size)
8
+ const el = new Array<number>(size)
9
+ for (let i = 0; i < n; i++) { acc.getElement(i, el); for (let k = 0; k < size; k++) data[i * size + k] = el[k] }
10
+ return { size, data }
11
+ }
12
+ export const readIndices = (p: Primitive): Uint32Array => {
13
+ const idx = p.getIndices()
14
+ if (idx) return Uint32Array.from(idx.getArray() as ArrayLike<number>)
15
+ const n = p.getAttribute('POSITION')!.getCount()
16
+ return Uint32Array.from({ length: n }, (_, i) => i)
17
+ }
18
+ /** Area-weighted vertex normals for a primitive that has none. */
19
+ export function flatNormals(pos: Float32Array, idx: Uint32Array): Float32Array {
20
+ const n = new Float32Array(pos.length)
21
+ for (let t = 0; t + 2 < idx.length; t += 3) {
22
+ const a = idx[t] * 3, b = idx[t + 1] * 3, c = idx[t + 2] * 3
23
+ const ux = pos[b] - pos[a], uy = pos[b + 1] - pos[a + 1], uz = pos[b + 2] - pos[a + 2]
24
+ const vx = pos[c] - pos[a], vy = pos[c + 1] - pos[a + 1], vz = pos[c + 2] - pos[a + 2]
25
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
26
+ for (const i of [ a, b, c ]) { n[i] += nx; n[i + 1] += ny; n[i + 2] += nz }
27
+ }
28
+ for (let i = 0; i < n.length; i += 3) { const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1; n[i] /= l; n[i + 1] /= l; n[i + 2] /= l }
29
+ return n
30
+ }
@@ -0,0 +1,94 @@
1
+ // Atlas layout: one tile per distinct material look, shelf-packed into a power-of-two square, scaled
2
+ // down BY SURFACE AREA (equal texel density) when the cap is hit.
3
+
4
+ import type { Material, Texture } from '@gltf-transform/core'
5
+ import type { MatInfo } from './classify'
6
+ import type { AtlasLayout, MapKind, Tile } from './types'
7
+
8
+ function shelfPack(items: { w: number; h: number }[], S: number): { x: number; y: number }[] | null {
9
+ const order = items.map((_, i) => i).sort((a, b) => items[b].h - items[a].h || items[b].w - items[a].w)
10
+ const pos: { x: number; y: number }[] = new Array(items.length)
11
+ let x = 0, y = 0, rowH = 0
12
+ for (const i of order) {
13
+ const { w, h } = items[i]
14
+ if (w > S || h > S) return null
15
+ if (x + w > S) { y += rowH; x = 0; rowH = 0 }
16
+ if (y + h > S) return null
17
+ pos[i] = { x, y }; x += w; rowH = Math.max(rowH, h)
18
+ }
19
+ return pos
20
+ }
21
+
22
+ /** Tile scales for a texel-density budget `D` (texels² per m² of surface): the tile's used texels
23
+ * (edge² × UV coverage) per square metre of the material's surface reach D, capped at the native map. */
24
+ const scalesForDensity = (tiles: Tile[], D: number): number[] => tiles.map((t) => {
25
+ const uv = Math.max(0.02, t.uvArea), area = Math.max(1e-6, t.area)
26
+ return Math.min(1, Math.sqrt(D * area / uv / (t.srcW * t.srcH)))
27
+ })
28
+
29
+ function layoutTiles(tiles: Tile[], atlasSize: number, pad: number, uniform: boolean): { size: number; scale: number; scaleMax: number } | null {
30
+ const place = (S: number, scales: number[]): boolean => {
31
+ const items = tiles.map((t, i) => ({ w: Math.max(4, Math.round(t.srcW * scales[i])) + 2 * pad, h: Math.max(4, Math.round(t.srcH * scales[i])) + 2 * pad }))
32
+ const pos = shelfPack(items, S)
33
+ if (!pos) return false
34
+ tiles.forEach((t, i) => { t.w = items[i].w - 2 * pad; t.h = items[i].h - 2 * pad; t.x = pos[i].x + pad; t.y = pos[i].y + pad; t.scale = t.w / t.srcW })
35
+ return true
36
+ }
37
+ const ones = tiles.map(() => 1)
38
+ const done = (S: number) => ({ size: S, scale: Math.min(...tiles.map((t) => t.scale)), scaleMax: Math.max(...tiles.map((t) => t.scale)) })
39
+ // the smallest power-of-two square that holds everything at full resolution …
40
+ for (let S = 64; S <= atlasSize; S *= 2) if (place(S, ones)) return done(S)
41
+ if (place(atlasSize, ones)) return done(atlasSize)
42
+ // … else the cap, with the tiles scaled down as little as possible: one factor for all, or a texel-density
43
+ // budget that gives every part the same texels per metre (the density at which the densest part is native
44
+ // is the upper bound — above it nothing changes)
45
+ const scalesAt = uniform ? (x: number) => tiles.map(() => x) : (x: number) => scalesForDensity(tiles, x)
46
+ let lo = 0, hi = uniform ? 1 : Math.max(...tiles.map((t) => t.srcW * t.srcH * Math.max(0.02, t.uvArea) / Math.max(1e-6, t.area)))
47
+ for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2; if (place(atlasSize, scalesAt(mid))) lo = mid; else hi = mid }
48
+ if (lo === 0 || !place(atlasSize, scalesAt(lo))) return null
49
+ return done(atlasSize)
50
+ }
51
+
52
+ function tileKey(info: MatInfo, texId: (t: Texture) => string): string {
53
+ const m = info.material
54
+ const id = (t?: Texture) => (t ? texId(t) : '-')
55
+ return [
56
+ id(info.maps.base), m.getBaseColorFactor().map((v) => v.toFixed(4)).join(','),
57
+ id(info.maps.normal), m.getNormalScale().toFixed(3),
58
+ id(info.maps.mr), id(info.maps.occ), m.getRoughnessFactor().toFixed(3), m.getMetallicFactor().toFixed(3), m.getOcclusionStrength().toFixed(3),
59
+ id(info.maps.emissive), m.getEmissiveFactor().map((v) => v.toFixed(4)).join(','),
60
+ info.uv ? `${info.uv.u0},${info.uv.v0},${info.uv.ru},${info.uv.rv}` : '-',
61
+ ].join('|')
62
+ }
63
+
64
+ export function planAtlas(infos: MatInfo[], atlasSize: number, pad: number, uniform: boolean, texId: (t: Texture) => string): { layout: AtlasLayout | null; reason?: string } {
65
+ const anyTex = (k: keyof MatInfo['maps']) => infos.some((i) => i.maps[k])
66
+ const differ = (f: (m: Material) => number[]) => { const first = f(infos[0].material); return infos.some((i) => f(i.material).some((v, k) => Math.abs(v - first[k]) > 1e-4)) }
67
+ const maps: MapKind[] = []
68
+ if (anyTex('base') || differ((m) => m.getBaseColorFactor())) maps.push('base')
69
+ if (anyTex('normal')) maps.push('normal')
70
+ if (anyTex('mr') || anyTex('occ') || differ((m) => [ m.getRoughnessFactor(), m.getMetallicFactor() ])) maps.push('orm')
71
+ if (anyTex('emissive') || infos.some((i) => i.material.getEmissiveFactor().some((v) => v > 0))) maps.push('emissive')
72
+ const byKey = new Map<string, Tile>()
73
+ for (const info of infos) {
74
+ const key = tileKey(info, texId)
75
+ let t = byKey.get(key)
76
+ if (!t) {
77
+ const dims = Object.values(info.sizes)
78
+ const uv = info.uv ?? { u0: 0, v0: 0, ru: 1, rv: 1 }
79
+ const maxW = dims.length ? Math.max(...dims.map((d) => d[0])) : 8
80
+ const maxH = dims.length ? Math.max(...dims.map((d) => d[1])) : 8
81
+ const cap = atlasSize - 2 * pad
82
+ t = { key, materials: [], srcW: Math.min(cap, maxW * uv.ru), srcH: Math.min(cap, maxH * uv.rv), ...uv, area: 0, uvArea: 0, w: 0, h: 0, x: 0, y: 0, scale: 1 }
83
+ byKey.set(key, t)
84
+ }
85
+ t.materials.push(info.material)
86
+ t.area += info.area
87
+ t.uvArea = Math.min(1, t.uvArea + info.uvArea)
88
+ }
89
+ const tiles = [ ...byKey.values() ]
90
+ const fit = layoutTiles(tiles, atlasSize, pad, uniform)
91
+ if (!fit) return { layout: null, reason: `${tiles.length} tiles do not fit a ${atlasSize}² atlas (raise --atlas-size or lower --atlas-pad)` }
92
+ const hasAlpha = infos[0].material.getAlphaMode() !== 'OPAQUE'
93
+ return { layout: { size: fit.size, scale: fit.scale, scaleMax: fit.scaleMax, byArea: !uniform, pad, maps, tiles, hasAlpha } }
94
+ }
@@ -0,0 +1,114 @@
1
+ // Atlas pixels: every tile rendered with its material's FACTORS baked in, blitted with edge
2
+ // replication, one PNG per map kind, and the merged material that reads them.
3
+
4
+ import type { Document, Material, Texture } from '@gltf-transform/core'
5
+ import { KHRMaterialsUnlit } from '@gltf-transform/extensions'
6
+ import sharp from 'sharp'
7
+ import { texels } from './textures'
8
+ import type { MapKind, MergeGroupPlan, Tile } from './types'
9
+
10
+ const SRGB_TO_LINEAR = new Float32Array(256).map((_, i) => { const c = i / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) })
11
+ const linearToSrgb = (v: number): number => { const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return Math.max(0, Math.min(255, Math.round(c * 255))) }
12
+ const clamp8 = (v: number): number => Math.max(0, Math.min(255, Math.round(v)))
13
+
14
+ /** The tile's pixels for one map kind, factors baked in. */
15
+ async function tilePixels(kind: MapKind, m: Material, t: Tile, ktxTool: string | null, cache: Map<Texture, Buffer | null>): Promise<Uint8Array> {
16
+ const n = t.w * t.h
17
+ const out = new Uint8Array(n * 4)
18
+ const fetch = (tex: Texture | null) => (tex ? texels(tex, t.w, t.h, t.ru, t.rv, ktxTool, cache) : Promise.resolve(null))
19
+ if (kind === 'base') {
20
+ const f = m.getBaseColorFactor()
21
+ const src = await fetch(m.getBaseColorTexture())
22
+ for (let i = 0; i < n; i++) {
23
+ const o = i * 4
24
+ if (src) {
25
+ for (let c = 0; c < 3; c++) out[o + c] = f[c] === 1 ? src[o + c] : linearToSrgb(SRGB_TO_LINEAR[src[o + c]] * f[c])
26
+ out[o + 3] = clamp8(src[o + 3] * f[3])
27
+ } else { out[o] = linearToSrgb(f[0]); out[o + 1] = linearToSrgb(f[1]); out[o + 2] = linearToSrgb(f[2]); out[o + 3] = clamp8(f[3] * 255) }
28
+ }
29
+ } else if (kind === 'normal') {
30
+ const src = await fetch(m.getNormalTexture())
31
+ const s = m.getNormalScale()
32
+ for (let i = 0; i < n; i++) {
33
+ const o = i * 4
34
+ if (src) {
35
+ if (s === 1) { out[o] = src[o]; out[o + 1] = src[o + 1]; out[o + 2] = src[o + 2] }
36
+ else {
37
+ const x = (src[o] / 255 * 2 - 1) * s, y = (src[o + 1] / 255 * 2 - 1) * s, z = src[o + 2] / 255 * 2 - 1
38
+ const l = Math.hypot(x, y, z) || 1
39
+ out[o] = clamp8((x / l * 0.5 + 0.5) * 255); out[o + 1] = clamp8((y / l * 0.5 + 0.5) * 255); out[o + 2] = clamp8((z / l * 0.5 + 0.5) * 255)
40
+ }
41
+ } else { out[o] = 128; out[o + 1] = 128; out[o + 2] = 255 }
42
+ out[o + 3] = 255
43
+ }
44
+ } else if (kind === 'orm') {
45
+ const mr = await fetch(m.getMetallicRoughnessTexture())
46
+ const occTex = m.getOcclusionTexture()
47
+ const occ = occTex === m.getMetallicRoughnessTexture() ? mr : await fetch(occTex)
48
+ const rough = m.getRoughnessFactor(), metal = m.getMetallicFactor(), strength = m.getOcclusionStrength()
49
+ for (let i = 0; i < n; i++) {
50
+ const o = i * 4
51
+ out[o] = occ ? clamp8(255 * (1 + strength * (occ[o] / 255 - 1))) : 255
52
+ out[o + 1] = mr ? clamp8(mr[o + 1] * rough) : clamp8(rough * 255)
53
+ out[o + 2] = mr ? clamp8(mr[o + 2] * metal) : clamp8(metal * 255)
54
+ out[o + 3] = 255
55
+ }
56
+ } else {
57
+ const f = m.getEmissiveFactor()
58
+ const src = await fetch(m.getEmissiveTexture())
59
+ for (let i = 0; i < n; i++) {
60
+ const o = i * 4
61
+ if (src) for (let c = 0; c < 3; c++) out[o + c] = f[c] === 1 ? src[o + c] : linearToSrgb(SRGB_TO_LINEAR[src[o + c]] * f[c])
62
+ else { out[o] = linearToSrgb(f[0]); out[o + 1] = linearToSrgb(f[1]); out[o + 2] = linearToSrgb(f[2]) }
63
+ out[o + 3] = 255
64
+ }
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** Blit a tile into the atlas with `pad` texels of edge replication around it. */
70
+ function blit(atlas: Uint8Array, S: number, t: Tile, pad: number, px: Uint8Array): void {
71
+ for (let y = t.y - pad; y < t.y + t.h + pad; y++) {
72
+ if (y < 0 || y >= S) continue
73
+ const sy = Math.max(0, Math.min(t.h - 1, y - t.y))
74
+ for (let x = t.x - pad; x < t.x + t.w + pad; x++) {
75
+ if (x < 0 || x >= S) continue
76
+ const sx = Math.max(0, Math.min(t.w - 1, x - t.x))
77
+ const si = (sy * t.w + sx) * 4, di = (y * S + x) * 4
78
+ atlas[di] = px[si]; atlas[di + 1] = px[si + 1]; atlas[di + 2] = px[si + 2]; atlas[di + 3] = px[si + 3]
79
+ }
80
+ }
81
+ }
82
+
83
+ export async function buildAtlas(doc: Document, g: MergeGroupPlan, ktxTool: string | null): Promise<{ material: Material; textures: Texture[]; tileOf: Map<Material, Tile> }> {
84
+ const a = g.atlas!
85
+ const S = a.size
86
+ const cache = new Map<Texture, Buffer | null>()
87
+ const tileOf = new Map<Material, Tile>()
88
+ for (const t of a.tiles) for (const m of t.materials) tileOf.set(m, t)
89
+ const textures: Texture[] = []
90
+ const material = doc.createMaterial(g.label).setAlphaMode(g.alphaMode).setAlphaCutoff(g.alphaCutoff).setDoubleSided(g.doubleSided)
91
+ if (g.unlit) material.setExtension('KHR_materials_unlit', doc.createExtension(KHRMaterialsUnlit).createUnlit())
92
+ for (const kind of a.maps) {
93
+ const atlas = new Uint8Array(S * S * 4)
94
+ // unused space: a neutral value per kind (mips of the border sample it)
95
+ const fill = kind === 'normal' ? [ 128, 128, 255, 255 ] : kind === 'orm' ? [ 255, 255, 0, 255 ] : kind === 'emissive' ? [ 0, 0, 0, 255 ] : [ 0, 0, 0, a.hasAlpha ? 0 : 255 ]
96
+ for (let i = 0; i < S * S; i++) atlas.set(fill, i * 4)
97
+ for (const t of a.tiles) blit(atlas, S, t, a.pad, await tilePixels(kind, t.materials[0], t, ktxTool, cache))
98
+ let pipe = sharp(Buffer.from(atlas.buffer, atlas.byteOffset, atlas.byteLength), { raw: { width: S, height: S, channels: 4 } })
99
+ if (kind !== 'base' || !a.hasAlpha) pipe = pipe.removeAlpha()
100
+ const png = new Uint8Array(await pipe.png({ compressionLevel: 6 }).toBuffer())
101
+ const tex = doc.createTexture(`${g.label}_${kind}`).setImage(png).setMimeType('image/png')
102
+ textures.push(tex)
103
+ if (kind === 'base') material.setBaseColorTexture(tex).setBaseColorFactor([ 1, 1, 1, 1 ])
104
+ else if (kind === 'normal') material.setNormalTexture(tex).setNormalScale(1)
105
+ else if (kind === 'orm') material.setMetallicRoughnessTexture(tex).setOcclusionTexture(tex).setRoughnessFactor(1).setMetallicFactor(1).setOcclusionStrength(1)
106
+ else material.setEmissiveTexture(tex).setEmissiveFactor([ 1, 1, 1 ])
107
+ }
108
+ // maps every material agreed on stay factors
109
+ const m0 = g.materials[0]
110
+ if (!a.maps.includes('base')) material.setBaseColorFactor(m0.getBaseColorFactor())
111
+ if (!a.maps.includes('orm')) material.setRoughnessFactor(m0.getRoughnessFactor()).setMetallicFactor(m0.getMetallicFactor())
112
+ if (!a.maps.includes('emissive')) material.setEmissiveFactor([ 0, 0, 0 ])
113
+ return { material, textures, tileOf }
114
+ }
@@ -0,0 +1,59 @@
1
+ // ONE bind space for several skins. Modular parts are often skinned separately, each with its own
2
+ // inverse bind matrices; two skins can be unified when, on every joint they share, their matrices
3
+ // differ by the SAME rigid transform D (IBM_part = IBM_canon · D) — the part was simply bound in
4
+ // another space, and its vertices move into the canonical one by D. Different transforms per joint
5
+ // mean the parts were bound in different POSES, and no single transform reconciles them.
6
+
7
+ import type { Node, Skin } from '@gltf-transform/core'
8
+ import { invert, isIdentity, mul, same } from './matrix'
9
+ import type { Mat4 } from './types'
10
+
11
+ const ibmAt = (skin: Skin, i: number): Mat4 => { const m = new Array<number>(16); skin.getInverseBindMatrices()!.getElement(i, m); return m }
12
+
13
+ /** Skins by joint count, largest first: the one with most joints defines the canonical space. */
14
+ export const bySize = (skins: Iterable<Skin | null>): Skin[] =>
15
+ [ ...new Set([ ...skins ].filter((s): s is Skin => !!s)) ].sort((a, b) => b.listJoints().length - a.listJoints().length)
16
+
17
+ /**
18
+ * The canonical inverse bind matrix per joint over `skins` (already ordered, see `bySize`), and the
19
+ * skins that cannot join: no inverse bind matrices, or joints that disagree by different transforms.
20
+ * `verify: false` is for skins the plan already vetted: D is taken from the first shared joint.
21
+ */
22
+ export function canonicalBinds(skins: Skin[], verify = true): { ibm: Map<Node, Mat4>; bad: Set<Skin> } {
23
+ const ibm = new Map<Node, Mat4>()
24
+ const bad = new Set<Skin>()
25
+ for (const skin of skins) {
26
+ if (!skin.getInverseBindMatrices()) { bad.add(skin); continue }
27
+ const js = skin.listJoints()
28
+ let D: Mat4 | null = null, ok = true
29
+ const own: [Node, Mat4][] = []
30
+ for (let i = 0; i < js.length && ok; i++) {
31
+ const m = ibmAt(skin, i)
32
+ const c = ibm.get(js[i])
33
+ if (!c) { own.push([ js[i], m ]); continue }
34
+ if (D && !verify) continue
35
+ const inv = invert(c)
36
+ if (!inv) { ok = false; break }
37
+ const d = mul(inv, m)
38
+ if (!D) D = d
39
+ else if (verify && !same(D, d, 1e-3)) ok = false
40
+ }
41
+ if (!ok) { bad.add(skin); continue }
42
+ // this skin's new joints enter the canon in the canonical space: IBM_canon = IBM_skin · D⁻¹
43
+ const Dinv = D ? invert(D) : null
44
+ for (const [ j, m ] of own) ibm.set(j, Dinv && !isIdentity(Dinv) ? mul(m, Dinv) : m)
45
+ }
46
+ return { ibm, bad }
47
+ }
48
+
49
+ /** The transform that moves a part skinned on `skin` into the canonical space (identity when it already is). */
50
+ export function intoCanon(skin: Skin, canon: Map<Node, Mat4>): Mat4 | null {
51
+ const js = skin.listJoints()
52
+ for (let i = 0; i < js.length; i++) {
53
+ const c = canon.get(js[i])
54
+ if (!c) continue
55
+ const D = mul(invert(c)!, ibmAt(skin, i))
56
+ return isIdentity(D, 1e-6) ? null : D
57
+ }
58
+ return null
59
+ }