lecodes-assets 0.2.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  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 +79 -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 +108 -0
  25. package/src/commands/shared.ts +140 -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 +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. 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. */