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.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,305 @@
1
+ // glTF doctor — analyze a Document against creator-gl/filament constraints and (optionally) fix it.
2
+ // The former tools/glb-reduce/glbDoctor.ts, as functions so `convert` can run the same passes on a
3
+ // freshly converted model (`convert … --ktx2`) and the CLI can run them on any GLB (`doctor`).
4
+ //
5
+ // Analyze : reports issues grouped by impact — LOSSLESS fixes (no degradation, e.g. dropping
6
+ // unbound joints / re-encoding webp→png) vs LOSSY fixes (bound-joint removal,
7
+ // webp→jpg, downscaling, heavy-png→jpg).
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 (toktx, or `ktx create` where toktx is gone).
10
+ import { gatherSkinPrims, planSkin, applySkinPlan, DEFAULT_TARGET, type SkinPlan } from './reduceGlbBones'
11
+ import { planTexture, applyTexturePlan, getTextureRoles, DEFAULT_MAX_SIZE, type TexturePlan } from './transcodeTextures'
12
+ import { planKtx2, applyKtx2, findKtxEncoder, KTX_ENCODER_MISSING, type Ktx2Plan, type KtxEncoder } from './transcodeKtx2'
13
+ import { measureScale, planScale, applyScale, type ScaleInfo, type ScalePlan, type ScaleOptions } from './normalizeScale'
14
+ import { analyzeShading, applyShading, type ShadingPlan, type ShadingOptions } from './shading'
15
+ import { planCardTrim, applyCardTrim, type CardTrimPlan, type CardTrimOptions, type CardTrimResult } from './cardTrim'
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'
18
+ import { duplicateNodeNames } from '../mergeSkins'
19
+ import { KHRTextureBasisu } from '@gltf-transform/extensions'
20
+ import type { Document, Material } from '@gltf-transform/core'
21
+ import { mkdirSync, existsSync, statSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+
24
+ export { DEFAULT_TARGET, DEFAULT_MAX_SIZE, findKtxEncoder, KTX_ENCODER_MISSING }
25
+ export { sceneBounds, measureScale, applyScale } from './normalizeScale'
26
+
27
+ interface MetallicWarn { mat: Material; name: string; metal: number }
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 }
30
+
31
+ export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promise<DoctorPlan> {
32
+ const target = opts.target ?? DEFAULT_TARGET
33
+ const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
34
+ const root = doc.getRoot()
35
+ const { bySkin, conflicts } = gatherSkinPrims(doc)
36
+ const skinPlans = root.listSkins().map((s, i) => planSkin(s, i, bySkin.get(s) ?? [], target))
37
+ const texPlans: TexturePlan[] = []
38
+ const ktx2Plans: Ktx2Plan[] = []
39
+ // KTX2 mode: everything → ETC1S (max compression). --ktx2-hq upgrades NORMAL maps to UASTC
40
+ // (much higher quality, same VRAM, larger file); colour/metalRough stay ETC1S either way.
41
+ const codecPolicy = opts.ktx2Uastc ? 'uastc' : opts.ktx2Hq ? 'hq' : 'default'
42
+ for (const tex of root.listTextures()) {
43
+ if (opts.ktx2) {
44
+ const p = await planKtx2(tex, root, maxSize, codecPolicy); if (p) ktx2Plans.push(p)
45
+ } else {
46
+ const p = await planTexture(tex, root, maxSize); if (p) texPlans.push(p)
47
+ }
48
+ }
49
+ // Materials with a flat metallic factor but NO metallicRoughness texture render BLACK without an
50
+ // IBL/environment (metal reflects nothing, has no diffuse). Common export quirk on non-metal assets.
51
+ const metallicWarn: MetallicWarn[] = root.listMaterials()
52
+ .filter((m) => m.getMetallicFactor() > 0.01 && !m.getMetallicRoughnessTexture())
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++
59
+ const shadingPlan = analyzeShading(doc, opts)
60
+ // Scale is always MEASURED (probe/report it even with no flag); it is only APPLIED when asked.
61
+ const scaleInfo = measureScale(doc)
62
+ const scalePlan = planScale(scaleInfo, opts)
63
+ // Duplicate node names: the Animator binds clips BY NAME and takes the first match. A leftover
64
+ // donor skeleton (two skinned files merged by hand) shadows the real joints → the mesh freezes.
65
+ const dupNames = duplicateNodeNames(doc)
66
+ // LOD levels are only ever ADDED on request (--lod); see doctor/lod.ts.
67
+ const lodPlan = planLod(doc, opts)
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 }
73
+ }
74
+
75
+ export const kb = (n?: number) => (n === undefined ? '?' : `${Math.round(n / 1024)}KB`)
76
+
77
+ /** A length in metres, in whatever unit reads best — 0.0055 is "5.5mm", not "0.01m". */
78
+ export const metres = (m: number): string =>
79
+ m >= 1 ? `${m.toFixed(m >= 10 ? 0 : 2)}m` : m >= 0.01 ? `${(m * 100).toFixed(1)}cm` : `${(m * 1000).toFixed(m >= 0.001 ? 1 : 3)}mm`
80
+ const dims3 = (d: readonly number[]): string => d.map((x) => metres(x)).join(' × ')
81
+ const fmtFactor = (f: number): string => (f >= 0.001 && f < 1e6 ? +f.toPrecision(6) : f.toExponential(2)).toString()
82
+
83
+ const EXT_BY_MIME: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/ktx2': 'ktx2' }
84
+ const safeName = (n: string) => (n || 'tex').replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60)
85
+
86
+ /** Write every texture's raw bytes to `dir` (+ a _manifest.txt) for manual inspection. */
87
+ export async function dumpTextures(doc: Document, dir: string, log: (s: string) => void = console.log) {
88
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
89
+ const root = doc.getRoot()
90
+ const rows: string[] = []
91
+ let sharp: any = null
92
+ try { sharp = (await import('sharp')).default } catch { /* dims/alpha columns stay '-' */ }
93
+ let i = 0
94
+ for (const tex of root.listTextures()) {
95
+ const img = tex.getImage()
96
+ const mime = tex.getMimeType() || ''
97
+ const ext = EXT_BY_MIME[mime] ?? 'bin'
98
+ const file = `${String(i).padStart(2, '0')}_${safeName(tex.getName())}.${ext}`
99
+ if (img) writeFileSync(`${dir}/${file}`, img)
100
+ const { roles } = getTextureRoles(tex, root)
101
+ let dims = '-', alpha = '-'
102
+ if (img && ext !== 'ktx2' && sharp) {
103
+ try {
104
+ const m = await sharp(Buffer.from(img)).metadata()
105
+ dims = `${m.width}x${m.height}`
106
+ alpha = (await sharp(Buffer.from(img)).stats()).isOpaque ? 'opaque' : 'ALPHA'
107
+ } catch { /* undecodable by sharp */ }
108
+ }
109
+ rows.push(`${file.padEnd(48)} ${ext.padEnd(5)} ${dims.padEnd(11)} ${alpha.padEnd(7)} ${roles.padEnd(12)} ${kb(img?.length)}`)
110
+ i++
111
+ }
112
+ const header = `# ${i} textures from this GLB\n# file format dimensions alpha role size\n`
113
+ writeFileSync(`${dir}/_manifest.txt`, header + rows.join('\n') + '\n')
114
+ log(`\n🖼 dumped ${i} textures → ${dir}`)
115
+ log(rows.join('\n'))
116
+ }
117
+
118
+ export function reportPlan(plan: DoctorPlan): { text: string; lossless: number; lossy: number; blocking: number } {
119
+ const lossless: string[] = [], lossy: string[] = [], blocking: string[] = []
120
+
121
+ for (const s of plan.skinPlans) {
122
+ if (s.before <= plan.target) continue
123
+ if (s.removedDeep === 0) {
124
+ lossless.push(`bones ${s.name}: drop ${s.removedUnbound} unbound joints (${s.before} → ${s.after})`)
125
+ } else {
126
+ if (s.removedUnbound > 0) lossless.push(`bones ${s.name}: drop ${s.removedUnbound} unbound joints`)
127
+ lossy.push(`bones ${s.name}: drop ${s.removedDeep} deepest BOUND joints, merge weights → parent (${s.before} → ${s.after})`)
128
+ }
129
+ if (s.after > plan.target) blocking.push(`bones ${s.name}: still ${s.after} > ${plan.target} after reduction (too many root joints)`)
130
+ }
131
+
132
+ for (const t of plan.texPlans) {
133
+ if (!t.toFmt) continue
134
+ const line = `tex "${t.name}" [${t.roles}] ${t.fromFmt}→${t.toFmt} (${t.reason}, ${kb(t.beforeBytes)})`
135
+ ;(t.category === 'lossless' ? lossless : lossy).push(line)
136
+ }
137
+ for (const t of plan.ktx2Plans) {
138
+ lossy.push(`tex "${t.name}" [${t.roles}] ${t.reason} (${kb(t.beforeBytes)})`)
139
+ }
140
+ if (plan.conflicts > 0) blocking.push(`${plan.conflicts} primitive(s) shared across skins — skipped`)
141
+ const shadowing = plan.dupNames.filter((d) => d.shadowsJoint)
142
+ if (shadowing.length) blocking.push(`names ${shadowing.length} skinned joint(s) shadowed by a plain node of the same name (${shadowing.slice(0, 5).map((d) => d.name).join(', ')}${shadowing.length > 5 ? '…' : ''}) — a name-bound Animator drives the copy and the mesh freezes; rebuild with convert --merge-skins, or delete the stray skeleton`)
143
+
144
+ const lines: string[] = []
145
+ if (lossless.length) { lines.push(' ── LOSSLESS (no quality loss) ──'); lossless.forEach((l) => lines.push(' • ' + l)) }
146
+ if (lossy.length) { lines.push(' ── LOSSY (some degradation) ──'); lossy.forEach((l) => lines.push(' • ' + l)) }
147
+ if (blocking.length) { lines.push(' ── BLOCKING / manual ──'); blocking.forEach((l) => lines.push(' ✗ ' + l)) }
148
+ if (plan.metallicWarn.length || plan.bareCount) lines.push(` ── MATERIALS ──`)
149
+ if (plan.metallicWarn.length) {
150
+ lines.push(` ℹ ${plan.metallicWarn.length} material(s) have metallic without an MR texture → may render BLACK without IBL (--fix-metallic zeroes them):`)
151
+ lines.push(` ${plan.metallicWarn.map((w) => `${w.name}(${w.metal.toFixed(2)})`).join(', ')}`)
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)`)
154
+ const selfLit = plan.shadingPlan.issues.filter((i) => i.selfLit)
155
+ const boosted = plan.shadingPlan.issues.filter((i) => i.specularBoost > 0)
156
+ if (selfLit.length || boosted.length) {
157
+ if (!plan.metallicWarn.length && !plan.bareCount) lines.push(` ── MATERIALS ──`)
158
+ if (selfLit.length) {
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):`)
160
+ lines.push(` ${selfLit.map((i) => i.name).join(', ')}`)
161
+ }
162
+ if (boosted.length) {
163
+ lines.push(` ℹ ${boosted.length} material(s) boost specular above 1 (${boosted.map((i) => `${i.name}(×${i.specularBoost.toFixed(1)})`).join(', ')}) — gltfio also refuses specular + unlit, so --unlit drops it`)
164
+ }
165
+ }
166
+ const sc = plan.scaleInfo
167
+ if (sc?.suspicious || plan.scalePlan) {
168
+ lines.push(` ── SCALE ──`)
169
+ if (sc) lines.push(` ${sc.suspicious ? '⚠' : 'ℹ'} model measures ${dims3(sc.dims)}${sc.suspicious ? ` — implausible for a real-world asset (${sc.label})` : ''}`)
170
+ else lines.push(` ℹ no geometry to measure (clip-only file)`)
171
+ if (plan.scalePlan) lines.push(` → ×${fmtFactor(plan.scalePlan.factor)}${plan.scalePlan.source === 'explicit' ? '' : ` (${plan.scalePlan.label})`}${sc ? ` → ${dims3(plan.scalePlan.after)}` : ''}`)
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`)
173
+ else lines.push(` --scale-to <m> sets the largest dimension explicitly`)
174
+ }
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 ? '…' : ''}`)
193
+ }
194
+ const plainDups = plan.dupNames.filter((d) => !d.shadowsJoint)
195
+ if (plainDups.length) {
196
+ lines.push(` ── NAMES ──`)
197
+ lines.push(` ℹ ${plainDups.length} node name(s) used more than once (${plainDups.slice(0, 6).map((d) => `${d.name} ×${d.count}`).join(', ')}${plainDups.length > 6 ? '…' : ''}) — clips and node paths address the FIRST one`)
198
+ }
199
+ if (!lines.length) lines.push(' ✅ no issues — model is within all limits')
200
+ return { text: lines.join('\n'), lossless: lossless.length, lossy: lossy.length, blocking: blocking.length }
201
+ }
202
+
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 }
205
+
206
+ /** Apply a plan to the document (mutates). Resolves the KTX encoder itself when `ktx2` and none is given. */
207
+ export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOptions = {}): Promise<ApplyResult> {
208
+ const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
209
+ for (const s of plan.skinPlans) applySkinPlan(doc, s)
210
+ // After the skin plan: bone reduction rewrites joints/IBMs, and scaling the reduced set is both
211
+ // cheaper and keeps the IBM offsets it just wrote consistent with the geometry.
212
+ if (plan.scalePlan) applyScale(doc, plan.scalePlan.factor)
213
+ let metallicZeroed = 0
214
+ if (opts.fixMetallic && plan.metallicWarn.length) {
215
+ for (const w of plan.metallicWarn) w.mat.setMetallicFactor(0)
216
+ metallicZeroed = plan.metallicWarn.length
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
+ }
223
+ const shading = applyShading(doc, plan.shadingPlan)
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
+ }
243
+ // LOD levels after every geometry pass (they copy the final base mesh) and before textures.
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
248
+ let ktx2Encoded = 0
249
+ if (opts.ktx2) {
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)
258
+ opts.progress?.(++ktx2Encoded, plan.ktx2Plans.length)
259
+ }))
260
+ // KTX2 textures must be written via KHR_texture_basisu (core texture.source allows only PNG/JPEG);
261
+ // gltf-transform only emits that form when the extension is attached to the document.
262
+ if (plan.ktx2Plans.length && !doc.getRoot().listExtensionsUsed().some((e) => e.extensionName === 'KHR_texture_basisu')) {
263
+ doc.createExtension(KHRTextureBasisu).setRequired(true)
264
+ }
265
+ } else {
266
+ for (const t of plan.texPlans) if (t.toFmt) await applyTexturePlan(t, maxSize)
267
+ }
268
+ // Once no texture is webp anymore, drop EXT_texture_webp — else it lingers in extensionsRequired
269
+ // and a strict loader (gltfio) can reject the file for a now-unused requirement.
270
+ if (!doc.getRoot().listTextures().some((t) => (t.getMimeType() || '').includes('webp'))) {
271
+ for (const ext of doc.getRoot().listExtensionsUsed()) {
272
+ if (ext.extensionName === 'EXT_texture_webp') ext.dispose()
273
+ }
274
+ }
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 }
292
+ }
293
+
294
+ /** Output path for a doctored file: single file + -o → exact path; -o as dir (or batch) → derived name inside it. */
295
+ export function outputFor(input: string, oVal: string | null, batch: boolean, ktx2: boolean): string {
296
+ const derived = input.replace(/\.glb$/i, ktx2 ? '.ktx2.glb' : '.fixed.glb')
297
+ if (!oVal) return derived
298
+ const oNorm = oVal.replace(/\\/g, '/')
299
+ const asDir = batch || /\/$/.test(oNorm) || (existsSync(oNorm) && statSync(oNorm).isDirectory())
300
+ if (asDir) {
301
+ if (!existsSync(oNorm)) mkdirSync(oNorm, { recursive: true })
302
+ return join(oNorm, derived.split(/[\\/]/).pop()!)
303
+ }
304
+ return oVal
305
+ }
@@ -0,0 +1,177 @@
1
+ // Mesh + skeleton LOD levels for creator-gl's LOD pass (docs/lod-plan.md §5).
2
+ //
3
+ // --lod 0.5,0.2 every mesh node with ≥ MIN_TRIS triangles gets sibling nodes `<name>_LOD1`,
4
+ // `<name>_LOD2` … (same parent / transform / skin / materials) whose primitives are
5
+ // meshoptimizer-simplified copies at the given ratios of the ORIGINAL triangle count.
6
+ // Skinned primitives keep JOINTS/WEIGHTS (the simplifier only collapses vertices), so
7
+ // every level rides the same skin. Morph targets are not carried to the levels.
8
+ // --lod-bones the LAST level of a skinned node also gets a reduced SKIN: vertices weighted to
9
+ // finger / toe / twist / leaf joints are re-weighted onto their nearest kept ancestor
10
+ // and the level references a new Skin over the kept joints only — the engine
11
+ // uploads a third of the bones for a far character.
12
+ //
13
+ // The engine groups levels by the `_LOD<n>` name (creator-gl src/lod.cpp) and shows one at a time; a
14
+ // viewer without LOD support draws them all — harmless. Extras carry the bookkeeping for probes.
15
+
16
+ import type { Accessor, Document, Mesh, Node as GltfNode, Primitive, Skin } from '@gltf-transform/core'
17
+ import { simplifyPrimitive, weldPrimitive } from '@gltf-transform/functions'
18
+ import { MeshoptSimplifier } from 'meshoptimizer'
19
+
20
+ export interface LodOptions { lod?: number[]; lodBones?: boolean }
21
+ export interface LodNodePlan { node: GltfNode; name: string; tris: number; skinned: boolean }
22
+ export interface LodPlan { ratios: number[]; bones: boolean; nodes: LodNodePlan[]; existing: number }
23
+ export interface LodResult { levels: number; nodes: number; tris: { name: string; levels: number[] }[]; bones: { name: string; before: number; after: number }[]; skipped: { name: string; level: number; tris: number }[] }
24
+
25
+ /** Meshes under this go without levels — a crate does not need one. */
26
+ export const LOD_MIN_TRIS = 2000
27
+ /** Joints a far character stops animating / weighting — the same vocabulary as creator-gl's joint mask
28
+ * (animation/animator.cpp lodMaskedName) and the retargeter's helper classes. Keep them in step. */
29
+ export const LOD_JOINT_MASK = /finger|thumb|index|middle|ring|pinky|toe|twist|roll|leaf|_end$/i
30
+ const LOD_NAME = /_LOD\d+$/
31
+
32
+ const shortName = (s: string): string => { const p = Math.max(s.lastIndexOf(':'), s.lastIndexOf('|')); return p < 0 ? s : s.slice(p + 1) }
33
+
34
+ const primTris = (p: Primitive): number => {
35
+ if (p.getMode() !== 4) return 0 // TRIANGLES only
36
+ const idx = p.getIndices()
37
+ const n = idx ? idx.getCount() : (p.getAttribute('POSITION')?.getCount() ?? 0)
38
+ return Math.floor(n / 3)
39
+ }
40
+ const meshTris = (m: Mesh): number => m.listPrimitives().reduce((s, p) => s + primTris(p), 0)
41
+
42
+ export function planLod(doc: Document, opts: LodOptions): LodPlan | null {
43
+ const ratios = (opts.lod ?? []).filter((r) => r > 0 && r < 1).sort((a, b) => b - a)
44
+ if (!ratios.length) return null
45
+ const nodes: LodNodePlan[] = []
46
+ let existing = 0
47
+ for (const node of doc.getRoot().listNodes()) {
48
+ const mesh = node.getMesh()
49
+ if (!mesh) continue
50
+ if (LOD_NAME.test(node.getName())) { existing++; continue }
51
+ const tris = meshTris(mesh)
52
+ if (tris < LOD_MIN_TRIS) continue
53
+ nodes.push({ node, name: node.getName() || mesh.getName() || `mesh${nodes.length}`, tris, skinned: !!node.getSkin() })
54
+ }
55
+ return { ratios, bones: !!opts.lodBones, nodes, existing }
56
+ }
57
+
58
+ /** A deep copy of a primitive's geometry (own accessors — simplify rewrites them in place), sharing the material. */
59
+ const clonePrimitive = (doc: Document, prim: Primitive): Primitive => {
60
+ const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
61
+ const cloneAcc = (a: Accessor): Accessor => doc.createAccessor(a.getName(), buffer).setType(a.getType()).setNormalized(a.getNormalized()).setArray(a.getArray()!.slice())
62
+ const out = doc.createPrimitive().setMode(prim.getMode()).setMaterial(prim.getMaterial())
63
+ for (const sem of prim.listSemantics()) out.setAttribute(sem, cloneAcc(prim.getAttribute(sem)!))
64
+ const idx = prim.getIndices()
65
+ if (idx) out.setIndices(cloneAcc(idx))
66
+ return out
67
+ }
68
+
69
+ /** The last level of a skinned node: masked joints fold onto their nearest kept ancestor, on a new Skin over the kept joints. */
70
+ const reduceSkinForLod = (doc: Document, lodNode: GltfNode): { before: number; after: number } | null => {
71
+ const skin = lodNode.getSkin()
72
+ const mesh = lodNode.getMesh()
73
+ if (!skin || !mesh) return null
74
+ const joints = skin.listJoints()
75
+ const removed = new Set<number>()
76
+ joints.forEach((j, i) => { if (LOD_JOINT_MASK.test(shortName(j.getName()))) removed.add(i) })
77
+ if (!removed.size) return null
78
+ const idxOf = new Map<GltfNode, number>(joints.map((j, i) => [j, i]))
79
+ const remap = joints.map((j, i) => {
80
+ if (!removed.has(i)) return i
81
+ for (let p = j.getParentNode(); p; p = p.getParentNode()) { const k = idxOf.get(p); if (k !== undefined && !removed.has(k)) return k }
82
+ return i // no kept ancestor: keep the joint
83
+ })
84
+ const kept = [...new Set(remap)].sort((a, b) => a - b)
85
+ const newIndex = new Map<number, number>(kept.map((old, k) => [old, k]))
86
+ const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
87
+ const newSkin: Skin = doc.createSkin(`${skin.getName() || 'skin'}_LOD`)
88
+ if (skin.getSkeleton()) newSkin.setSkeleton(skin.getSkeleton())
89
+ for (const i of kept) newSkin.addJoint(joints[i]!)
90
+ const ibm = skin.getInverseBindMatrices()
91
+ if (ibm) {
92
+ const src = ibm.getArray()!
93
+ const out = new Float32Array(kept.length * 16)
94
+ kept.forEach((i, k) => out.set(src.subarray(i * 16, i * 16 + 16), k * 16))
95
+ newSkin.setInverseBindMatrices(doc.createAccessor(`${ibm.getName() || 'ibm'}_LOD`, buffer).setType('MAT4').setArray(out))
96
+ }
97
+ const jv: number[] = [], wv: number[] = []
98
+ for (const prim of mesh.listPrimitives()) {
99
+ // every set is gathered per vertex, folded by target joint, then written back set by set (largest first)
100
+ const sets: { j: Accessor; w: Accessor }[] = []
101
+ for (let s = 0; ; s++) { const j = prim.getAttribute(`JOINTS_${s}`), w = prim.getAttribute(`WEIGHTS_${s}`); if (!j || !w) break; sets.push({ j, w }) }
102
+ if (!sets.length) continue
103
+ const count = sets[0]!.j.getCount()
104
+ const folded = new Map<number, number>()
105
+ for (let v = 0; v < count; v++) {
106
+ folded.clear()
107
+ for (const { j, w } of sets) {
108
+ j.getElement(v, jv); w.getElement(v, wv)
109
+ for (let k = 0; k < 4; k++) {
110
+ const weight = wv[k] ?? 0
111
+ if (weight <= 0) continue
112
+ const target = newIndex.get(remap[jv[k]!] ?? jv[k]!) ?? 0
113
+ folded.set(target, (folded.get(target) ?? 0) + weight)
114
+ }
115
+ }
116
+ const pairs = [...folded].sort((a, b) => b[1] - a[1])
117
+ let n = 0
118
+ for (const { j, w } of sets) {
119
+ for (let k = 0; k < 4; k++) { const p = pairs[n++]; jv[k] = p ? p[0] : 0; wv[k] = p ? p[1] : 0 }
120
+ j.setElement(v, jv); w.setElement(v, wv)
121
+ }
122
+ }
123
+ }
124
+ lodNode.setSkin(newSkin)
125
+ return { before: joints.length, after: kept.length }
126
+ }
127
+
128
+ export async function applyLod(doc: Document, plan: LodPlan): Promise<LodResult> {
129
+ await MeshoptSimplifier.ready
130
+ const result: LodResult = { levels: plan.ratios.length, nodes: 0, tris: [], bones: [], skipped: [] }
131
+ for (const entry of plan.nodes) {
132
+ const { node } = entry
133
+ const baseMesh = node.getMesh()!
134
+ const parent = node.getParentNode()
135
+ const scenes = parent ? [] : doc.getRoot().listScenes().filter((s) => s.listChildren().includes(node))
136
+ const baseExtras = (node.getExtras() ?? {}) as Record<string, unknown>
137
+ const baseLecodes = (baseExtras.lecodes ?? {}) as Record<string, unknown>
138
+ node.setExtras({ ...baseExtras, lecodes: { ...baseLecodes, lod: { level: 0, levels: plan.ratios.length + 1, tris: entry.tris } } })
139
+ const levels: number[] = [entry.tris]
140
+ for (let i = 0; i < plan.ratios.length; i++) {
141
+ const level = i + 1
142
+ const ratio = plan.ratios[i]!
143
+ const mesh = doc.createMesh(`${baseMesh.getName() || entry.name}_LOD${level}`)
144
+ let tris = 0
145
+ for (const prim of baseMesh.listPrimitives()) {
146
+ if (prim.getMode() !== 4) continue
147
+ let copy = clonePrimitive(doc, prim)
148
+ weldPrimitive(copy)
149
+ copy = simplifyPrimitive(copy, { simplifier: MeshoptSimplifier, ratio, error: 0.02, lockBorder: false })
150
+ tris += primTris(copy)
151
+ mesh.addPrimitive(copy)
152
+ }
153
+ // A level the simplifier could not shrink (hard-surface meshes with many seams stop early) is not
154
+ // worth its bytes or its draw: drop it rather than ship a near-duplicate.
155
+ if (tris > levels[levels.length - 1]! * 0.85) {
156
+ for (const p of mesh.listPrimitives()) { for (const sem of p.listSemantics()) p.getAttribute(sem)?.dispose(); p.getIndices()?.dispose(); p.dispose() }
157
+ mesh.dispose()
158
+ result.skipped.push({ name: entry.name, level, tris })
159
+ break
160
+ }
161
+ const lodNode = doc.createNode(`${node.getName() || entry.name}_LOD${level}`).setMesh(mesh)
162
+ .setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale())
163
+ if (node.getSkin()) lodNode.setSkin(node.getSkin())
164
+ lodNode.setExtras({ lecodes: { lod: { level, ratio, tris } } })
165
+ if (parent) parent.addChild(lodNode)
166
+ else for (const s of scenes) s.addChild(lodNode)
167
+ levels.push(tris)
168
+ if (plan.bones && i === plan.ratios.length - 1 && node.getSkin()) {
169
+ const r = reduceSkinForLod(doc, lodNode)
170
+ if (r) result.bones.push({ name: entry.name, ...r })
171
+ }
172
+ }
173
+ result.nodes++
174
+ result.tris.push({ name: entry.name, levels })
175
+ }
176
+ return result
177
+ }
@@ -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
+ }