lecodes-assets 0.1.2 → 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.
Files changed (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli.js +7757 -683
  3. package/dist/index.js +7866 -675
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,220 @@
1
+ // Scale normalization — bring a model to real-world metres before it reaches creator-gl.
2
+ //
3
+ // Exporters routinely ship a model at 1/100 (the Blender/FBX "armature scale 0.01" quirk), in cm, or
4
+ // in inches. Nothing in the file records that, and the SDK carries no unit metadata — the asset just
5
+ // turns up 5 mm across in a scene and every consumer needs its own magic multiplier.
6
+ //
7
+ // measure : the RENDERED world size. Skinned primitives do not use their node's matrix (the glTF
8
+ // spec says a skinned mesh node's transform is ignored) — they land at world(joint)×IBM,
9
+ // so that is what we measure them with.
10
+ // plan : an explicit factor, a target largest-dimension, or an auto unit guess.
11
+ // apply : BAKES the factor into the data — positions, node translations, IBM offsets and
12
+ // translation tracks all × s. Local scales are left alone, so no wrapper node appears
13
+ // and every node path stays what it was.
14
+ //
15
+ // Why the bake is those four edits and nothing else: scaling every translation by s and leaving
16
+ // every rotation/scale block alone gives world' = S·world·S⁻¹ for every node (S = uniform s). For a
17
+ // skinned vertex, Σw·world'·IBM'·(s·v) == s·(Σw·world·IBM·v) holds exactly when IBM' = S·IBM·S⁻¹,
18
+ // and S·IBM·S⁻¹ is just IBM with its translation column × s.
19
+ import type { Accessor, Document } from '@gltf-transform/core'
20
+
21
+ const FLOAT = 5126
22
+
23
+ /** A model whose largest dimension lands outside this (metres) is almost certainly mis-scaled. */
24
+ export const PLAUSIBLE_MIN = 0.1
25
+ export const PLAUSIBLE_MAX = 100
26
+
27
+ // Multiply-by factors we are willing to guess, with what they mean. Ordered by |ln f| so the first
28
+ // in-band hit is the SMALLEST correction that explains the model.
29
+ const CANDIDATES: { f: number; label: string }[] = [
30
+ { f: 100, label: 'cm units / the 0.01 armature-scale export quirk' },
31
+ { f: 0.01, label: '100× too big (metre values authored as cm)' },
32
+ { f: 0.3048, label: 'feet' },
33
+ { f: 0.0254, label: 'inches' },
34
+ { f: 1000, label: 'mm units' },
35
+ { f: 0.001, label: '1000× too big' },
36
+ ].sort((a, b) => Math.abs(Math.log(a.f)) - Math.abs(Math.log(b.f)))
37
+
38
+ type Vec3 = [number, number, number]
39
+ export interface ScaleInfo {
40
+ dims: Vec3 // rendered world size, metres
41
+ largest: number
42
+ suggested: number // auto factor (1 = looks fine, or no candidate explains it)
43
+ label: string
44
+ suspicious: boolean
45
+ }
46
+ export interface ScalePlan { factor: number; before: Vec3; after: Vec3; source: 'auto' | 'explicit' | 'fit'; label: string }
47
+
48
+ const mul = (a: number[], b: number[]): number[] => {
49
+ const o = new Array(16).fill(0)
50
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
51
+ return o
52
+ }
53
+ const applyMat = (m: number[], v: number[]): Vec3 => [
54
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12],
55
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],
56
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14],
57
+ ]
58
+
59
+ /** Rendered world-space bounds of every mesh in the document, or null when there is no geometry. */
60
+ export function sceneBounds(doc: Document): { min: Vec3; max: Vec3; dims: Vec3 } | null {
61
+ const cache = new Map<object, number[]>()
62
+ const world = (n: any): number[] => {
63
+ const hit = cache.get(n)
64
+ if (hit) return hit
65
+ const parent = n.getParentNode()
66
+ const local = Array.from(n.getMatrix() as ArrayLike<number>)
67
+ const w = parent ? mul(world(parent), local) : local
68
+ cache.set(n, w)
69
+ return w
70
+ }
71
+ const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
72
+ let any = false
73
+ for (const node of doc.getRoot().listNodes()) {
74
+ const mesh = node.getMesh()
75
+ if (!mesh) continue
76
+ // A skinned primitive ignores its node's transform: at rest it lands at world(joint₀)·IBM₀, the
77
+ // skin's bind space (constant across joints for a sane rig — probe reports the deviation).
78
+ let m = world(node)
79
+ const skin = node.getSkin()
80
+ if (skin) {
81
+ const joints = skin.listJoints()
82
+ const ibm = skin.getInverseBindMatrices()
83
+ if (joints.length && ibm) m = mul(world(joints[0]), ibm.getElement(0, []))
84
+ else if (joints.length) m = world(joints[0])
85
+ }
86
+ for (const prim of mesh.listPrimitives()) {
87
+ const pos = prim.getAttribute('POSITION')
88
+ if (!pos) continue
89
+ const lo = pos.getMin([]), hi = pos.getMax([])
90
+ for (let corner = 0; corner < 8; corner++) {
91
+ const p = applyMat(m, [ corner & 1 ? hi[0] : lo[0], corner & 2 ? hi[1] : lo[1], corner & 4 ? hi[2] : lo[2] ])
92
+ for (let i = 0; i < 3; i++) { min[i] = Math.min(min[i], p[i]); max[i] = Math.max(max[i], p[i]) }
93
+ }
94
+ any = true
95
+ }
96
+ }
97
+ if (!any) return null
98
+ return { min, max, dims: [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ] }
99
+ }
100
+
101
+ /** Measure the document and guess whether (and by how much) it is mis-scaled. */
102
+ export function measureScale(doc: Document): ScaleInfo | null {
103
+ const b = sceneBounds(doc)
104
+ if (!b) return null
105
+ const largest = Math.max(...b.dims)
106
+ if (!(largest > 0)) return null
107
+ const inBand = (x: number): boolean => x >= PLAUSIBLE_MIN && x <= PLAUSIBLE_MAX
108
+ if (inBand(largest)) return { dims: b.dims, largest, suggested: 1, label: 'plausible', suspicious: false }
109
+ const hit = CANDIDATES.find((c) => inBand(largest * c.f))
110
+ return {
111
+ dims: b.dims,
112
+ largest,
113
+ suggested: hit?.f ?? 1,
114
+ label: hit?.label ?? 'no standard unit factor explains this size — pass --scale-to',
115
+ suspicious: true,
116
+ }
117
+ }
118
+
119
+ export interface ScaleOptions { scale?: number; scaleTo?: number; fixScale?: boolean }
120
+
121
+ /** Turn options + measurement into a concrete plan, or null when there is nothing to do. */
122
+ export function planScale(info: ScaleInfo | null, opts: ScaleOptions): ScalePlan | null {
123
+ let factor = 1
124
+ let source: ScalePlan['source'] = 'auto'
125
+ let label = info?.label ?? ''
126
+ if (opts.scale !== undefined) {
127
+ // An explicit factor needs no measurement — it still applies to a clip-only file with no geometry.
128
+ factor = opts.scale; source = 'explicit'; label = 'explicit factor'
129
+ } else if (opts.scaleTo !== undefined) {
130
+ if (!info) throw new Error('--scale-to needs geometry to measure; this file has no meshes — use --scale <factor>')
131
+ factor = opts.scaleTo / info.largest; source = 'fit'; label = `largest dimension → ${opts.scaleTo} m`
132
+ } else if (opts.fixScale) {
133
+ if (!info) return null
134
+ factor = info.suggested
135
+ }
136
+ if (!(factor > 0) || !Number.isFinite(factor)) throw new Error(`bad scale factor ${factor}`)
137
+ if (Math.abs(factor - 1) < 1e-9) return null
138
+ const before: Vec3 = info?.dims ?? [ 0, 0, 0 ]
139
+ return { factor, before, after: before.map((d) => d * factor) as Vec3, source, label }
140
+ }
141
+
142
+ const scaleAccessor = (acc: Accessor, s: number, what: string): void => {
143
+ if (acc.getComponentType() !== FLOAT) throw new Error(`cannot scale quantized/normalized ${what} (component type ${acc.getComponentType()}) — dequantize first`)
144
+ const src = acc.getArray()!
145
+ const out = new Float32Array(src.length)
146
+ for (let i = 0; i < src.length; i++) out[i] = src[i] * s
147
+ acc.setArray(out)
148
+ }
149
+
150
+ export interface ScaleResult { positions: number; nodes: number; skins: number; tracks: number; cameras: number; lights: number }
151
+
152
+ /** Bake a uniform scale into the document (mutates). Adds no nodes and changes no local scales. */
153
+ export function applyScale(doc: Document, s: number): ScaleResult {
154
+ const root = doc.getRoot()
155
+ const res: ScaleResult = { positions: 0, nodes: 0, skins: 0, tracks: 0, cameras: 0, lights: 0 }
156
+
157
+ const seen = new Set<Accessor>()
158
+ const once = (acc: Accessor | null, what: string): void => {
159
+ if (!acc || seen.has(acc)) return
160
+ seen.add(acc)
161
+ scaleAccessor(acc, s, what)
162
+ res.positions++
163
+ }
164
+ for (const mesh of root.listMeshes()) for (const prim of mesh.listPrimitives()) {
165
+ once(prim.getAttribute('POSITION'), 'POSITION')
166
+ for (const target of prim.listTargets()) once(target.getAttribute('POSITION'), 'morph POSITION')
167
+ }
168
+
169
+ for (const node of root.listNodes()) {
170
+ const t = node.getTranslation()
171
+ if (t[0] || t[1] || t[2]) { node.setTranslation([ t[0] * s, t[1] * s, t[2] * s ]); res.nodes++ }
172
+ }
173
+
174
+ // IBM' = S·IBM·S⁻¹ — the 3×3 block is untouched, only the offset moves.
175
+ for (const skin of root.listSkins()) {
176
+ const ibm = skin.getInverseBindMatrices()
177
+ if (!ibm) continue
178
+ const m: number[] = []
179
+ for (let i = 0; i < ibm.getCount(); i++) {
180
+ ibm.getElement(i, m)
181
+ m[12] *= s; m[13] *= s; m[14] *= s
182
+ ibm.setElement(i, m)
183
+ }
184
+ res.skins++
185
+ }
186
+
187
+ // Translation tracks only — rotation and scale tracks live in the untouched 3×3 block.
188
+ for (const anim of root.listAnimations()) {
189
+ const moves = new Set<Accessor>(), keeps = new Set<Accessor>()
190
+ for (const ch of anim.listChannels()) {
191
+ const out = ch.getSampler()?.getOutput()
192
+ if (out) (ch.getTargetPath() === 'translation' ? moves : keeps).add(out)
193
+ }
194
+ for (const acc of moves) {
195
+ if (keeps.has(acc)) throw new Error(`animation "${anim.getName()}" shares one output accessor between translation and other channels — cannot scale safely`)
196
+ if (seen.has(acc)) continue
197
+ seen.add(acc)
198
+ scaleAccessor(acc, s, 'translation track')
199
+ res.tracks++
200
+ }
201
+ }
202
+
203
+ for (const cam of root.listCameras()) {
204
+ cam.setZNear(cam.getZNear() * s)
205
+ const zf = cam.getZFar()
206
+ if (zf) cam.setZFar(zf * s)
207
+ if (cam.getType() === 'orthographic') { cam.setXMag(cam.getXMag() * s); cam.setYMag(cam.getYMag() * s) }
208
+ res.cameras++
209
+ }
210
+
211
+ // KHR_lights_punctual: range is a distance; intensity is candela/lux and does not scale with size.
212
+ for (const node of root.listNodes()) {
213
+ const light = node.getExtension('KHR_lights_punctual') as { getRange?: () => number | null; setRange?: (r: number) => unknown } | null
214
+ if (!light?.getRange || !light.setRange) continue
215
+ const range = light.getRange()
216
+ if (range) light.setRange(range * s)
217
+ res.lights++
218
+ }
219
+ return res
220
+ }
@@ -0,0 +1,218 @@
1
+ // Skin bone-count reduction for filament's CONFIG_MAX_BONE_COUNT (256), without Blender.
2
+ // Split into PLAN (pure analysis, no mutation) and APPLY (mutation) so the same logic powers
3
+ // both the analyzer and the fixer in glbDoctor.ts.
4
+ //
5
+ // Pass 1 — drop joints not bound to any vertex (leftover IK / control bones) -> LOSSLESS
6
+ // Pass 2 — drop the deepest remaining joints, merging their weights into the
7
+ // nearest surviving ancestor joint, until each skin has <= target -> LOSSY
8
+ import { NodeIO, type Document, type Skin, type Node as GltfNode, type Primitive, type Accessor } from '@gltf-transform/core'
9
+ import { ALL_EXTENSIONS } from '@gltf-transform/extensions'
10
+ import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer'
11
+
12
+ export const DEFAULT_TARGET = 256
13
+ const WEIGHT_EPS = 1e-5
14
+
15
+ export interface PrimSet { j: Accessor; w: Accessor }
16
+ export interface PrimEntry { prim: Primitive; sets: PrimSet[] }
17
+
18
+ export interface SkinPlan {
19
+ skin: Skin
20
+ skinIdx: number
21
+ name: string
22
+ before: number
23
+ after: number
24
+ removedUnbound: number
25
+ removedDeep: number
26
+ prims: PrimEntry[]
27
+ // internals reused by applySkinPlan:
28
+ joints: GltfNode[]
29
+ removeSet: Set<number>
30
+ kept: number[]
31
+ newPos: Map<number, number>
32
+ mergeTarget: Int32Array
33
+ }
34
+
35
+ export function makeIO(): NodeIO {
36
+ return new NodeIO()
37
+ .registerExtensions(ALL_EXTENSIONS)
38
+ .registerDependencies({ 'meshopt.decoder': MeshoptDecoder, 'meshopt.encoder': MeshoptEncoder })
39
+ }
40
+
41
+ /** Meshopt codecs are WASM — must be ready before any read/write. */
42
+ export async function ensureCodecs(): Promise<void> {
43
+ await MeshoptDecoder.ready
44
+ await MeshoptEncoder.ready
45
+ }
46
+
47
+ /** Group every skinned primitive under the single skin that uses it. */
48
+ export function gatherSkinPrims(doc: Document): { bySkin: Map<Skin, PrimEntry[]>; conflicts: number } {
49
+ const owner = new Map<Primitive, Skin>()
50
+ const bySkin = new Map<Skin, PrimEntry[]>()
51
+ let conflicts = 0
52
+ for (const node of doc.getRoot().listNodes()) {
53
+ const skin = node.getSkin(), mesh = node.getMesh()
54
+ if (!skin || !mesh) continue
55
+ for (const prim of mesh.listPrimitives()) {
56
+ if (!prim.getAttribute('JOINTS_0')) continue
57
+ const prev = owner.get(prim)
58
+ if (prev) { if (prev !== skin) conflicts++; continue }
59
+ owner.set(prim, skin)
60
+ const sets: PrimSet[] = []
61
+ for (let s = 0; ; s++) {
62
+ const j = prim.getAttribute(`JOINTS_${s}`), w = prim.getAttribute(`WEIGHTS_${s}`)
63
+ if (!j || !w) break
64
+ sets.push({ j, w })
65
+ }
66
+ ;(bySkin.get(skin) ?? bySkin.set(skin, []).get(skin)!).push({ prim, sets })
67
+ }
68
+ }
69
+ return { bySkin, conflicts }
70
+ }
71
+
72
+ function depthOf(node: GltfNode): number {
73
+ let d = 0, p = node.getParentNode()
74
+ while (p) { d++; p = p.getParentNode() }
75
+ return d
76
+ }
77
+
78
+ /** Pure: decide which joints to drop. No mutation. */
79
+ export function planSkin(skin: Skin, skinIdx: number, prims: PrimEntry[], target: number): SkinPlan {
80
+ const joints = skin.listJoints()
81
+ const N = joints.length
82
+ const name = skin.getName() || `skin[${skinIdx}]`
83
+ const posOf = new Map<GltfNode, number>(joints.map((n, i) => [n, i]))
84
+ const removeSet = new Set<number>()
85
+
86
+ const empty: SkinPlan = {
87
+ skin, skinIdx, name, before: N, after: N, removedUnbound: 0, removedDeep: 0,
88
+ prims, joints, removeSet, kept: [...Array(N).keys()], newPos: new Map(), mergeTarget: new Int32Array(N).fill(-1),
89
+ }
90
+ if (N <= target || prims.length === 0) return empty
91
+
92
+ // total weight per joint position across all primitives of this skin
93
+ const total = new Float64Array(N)
94
+ const je: number[] = [0, 0, 0, 0], we: number[] = [0, 0, 0, 0]
95
+ for (const { sets } of prims) {
96
+ for (const { j, w } of sets) {
97
+ const c = j.getCount()
98
+ for (let v = 0; v < c; v++) {
99
+ j.getElement(v, je); w.getElement(v, we)
100
+ for (let k = 0; k < 4; k++) if (we[k] > 0) total[je[k]] += we[k]
101
+ }
102
+ }
103
+ }
104
+
105
+ // Pass 1 — unbound
106
+ for (let i = 0; i < N; i++) if (total[i] <= WEIGHT_EPS) removeSet.add(i)
107
+ const removedUnbound = removeSet.size
108
+
109
+ // Pass 2 — deepest-first, only if still over budget
110
+ const depth = joints.map(depthOf)
111
+ const nearestKept = (i: number): number => {
112
+ let p = joints[i].getParentNode()
113
+ while (p) { const pos = posOf.get(p); if (pos !== undefined && !removeSet.has(pos)) return pos; p = p.getParentNode() }
114
+ return -1
115
+ }
116
+ let remaining = N - removeSet.size, removedDeep = 0
117
+ if (remaining > target) {
118
+ const order = [...Array(N).keys()].filter((i) => !removeSet.has(i))
119
+ .sort((a, b) => depth[b] - depth[a] || joints[b].getName().localeCompare(joints[a].getName()))
120
+ for (const i of order) {
121
+ if (remaining <= target) break
122
+ if (nearestKept(i) === -1) continue
123
+ removeSet.add(i); remaining--; removedDeep++
124
+ }
125
+ }
126
+
127
+ const kept = [...Array(N).keys()].filter((i) => !removeSet.has(i))
128
+ const newPos = new Map<number, number>(kept.map((old, ni) => [old, ni]))
129
+ const mergeTarget = new Int32Array(N).fill(-1)
130
+ for (const i of removeSet) {
131
+ let p = joints[i].getParentNode(), t = -1
132
+ while (p) { const pos = posOf.get(p); if (pos !== undefined && !removeSet.has(pos)) { t = pos; break } p = p.getParentNode() }
133
+ mergeTarget[i] = t
134
+ }
135
+
136
+ return { skin, skinIdx, name, before: N, after: kept.length, removedUnbound, removedDeep, prims, joints, removeSet, kept, newPos, mergeTarget }
137
+ }
138
+
139
+ /** Mutating: rewrite skinning attributes + skin joints/IBM per the plan. Returns dropped weight. */
140
+ export function applySkinPlan(doc: Document, plan: SkinPlan): number {
141
+ const { skin, joints, removeSet, kept, newPos, mergeTarget, prims } = plan
142
+ if (removeSet.size === 0) return 0
143
+
144
+ const finalPos = (old: number): number => {
145
+ const np = newPos.get(old)
146
+ if (np !== undefined) return np
147
+ const t = mergeTarget[old]
148
+ return t < 0 ? -1 : newPos.get(t)!
149
+ }
150
+
151
+ let droppedWeight = 0
152
+ for (const { prim, sets } of prims) {
153
+ const c = sets[0].j.getCount()
154
+ const numSets = sets.length
155
+ const cap = 4 * numSets
156
+ const outJ = sets.map(() => new Array(c * 4).fill(0))
157
+ const outW = sets.map(() => new Array(c * 4).fill(0))
158
+ let maxInfluences = 0
159
+ const jv: number[] = [0, 0, 0, 0], wv: number[] = [0, 0, 0, 0]
160
+
161
+ for (let v = 0; v < c; v++) {
162
+ const acc = new Map<number, number>()
163
+ for (const { j, w } of sets) {
164
+ j.getElement(v, jv); w.getElement(v, wv)
165
+ for (let k = 0; k < 4; k++) {
166
+ const ww = wv[k]
167
+ if (ww <= 0) continue
168
+ const fp = finalPos(jv[k])
169
+ if (fp < 0) { droppedWeight += ww; continue }
170
+ acc.set(fp, (acc.get(fp) || 0) + ww)
171
+ }
172
+ }
173
+ let pairs = [...acc.entries()].sort((a, b) => b[1] - a[1])
174
+ if (pairs.length > cap) pairs = pairs.slice(0, cap)
175
+ maxInfluences = Math.max(maxInfluences, pairs.length)
176
+ let sum = 0; for (const p of pairs) sum += p[1]; if (sum <= 0) sum = 1
177
+ for (let s = 0; s < numSets; s++)
178
+ for (let k = 0; k < 4; k++) {
179
+ const idx = s * 4 + k
180
+ if (idx < pairs.length) { outJ[s][v * 4 + k] = pairs[idx][0]; outW[s][v * 4 + k] = pairs[idx][1] / sum }
181
+ }
182
+ }
183
+
184
+ for (let s = 0; s < numSets; s++) {
185
+ const Ja = sets[s].j, Wa = sets[s].w
186
+ for (let v = 0; v < c; v++) {
187
+ Ja.setElement(v, [outJ[s][v * 4], outJ[s][v * 4 + 1], outJ[s][v * 4 + 2], outJ[s][v * 4 + 3]])
188
+ Wa.setElement(v, [outW[s][v * 4], outW[s][v * 4 + 1], outW[s][v * 4 + 2], outW[s][v * 4 + 3]])
189
+ }
190
+ }
191
+ const neededSets = Math.max(1, Math.ceil(maxInfluences / 4))
192
+ for (let s = neededSets; s < numSets; s++) { prim.setAttribute(`JOINTS_${s}`, null); prim.setAttribute(`WEIGHTS_${s}`, null) }
193
+ }
194
+
195
+ const oldIbm = skin.getInverseBindMatrices()
196
+ if (oldIbm) {
197
+ const m: number[] = new Array(16).fill(0)
198
+ const arr = new Float32Array(kept.length * 16)
199
+ for (let ni = 0; ni < kept.length; ni++) { oldIbm.getElement(kept[ni], m); arr.set(m, ni * 16) }
200
+ const newIbm = doc.createAccessor(`${plan.name}_ibm`).setType('MAT4').setArray(arr).setBuffer(doc.getRoot().listBuffers()[0])
201
+ skin.setInverseBindMatrices(newIbm)
202
+ if (!doc.getRoot().listSkins().some((s) => s.getInverseBindMatrices() === oldIbm)) oldIbm.dispose()
203
+ }
204
+ for (const i of removeSet) skin.removeJoint(joints[i])
205
+ return droppedWeight
206
+ }
207
+
208
+ // Convenience one-shot (bones only), kept for standalone use.
209
+ export async function reduceGlbBones(inputPath: string, outputPath: string, target = DEFAULT_TARGET): Promise<SkinPlan[]> {
210
+ await ensureCodecs()
211
+ const io = makeIO()
212
+ const doc = await io.read(inputPath)
213
+ const { bySkin } = gatherSkinPrims(doc)
214
+ const plans = doc.getRoot().listSkins().map((skin, i) => planSkin(skin, i, bySkin.get(skin) ?? [], target))
215
+ for (const p of plans) applySkinPlan(doc, p)
216
+ await io.write(outputPath, doc)
217
+ return plans
218
+ }
@@ -0,0 +1,95 @@
1
+ // Shading pass — the two material quirks that make an exported model render far brighter than the
2
+ // art it was painted from, plus the flat-colour (unlit) conversion stylised assets usually want.
3
+ //
4
+ // SELF-LIT. A PBR material's emissive is meant for the parts that GLOW. Several exporters (and
5
+ // Sketchfab's own download path) instead wire the BASE COLOUR texture into `emissiveTexture` with
6
+ // `emissiveFactor = [1,1,1]`, so the shader adds a full copy of the albedo on top of the lit result:
7
+ //
8
+ // out = lit(baseColor) + baseColor instead of out = lit(baseColor)
9
+ //
10
+ // Under any decent IBL that is ~2× too bright — the model looks washed out and loses its shading,
11
+ // and the usual first suspects (metallic, the texture itself) are innocent.
12
+ //
13
+ // SPECULAR BOOST. `KHR_materials_specular` with a `specularColorFactor` above 1 is the same story
14
+ // for the highlight. gltfio ALSO refuses to combine it with unlit ("Cannot use specular extension
15
+ // with unlit"), so an unlit conversion must drop it or the shading model silently reverts to lit.
16
+ import { KHRMaterialsUnlit } from '@gltf-transform/extensions'
17
+ import type { Document, Material } from '@gltf-transform/core'
18
+
19
+ const DROP_ON_UNLIT = [
20
+ 'KHR_materials_specular', 'KHR_materials_ior', 'KHR_materials_sheen', 'KHR_materials_clearcoat',
21
+ 'KHR_materials_transmission', 'KHR_materials_volume', 'KHR_materials_iridescence',
22
+ 'KHR_materials_anisotropy', 'KHR_materials_dispersion',
23
+ ]
24
+
25
+ export interface ShadingIssue {
26
+ mat: Material
27
+ name: string
28
+ /** emissive is a full copy of the base colour — the model renders ~2× too bright */
29
+ selfLit: boolean
30
+ /** highest component of a `KHR_materials_specular` colour factor above 1, else 0 */
31
+ specularBoost: number
32
+ /** already flat-shaded via KHR_materials_unlit */
33
+ unlit: boolean
34
+ }
35
+ export interface ShadingPlan { issues: ShadingIssue[]; unlit: boolean; fixEmissive: boolean }
36
+ export interface ShadingOptions { unlit?: boolean; fixEmissive?: boolean }
37
+
38
+ const specularOf = (m: Material): number => {
39
+ const spec = m.getExtension('KHR_materials_specular') as unknown as { getSpecularColorFactor?: () => number[] } | null
40
+ const f = spec?.getSpecularColorFactor?.() ?? []
41
+ return Math.max(0, ...f.map((v) => (v > 1 ? v : 0)))
42
+ }
43
+
44
+ export function analyzeShading(doc: Document, opts: ShadingOptions = {}): ShadingPlan {
45
+ const issues: ShadingIssue[] = []
46
+ for (const mat of doc.getRoot().listMaterials()) {
47
+ const emissive = mat.getEmissiveFactor()
48
+ const emTex = mat.getEmissiveTexture()
49
+ // "A full copy" means the emissive channel carries the albedo at (near) full strength: either
50
+ // literally the same Texture object, or — for untextured materials — a white emissive factor.
51
+ const strong = Math.max(...emissive) >= 0.5
52
+ const selfLit = strong && (emTex ? emTex === mat.getBaseColorTexture() : true)
53
+ const specularBoost = specularOf(mat)
54
+ const unlit = mat.listExtensions().some((e) => e.extensionName === 'KHR_materials_unlit')
55
+ if (selfLit || specularBoost > 0 || (opts.unlit && !unlit)) {
56
+ issues.push({ mat, name: mat.getName() || '?', selfLit, specularBoost, unlit })
57
+ }
58
+ }
59
+ return { issues, unlit: !!opts.unlit, fixEmissive: !!opts.fixEmissive }
60
+ }
61
+
62
+ export interface ShadingResult { unlit: number; emissiveCleared: number; extensionsDropped: number }
63
+
64
+ export function applyShading(doc: Document, plan: ShadingPlan): ShadingResult {
65
+ const res: ShadingResult = { unlit: 0, emissiveCleared: 0, extensionsDropped: 0 }
66
+ if (!plan.unlit && !plan.fixEmissive) return res
67
+ const unlitExt = plan.unlit ? doc.createExtension(KHRMaterialsUnlit) : null
68
+ for (const { mat, selfLit, unlit } of plan.issues) {
69
+ // Unlit shades baseColorFactor × baseColorTexture and nothing else, so the emissive copy has to
70
+ // go either way — left in place a lit fallback would still double the albedo.
71
+ if (plan.unlit || (plan.fixEmissive && selfLit)) {
72
+ if (Math.max(...mat.getEmissiveFactor()) > 0 || mat.getEmissiveTexture()) {
73
+ mat.setEmissiveFactor([ 0, 0, 0 ]).setEmissiveTexture(null)
74
+ res.emissiveCleared++
75
+ }
76
+ }
77
+ if (!plan.unlit) continue
78
+ if (!unlit) { mat.setExtension('KHR_materials_unlit', unlitExt!.createUnlit()); res.unlit++ }
79
+ for (const name of DROP_ON_UNLIT) {
80
+ if (mat.getExtension(name)) { mat.setExtension(name, null); res.extensionsDropped++ }
81
+ }
82
+ // Inputs an unlit shader never reads: keep the file honest so a lit viewer that ignores the
83
+ // extension still renders the flat colour rather than a stray highlight.
84
+ mat.setMetallicFactor(0).setRoughnessFactor(1)
85
+ .setMetallicRoughnessTexture(null).setNormalTexture(null).setOcclusionTexture(null)
86
+ }
87
+ // Unlinking the property is not enough: the Extension stays registered on the document and keeps
88
+ // writing its name into `extensionsUsed`. Drop it once no material references it at all.
89
+ const materials = doc.getRoot().listMaterials()
90
+ for (const ext of doc.getRoot().listExtensionsUsed()) {
91
+ if (!DROP_ON_UNLIT.includes(ext.extensionName)) continue
92
+ if (!materials.some((m) => m.getExtension(ext.extensionName))) ext.dispose()
93
+ }
94
+ return res
95
+ }
@@ -0,0 +1,124 @@
1
+ // KTX2 / Basis-Universal encoding via KTX-Software's `toktx` (filament supports image/ktx2 through
2
+ // createKtx2Provider — GPU-compressed, supports alpha, smaller on disk AND in VRAM).
3
+ //
4
+ // Codec by texture role:
5
+ // - colour (base/emissive) -> ETC1S, sRGB (small, lossy)
6
+ // - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise)
7
+ // …unless `codecPolicy: 'uastc'` asks for UASTC everywhere. That is for HERO assets — a
8
+ // first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
9
+ // on the base colour are plainly visible. World props stay on the default: ETC1S is the right
10
+ // trade there and the size difference is roughly 8x.
11
+ // Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it).
12
+ import sharp from 'sharp'
13
+ import { spawnSync } from 'node:child_process'
14
+ import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import type { Root, Texture } from '@gltf-transform/core'
18
+ import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
19
+
20
+ /** Locate the toktx binary: $KTX_TOKTX → %LOCALAPPDATA%\Programs\KTX-Software → PATH. */
21
+ export function findToktx(): string {
22
+ const env = process.env.KTX_TOKTX
23
+ if (env && existsSync(env)) return env
24
+ const la = process.env.LOCALAPPDATA
25
+ if (la) {
26
+ const guess = join(la, 'Programs', 'KTX-Software', 'bin', 'toktx.exe')
27
+ if (existsSync(guess)) return guess
28
+ }
29
+ return 'toktx' // assume on PATH
30
+ }
31
+
32
+ export function checkToktx(toktx: string): string | null {
33
+ try {
34
+ const p = spawnSync(toktx, ['--version'], { encoding: 'utf8' })
35
+ if (p.status === 0) return (p.stdout ?? '').toString().trim() || 'toktx'
36
+ } catch { /* not found */ }
37
+ return null
38
+ }
39
+
40
+ export interface Ktx2Plan {
41
+ tex: Texture
42
+ name: string
43
+ roles: string
44
+ fromFmt: string
45
+ codec: 'etc1s' | 'uastc'
46
+ oetf: 'srgb' | 'linear'
47
+ resize: boolean
48
+ width: number
49
+ height: number
50
+ hasAlpha: boolean
51
+ beforeBytes: number
52
+ afterBytes?: number
53
+ category: 'lossy'
54
+ reason: string
55
+ }
56
+
57
+ export type CodecPolicy = 'default' | 'hq' | 'uastc'
58
+
59
+ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_SIZE, policy: CodecPolicy = 'hq'): Promise<Ktx2Plan | null> {
60
+ const img = tex.getImage()
61
+ const mime = tex.getMimeType() || ''
62
+ const fromFmt = mime.replace('image/', '') || '?'
63
+ if (!img || fromFmt.includes('ktx')) return null // missing or already ktx2
64
+
65
+ const { roles, isNormal, isData } = getTextureRoles(tex, root)
66
+ let width = 0, height = 0, hasAlpha = false
67
+ try {
68
+ const b = Buffer.from(img)
69
+ const m = await sharp(b).metadata()
70
+ width = m.width || 0; height = m.height || 0
71
+ hasAlpha = !(await sharp(b).stats()).isOpaque
72
+ } catch {
73
+ return null
74
+ }
75
+
76
+ // Normally only normal maps justify UASTC (high quality, ~8bpp): colour and metalRough/occlusion
77
+ // compress far smaller with ETC1S at acceptable quality. 'uastc' overrides that for hero assets.
78
+ // oetf: linear for any non-colour data.
79
+ const codec: 'etc1s' | 'uastc' =
80
+ policy === 'uastc' ? 'uastc'
81
+ : isNormal ? (policy === 'hq' ? 'uastc' : 'etc1s')
82
+ : 'etc1s'
83
+ const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
84
+ const resize = Math.max(width, height) > maxSize
85
+ return {
86
+ tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
87
+ beforeBytes: img.length, category: 'lossy',
88
+ reason: `${fromFmt} → ktx2/${codec}${hasAlpha ? '+alpha' : ''}${resize ? ` (≤${maxSize}px)` : ''}`,
89
+ }
90
+ }
91
+
92
+ export async function applyKtx2(plan: Ktx2Plan, toktx: string, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
93
+ const dir = mkdtempSync(join(tmpdir(), 'glbktx-'))
94
+ const inPng = join(dir, 'in.png')
95
+ const outKtx = join(dir, 'out.ktx2')
96
+ try {
97
+ // GPU block-compressed formats (BC7/BC3/ETC2, 4×4 blocks) require the texture's base dimensions
98
+ // to be MULTIPLES OF 4 — otherwise filament's glTexStorage2D fails ("Invalid compressed image
99
+ // size") and the texture renders broken. Snap dims to ×4 (and apply the maxSize cap) before toktx.
100
+ const snap4 = (n: number) => Math.max(4, Math.round(n / 4) * 4)
101
+ let tw = plan.width, th = plan.height
102
+ const m = Math.max(tw, th)
103
+ if (m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
104
+ tw = snap4(tw); th = snap4(th)
105
+
106
+ let pipe = sharp(Buffer.from(plan.tex.getImage()!))
107
+ if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
108
+ if (!plan.hasAlpha) pipe = pipe.removeAlpha() // RGB → no wasted alpha slice
109
+ writeFileSync(inPng, await pipe.png().toBuffer())
110
+
111
+ const args = plan.codec === 'etc1s'
112
+ ? ['--encode', 'etc1s', '--clevel', '2', '--qlevel', '128', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
113
+ : ['--encode', 'uastc', '--uastc_quality', '2', '--zcmp', '18', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
114
+
115
+ const proc = spawnSync(toktx, args, { encoding: 'utf8' })
116
+ if (proc.status !== 0) throw new Error(`toktx failed (exit ${proc.status}) for "${plan.name}": ${(proc.stderr ?? '').toString().slice(0, 300)}`)
117
+
118
+ const ktx = readFileSync(outKtx)
119
+ plan.tex.setImage(new Uint8Array(ktx)).setMimeType('image/ktx2')
120
+ plan.afterBytes = ktx.length
121
+ } finally {
122
+ rmSync(dir, { recursive: true, force: true })
123
+ }
124
+ }