lecodes-assets 0.2.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +79 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +108 -0
  25. package/src/commands/shared.ts +140 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,94 @@
1
+ // Atlas layout: one tile per distinct material look, shelf-packed into a power-of-two square, scaled
2
+ // down BY SURFACE AREA (equal texel density) when the cap is hit.
3
+
4
+ import type { Material, Texture } from '@gltf-transform/core'
5
+ import type { MatInfo } from './classify'
6
+ import type { AtlasLayout, MapKind, Tile } from './types'
7
+
8
+ function shelfPack(items: { w: number; h: number }[], S: number): { x: number; y: number }[] | null {
9
+ const order = items.map((_, i) => i).sort((a, b) => items[b].h - items[a].h || items[b].w - items[a].w)
10
+ const pos: { x: number; y: number }[] = new Array(items.length)
11
+ let x = 0, y = 0, rowH = 0
12
+ for (const i of order) {
13
+ const { w, h } = items[i]
14
+ if (w > S || h > S) return null
15
+ if (x + w > S) { y += rowH; x = 0; rowH = 0 }
16
+ if (y + h > S) return null
17
+ pos[i] = { x, y }; x += w; rowH = Math.max(rowH, h)
18
+ }
19
+ return pos
20
+ }
21
+
22
+ /** Tile scales for a texel-density budget `D` (texels² per m² of surface): the tile's used texels
23
+ * (edge² × UV coverage) per square metre of the material's surface reach D, capped at the native map. */
24
+ const scalesForDensity = (tiles: Tile[], D: number): number[] => tiles.map((t) => {
25
+ const uv = Math.max(0.02, t.uvArea), area = Math.max(1e-6, t.area)
26
+ return Math.min(1, Math.sqrt(D * area / uv / (t.srcW * t.srcH)))
27
+ })
28
+
29
+ function layoutTiles(tiles: Tile[], atlasSize: number, pad: number, uniform: boolean): { size: number; scale: number; scaleMax: number } | null {
30
+ const place = (S: number, scales: number[]): boolean => {
31
+ const items = tiles.map((t, i) => ({ w: Math.max(4, Math.round(t.srcW * scales[i])) + 2 * pad, h: Math.max(4, Math.round(t.srcH * scales[i])) + 2 * pad }))
32
+ const pos = shelfPack(items, S)
33
+ if (!pos) return false
34
+ tiles.forEach((t, i) => { t.w = items[i].w - 2 * pad; t.h = items[i].h - 2 * pad; t.x = pos[i].x + pad; t.y = pos[i].y + pad; t.scale = t.w / t.srcW })
35
+ return true
36
+ }
37
+ const ones = tiles.map(() => 1)
38
+ const done = (S: number) => ({ size: S, scale: Math.min(...tiles.map((t) => t.scale)), scaleMax: Math.max(...tiles.map((t) => t.scale)) })
39
+ // the smallest power-of-two square that holds everything at full resolution …
40
+ for (let S = 64; S <= atlasSize; S *= 2) if (place(S, ones)) return done(S)
41
+ if (place(atlasSize, ones)) return done(atlasSize)
42
+ // … else the cap, with the tiles scaled down as little as possible: one factor for all, or a texel-density
43
+ // budget that gives every part the same texels per metre (the density at which the densest part is native
44
+ // is the upper bound — above it nothing changes)
45
+ const scalesAt = uniform ? (x: number) => tiles.map(() => x) : (x: number) => scalesForDensity(tiles, x)
46
+ let lo = 0, hi = uniform ? 1 : Math.max(...tiles.map((t) => t.srcW * t.srcH * Math.max(0.02, t.uvArea) / Math.max(1e-6, t.area)))
47
+ for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2; if (place(atlasSize, scalesAt(mid))) lo = mid; else hi = mid }
48
+ if (lo === 0 || !place(atlasSize, scalesAt(lo))) return null
49
+ return done(atlasSize)
50
+ }
51
+
52
+ function tileKey(info: MatInfo, texId: (t: Texture) => string): string {
53
+ const m = info.material
54
+ const id = (t?: Texture) => (t ? texId(t) : '-')
55
+ return [
56
+ id(info.maps.base), m.getBaseColorFactor().map((v) => v.toFixed(4)).join(','),
57
+ id(info.maps.normal), m.getNormalScale().toFixed(3),
58
+ id(info.maps.mr), id(info.maps.occ), m.getRoughnessFactor().toFixed(3), m.getMetallicFactor().toFixed(3), m.getOcclusionStrength().toFixed(3),
59
+ id(info.maps.emissive), m.getEmissiveFactor().map((v) => v.toFixed(4)).join(','),
60
+ info.uv ? `${info.uv.u0},${info.uv.v0},${info.uv.ru},${info.uv.rv}` : '-',
61
+ ].join('|')
62
+ }
63
+
64
+ export function planAtlas(infos: MatInfo[], atlasSize: number, pad: number, uniform: boolean, texId: (t: Texture) => string): { layout: AtlasLayout | null; reason?: string } {
65
+ const anyTex = (k: keyof MatInfo['maps']) => infos.some((i) => i.maps[k])
66
+ const differ = (f: (m: Material) => number[]) => { const first = f(infos[0].material); return infos.some((i) => f(i.material).some((v, k) => Math.abs(v - first[k]) > 1e-4)) }
67
+ const maps: MapKind[] = []
68
+ if (anyTex('base') || differ((m) => m.getBaseColorFactor())) maps.push('base')
69
+ if (anyTex('normal')) maps.push('normal')
70
+ if (anyTex('mr') || anyTex('occ') || differ((m) => [ m.getRoughnessFactor(), m.getMetallicFactor() ])) maps.push('orm')
71
+ if (anyTex('emissive') || infos.some((i) => i.material.getEmissiveFactor().some((v) => v > 0))) maps.push('emissive')
72
+ const byKey = new Map<string, Tile>()
73
+ for (const info of infos) {
74
+ const key = tileKey(info, texId)
75
+ let t = byKey.get(key)
76
+ if (!t) {
77
+ const dims = Object.values(info.sizes)
78
+ const uv = info.uv ?? { u0: 0, v0: 0, ru: 1, rv: 1 }
79
+ const maxW = dims.length ? Math.max(...dims.map((d) => d[0])) : 8
80
+ const maxH = dims.length ? Math.max(...dims.map((d) => d[1])) : 8
81
+ const cap = atlasSize - 2 * pad
82
+ t = { key, materials: [], srcW: Math.min(cap, maxW * uv.ru), srcH: Math.min(cap, maxH * uv.rv), ...uv, area: 0, uvArea: 0, w: 0, h: 0, x: 0, y: 0, scale: 1 }
83
+ byKey.set(key, t)
84
+ }
85
+ t.materials.push(info.material)
86
+ t.area += info.area
87
+ t.uvArea = Math.min(1, t.uvArea + info.uvArea)
88
+ }
89
+ const tiles = [ ...byKey.values() ]
90
+ const fit = layoutTiles(tiles, atlasSize, pad, uniform)
91
+ if (!fit) return { layout: null, reason: `${tiles.length} tiles do not fit a ${atlasSize}² atlas (raise --atlas-size or lower --atlas-pad)` }
92
+ const hasAlpha = infos[0].material.getAlphaMode() !== 'OPAQUE'
93
+ return { layout: { size: fit.size, scale: fit.scale, scaleMax: fit.scaleMax, byArea: !uniform, pad, maps, tiles, hasAlpha } }
94
+ }
@@ -0,0 +1,114 @@
1
+ // Atlas pixels: every tile rendered with its material's FACTORS baked in, blitted with edge
2
+ // replication, one PNG per map kind, and the merged material that reads them.
3
+
4
+ import type { Document, Material, Texture } from '@gltf-transform/core'
5
+ import { KHRMaterialsUnlit } from '@gltf-transform/extensions'
6
+ import sharp from 'sharp'
7
+ import { texels } from './textures'
8
+ import type { MapKind, MergeGroupPlan, Tile } from './types'
9
+
10
+ const SRGB_TO_LINEAR = new Float32Array(256).map((_, i) => { const c = i / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) })
11
+ const linearToSrgb = (v: number): number => { const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return Math.max(0, Math.min(255, Math.round(c * 255))) }
12
+ const clamp8 = (v: number): number => Math.max(0, Math.min(255, Math.round(v)))
13
+
14
+ /** The tile's pixels for one map kind, factors baked in. */
15
+ async function tilePixels(kind: MapKind, m: Material, t: Tile, ktxTool: string | null, cache: Map<Texture, Buffer | null>): Promise<Uint8Array> {
16
+ const n = t.w * t.h
17
+ const out = new Uint8Array(n * 4)
18
+ const fetch = (tex: Texture | null) => (tex ? texels(tex, t.w, t.h, t.ru, t.rv, ktxTool, cache) : Promise.resolve(null))
19
+ if (kind === 'base') {
20
+ const f = m.getBaseColorFactor()
21
+ const src = await fetch(m.getBaseColorTexture())
22
+ for (let i = 0; i < n; i++) {
23
+ const o = i * 4
24
+ if (src) {
25
+ for (let c = 0; c < 3; c++) out[o + c] = f[c] === 1 ? src[o + c] : linearToSrgb(SRGB_TO_LINEAR[src[o + c]] * f[c])
26
+ out[o + 3] = clamp8(src[o + 3] * f[3])
27
+ } else { out[o] = linearToSrgb(f[0]); out[o + 1] = linearToSrgb(f[1]); out[o + 2] = linearToSrgb(f[2]); out[o + 3] = clamp8(f[3] * 255) }
28
+ }
29
+ } else if (kind === 'normal') {
30
+ const src = await fetch(m.getNormalTexture())
31
+ const s = m.getNormalScale()
32
+ for (let i = 0; i < n; i++) {
33
+ const o = i * 4
34
+ if (src) {
35
+ if (s === 1) { out[o] = src[o]; out[o + 1] = src[o + 1]; out[o + 2] = src[o + 2] }
36
+ else {
37
+ const x = (src[o] / 255 * 2 - 1) * s, y = (src[o + 1] / 255 * 2 - 1) * s, z = src[o + 2] / 255 * 2 - 1
38
+ const l = Math.hypot(x, y, z) || 1
39
+ out[o] = clamp8((x / l * 0.5 + 0.5) * 255); out[o + 1] = clamp8((y / l * 0.5 + 0.5) * 255); out[o + 2] = clamp8((z / l * 0.5 + 0.5) * 255)
40
+ }
41
+ } else { out[o] = 128; out[o + 1] = 128; out[o + 2] = 255 }
42
+ out[o + 3] = 255
43
+ }
44
+ } else if (kind === 'orm') {
45
+ const mr = await fetch(m.getMetallicRoughnessTexture())
46
+ const occTex = m.getOcclusionTexture()
47
+ const occ = occTex === m.getMetallicRoughnessTexture() ? mr : await fetch(occTex)
48
+ const rough = m.getRoughnessFactor(), metal = m.getMetallicFactor(), strength = m.getOcclusionStrength()
49
+ for (let i = 0; i < n; i++) {
50
+ const o = i * 4
51
+ out[o] = occ ? clamp8(255 * (1 + strength * (occ[o] / 255 - 1))) : 255
52
+ out[o + 1] = mr ? clamp8(mr[o + 1] * rough) : clamp8(rough * 255)
53
+ out[o + 2] = mr ? clamp8(mr[o + 2] * metal) : clamp8(metal * 255)
54
+ out[o + 3] = 255
55
+ }
56
+ } else {
57
+ const f = m.getEmissiveFactor()
58
+ const src = await fetch(m.getEmissiveTexture())
59
+ for (let i = 0; i < n; i++) {
60
+ const o = i * 4
61
+ if (src) for (let c = 0; c < 3; c++) out[o + c] = f[c] === 1 ? src[o + c] : linearToSrgb(SRGB_TO_LINEAR[src[o + c]] * f[c])
62
+ else { out[o] = linearToSrgb(f[0]); out[o + 1] = linearToSrgb(f[1]); out[o + 2] = linearToSrgb(f[2]) }
63
+ out[o + 3] = 255
64
+ }
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** Blit a tile into the atlas with `pad` texels of edge replication around it. */
70
+ function blit(atlas: Uint8Array, S: number, t: Tile, pad: number, px: Uint8Array): void {
71
+ for (let y = t.y - pad; y < t.y + t.h + pad; y++) {
72
+ if (y < 0 || y >= S) continue
73
+ const sy = Math.max(0, Math.min(t.h - 1, y - t.y))
74
+ for (let x = t.x - pad; x < t.x + t.w + pad; x++) {
75
+ if (x < 0 || x >= S) continue
76
+ const sx = Math.max(0, Math.min(t.w - 1, x - t.x))
77
+ const si = (sy * t.w + sx) * 4, di = (y * S + x) * 4
78
+ atlas[di] = px[si]; atlas[di + 1] = px[si + 1]; atlas[di + 2] = px[si + 2]; atlas[di + 3] = px[si + 3]
79
+ }
80
+ }
81
+ }
82
+
83
+ export async function buildAtlas(doc: Document, g: MergeGroupPlan, ktxTool: string | null): Promise<{ material: Material; textures: Texture[]; tileOf: Map<Material, Tile> }> {
84
+ const a = g.atlas!
85
+ const S = a.size
86
+ const cache = new Map<Texture, Buffer | null>()
87
+ const tileOf = new Map<Material, Tile>()
88
+ for (const t of a.tiles) for (const m of t.materials) tileOf.set(m, t)
89
+ const textures: Texture[] = []
90
+ const material = doc.createMaterial(g.label).setAlphaMode(g.alphaMode).setAlphaCutoff(g.alphaCutoff).setDoubleSided(g.doubleSided)
91
+ if (g.unlit) material.setExtension('KHR_materials_unlit', doc.createExtension(KHRMaterialsUnlit).createUnlit())
92
+ for (const kind of a.maps) {
93
+ const atlas = new Uint8Array(S * S * 4)
94
+ // unused space: a neutral value per kind (mips of the border sample it)
95
+ const fill = kind === 'normal' ? [ 128, 128, 255, 255 ] : kind === 'orm' ? [ 255, 255, 0, 255 ] : kind === 'emissive' ? [ 0, 0, 0, 255 ] : [ 0, 0, 0, a.hasAlpha ? 0 : 255 ]
96
+ for (let i = 0; i < S * S; i++) atlas.set(fill, i * 4)
97
+ for (const t of a.tiles) blit(atlas, S, t, a.pad, await tilePixels(kind, t.materials[0], t, ktxTool, cache))
98
+ let pipe = sharp(Buffer.from(atlas.buffer, atlas.byteOffset, atlas.byteLength), { raw: { width: S, height: S, channels: 4 } })
99
+ if (kind !== 'base' || !a.hasAlpha) pipe = pipe.removeAlpha()
100
+ const png = new Uint8Array(await pipe.png({ compressionLevel: 6 }).toBuffer())
101
+ const tex = doc.createTexture(`${g.label}_${kind}`).setImage(png).setMimeType('image/png')
102
+ textures.push(tex)
103
+ if (kind === 'base') material.setBaseColorTexture(tex).setBaseColorFactor([ 1, 1, 1, 1 ])
104
+ else if (kind === 'normal') material.setNormalTexture(tex).setNormalScale(1)
105
+ else if (kind === 'orm') material.setMetallicRoughnessTexture(tex).setOcclusionTexture(tex).setRoughnessFactor(1).setMetallicFactor(1).setOcclusionStrength(1)
106
+ else material.setEmissiveTexture(tex).setEmissiveFactor([ 1, 1, 1 ])
107
+ }
108
+ // maps every material agreed on stay factors
109
+ const m0 = g.materials[0]
110
+ if (!a.maps.includes('base')) material.setBaseColorFactor(m0.getBaseColorFactor())
111
+ if (!a.maps.includes('orm')) material.setRoughnessFactor(m0.getRoughnessFactor()).setMetallicFactor(m0.getMetallicFactor())
112
+ if (!a.maps.includes('emissive')) material.setEmissiveFactor([ 0, 0, 0 ])
113
+ return { material, textures, tileOf }
114
+ }
@@ -0,0 +1,59 @@
1
+ // ONE bind space for several skins. Modular parts are often skinned separately, each with its own
2
+ // inverse bind matrices; two skins can be unified when, on every joint they share, their matrices
3
+ // differ by the SAME rigid transform D (IBM_part = IBM_canon · D) — the part was simply bound in
4
+ // another space, and its vertices move into the canonical one by D. Different transforms per joint
5
+ // mean the parts were bound in different POSES, and no single transform reconciles them.
6
+
7
+ import type { Node, Skin } from '@gltf-transform/core'
8
+ import { invert, isIdentity, mul, same } from './matrix'
9
+ import type { Mat4 } from './types'
10
+
11
+ const ibmAt = (skin: Skin, i: number): Mat4 => { const m = new Array<number>(16); skin.getInverseBindMatrices()!.getElement(i, m); return m }
12
+
13
+ /** Skins by joint count, largest first: the one with most joints defines the canonical space. */
14
+ export const bySize = (skins: Iterable<Skin | null>): Skin[] =>
15
+ [ ...new Set([ ...skins ].filter((s): s is Skin => !!s)) ].sort((a, b) => b.listJoints().length - a.listJoints().length)
16
+
17
+ /**
18
+ * The canonical inverse bind matrix per joint over `skins` (already ordered, see `bySize`), and the
19
+ * skins that cannot join: no inverse bind matrices, or joints that disagree by different transforms.
20
+ * `verify: false` is for skins the plan already vetted: D is taken from the first shared joint.
21
+ */
22
+ export function canonicalBinds(skins: Skin[], verify = true): { ibm: Map<Node, Mat4>; bad: Set<Skin> } {
23
+ const ibm = new Map<Node, Mat4>()
24
+ const bad = new Set<Skin>()
25
+ for (const skin of skins) {
26
+ if (!skin.getInverseBindMatrices()) { bad.add(skin); continue }
27
+ const js = skin.listJoints()
28
+ let D: Mat4 | null = null, ok = true
29
+ const own: [Node, Mat4][] = []
30
+ for (let i = 0; i < js.length && ok; i++) {
31
+ const m = ibmAt(skin, i)
32
+ const c = ibm.get(js[i])
33
+ if (!c) { own.push([ js[i], m ]); continue }
34
+ if (D && !verify) continue
35
+ const inv = invert(c)
36
+ if (!inv) { ok = false; break }
37
+ const d = mul(inv, m)
38
+ if (!D) D = d
39
+ else if (verify && !same(D, d, 1e-3)) ok = false
40
+ }
41
+ if (!ok) { bad.add(skin); continue }
42
+ // this skin's new joints enter the canon in the canonical space: IBM_canon = IBM_skin · D⁻¹
43
+ const Dinv = D ? invert(D) : null
44
+ for (const [ j, m ] of own) ibm.set(j, Dinv && !isIdentity(Dinv) ? mul(m, Dinv) : m)
45
+ }
46
+ return { ibm, bad }
47
+ }
48
+
49
+ /** The transform that moves a part skinned on `skin` into the canonical space (identity when it already is). */
50
+ export function intoCanon(skin: Skin, canon: Map<Node, Mat4>): Mat4 | null {
51
+ const js = skin.listJoints()
52
+ for (let i = 0; i < js.length; i++) {
53
+ const c = canon.get(js[i])
54
+ if (!c) continue
55
+ const D = mul(invert(c)!, ibmAt(skin, i))
56
+ return isIdentity(D, 1e-6) ? null : D
57
+ }
58
+ return null
59
+ }
@@ -0,0 +1,136 @@
1
+ // Which primitives can merge (and as what), and what each material brings to an atlas.
2
+
3
+ import type { Document, Material, Node, Primitive, Texture, TextureInfo } from '@gltf-transform/core'
4
+ import { readAttr, readIndices } from './accessors'
5
+ import { I4, point, worldMatrix } from './matrix'
6
+ import { imageSize } from './textures'
7
+ import type { Kind, Mat4, MergeSkipped, Source } from './types'
8
+
9
+ export interface MatInfo {
10
+ material: Material
11
+ classKey: string
12
+ /** why the material cannot share an atlas (own group) */
13
+ own: string | null
14
+ maps: Partial<Record<'base' | 'normal' | 'mr' | 'occ' | 'emissive', Texture>>
15
+ sizes: Partial<Record<'base' | 'normal' | 'mr' | 'occ' | 'emissive', [number, number]>>
16
+ /** UV range over every candidate primitive using it */
17
+ uv: { u0: number; v0: number; ru: number; rv: number } | null
18
+ /** surface on the model and UV coverage (fraction of the [u0,u0+ru)×[v0,v0+rv) rectangle) */
19
+ area: number; uvArea: number
20
+ }
21
+
22
+ const UV_EPS = 2e-3
23
+ const MAX_REPEAT = 4
24
+
25
+ export const classSuffix = (m: Material): string => {
26
+ const parts: string[] = []
27
+ if (m.getAlphaMode() === 'BLEND') parts.push('blend')
28
+ if (m.getAlphaMode() === 'MASK') parts.push('mask')
29
+ if (m.getDoubleSided()) parts.push('2side')
30
+ if (m.getExtension('KHR_materials_unlit')) parts.push('unlit')
31
+ return parts.join('.')
32
+ }
33
+
34
+ function texInfos(m: Material): TextureInfo[] {
35
+ return [ m.getBaseColorTextureInfo(), m.getNormalTextureInfo(), m.getMetallicRoughnessTextureInfo(), m.getOcclusionTextureInfo(), m.getEmissiveTextureInfo() ].filter((t): t is TextureInfo => !!t)
36
+ }
37
+
38
+ export async function materialInfo(m: Material, ownId: number, uvOf: (m: Material) => number[][], measure: (m: Material) => { area: number; uvArea: number }): Promise<MatInfo> {
39
+ const maps: MatInfo['maps'] = {}
40
+ if (m.getBaseColorTexture()) maps.base = m.getBaseColorTexture()!
41
+ if (m.getNormalTexture()) maps.normal = m.getNormalTexture()!
42
+ if (m.getMetallicRoughnessTexture()) maps.mr = m.getMetallicRoughnessTexture()!
43
+ if (m.getOcclusionTexture()) maps.occ = m.getOcclusionTexture()!
44
+ if (m.getEmissiveTexture()) maps.emissive = m.getEmissiveTexture()!
45
+ const sizes: MatInfo['sizes'] = {}
46
+ for (const k of Object.keys(maps) as (keyof MatInfo['maps'])[]) { const s = await imageSize(maps[k]!); if (s) sizes[k] = s }
47
+ let own: string | null = null
48
+ const exts = m.listExtensions().map((e) => e.extensionName).filter((n) => n !== 'KHR_materials_unlit')
49
+ if (exts.length) own = `material extension ${exts.join(', ')}`
50
+ else if (texInfos(m).some((i) => i.getExtension('KHR_texture_transform'))) own = 'KHR_texture_transform'
51
+ else if ((Object.keys(maps) as (keyof MatInfo['maps'])[]).some((k) => !sizes[k])) own = 'a texture could not be read'
52
+ // UV range: every primitive of the material, over all its texture-using channels (one UV set: TEXCOORD_0)
53
+ let uv: MatInfo['uv'] = null
54
+ if (Object.keys(maps).length) {
55
+ let umin = Infinity, umax = -Infinity, vmin = Infinity, vmax = -Infinity
56
+ for (const arr of uvOf(m)) for (let i = 0; i < arr.length; i += 2) { umin = Math.min(umin, arr[i]); umax = Math.max(umax, arr[i]); vmin = Math.min(vmin, arr[i + 1]); vmax = Math.max(vmax, arr[i + 1]) }
57
+ if (umin === Infinity) uv = { u0: 0, v0: 0, ru: 1, rv: 1 }
58
+ else {
59
+ const inUnit = umin >= -UV_EPS && umax <= 1 + UV_EPS && vmin >= -UV_EPS && vmax <= 1 + UV_EPS
60
+ if (inUnit) uv = { u0: 0, v0: 0, ru: 1, rv: 1 }
61
+ else {
62
+ const u0 = Math.floor(umin + UV_EPS), v0 = Math.floor(vmin + UV_EPS)
63
+ const ru = Math.ceil(umax - UV_EPS) - u0, rv = Math.ceil(vmax - UV_EPS) - v0
64
+ if (ru <= MAX_REPEAT && rv <= MAX_REPEAT && ru >= 1 && rv >= 1) uv = { u0, v0, ru, rv }
65
+ else if (!own) own = `UVs tile the texture ${Math.max(ru, rv)}×`
66
+ }
67
+ }
68
+ }
69
+ const classKey = own ? `own:${ownId}` : `${m.getAlphaMode()}|${m.getAlphaMode() === 'MASK' ? m.getAlphaCutoff().toFixed(3) : ''}|${m.getDoubleSided() ? 1 : 0}|${m.getExtension('KHR_materials_unlit') ? 1 : 0}`
70
+ const { area, uvArea } = measure(m)
71
+ const span = uv ? uv.ru * uv.rv : 1
72
+ return { material: m, classKey, own, maps, sizes, uv, area, uvArea: uvArea / span }
73
+ }
74
+
75
+ const primTris = (p: Primitive): number => Math.floor((p.getIndices()?.getCount() ?? p.getAttribute('POSITION')?.getCount() ?? 0) / 3)
76
+
77
+ /** Every mergeable primitive with its kind, and the reasons for the rest. */
78
+ export function classify(doc: Document, exclude: RegExp | null): { sources: Source[]; skipped: MergeSkipped[]; joints: Set<Node>; animated: Set<Node> } {
79
+ const root = doc.getRoot()
80
+ const joints = new Set<Node>(root.listSkins().flatMap((s) => s.listJoints()))
81
+ const animated = new Set<Node>()
82
+ for (const a of root.listAnimations()) for (const c of a.listChannels()) { const n = c.getTargetNode(); if (n) animated.add(n) }
83
+ const sources: Source[] = []
84
+ const skipped: MergeSkipped[] = []
85
+ const seen = new Set<Node>()
86
+ for (const scene of root.listScenes()) scene.traverse((node) => {
87
+ if (seen.has(node)) return
88
+ seen.add(node)
89
+ const mesh = node.getMesh()
90
+ if (!mesh) return
91
+ const name = node.getName() || mesh.getName() || 'mesh'
92
+ if (exclude && (exclude.test(node.getName()) || exclude.test(mesh.getName()))) { skipped.push({ name, reason: 'excluded' }); return }
93
+ // the chain up to the nearest joint (a rigid part) or the root (a static part)
94
+ let kind: Kind = node.getSkin() ? 'skinned' : 'static'
95
+ let joint: Node | null = null
96
+ if (kind !== 'skinned') {
97
+ for (let n: Node | null = node; n; n = n.getParentNode()) {
98
+ if (joints.has(n)) { joint = n; break }
99
+ if (animated.has(n)) { skipped.push({ name, reason: `animated node${n === node ? '' : ` (${n.getName()})`}` }); return }
100
+ }
101
+ if (joint) kind = 'rigid'
102
+ }
103
+ for (const prim of mesh.listPrimitives()) {
104
+ const pname = mesh.listPrimitives().length > 1 ? `${name}[${mesh.listPrimitives().indexOf(prim)}]` : name
105
+ if (prim.getMode() !== 4) { skipped.push({ name: pname, reason: 'not triangles' }); continue }
106
+ if (!prim.getAttribute('POSITION')) { skipped.push({ name: pname, reason: 'no positions' }); continue }
107
+ if (prim.listTargets().length) { skipped.push({ name: pname, reason: 'morph targets' }); continue }
108
+ if (kind === 'skinned' && (!prim.getAttribute('JOINTS_0') || !prim.getAttribute('WEIGHTS_0'))) { skipped.push({ name: pname, reason: 'skinned node without joints/weights' }); continue }
109
+ sources.push({ node, mesh, prim, kind, skin: node.getSkin(), joint, name: pname, material: prim.getMaterial(), triangles: primTris(prim) })
110
+ }
111
+ })
112
+ return { sources, skipped, joints, animated }
113
+ }
114
+
115
+ /** Surface area on the model (bind space for skinned parts, the rest pose for rigid / static ones) and
116
+ * how much of the UV square the triangles cover — the texel-density budget of the atlas packer. */
117
+ export function measureSurface(sources: Source[], worldCache: Map<Node, Mat4>): { area: number; uvArea: number } {
118
+ let area = 0, uvArea = 0
119
+ for (const s of sources) {
120
+ const M = s.kind === 'skinned' ? I4 : worldMatrix(s.node, worldCache)
121
+ const pos = readAttr(s.prim.getAttribute('POSITION')!).data
122
+ const uvAcc = s.prim.getAttribute('TEXCOORD_0')
123
+ const uv = uvAcc ? readAttr(uvAcc).data : null
124
+ const idx = readIndices(s.prim)
125
+ for (let t = 0; t + 2 < idx.length; t += 3) {
126
+ const [ a, b, c ] = [ idx[t], idx[t + 1], idx[t + 2] ].map((i) => point(M, [ pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2] ]))
127
+ const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2], vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2]
128
+ area += 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx)
129
+ if (uv) {
130
+ const i0 = idx[t] * 2, i1 = idx[t + 1] * 2, i2 = idx[t + 2] * 2
131
+ uvArea += 0.5 * Math.abs((uv[i1] - uv[i0]) * (uv[i2 + 1] - uv[i0 + 1]) - (uv[i2] - uv[i0]) * (uv[i1 + 1] - uv[i0 + 1]))
132
+ }
133
+ }
134
+ }
135
+ return { area, uvArea }
136
+ }
@@ -0,0 +1,167 @@
1
+ // The merged primitive: every source's vertices moved into one space (world for static parts, the
2
+ // canonical bind space for skinned / rigid ones), UVs remapped into the atlas, one union skin.
3
+
4
+ import type { Accessor, Document, Material, Node, Primitive, Skin } from '@gltf-transform/core'
5
+ import { flatNormals, readAttr, readIndices } from './accessors'
6
+ import { bySize, canonicalBinds, intoCanon } from './bindSpace'
7
+ import { I4, det3, dir, invert, localMatrix, mul, normalMatrix, point, worldMatrix } from './matrix'
8
+ import type { AtlasLayout, Mat4, MergeGroupPlan, Source, Tile } from './types'
9
+
10
+ interface UnionSkin { skin: Skin; index: Map<Node, number>; ibm: Map<Node, Mat4>; bindSpace: Mat4 }
11
+
12
+ /** One skin over every joint the group touches, inverse bind matrices in one canonical space. */
13
+ function unionSkin(doc: Document, g: MergeGroupPlan): UnionSkin {
14
+ const root = doc.getRoot()
15
+ const skins = bySize(g.sources.map((s) => s.skin))
16
+ const { ibm } = canonicalBinds(skins, false) // the plan kept only skins that can be unified
17
+ const cache = new Map<Node, Mat4>()
18
+ const lead = skins[0]?.listJoints()[0]
19
+ const bindSpace: Mat4 = lead ? mul(worldMatrix(lead, cache), ibm.get(lead)!) : I4
20
+ // a rigid part's joint that no skin of the group lists: any skin of the file that has it, else the rest pose
21
+ for (const s of g.sources) {
22
+ if (!s.joint || ibm.has(s.joint)) continue
23
+ let found: Mat4 | null = null
24
+ for (const skin of root.listSkins()) {
25
+ const i = skin.listJoints().indexOf(s.joint)
26
+ const acc = skin.getInverseBindMatrices()
27
+ if (i >= 0 && acc) { const m = new Array<number>(16); acc.getElement(i, m); found = m; break }
28
+ }
29
+ ibm.set(s.joint, found ?? mul(invert(worldMatrix(s.joint, cache)) ?? I4, bindSpace))
30
+ }
31
+ // joints in tree order
32
+ const ordered: Node[] = []
33
+ const seen = new Set<Node>()
34
+ for (const scene of root.listScenes()) scene.traverse((n) => { if (ibm.has(n) && !seen.has(n)) { seen.add(n); ordered.push(n) } })
35
+ for (const j of ibm.keys()) if (!seen.has(j)) { seen.add(j); ordered.push(j) }
36
+ const index = new Map<Node, number>(ordered.map((j, i) => [ j, i ]))
37
+ const mats = new Float32Array(ordered.length * 16)
38
+ ordered.forEach((j, i) => mats.set(ibm.get(j)!, i * 16))
39
+ const buffer = root.listBuffers()[0] ?? doc.createBuffer()
40
+ const skin = doc.createSkin(g.label).setInverseBindMatrices(doc.createAccessor(`${g.label}_ibm`).setType('MAT4').setArray(mats).setBuffer(buffer))
41
+ for (const j of ordered) skin.addJoint(j)
42
+ const skel = skins[0]?.getSkeleton()
43
+ if (skel) skin.setSkeleton(skel)
44
+ return { skin, index, ibm, bindSpace }
45
+ }
46
+
47
+ /** Where a source's vertices go: the transform into the merged primitive's space. */
48
+ function sourceTransform(s: Source, u: UnionSkin | null, cache: Map<Node, Mat4>): Mat4 {
49
+ if (s.kind === 'static') return worldMatrix(s.node, cache)
50
+ if (s.kind === 'rigid') {
51
+ // world = J(t) · IBM_j · p' must equal J(t) · L · p (L = the chain from the joint down to the node)
52
+ let L: Mat4 = I4
53
+ const chain: Node[] = []
54
+ for (let n: Node | null = s.node; n && n !== s.joint; n = n.getParentNode()) chain.unshift(n)
55
+ for (const n of chain) L = mul(L, localMatrix(n))
56
+ return mul(invert(u!.ibm.get(s.joint!)!) ?? I4, L)
57
+ }
58
+ // skinned: the part's bind space → the canonical one (D = IBM_canon⁻¹ · IBM_part, same for every shared joint)
59
+ return intoCanon(s.skin!, u!.ibm) ?? I4
60
+ }
61
+
62
+ export function mergeGroup(doc: Document, g: MergeGroupPlan, tileOf: Map<Material, Tile> | null, atlas: AtlasLayout | null, material: Material | null): { node: Node; vertices: number; triangles: number } {
63
+ const root = doc.getRoot()
64
+ const buffer = root.listBuffers()[0] ?? doc.createBuffer()
65
+ const cache = new Map<Node, Mat4>()
66
+ const u = g.family === 'skinned' ? unionSkin(doc, g) : null
67
+ const has = (sem: string) => g.sources.map((s) => !!s.prim.getAttribute(sem))
68
+ const wantTangent = has('TANGENT').every(Boolean)
69
+ const wantUv1 = has('TEXCOORD_1').every(Boolean)
70
+ const wantColor = has('COLOR_0').some(Boolean)
71
+ const wantJ1 = u ? has('JOINTS_1').some(Boolean) : false
72
+ const total = g.sources.reduce((n, s) => n + s.prim.getAttribute('POSITION')!.getCount(), 0)
73
+ const P = new Float32Array(total * 3), N = new Float32Array(total * 3), UV = new Float32Array(total * 2)
74
+ const T = wantTangent ? new Float32Array(total * 4) : null
75
+ const UV1 = wantUv1 ? new Float32Array(total * 2) : null
76
+ const C = wantColor ? new Float32Array(total * 4).fill(1) : null
77
+ const J = u ? new Uint16Array(total * 4) : null, W = u ? new Float32Array(total * 4) : null
78
+ const J1 = wantJ1 ? new Uint16Array(total * 4) : null, W1 = wantJ1 ? new Float32Array(total * 4) : null
79
+ const indices: number[] = []
80
+ let base = 0
81
+ for (const s of g.sources) {
82
+ const prim = s.prim
83
+ const count = prim.getAttribute('POSITION')!.getCount()
84
+ const M = sourceTransform(s, u, cache)
85
+ const n3 = normalMatrix(M)
86
+ const flip = det3(M) < 0
87
+ const pos = readAttr(prim.getAttribute('POSITION')!).data
88
+ const idx = readIndices(prim)
89
+ const nrmAcc = prim.getAttribute('NORMAL')
90
+ const nrm = nrmAcc ? readAttr(nrmAcc).data : flatNormals(pos, idx)
91
+ const uvAcc = prim.getAttribute('TEXCOORD_0')
92
+ const uv = uvAcc ? readAttr(uvAcc).data : null
93
+ const tile = s.material && tileOf ? tileOf.get(s.material) ?? null : null
94
+ const S = atlas?.size ?? 1
95
+ for (let i = 0; i < count; i++) {
96
+ const p = point(M, [ pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2] ])
97
+ P[(base + i) * 3] = p[0]; P[(base + i) * 3 + 1] = p[1]; P[(base + i) * 3 + 2] = p[2]
98
+ const nn = dir(n3, [ nrm[i * 3], nrm[i * 3 + 1], nrm[i * 3 + 2] ])
99
+ N[(base + i) * 3] = nn[0]; N[(base + i) * 3 + 1] = nn[1]; N[(base + i) * 3 + 2] = nn[2]
100
+ let uu = uv ? uv[i * 2] : 0.5, vv = uv ? uv[i * 2 + 1] : 0.5
101
+ if (tile) {
102
+ // into the tile: [u0, u0 + ru) → [x, x + w) of an S-wide atlas (clamped to the tile's own texels)
103
+ const fu = Math.max(0, Math.min(1, (uu - tile.u0) / tile.ru)), fv = Math.max(0, Math.min(1, (vv - tile.v0) / tile.rv))
104
+ uu = (tile.x + fu * tile.w) / S; vv = (tile.y + fv * tile.h) / S
105
+ }
106
+ UV[(base + i) * 2] = uu; UV[(base + i) * 2 + 1] = vv
107
+ }
108
+ if (T) {
109
+ const t = readAttr(prim.getAttribute('TANGENT')!)
110
+ for (let i = 0; i < count; i++) {
111
+ const el = t.size === 4 ? [ t.data[i * 4], t.data[i * 4 + 1], t.data[i * 4 + 2], t.data[i * 4 + 3] ] : [ t.data[i * 3], t.data[i * 3 + 1], t.data[i * 3 + 2], 1 ]
112
+ const d = dir(n3, el)
113
+ T[(base + i) * 4] = d[0]; T[(base + i) * 4 + 1] = d[1]; T[(base + i) * 4 + 2] = d[2]; T[(base + i) * 4 + 3] = flip ? -el[3] : el[3]
114
+ }
115
+ }
116
+ if (UV1) UV1.set(readAttr(prim.getAttribute('TEXCOORD_1')!).data, base * 2)
117
+ if (C) {
118
+ const cAcc = prim.getAttribute('COLOR_0')
119
+ if (cAcc) { const c = readAttr(cAcc); for (let i = 0; i < count; i++) for (let k = 0; k < 4; k++) C[(base + i) * 4 + k] = k < c.size ? c.data[i * c.size + k] : 1 }
120
+ }
121
+ if (J && W) {
122
+ if (s.kind === 'skinned') {
123
+ const remap = s.skin!.listJoints().map((j) => u!.index.get(j) ?? 0)
124
+ const readSet = (n: number, Jo: Uint16Array, Wo: Float32Array) => {
125
+ const ja = readAttr(prim.getAttribute(`JOINTS_${n}`)!).data, wa = readAttr(prim.getAttribute(`WEIGHTS_${n}`)!).data
126
+ for (let i = 0; i < count * 4; i++) { Jo[base * 4 + i] = remap[ja[i]] ?? 0; Wo[base * 4 + i] = wa[i] }
127
+ }
128
+ readSet(0, J, W)
129
+ if (J1 && W1 && prim.getAttribute('JOINTS_1')) readSet(1, J1, W1)
130
+ } else {
131
+ const ji = u!.index.get(s.joint!) ?? 0
132
+ for (let i = 0; i < count; i++) { J[(base + i) * 4] = ji; W[(base + i) * 4] = 1 }
133
+ }
134
+ }
135
+ for (let t = 0; t + 2 < idx.length; t += 3) {
136
+ if (flip) indices.push(base + idx[t], base + idx[t + 2], base + idx[t + 1])
137
+ else indices.push(base + idx[t], base + idx[t + 1], base + idx[t + 2])
138
+ }
139
+ base += count
140
+ }
141
+ const acc = (name: string, type: 'SCALAR' | 'VEC2' | 'VEC3' | 'VEC4', arr: Float32Array | Uint16Array | Uint8Array | Uint32Array) => doc.createAccessor(`${g.label}_${name}`).setType(type).setArray(arr as never).setBuffer(buffer)
142
+ const prim = doc.createPrimitive().setMode(4)
143
+ .setAttribute('POSITION', acc('pos', 'VEC3', P))
144
+ .setAttribute('NORMAL', acc('nrm', 'VEC3', N))
145
+ .setAttribute('TEXCOORD_0', acc('uv', 'VEC2', UV))
146
+ if (T) prim.setAttribute('TANGENT', acc('tan', 'VEC4', T))
147
+ if (UV1) prim.setAttribute('TEXCOORD_1', acc('uv1', 'VEC2', UV1))
148
+ if (C) prim.setAttribute('COLOR_0', acc('col', 'VEC4', C))
149
+ if (J && W) {
150
+ prim.setAttribute('JOINTS_0', acc('j0', 'VEC4', u!.index.size <= 256 ? Uint8Array.from(J) : J)).setAttribute('WEIGHTS_0', acc('w0', 'VEC4', W))
151
+ if (J1 && W1) prim.setAttribute('JOINTS_1', acc('j1', 'VEC4', u!.index.size <= 256 ? Uint8Array.from(J1) : J1)).setAttribute('WEIGHTS_1', acc('w1', 'VEC4', W1))
152
+ }
153
+ prim.setIndices(acc('idx', 'SCALAR', total > 65535 ? Uint32Array.from(indices) : Uint16Array.from(indices)))
154
+ prim.setMaterial(material ?? g.materials[0] ?? null)
155
+ const mesh = doc.createMesh(g.label).addPrimitive(prim)
156
+ const node = doc.createNode(g.label).setMesh(mesh)
157
+ if (u) {
158
+ node.setSkin(u.skin)
159
+ // where the canonical part sat (the spec ignores a skinned node's transform; a host that reads it sees the same)
160
+ const first = g.sources.find((s) => s.kind === 'skinned')?.node
161
+ const parent = first?.getParentNode()
162
+ if (first) node.setTranslation(first.getTranslation()).setRotation(first.getRotation()).setScale(first.getScale())
163
+ if (parent) parent.addChild(node)
164
+ else (root.getDefaultScene() ?? root.listScenes()[0]).addChild(node)
165
+ } else (root.getDefaultScene() ?? root.listScenes()[0]).addChild(node)
166
+ return { node, vertices: total, triangles: indices.length / 3 }
167
+ }