lecodes-assets 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +561 -449
- package/dist/cli.js +5632 -521
- package/dist/index.js +5647 -526
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +171 -0
- package/src/retarget/humanoid.ts +561 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,241 @@
|
|
|
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's toktx.
|
|
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, findToktx, checkToktx, type Ktx2Plan } 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 { analyzeLightmapUv, applyLightmapUv, type LightmapUvPlan, type LightmapUvOptions, type LightmapUvResult } from './lightmapUv'
|
|
16
|
+
import { planLod, applyLod, type LodPlan, type LodOptions, type LodResult } from './lod'
|
|
17
|
+
import { duplicateNodeNames } from '../mergeSkins'
|
|
18
|
+
import { KHRTextureBasisu } from '@gltf-transform/extensions'
|
|
19
|
+
import type { Document, Material } from '@gltf-transform/core'
|
|
20
|
+
import { mkdirSync, existsSync, statSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
export { DEFAULT_TARGET, DEFAULT_MAX_SIZE, findToktx, checkToktx }
|
|
24
|
+
export { sceneBounds, measureScale, applyScale } from './normalizeScale'
|
|
25
|
+
|
|
26
|
+
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 }
|
|
29
|
+
|
|
30
|
+
export async function analyzeDoc(doc: Document, opts: DoctorOptions = {}): Promise<DoctorPlan> {
|
|
31
|
+
const target = opts.target ?? DEFAULT_TARGET
|
|
32
|
+
const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
|
|
33
|
+
const root = doc.getRoot()
|
|
34
|
+
const { bySkin, conflicts } = gatherSkinPrims(doc)
|
|
35
|
+
const skinPlans = root.listSkins().map((s, i) => planSkin(s, i, bySkin.get(s) ?? [], target))
|
|
36
|
+
const texPlans: TexturePlan[] = []
|
|
37
|
+
const ktx2Plans: Ktx2Plan[] = []
|
|
38
|
+
// KTX2 mode: everything → ETC1S (max compression). --ktx2-hq upgrades NORMAL maps to UASTC
|
|
39
|
+
// (much higher quality, same VRAM, larger file); colour/metalRough stay ETC1S either way.
|
|
40
|
+
const codecPolicy = opts.ktx2Uastc ? 'uastc' : opts.ktx2Hq ? 'hq' : 'default'
|
|
41
|
+
for (const tex of root.listTextures()) {
|
|
42
|
+
if (opts.ktx2) {
|
|
43
|
+
const p = await planKtx2(tex, root, maxSize, codecPolicy); if (p) ktx2Plans.push(p)
|
|
44
|
+
} else {
|
|
45
|
+
const p = await planTexture(tex, root, maxSize); if (p) texPlans.push(p)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Materials with a flat metallic factor but NO metallicRoughness texture render BLACK without an
|
|
49
|
+
// IBL/environment (metal reflects nothing, has no diffuse). Common export quirk on non-metal assets.
|
|
50
|
+
const metallicWarn: MetallicWarn[] = root.listMaterials()
|
|
51
|
+
.filter((m) => m.getMetallicFactor() > 0.01 && !m.getMetallicRoughnessTexture())
|
|
52
|
+
.map((m) => ({ mat: m, name: m.getName() || '?', metal: m.getMetallicFactor() }))
|
|
53
|
+
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
|
+
// Scale is always MEASURED (probe/report it even with no flag); it is only APPLIED when asked.
|
|
57
|
+
const scaleInfo = measureScale(doc)
|
|
58
|
+
const scalePlan = planScale(scaleInfo, opts)
|
|
59
|
+
// Duplicate node names: the Animator binds clips BY NAME and takes the first match. A leftover
|
|
60
|
+
// donor skeleton (two skinned files merged by hand) shadows the real joints → the mesh freezes.
|
|
61
|
+
const dupNames = duplicateNodeNames(doc)
|
|
62
|
+
// LOD levels are only ever ADDED on request (--lod); see doctor/lod.ts.
|
|
63
|
+
const lodPlan = planLod(doc, opts)
|
|
64
|
+
return { skinPlans, texPlans, ktx2Plans, metallicWarn, shadingPlan, lightmapPlan, scaleInfo, scalePlan, lodPlan, conflicts, target, dupNames }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const kb = (n?: number) => (n === undefined ? '?' : `${Math.round(n / 1024)}KB`)
|
|
68
|
+
|
|
69
|
+
/** A length in metres, in whatever unit reads best — 0.0055 is "5.5mm", not "0.01m". */
|
|
70
|
+
export const metres = (m: number): string =>
|
|
71
|
+
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`
|
|
72
|
+
const dims3 = (d: readonly number[]): string => d.map((x) => metres(x)).join(' × ')
|
|
73
|
+
const fmtFactor = (f: number): string => (f >= 0.001 && f < 1e6 ? +f.toPrecision(6) : f.toExponential(2)).toString()
|
|
74
|
+
|
|
75
|
+
const EXT_BY_MIME: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/ktx2': 'ktx2' }
|
|
76
|
+
const safeName = (n: string) => (n || 'tex').replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60)
|
|
77
|
+
|
|
78
|
+
/** Write every texture's raw bytes to `dir` (+ a _manifest.txt) for manual inspection. */
|
|
79
|
+
export async function dumpTextures(doc: Document, dir: string, log: (s: string) => void = console.log) {
|
|
80
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
81
|
+
const root = doc.getRoot()
|
|
82
|
+
const rows: string[] = []
|
|
83
|
+
let sharp: any = null
|
|
84
|
+
try { sharp = (await import('sharp')).default } catch { /* dims/alpha columns stay '-' */ }
|
|
85
|
+
let i = 0
|
|
86
|
+
for (const tex of root.listTextures()) {
|
|
87
|
+
const img = tex.getImage()
|
|
88
|
+
const mime = tex.getMimeType() || ''
|
|
89
|
+
const ext = EXT_BY_MIME[mime] ?? 'bin'
|
|
90
|
+
const file = `${String(i).padStart(2, '0')}_${safeName(tex.getName())}.${ext}`
|
|
91
|
+
if (img) writeFileSync(`${dir}/${file}`, img)
|
|
92
|
+
const { roles } = getTextureRoles(tex, root)
|
|
93
|
+
let dims = '-', alpha = '-'
|
|
94
|
+
if (img && ext !== 'ktx2' && sharp) {
|
|
95
|
+
try {
|
|
96
|
+
const m = await sharp(Buffer.from(img)).metadata()
|
|
97
|
+
dims = `${m.width}x${m.height}`
|
|
98
|
+
alpha = (await sharp(Buffer.from(img)).stats()).isOpaque ? 'opaque' : 'ALPHA'
|
|
99
|
+
} catch { /* undecodable by sharp */ }
|
|
100
|
+
}
|
|
101
|
+
rows.push(`${file.padEnd(48)} ${ext.padEnd(5)} ${dims.padEnd(11)} ${alpha.padEnd(7)} ${roles.padEnd(12)} ${kb(img?.length)}`)
|
|
102
|
+
i++
|
|
103
|
+
}
|
|
104
|
+
const header = `# ${i} textures from this GLB\n# file format dimensions alpha role size\n`
|
|
105
|
+
writeFileSync(`${dir}/_manifest.txt`, header + rows.join('\n') + '\n')
|
|
106
|
+
log(`\n🖼 dumped ${i} textures → ${dir}`)
|
|
107
|
+
log(rows.join('\n'))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function reportPlan(plan: DoctorPlan): { text: string; lossless: number; lossy: number; blocking: number } {
|
|
111
|
+
const lossless: string[] = [], lossy: string[] = [], blocking: string[] = []
|
|
112
|
+
|
|
113
|
+
for (const s of plan.skinPlans) {
|
|
114
|
+
if (s.before <= plan.target) continue
|
|
115
|
+
if (s.removedDeep === 0) {
|
|
116
|
+
lossless.push(`bones ${s.name}: drop ${s.removedUnbound} unbound joints (${s.before} → ${s.after})`)
|
|
117
|
+
} else {
|
|
118
|
+
if (s.removedUnbound > 0) lossless.push(`bones ${s.name}: drop ${s.removedUnbound} unbound joints`)
|
|
119
|
+
lossy.push(`bones ${s.name}: drop ${s.removedDeep} deepest BOUND joints, merge weights → parent (${s.before} → ${s.after})`)
|
|
120
|
+
}
|
|
121
|
+
if (s.after > plan.target) blocking.push(`bones ${s.name}: still ${s.after} > ${plan.target} after reduction (too many root joints)`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const t of plan.texPlans) {
|
|
125
|
+
if (!t.toFmt) continue
|
|
126
|
+
const line = `tex "${t.name}" [${t.roles}] ${t.fromFmt}→${t.toFmt} (${t.reason}, ${kb(t.beforeBytes)})`
|
|
127
|
+
;(t.category === 'lossless' ? lossless : lossy).push(line)
|
|
128
|
+
}
|
|
129
|
+
for (const t of plan.ktx2Plans) {
|
|
130
|
+
lossy.push(`tex "${t.name}" [${t.roles}] ${t.reason} (${kb(t.beforeBytes)})`)
|
|
131
|
+
}
|
|
132
|
+
if (plan.conflicts > 0) blocking.push(`${plan.conflicts} primitive(s) shared across skins — skipped`)
|
|
133
|
+
const shadowing = plan.dupNames.filter((d) => d.shadowsJoint)
|
|
134
|
+
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`)
|
|
135
|
+
|
|
136
|
+
const lines: string[] = []
|
|
137
|
+
if (lossless.length) { lines.push(' ── LOSSLESS (no quality loss) ──'); lossless.forEach((l) => lines.push(' • ' + l)) }
|
|
138
|
+
if (lossy.length) { lines.push(' ── LOSSY (some degradation) ──'); lossy.forEach((l) => lines.push(' • ' + l)) }
|
|
139
|
+
if (blocking.length) { lines.push(' ── BLOCKING / manual ──'); blocking.forEach((l) => lines.push(' ✗ ' + l)) }
|
|
140
|
+
if (plan.metallicWarn.length) {
|
|
141
|
+
lines.push(` ── MATERIALS ──`)
|
|
142
|
+
lines.push(` ℹ ${plan.metallicWarn.length} material(s) have metallic without an MR texture → may render BLACK without IBL (--fix-metallic zeroes them):`)
|
|
143
|
+
lines.push(` ${plan.metallicWarn.map((w) => `${w.name}(${w.metal.toFixed(2)})`).join(', ')}`)
|
|
144
|
+
}
|
|
145
|
+
const selfLit = plan.shadingPlan.issues.filter((i) => i.selfLit)
|
|
146
|
+
const boosted = plan.shadingPlan.issues.filter((i) => i.specularBoost > 0)
|
|
147
|
+
if (selfLit.length || boosted.length) {
|
|
148
|
+
if (!plan.metallicWarn.length) lines.push(` ── MATERIALS ──`)
|
|
149
|
+
if (selfLit.length) {
|
|
150
|
+
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
|
+
lines.push(` ${selfLit.map((i) => i.name).join(', ')}`)
|
|
152
|
+
}
|
|
153
|
+
if (boosted.length) {
|
|
154
|
+
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`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const sc = plan.scaleInfo
|
|
158
|
+
if (sc?.suspicious || plan.scalePlan) {
|
|
159
|
+
lines.push(` ── SCALE ──`)
|
|
160
|
+
if (sc) lines.push(` ${sc.suspicious ? '⚠' : 'ℹ'} model measures ${dims3(sc.dims)}${sc.suspicious ? ` — implausible for a real-world asset (${sc.label})` : ''}`)
|
|
161
|
+
else lines.push(` ℹ no geometry to measure (clip-only file)`)
|
|
162
|
+
if (plan.scalePlan) lines.push(` → ×${fmtFactor(plan.scalePlan.factor)}${plan.scalePlan.source === 'explicit' ? '' : ` (${plan.scalePlan.label})`}${sc ? ` → ${dims3(plan.scalePlan.after)}` : ''}`)
|
|
163
|
+
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
|
+
else lines.push(` --scale-to <m> sets the largest dimension explicitly`)
|
|
165
|
+
}
|
|
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 ? '…' : ''}`)
|
|
174
|
+
}
|
|
175
|
+
const plainDups = plan.dupNames.filter((d) => !d.shadowsJoint)
|
|
176
|
+
if (plainDups.length) {
|
|
177
|
+
lines.push(` ── NAMES ──`)
|
|
178
|
+
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`)
|
|
179
|
+
}
|
|
180
|
+
if (!lines.length) lines.push(' ✅ no issues — model is within all limits')
|
|
181
|
+
return { text: lines.join('\n'), lossless: lossless.length, lossy: lossy.length, blocking: blocking.length }
|
|
182
|
+
}
|
|
183
|
+
|
|
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 }
|
|
186
|
+
|
|
187
|
+
/** Apply a plan to the document (mutates). Resolves toktx itself when `ktx2` and no path is given. */
|
|
188
|
+
export async function applyPlan(doc: Document, plan: DoctorPlan, opts: ApplyOptions = {}): Promise<ApplyResult> {
|
|
189
|
+
const maxSize = opts.maxSize ?? DEFAULT_MAX_SIZE
|
|
190
|
+
for (const s of plan.skinPlans) applySkinPlan(doc, s)
|
|
191
|
+
// After the skin plan: bone reduction rewrites joints/IBMs, and scaling the reduced set is both
|
|
192
|
+
// cheaper and keeps the IBM offsets it just wrote consistent with the geometry.
|
|
193
|
+
if (plan.scalePlan) applyScale(doc, plan.scalePlan.factor)
|
|
194
|
+
let metallicZeroed = 0
|
|
195
|
+
if (opts.fixMetallic && plan.metallicWarn.length) {
|
|
196
|
+
for (const w of plan.metallicWarn) w.mat.setMetallicFactor(0)
|
|
197
|
+
metallicZeroed = plan.metallicWarn.length
|
|
198
|
+
}
|
|
199
|
+
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
|
|
202
|
+
// LOD levels after every geometry pass (they copy the final base mesh) and before textures.
|
|
203
|
+
const lod = plan.lodPlan ? await applyLod(doc, plan.lodPlan) : null
|
|
204
|
+
let ktx2Encoded = 0
|
|
205
|
+
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)
|
|
210
|
+
opts.progress?.(++ktx2Encoded, plan.ktx2Plans.length)
|
|
211
|
+
}
|
|
212
|
+
// KTX2 textures must be written via KHR_texture_basisu (core texture.source allows only PNG/JPEG);
|
|
213
|
+
// gltf-transform only emits that form when the extension is attached to the document.
|
|
214
|
+
if (plan.ktx2Plans.length && !doc.getRoot().listExtensionsUsed().some((e) => e.extensionName === 'KHR_texture_basisu')) {
|
|
215
|
+
doc.createExtension(KHRTextureBasisu).setRequired(true)
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
for (const t of plan.texPlans) if (t.toFmt) await applyTexturePlan(t, maxSize)
|
|
219
|
+
}
|
|
220
|
+
// Once no texture is webp anymore, drop EXT_texture_webp — else it lingers in extensionsRequired
|
|
221
|
+
// and a strict loader (gltfio) can reject the file for a now-unused requirement.
|
|
222
|
+
if (!doc.getRoot().listTextures().some((t) => (t.getMimeType() || '').includes('webp'))) {
|
|
223
|
+
for (const ext of doc.getRoot().listExtensionsUsed()) {
|
|
224
|
+
if (ext.extensionName === 'EXT_texture_webp') ext.dispose()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { metallicZeroed, ktx2Encoded, scaled: plan.scalePlan?.factor ?? 1, unlit: shading.unlit, emissiveCleared: shading.emissiveCleared, lightmapUv, lod }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Output path for a doctored file: single file + -o → exact path; -o as dir (or batch) → derived name inside it. */
|
|
231
|
+
export function outputFor(input: string, oVal: string | null, batch: boolean, ktx2: boolean): string {
|
|
232
|
+
const derived = input.replace(/\.glb$/i, ktx2 ? '.ktx2.glb' : '.fixed.glb')
|
|
233
|
+
if (!oVal) return derived
|
|
234
|
+
const oNorm = oVal.replace(/\\/g, '/')
|
|
235
|
+
const asDir = batch || /\/$/.test(oNorm) || (existsSync(oNorm) && statSync(oNorm).isDirectory())
|
|
236
|
+
if (asDir) {
|
|
237
|
+
if (!existsSync(oNorm)) mkdirSync(oNorm, { recursive: true })
|
|
238
|
+
return join(oNorm, derived.split(/[\\/]/).pop()!)
|
|
239
|
+
}
|
|
240
|
+
return oVal
|
|
241
|
+
}
|