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,88 @@
1
+ // The plan: classify, group by what one draw cannot mix, check the skins can be unified, lay the atlases out.
2
+
3
+ import type { Document, Material, Node, Texture } from '@gltf-transform/core'
4
+ import { planAtlas } from './atlasLayout'
5
+ import { bySize, canonicalBinds } from './bindSpace'
6
+ import { classify, classSuffix, materialInfo, measureSurface, type MatInfo } from './classify'
7
+ import { readAttr } from './accessors'
8
+ import { findKtxTool, isKtx2 } from './textures'
9
+ import { DEFAULT_ATLAS_PAD, DEFAULT_ATLAS_SIZE, type AtlasLayout, type Kind, type Mat4, type MergeGroupPlan, type MergeOptions, type MergePlan, type Source } from './types'
10
+
11
+ export async function planMerge(doc: Document, opts: MergeOptions): Promise<MergePlan> {
12
+ const atlasSize = Math.max(64, Math.round(opts.atlasSize ?? DEFAULT_ATLAS_SIZE))
13
+ const pad = Math.max(0, Math.round(opts.atlasPad ?? DEFAULT_ATLAS_PAD))
14
+ const exclude = opts.mergeExclude ? new RegExp(opts.mergeExclude) : null
15
+ const root = doc.getRoot()
16
+ const primitivesBefore = root.listMeshes().reduce((n, m) => n + m.listPrimitives().length, 0)
17
+ const materialsBefore = root.listMaterials().length
18
+ const empty: MergePlan = { enabled: false, atlasSize, pad, groups: [], skipped: [], primitivesBefore, materialsBefore, primitivesAfter: primitivesBefore, joints: 0, ktxTool: null }
19
+ if (primitivesBefore < 2) return empty
20
+ const { sources, skipped } = classify(doc, exclude)
21
+ if (!sources.length) return { ...empty, skipped }
22
+
23
+ // material infos (UV ranges over the candidate primitives)
24
+ const uvOf = (m: Material): number[][] => sources.filter((s) => s.material === m).map((s) => { const a = s.prim.getAttribute('TEXCOORD_0'); return a ? Array.from(readAttr(a).data) : [] })
25
+ const texId = (t: Texture): string => String(root.listTextures().indexOf(t))
26
+ const wm = new Map<Node, Mat4>()
27
+ const measure = (m: Material) => measureSurface(sources.filter((x) => x.material === m), wm)
28
+ const infos = new Map<Material, MatInfo>()
29
+ for (const s of sources) if (s.material && !infos.has(s.material)) infos.set(s.material, await materialInfo(s.material, root.listMaterials().indexOf(s.material), uvOf, measure))
30
+
31
+ // KTX2 sources need the ktx tool — checked once, only when some candidate map is KTX2
32
+ const needsKtx = [ ...infos.values() ].some((i) => Object.values(i.maps).some((t) => isKtx2(t)))
33
+ const ktxTool = needsKtx ? findKtxTool() : null
34
+
35
+ // group by family × material class
36
+ const groupsMap = new Map<string, Source[]>()
37
+ for (const s of sources) {
38
+ const family = s.kind === 'static' ? 'static' : 'skinned'
39
+ const cls = s.material ? infos.get(s.material)!.classKey : 'OPAQUE||0|0'
40
+ const key = `${family}|${cls}`
41
+ const list = groupsMap.get(key) ?? []
42
+ list.push(s)
43
+ groupsMap.set(key, list)
44
+ }
45
+ // skinned family: can every skin join one bind space? (against the skin with most joints)
46
+ const badSkins = canonicalBinds(bySize(sources.filter((x) => x.kind !== 'static').map((x) => x.skin))).bad
47
+ const groups: MergeGroupPlan[] = []
48
+ let kept = 0
49
+ const usedLabels = new Set<string>()
50
+ for (const [ key, list ] of groupsMap) {
51
+ const family = key.startsWith('static') ? 'static' : 'skinned'
52
+ const usable = list.filter((s) => { if (s.skin && badSkins.has(s.skin)) { skipped.push({ name: s.name, reason: 'bind spaces differ per joint (skin cannot be unified)' }); return false } return true })
53
+ if (usable.length < 2) { kept += usable.length; continue }
54
+ // skinned parts first (the merged node is placed where the first one sat), then rigid, then static
55
+ const rank: Record<Kind, number> = { skinned: 0, rigid: 1, static: 2 }
56
+ usable.sort((x, y) => rank[x.kind] - rank[y.kind])
57
+ const mats = [ ...new Set(usable.map((s) => s.material).filter((m): m is Material => !!m)) ]
58
+ const first = mats[0]
59
+ let label = family === 'static' && groupsMap.size > 1 && [ ...groupsMap.keys() ].some((k) => k.startsWith('skinned')) ? 'merged.static' : 'merged'
60
+ if (first) {
61
+ const info = infos.get(first)!
62
+ const suffix = info.own ? (first.getName() || 'material').replace(/[^\w.-]+/g, '_') : classSuffix(first)
63
+ if (suffix) label = `${label}.${suffix}`
64
+ }
65
+ while (usedLabels.has(label)) label += '_'
66
+ usedLabels.add(label)
67
+ let atlas: AtlasLayout | null = null
68
+ let note: string | undefined
69
+ if (mats.length > 1) {
70
+ const minfos = mats.map((m) => infos.get(m)!)
71
+ const ktxNeeded = minfos.some((i) => Object.values(i.maps).some((t) => isKtx2(t)))
72
+ if (ktxNeeded && !ktxTool) { skipped.push({ name: label, reason: `${mats.length} materials carry KTX2 textures and the ktx tool was not found (KTX-Software, next to toktx)` }); kept += usable.length; continue }
73
+ const a = planAtlas(minfos, atlasSize, pad, !!opts.atlasUniform, texId)
74
+ if (!a.layout) { skipped.push({ name: label, reason: a.reason! }); kept += usable.length; continue }
75
+ atlas = a.layout
76
+ } else if (first && infos.get(first)!.own) note = infos.get(first)!.own!
77
+ const jointSet = new Set<Node>()
78
+ for (const s of usable) { if (s.skin) for (const j of s.skin.listJoints()) jointSet.add(j); if (s.joint) jointSet.add(s.joint) }
79
+ groups.push({
80
+ label, family, sources: usable, materials: mats, atlas,
81
+ alphaMode: first?.getAlphaMode() ?? 'OPAQUE', alphaCutoff: first?.getAlphaCutoff() ?? 0.5, doubleSided: first?.getDoubleSided() ?? false, unlit: !!first?.getExtension('KHR_materials_unlit'),
82
+ joints: jointSet.size, note,
83
+ })
84
+ }
85
+ const inGroups = groups.reduce((n, g) => n + g.sources.length, 0)
86
+ const primitivesAfter = primitivesBefore - inGroups + groups.length
87
+ return { enabled: !!opts.merge && groups.length > 0, atlasSize, pad, groups, skipped, primitivesBefore, materialsBefore, primitivesAfter, joints: Math.max(0, ...groups.map((g) => g.joints)), ktxTool }
88
+ }
@@ -0,0 +1,60 @@
1
+ // Reading the parts' textures: sizes without decoding, pixels at a tile's size — a Basis KTX2 goes
2
+ // back to PNG through KTX-Software's `ktx extract`.
3
+
4
+ import type { Texture } from '@gltf-transform/core'
5
+ import sharp from 'sharp'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { checkToktx, findKtxCli } from '../transcodeKtx2'
11
+
12
+ export function findKtxTool(): string | null {
13
+ const ktx = findKtxCli()
14
+ return checkToktx(ktx) ? ktx : null
15
+ }
16
+
17
+ export const isKtx2 = (t: Texture): boolean => (t.getMimeType() || '').includes('ktx2')
18
+
19
+ /** Pixel size of a texture without decoding it: the KTX2 header, or sharp's metadata. */
20
+ export async function imageSize(t: Texture): Promise<[number, number] | null> {
21
+ const img = t.getImage()
22
+ if (!img) return null
23
+ if (isKtx2(t)) {
24
+ if (img.length < 28) return null
25
+ const dv = new DataView(img.buffer, img.byteOffset, img.byteLength)
26
+ return [ dv.getUint32(20, true), dv.getUint32(24, true) ]
27
+ }
28
+ try { const m = await sharp(Buffer.from(img)).metadata(); return m.width && m.height ? [ m.width, m.height ] : null } catch { return null }
29
+ }
30
+
31
+ /** Encoded bytes sharp can read for a texture (a KTX2 goes through `ktx extract`). */
32
+ export async function decodable(tex: Texture, ktxTool: string | null, cache: Map<Texture, Buffer | null>): Promise<Buffer | null> {
33
+ if (cache.has(tex)) return cache.get(tex)!
34
+ const img = tex.getImage()
35
+ let out: Buffer | null = null
36
+ if (img && !isKtx2(tex)) out = Buffer.from(img)
37
+ else if (img && ktxTool) {
38
+ const dir = mkdtempSync(join(tmpdir(), 'glbatlas-'))
39
+ try {
40
+ const inK = join(dir, 'in.ktx2'), outP = join(dir, 'out.png')
41
+ writeFileSync(inK, img)
42
+ const p = spawnSync(ktxTool, [ 'extract', '--transcode', 'rgba8', inK, outP ], { encoding: 'utf8' })
43
+ if (p.status === 0 && existsSync(outP)) out = readFileSync(outP)
44
+ } finally { rmSync(dir, { recursive: true, force: true }) }
45
+ }
46
+ cache.set(tex, out)
47
+ return out
48
+ }
49
+
50
+ /** A texture as RGBA at exactly w×h (tiled `ru`×`rv` times), or null when it cannot be read. */
51
+ export async function texels(tex: Texture, w: number, h: number, ru: number, rv: number, ktxTool: string | null, cache: Map<Texture, Buffer | null>): Promise<Uint8Array | null> {
52
+ const bytes = await decodable(tex, ktxTool, cache)
53
+ if (!bytes) return null
54
+ const cw = Math.max(1, Math.round(w / ru)), ch = Math.max(1, Math.round(h / rv))
55
+ const cell = new Uint8Array(await sharp(bytes).resize({ width: cw, height: ch, fit: 'fill', kernel: 'lanczos3' }).ensureAlpha().raw().toBuffer())
56
+ if (ru === 1 && rv === 1 && cw === w && ch === h) return cell
57
+ const out = new Uint8Array(w * h * 4)
58
+ for (let y = 0; y < h; y++) { const sy = y % ch; for (let x = 0; x < w; x++) { const sx = x % cw; const si = (sy * cw + sx) * 4, di = (y * w + x) * 4; out[di] = cell[si]; out[di + 1] = cell[si + 1]; out[di + 2] = cell[si + 2]; out[di + 3] = cell[si + 3] } }
59
+ return out
60
+ }
@@ -0,0 +1,77 @@
1
+ // The merge pass's vocabulary: options, the plan (what WOULD be merged — the doctor reports it), the result.
2
+
3
+ import type { Material, Mesh, Node, Primitive, Skin, Texture } from '@gltf-transform/core'
4
+
5
+ export interface MergeOptions {
6
+ /** Merge every mergeable primitive into one per material class + atlas the textures (see the header). */
7
+ merge?: boolean
8
+ /** Largest atlas edge in texels (default 2048); tiles scale down uniformly to fit. */
9
+ atlasSize?: number
10
+ /** Edge-replication padding around every tile, in texels (default 8). */
11
+ atlasPad?: number
12
+ /** Node names (regex) to leave out of the merge — a part the game hides or swaps. */
13
+ mergeExclude?: string
14
+ /** Scale every tile by the same factor to fit (default: by surface area — equal texel density). */
15
+ atlasUniform?: boolean
16
+ }
17
+
18
+ export const DEFAULT_ATLAS_SIZE = 2048
19
+ export const DEFAULT_ATLAS_PAD = 8
20
+
21
+ export type Mat4 = number[]
22
+ export type Kind = 'skinned' | 'rigid' | 'static'
23
+ export type MapKind = 'base' | 'normal' | 'orm' | 'emissive'
24
+
25
+ export interface Source { node: Node; mesh: Mesh; prim: Primitive; kind: Kind; skin: Skin | null; joint: Node | null; name: string; material: Material | null; triangles: number }
26
+ export interface MergeSkipped { name: string; reason: string }
27
+
28
+ export interface Tile {
29
+ key: string
30
+ materials: Material[]
31
+ /** source texel size (the material's largest map, × the UV repeat) */
32
+ srcW: number; srcH: number
33
+ /** UV repeat and origin: the tile covers [u0, u0 + ru) × [v0, v0 + rv) of the material's UV space */
34
+ u0: number; v0: number; ru: number; rv: number
35
+ /** surface on the model (m², bind / world space) and the fraction of the tile's UV square the triangles cover */
36
+ area: number; uvArea: number
37
+ /** placed size and position (inner rect, without the padding) at the chosen scale */
38
+ w: number; h: number; x: number; y: number
39
+ /** tile edge / source edge */
40
+ scale: number
41
+ }
42
+ export interface AtlasLayout { size: number; /** smallest tile scale */ scale: number; /** largest tile scale */ scaleMax: number; byArea: boolean; pad: number; maps: MapKind[]; tiles: Tile[]; hasAlpha: boolean }
43
+
44
+ export interface MergeGroupPlan {
45
+ label: string
46
+ family: 'skinned' | 'static'
47
+ sources: Source[]
48
+ materials: Material[]
49
+ /** null = the group keeps its (single) material — geometry-only merge */
50
+ atlas: AtlasLayout | null
51
+ /** the class the merged material inherits */
52
+ alphaMode: 'OPAQUE' | 'MASK' | 'BLEND'; alphaCutoff: number; doubleSided: boolean; unlit: boolean
53
+ joints: number
54
+ note?: string
55
+ }
56
+ export interface MergePlan {
57
+ enabled: boolean
58
+ atlasSize: number
59
+ pad: number
60
+ groups: MergeGroupPlan[]
61
+ skipped: MergeSkipped[]
62
+ primitivesBefore: number
63
+ materialsBefore: number
64
+ /** primitives that stay (skipped) + one per group */
65
+ primitivesAfter: number
66
+ /** joints of the largest merged skin (the bone cap applies to it) */
67
+ joints: number
68
+ ktxTool: string | null
69
+ }
70
+ export interface MergeResult {
71
+ groups: { label: string; primitives: number; materials: number; vertices: number; triangles: number; atlas: string | null; joints: number }[]
72
+ skipped: MergeSkipped[]
73
+ /** the atlas textures created (for the caller's texture passes) */
74
+ textures: Texture[]
75
+ primitivesBefore: number
76
+ primitivesAfter: number
77
+ }
@@ -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
+ }