lecodes-assets 0.2.1 → 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 (97) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +17948 -11911
  3. package/dist/index.js +17525 -11479
  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 +67 -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 +103 -0
  25. package/src/commands/shared.ts +121 -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 +286 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/merge/accessors.ts +30 -0
  34. package/src/doctor/merge/atlasLayout.ts +94 -0
  35. package/src/doctor/merge/atlasPixels.ts +114 -0
  36. package/src/doctor/merge/bindSpace.ts +59 -0
  37. package/src/doctor/merge/classify.ts +136 -0
  38. package/src/doctor/merge/geometry.ts +167 -0
  39. package/src/doctor/merge/index.ts +98 -0
  40. package/src/doctor/merge/matrix.ts +60 -0
  41. package/src/doctor/merge/plan.ts +88 -0
  42. package/src/doctor/merge/textures.ts +60 -0
  43. package/src/doctor/merge/types.ts +77 -0
  44. package/src/doctor/transcodeKtx2.ts +179 -28
  45. package/src/doctor/validateGlb.ts +2 -2
  46. package/src/doctor/vertexAo.ts +321 -0
  47. package/src/fbx/read.ts +2 -0
  48. package/src/fbx/toDocument.ts +227 -209
  49. package/src/geometry.ts +222 -0
  50. package/src/index.ts +2 -0
  51. package/src/retarget/heading.ts +238 -171
  52. package/src/retarget/humanoid.ts +572 -561
  53. package/src/retarget/retarget.ts +2375 -2272
  54. package/src/retarget/rig.ts +4 -1
  55. package/src/retarget/writeMarks.ts +134 -132
  56. package/src/sky/cmgen.ts +3 -3
  57. package/src/sky/index.ts +57 -8
  58. package/src/terrainPack.ts +61 -22
  59. package/src/unity/colliderFit.ts +287 -0
  60. package/src/unity/fbxModel.ts +194 -0
  61. package/src/unity/foliage.ts +63 -0
  62. package/src/unity/gameObjects.ts +81 -0
  63. package/src/unity/guid.ts +104 -0
  64. package/src/unity/image.ts +58 -0
  65. package/src/unity/kindGlb.ts +364 -0
  66. package/src/unity/lecodesLevel.ts +381 -0
  67. package/src/unity/levelTerrain.ts +116 -0
  68. package/src/unity/matFile.ts +179 -0
  69. package/src/unity/materialSlots.ts +53 -0
  70. package/src/unity/materialWriter.ts +306 -0
  71. package/src/unity/materials.ts +34 -429
  72. package/src/unity/math.ts +82 -0
  73. package/src/unity/prefabTree.ts +285 -0
  74. package/src/unity/prefabs.ts +10 -36
  75. package/src/unity/propertyPath.ts +43 -0
  76. package/src/unity/scene.ts +325 -304
  77. package/src/unity/serialized.ts +242 -0
  78. package/src/unity/terrain.ts +221 -0
  79. package/src/unity/triBudget.ts +77 -0
  80. package/src/unity/triplanar.ts +122 -0
  81. package/src/unity/unityMesh.ts +151 -0
  82. package/src/unity/unpack.ts +24 -5
  83. package/src/unity/yaml.ts +131 -0
  84. package/src/unreal/index.ts +6 -5
  85. package/src/unreal/materialGraph.ts +224 -0
  86. package/src/unreal/materials.ts +117 -9
  87. package/src/unreal/package.ts +30 -12
  88. package/src/unreal/resolve.ts +6 -4
  89. package/src/unreal/sound.ts +128 -0
  90. package/src/unreal/texture.ts +70 -3
  91. package/src/unreal/toDocument.ts +7 -0
  92. package/src/unreal/versions.ts +8 -0
  93. package/src/unwrap/index.ts +167 -0
  94. package/src/unwrap/lightmapUv.ts +761 -0
  95. package/src/wasm/fbxReader.js +1 -1
  96. package/src/wasm/lightmapUv.js +1 -1
  97. package/src/doctor/lightmapUv.ts +0 -452
@@ -268,7 +268,10 @@ export const bindVsRest = (rig: Rig): { worst: number; bone: string; skinned: nu
268
268
  export const checkUniformScale = (rig: Rig): void => {
269
269
  for (let i = 0; i < rig.names.length; i++) {
270
270
  const [ a, b, c ] = rig.rest.s[i]
271
- if (Math.abs(a - b) > 1e-4 || Math.abs(b - c) > 1e-4) {
271
+ // RELATIVE: a Blender 0.01 armature under a 100× export leaves [99.99986, 99.99999, 100.00001] — float
272
+ // noise, not a squash (an absolute 1e-4 refused every rig-studio cloak bone)
273
+ const tol = 1e-5 * Math.max(1, Math.abs(a), Math.abs(b), Math.abs(c))
274
+ if (Math.abs(a - b) > tol || Math.abs(b - c) > tol) {
272
275
  throw new Error(`${rig.label}: bone "${rig.names[i]}" has a non-uniform rest scale [${rig.rest.s[i]}] — retargeting needs uniform scales`)
273
276
  }
274
277
  }
@@ -1,132 +1,134 @@
1
- // Foot marks for a GLB that was NOT retargeted (a pack whose clips were captured on the model's own
2
- // rig): every clip is posed on the document's skeleton, the feet found by name (the same vocabulary
3
- // the runtime uses: a `foot`/`ankle` bone per side, its `toe`/`ball` child as the contact), the marks
4
- // measured (marks.ts) and written to the animation's `extras.lecodes.contacts` — the block
5
- // `retarget()` writes for the clips it produces.
6
-
7
- import type { Document } from '@gltf-transform/core'
8
- import { rigFromDocument, worldOf, type Vec3, type Quat } from './rig'
9
- import { headingCurve } from './heading'
10
- import { detectMarks, type ClipMarks, type FootTrack, type LevelKind } from './marks'
11
-
12
- export interface WriteMarksOptions {
13
- /** the baked heading curve must END here (degrees, + = left) — a stop that turns; heading.ts `endAt` */
14
- headingEnd?: number
15
- /** which clips carry LEVELS: a staircase or a platform (see `stairsMatcher`) */
16
- stairs?: (clip: string) => LevelKind
17
- /** sampling rate of the measurement (default 60) */
18
- fps?: number
19
- /** only these clips (default: all) */
20
- clips?: string[]
21
- warn?: (message: string) => void
22
- }
23
-
24
- const lower = (s: string): string => s.toLowerCase()
25
- const shortName = (s: string): string => { const i = s.lastIndexOf(':'); return i >= 0 ? s.slice(i + 1) : s }
26
- /** 1 left / 2 right / 0 unknown — the runtime's `sideOf` (curves.cpp). */
27
- const sideOf = (full: string): 0 | 1 | 2 => {
28
- const n = lower(shortName(full))
29
- const has = (s: string): boolean => n.includes(s)
30
- const starts = (s: string): boolean => n.startsWith(s)
31
- const ends = (s: string): boolean => n.endsWith(s)
32
- if (has('left') || starts('l_') || starts('l.') || starts('l ') || ends('_l') || ends('.l') || ends(' l') || has('lft')) return 1
33
- if (has('right') || starts('r_') || starts('r.') || starts('r ') || ends('_r') || ends('.r') || ends(' r') || has('rgt')) return 2
34
- const raw = shortName(full)
35
- if (raw.length > 1 && raw[0] === 'l' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 1
36
- if (raw.length > 1 && raw[0] === 'r' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 2
37
- return 0
38
- }
39
- const isFootName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('foot') || n.includes('ankle') }
40
- const isToeName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('toe') || n.includes('ball') }
41
- const isEndName = (s: string): boolean => { const n = lower(shortName(s)); return n.endsWith('_end') || n.endsWith('end') && n.includes('toe') }
42
- const headingOf = (q: Quat): number => Math.atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1]))
43
-
44
- /** Measure and write every clip's marks. Returns one row per clip written. */
45
- export const writeMarks = (doc: Document, opts: WriteMarksOptions = {}): { clip: string; marks: ClipMarks; travel: [ number, number ] }[] => {
46
- const rig = rigFromDocument(doc, 'marks')
47
- const fps = opts.fps ?? 60
48
- const depth = (i: number): number => { let d = 0; for (let p = rig.parents[i]!; p >= 0; p = rig.parents[p]!) d++; return d }
49
- const isDesc = (j: number, anc: number): boolean => { for (let p = rig.parents[j]!; p >= 0; p = rig.parents[p]!) if (p === anc) return true; return false }
50
- // the feet: per side the shallowest foot-named joint, its contact = the first toe-named descendant that is not a leaf end
51
- const packFeet: { foot: number; contact: number }[] = [ { foot: -1, contact: -1 }, { foot: -1, contact: -1 } ]
52
- const feet = packFeet
53
- rig.names.forEach((n, i) => {
54
- if (!n || !isFootName(n) || isEndName(n)) return
55
- const s = sideOf(n)
56
- if (!s) return
57
- const f = feet[s - 1]!
58
- if (f.foot < 0 || depth(i) < depth(f.foot)) f.foot = i
59
- })
60
- for (const f of feet) {
61
- if (f.foot < 0) continue
62
- for (let j = 0; j < rig.names.length; j++) if (isToeName(rig.names[j]!) && !isEndName(rig.names[j]!) && isDesc(j, f.foot)) { f.contact = j; break }
63
- if (f.contact < 0) f.contact = f.foot
64
- }
65
- if (feet[0]!.foot < 0 && feet[1]!.foot < 0) throw new Error('writeMarks: no foot bones found (names with "foot"/"ankle" per side)')
66
- const anims = doc.getRoot().listAnimations()
67
- const nodes = doc.getRoot().listNodes()
68
- const rows: { clip: string; marks: ClipMarks; travel: [ number, number ] }[] = []
69
- for (const clip of rig.clips) {
70
- if (opts.clips && !opts.clips.includes(clip.name)) continue
71
- const anim = anims.find((a) => a.getName() === clip.name)
72
- if (!anim) continue
73
- // A GLB that merged several rigs (a donor built from many clip files) has several joints per
74
- // name: use the feet THIS clip animates — the ones under a node with a channel — else the
75
- // pack's feet found above (a native pack has one rig)
76
- const animated = new Set<number>()
77
- for (const ch of anim.listChannels()) { const i = nodes.indexOf(ch.getTargetNode()!); if (i >= 0) animated.add(i) }
78
- const feet = packFeet.map((f) => ({ ...f }))
79
- if (animated.size) {
80
- const under = (j: number): boolean => { for (let p = j; p >= 0; p = rig.parents[p]!) if (animated.has(p)) return true; return false }
81
- rig.names.forEach((n, i) => {
82
- if (!n || !isFootName(n) || isEndName(n) || !under(i)) return
83
- const sd = sideOf(n)
84
- if (!sd) return
85
- const f = feet[sd - 1]!
86
- if (f.foot < 0 || !under(f.foot) || depth(i) < depth(f.foot)) { f.foot = i; f.contact = -1 }
87
- })
88
- for (const f of feet) {
89
- if (f.foot < 0 || f.contact >= 0) continue
90
- for (let j = 0; j < rig.names.length; j++) if (isToeName(rig.names[j]!) && !isEndName(rig.names[j]!) && isDesc(j, f.foot)) { f.contact = j; break }
91
- if (f.contact < 0) f.contact = f.foot
92
- }
93
- }
94
- // the clip's root: the shallowest joint it translates among the feet's ancestors (the hips when
95
- // nothing above them moves) — travel and the `at` frame are measured against it
96
- let rootIdx = -1
97
- for (const ch of anim.listChannels()) {
98
- if (ch.getTargetPath() !== 'translation') continue
99
- const i = nodes.indexOf(ch.getTargetNode()!)
100
- if (i < 0) continue
101
- const above = feet.some((f) => f.foot >= 0 && isDesc(f.foot, i))
102
- if (above && (rootIdx < 0 || depth(i) < depth(rootIdx))) rootIdx = i
103
- }
104
- if (rootIdx < 0) { const f = feet.find((x) => x.foot >= 0)!; rootIdx = rig.parents[f.foot]!; while (rig.parents[rootIdx]! >= 0 && !/hips|pelvis|root/i.test(rig.names[rootIdx]!)) rootIdx = rig.parents[rootIdx]! }
105
- const n = Math.max(2, Math.round(clip.duration * fps) + 1)
106
- const dt = clip.duration / (n - 1)
107
- const root: Vec3[] = [], heading: number[] = []
108
- const tracks: (FootTrack | null)[] = feet.map((f) => (f.foot >= 0 ? { contact: [], ankle: [] } : null))
109
- for (let k = 0; k < n; k++) {
110
- const w = worldOf(rig, clip.sample(k * dt))
111
- root.push(w.pos[rootIdx]!)
112
- heading.push(headingOf(w.rot[rootIdx]!))
113
- feet.forEach((f, s) => { const t = tracks[s]; if (t) { t.contact.push(w.pos[f.contact]!); t.ankle.push(w.pos[f.foot]!) } })
114
- // (the standalone writer measures feet only — a native pack's hands are the retargeter's concern)
115
- }
116
- const kind = opts.stairs?.(clip.name) ?? false
117
- const floor = Math.min(...feet.filter((f) => f.foot >= 0).map((f) => rig.bind.worldPos[f.contact]![1]))
118
- const res = detectMarks({ dt, root, heading, feet: { l: tracks[0] ?? undefined, r: tracks[1] ?? undefined }, stairs: kind === 'stairs' ? true : kind, floor: Number.isFinite(floor) ? floor : undefined })
119
- for (const w of res.warnings) opts.warn?.(`${clip.name}: ${w}`)
120
- const marks: ClipMarks = {
121
- feet: { l: tracks[0] ? rig.names[feet[0]!.contact]! : null, r: tracks[1] ? rig.names[feet[1]!.contact]! : null },
122
- l: res.l, r: res.r, first: res.first, ...(res.stairs ? { stairs: res.stairs } : {}),
123
- }
124
- const travel: [ number, number ] = [ root[n - 1]![0] - root[0]![0], root[n - 1]![2] - root[0]![2] ]
125
- const extras = (anim.getExtras() ?? {}) as Record<string, unknown>
126
- // the HEADING curve (heading.ts): the smoothed direction of the root's travel — the node's turn under root rotation
127
- const headingBaked = headingCurve(root, heading, dt, { endAt: opts.headingEnd })
128
- anim.setExtras({ ...extras, lecodes: { ...((extras.lecodes as Record<string, unknown>) ?? {}), contacts: marks, travel, heading: headingBaked } })
129
- rows.push({ clip: clip.name, marks, travel })
130
- }
131
- return rows
132
- }
1
+ // Foot marks for a GLB that was NOT retargeted (a pack whose clips were captured on the model's own
2
+ // rig): every clip is posed on the document's skeleton, the feet found by name (the same vocabulary
3
+ // the runtime uses: a `foot`/`ankle` bone per side, its `toe`/`ball` child as the contact), the marks
4
+ // measured (marks.ts) and written to the animation's `extras.lecodes.contacts` — the block
5
+ // `retarget()` writes for the clips it produces.
6
+
7
+ import type { Document } from '@gltf-transform/core'
8
+ import { rigFromDocument, worldOf, type Vec3, type Quat } from './rig'
9
+ import { headingCurve } from './heading'
10
+ import { detectMarks, type ClipMarks, type FootTrack, type LevelKind } from './marks'
11
+
12
+ export interface WriteMarksOptions {
13
+ /** the baked heading curve must END here (degrees, + = left) — a stop that turns; heading.ts `endAt` */
14
+ headingEnd?: number
15
+ /** these clips are LOOPS (heading.ts `loop`: the travel direction smoothed periodically) */
16
+ loops?: string[]
17
+ /** which clips carry LEVELS: a staircase or a platform (see `stairsMatcher`) */
18
+ stairs?: (clip: string) => LevelKind
19
+ /** sampling rate of the measurement (default 60) */
20
+ fps?: number
21
+ /** only these clips (default: all) */
22
+ clips?: string[]
23
+ warn?: (message: string) => void
24
+ }
25
+
26
+ const lower = (s: string): string => s.toLowerCase()
27
+ const shortName = (s: string): string => { const i = s.lastIndexOf(':'); return i >= 0 ? s.slice(i + 1) : s }
28
+ /** 1 left / 2 right / 0 unknown — the runtime's `sideOf` (curves.cpp). */
29
+ const sideOf = (full: string): 0 | 1 | 2 => {
30
+ const n = lower(shortName(full))
31
+ const has = (s: string): boolean => n.includes(s)
32
+ const starts = (s: string): boolean => n.startsWith(s)
33
+ const ends = (s: string): boolean => n.endsWith(s)
34
+ if (has('left') || starts('l_') || starts('l.') || starts('l ') || ends('_l') || ends('.l') || ends(' l') || has('lft')) return 1
35
+ if (has('right') || starts('r_') || starts('r.') || starts('r ') || ends('_r') || ends('.r') || ends(' r') || has('rgt')) return 2
36
+ const raw = shortName(full)
37
+ if (raw.length > 1 && raw[0] === 'l' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 1
38
+ if (raw.length > 1 && raw[0] === 'r' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 2
39
+ return 0
40
+ }
41
+ const isFootName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('foot') || n.includes('ankle') }
42
+ const isToeName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('toe') || n.includes('ball') }
43
+ const isEndName = (s: string): boolean => { const n = lower(shortName(s)); return n.endsWith('_end') || n.endsWith('end') && n.includes('toe') }
44
+ const headingOf = (q: Quat): number => Math.atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1]))
45
+
46
+ /** Measure and write every clip's marks. Returns one row per clip written. */
47
+ export const writeMarks = (doc: Document, opts: WriteMarksOptions = {}): { clip: string; marks: ClipMarks; travel: [ number, number ] }[] => {
48
+ const rig = rigFromDocument(doc, 'marks')
49
+ const fps = opts.fps ?? 60
50
+ const depth = (i: number): number => { let d = 0; for (let p = rig.parents[i]!; p >= 0; p = rig.parents[p]!) d++; return d }
51
+ const isDesc = (j: number, anc: number): boolean => { for (let p = rig.parents[j]!; p >= 0; p = rig.parents[p]!) if (p === anc) return true; return false }
52
+ // the feet: per side the shallowest foot-named joint, its contact = the first toe-named descendant that is not a leaf end
53
+ const packFeet: { foot: number; contact: number }[] = [ { foot: -1, contact: -1 }, { foot: -1, contact: -1 } ]
54
+ const feet = packFeet
55
+ rig.names.forEach((n, i) => {
56
+ if (!n || !isFootName(n) || isEndName(n)) return
57
+ const s = sideOf(n)
58
+ if (!s) return
59
+ const f = feet[s - 1]!
60
+ if (f.foot < 0 || depth(i) < depth(f.foot)) f.foot = i
61
+ })
62
+ for (const f of feet) {
63
+ if (f.foot < 0) continue
64
+ for (let j = 0; j < rig.names.length; j++) if (isToeName(rig.names[j]!) && !isEndName(rig.names[j]!) && isDesc(j, f.foot)) { f.contact = j; break }
65
+ if (f.contact < 0) f.contact = f.foot
66
+ }
67
+ if (feet[0]!.foot < 0 && feet[1]!.foot < 0) throw new Error('writeMarks: no foot bones found (names with "foot"/"ankle" per side)')
68
+ const anims = doc.getRoot().listAnimations()
69
+ const nodes = doc.getRoot().listNodes()
70
+ const rows: { clip: string; marks: ClipMarks; travel: [ number, number ] }[] = []
71
+ for (const clip of rig.clips) {
72
+ if (opts.clips && !opts.clips.includes(clip.name)) continue
73
+ const anim = anims.find((a) => a.getName() === clip.name)
74
+ if (!anim) continue
75
+ // A GLB that merged several rigs (a donor built from many clip files) has several joints per
76
+ // name: use the feet THIS clip animates — the ones under a node with a channel — else the
77
+ // pack's feet found above (a native pack has one rig)
78
+ const animated = new Set<number>()
79
+ for (const ch of anim.listChannels()) { const i = nodes.indexOf(ch.getTargetNode()!); if (i >= 0) animated.add(i) }
80
+ const feet = packFeet.map((f) => ({ ...f }))
81
+ if (animated.size) {
82
+ const under = (j: number): boolean => { for (let p = j; p >= 0; p = rig.parents[p]!) if (animated.has(p)) return true; return false }
83
+ rig.names.forEach((n, i) => {
84
+ if (!n || !isFootName(n) || isEndName(n) || !under(i)) return
85
+ const sd = sideOf(n)
86
+ if (!sd) return
87
+ const f = feet[sd - 1]!
88
+ if (f.foot < 0 || !under(f.foot) || depth(i) < depth(f.foot)) { f.foot = i; f.contact = -1 }
89
+ })
90
+ for (const f of feet) {
91
+ if (f.foot < 0 || f.contact >= 0) continue
92
+ for (let j = 0; j < rig.names.length; j++) if (isToeName(rig.names[j]!) && !isEndName(rig.names[j]!) && isDesc(j, f.foot)) { f.contact = j; break }
93
+ if (f.contact < 0) f.contact = f.foot
94
+ }
95
+ }
96
+ // the clip's root: the shallowest joint it translates among the feet's ancestors (the hips when
97
+ // nothing above them moves) — travel and the `at` frame are measured against it
98
+ let rootIdx = -1
99
+ for (const ch of anim.listChannels()) {
100
+ if (ch.getTargetPath() !== 'translation') continue
101
+ const i = nodes.indexOf(ch.getTargetNode()!)
102
+ if (i < 0) continue
103
+ const above = feet.some((f) => f.foot >= 0 && isDesc(f.foot, i))
104
+ if (above && (rootIdx < 0 || depth(i) < depth(rootIdx))) rootIdx = i
105
+ }
106
+ if (rootIdx < 0) { const f = feet.find((x) => x.foot >= 0)!; rootIdx = rig.parents[f.foot]!; while (rig.parents[rootIdx]! >= 0 && !/hips|pelvis|root/i.test(rig.names[rootIdx]!)) rootIdx = rig.parents[rootIdx]! }
107
+ const n = Math.max(2, Math.round(clip.duration * fps) + 1)
108
+ const dt = clip.duration / (n - 1)
109
+ const root: Vec3[] = [], heading: number[] = []
110
+ const tracks: (FootTrack | null)[] = feet.map((f) => (f.foot >= 0 ? { contact: [], ankle: [] } : null))
111
+ for (let k = 0; k < n; k++) {
112
+ const w = worldOf(rig, clip.sample(k * dt))
113
+ root.push(w.pos[rootIdx]!)
114
+ heading.push(headingOf(w.rot[rootIdx]!))
115
+ feet.forEach((f, s) => { const t = tracks[s]; if (t) { t.contact.push(w.pos[f.contact]!); t.ankle.push(w.pos[f.foot]!) } })
116
+ // (the standalone writer measures feet only — a native pack's hands are the retargeter's concern)
117
+ }
118
+ const kind = opts.stairs?.(clip.name) ?? false
119
+ const floor = Math.min(...feet.filter((f) => f.foot >= 0).map((f) => rig.bind.worldPos[f.contact]![1]))
120
+ const res = detectMarks({ dt, root, heading, feet: { l: tracks[0] ?? undefined, r: tracks[1] ?? undefined }, stairs: kind === 'stairs' ? true : kind, floor: Number.isFinite(floor) ? floor : undefined })
121
+ for (const w of res.warnings) opts.warn?.(`${clip.name}: ${w}`)
122
+ const marks: ClipMarks = {
123
+ feet: { l: tracks[0] ? rig.names[feet[0]!.contact]! : null, r: tracks[1] ? rig.names[feet[1]!.contact]! : null },
124
+ l: res.l, r: res.r, first: res.first, ...(res.stairs ? { stairs: res.stairs } : {}),
125
+ }
126
+ const travel: [ number, number ] = [ root[n - 1]![0] - root[0]![0], root[n - 1]![2] - root[0]![2] ]
127
+ const extras = (anim.getExtras() ?? {}) as Record<string, unknown>
128
+ // the HEADING curve (heading.ts): the smoothed direction of the root's travel — the node's turn under root rotation
129
+ const headingBaked = headingCurve(root, heading, dt, { endAt: opts.headingEnd, loop: opts.loops?.includes(clip.name) })
130
+ anim.setExtras({ ...extras, lecodes: { ...((extras.lecodes as Record<string, unknown>) ?? {}), contacts: marks, travel, heading: headingBaked } })
131
+ rows.push({ clip: clip.name, marks, travel })
132
+ }
133
+ return rows
134
+ }
package/src/sky/cmgen.ts CHANGED
@@ -36,7 +36,7 @@ const findIn = (dir: string, depth = 3): string | null => {
36
36
  const fromBuildTree = (): string | null => {
37
37
  let dir = resolve(process.cwd())
38
38
  for (let i = 0; i < 6; i++) {
39
- const hit = join(dir, "packages", "desktop", "build", "creator-gl", "filament", "tools", "cmgen", exeName())
39
+ const hit = join(dir, "hosts", "desktop", "build", "creator-gl", "filament", "tools", "cmgen", exeName())
40
40
  if (existsSync(hit)) return hit
41
41
  const up = dirname(dir)
42
42
  if (up === dir) break
@@ -67,5 +67,5 @@ export const checkCmgen = (cmgen: string): string | null => {
67
67
 
68
68
  export const CMGEN_HELP =
69
69
  `cmgen not found (looked at $LECODES_CMGEN, ~/.lecodes/cmgen, a sibling desktop build, PATH).\n` +
70
- ` It ships with filament: build the desktop host once (packages/desktop/build.ps1) and it lands at\n` +
71
- ` packages/desktop/build/creator-gl/filament/tools/cmgen/, or set LECODES_CMGEN=<path to cmgen>.`
70
+ ` It ships with filament: build the desktop host once (hosts/desktop/build.ps1) and it lands at\n` +
71
+ ` hosts/desktop/build/creator-gl/filament/tools/cmgen/, or set LECODES_CMGEN=<path to cmgen>.`
package/src/sky/index.ts CHANGED
@@ -31,22 +31,40 @@ export type SkyOptions = {
31
31
  iblName?: string
32
32
  /** Skip the sky, emit only the lighting probe. */
33
33
  iblOnly?: boolean
34
+ /** Take the SUN out of the lighting probe (cmgen --clamp-sun, the LeCodes fork's): for a level that has a directional
35
+ * sun. The sun in the probe is the same light a second time - from everywhere, without shadows: it flattens every
36
+ * object and halves the contrast of the baked shade. The drawn sky keeps its sun. `true` = 8 degrees. */
37
+ noSun?: boolean | number
34
38
  log?: (msg: string) => void
35
39
  }
36
40
 
41
+ /** What `noSun` measured: the disc taken out of the probe, and what the scene should be given for it. */
42
+ export type SunCalibration = {
43
+ /** The disc's illuminance on a surface facing it, in the picture's own units. */
44
+ discUnits: number
45
+ /** Degrees above the horizon (from the unclamped picture). */
46
+ elevation: number
47
+ /** A clear day's direct sun at that elevation, lux: 127 500 x exp(-0.21 / sin(elevation)). */
48
+ sunLux: number
49
+ /** environmentIntensity that makes the picture's own disc that bright: sunLux / discUnits - pixel 1.0 in nits. */
50
+ environmentIntensity: number
51
+ }
52
+
37
53
  export type SkyResult = {
54
+ sun?: SunCalibration
38
55
  skyPath: string | null
39
56
  iblPath: string | null
40
57
  analysis: SkyAnalysis | null
41
58
  }
42
59
 
43
- const runCmgen = (cmgen: string, args: string[]): void => {
60
+ const runCmgen = (cmgen: string, args: string[]): string => {
44
61
  const p = spawnSync(cmgen, args, { encoding: "utf8" })
45
62
  if (p.error) throw new Error(`cmgen failed to start: ${p.error.message}`)
46
63
  if (p.status !== 0) {
47
64
  const out = `${p.stdout ?? ""}${p.stderr ?? ""}`.trim()
48
65
  throw new Error(`cmgen exited with ${p.status}${out ? `:\n${out}` : ""}`)
49
66
  }
67
+ return `${p.stdout ?? ""}${p.stderr ?? ""}`
50
68
  }
51
69
 
52
70
  /** The file cmgen's --deploy wrote for a given role, whatever it chose to call the folder. */
@@ -91,19 +109,42 @@ export const buildSky = async (input: string, opts: SkyOptions = {}): Promise<Sk
91
109
 
92
110
  log(` cubemap ${iblSize}… (lighting probe + SH)`)
93
111
  const d2 = join(stage, "ibl")
94
- runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, `--deploy=${d2}`, input])
112
+ let discUnits = 0
113
+ const sunArgs = opts.noSun ? [typeof opts.noSun === "number" ? `--clamp-sun=${opts.noSun}` : "--clamp-sun"] : []
114
+ const iblOut = runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, ...sunArgs, `--deploy=${d2}`, input])
115
+ if (opts.noSun) {
116
+ const line = /Sun clamped:[^\r\n]*/.exec(iblOut)
117
+ if (!line) throw new Error("this cmgen has no --clamp-sun (it is the LeCodes filament fork's): point LECODES_CMGEN at hosts/desktop/build/creator-gl/filament/tools/cmgen")
118
+ log(` ${line[0]}`)
119
+ const disc = /disc's illuminance ([0-9.eE+-]+)/.exec(iblOut)
120
+ if (disc) discUnits = parseFloat(disc[1])
121
+ }
95
122
  const iblSrc = deployed(d2, "_ibl")
96
123
  if (!iblSrc) throw new Error("cmgen produced no _ibl.ktx")
97
124
  result.iblPath = join(outDir, `${iblName}.ktx`)
98
125
  copyFileSync(iblSrc, result.iblPath)
99
126
 
100
- // Analyse whichever cubemap is sharpest — the sky when we made one, else the probe.
101
- const toScan = result.skyPath ?? result.iblPath
127
+ // Analyse whichever cubemap is sharpest — the sky when we made one, else the probe. A probe WITHOUT its sun says
128
+ // nothing about where the sun is: with `noSun` and no drawn sky, one more small pass keeps the disc for the reading.
129
+ let toScan = result.skyPath ?? result.iblPath
130
+ if (opts.noSun && !result.skyPath) {
131
+ const d3 = join(stage, "scan")
132
+ runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, `--deploy=${d3}`, input])
133
+ toScan = deployed(d3, "_skybox") ?? deployed(d3, "_ibl") ?? toScan
134
+ }
102
135
  try {
103
136
  result.analysis = analyzeSkyKtx(new Uint8Array(readFileSync(toScan)))
104
137
  } catch (e) {
105
138
  log(` (could not analyse the environment: ${(e as Error).message})`)
106
139
  }
140
+ // THE CALIBRATION: the picture's units are relative, the sun's illuminance is not. A clear day's direct sun at
141
+ // elevation h is 127 500 x exp(-0.21 / sin h) lux (the extraterrestrial constant through a clear atmosphere's
142
+ // optical depth); environmentIntensity = that / the disc's illuminance in the picture's units - pixel 1.0 in nits.
143
+ const elevation = result.analysis?.elevation ?? 0
144
+ if (discUnits > 0 && elevation > 2 && (result.analysis?.peakLuminance ?? 0) >= 100) {
145
+ const sunLux = 127500 * Math.exp(-0.21 / Math.sin(elevation * Math.PI / 180))
146
+ result.sun = { discUnits, elevation, sunLux, environmentIntensity: sunLux / discUnits }
147
+ }
107
148
  } finally {
108
149
  rmSync(stage, { recursive: true, force: true })
109
150
  }
@@ -142,10 +183,18 @@ export const formatSkyReport = (r: SkyResult, name = "SKY"): string => {
142
183
  ` warm white (#fff2e2 or so) and tint to taste.`,
143
184
  )
144
185
  }
145
- out.push(` mean luminance ${a.meanLuminance} (upper hemisphere ${a.upperMeanLuminance}) — the brighter this is,`)
146
- out.push(` the lower environmentIntensity wants to be.`)
186
+ const round2 = (v: number): number => { const m = Math.pow(10, Math.max(0, Math.floor(Math.log10(v)) - 1)); return Math.round(v / m) * m }
187
+ const c = r.sun
188
+ if (c) {
189
+ out.push(` CALIBRATED BY THE SUN: its disc is ${c.discUnits.toFixed(2)} of the picture's units of illuminance; a clear day's sun`)
190
+ out.push(` at ${c.elevation}° is ~${round2(c.sunLux)} lux, so pixel 1.0 = ${round2(c.environmentIntensity)} nits. The probe has the disc taken out:`)
191
+ out.push(` the directional light below IS the sun - give it that illuminance, do not tune the two by eye.`)
192
+ } else {
193
+ out.push(` mean luminance ${a.meanLuminance} (upper hemisphere ${a.upperMeanLuminance}) — the brighter this is,`)
194
+ out.push(` the lower environmentIntensity wants to be.${a.peakLuminance >= 100 ? " --no-sun measures the disc and gives the number." : ""}`)
195
+ }
147
196
  out.push("")
148
- out.push(` env: { skybox: { texture: ${name} }, environmentIntensity: 16000 }`)
149
- out.push(` light: { kind: 'sun', direction: [${dx}, ${dy}, ${dz}], color: '${blueSun ? "#fff2e2" : a.sunTint}' }`)
197
+ out.push(` env: { skybox: { texture: ${name} }, environmentIntensity: ${c ? round2(c.environmentIntensity) : 16000} }`)
198
+ out.push(` light: { kind: 'sun', direction: [${dx}, ${dy}, ${dz}], color: '${blueSun ? "#fff2e2" : a.sunTint}'${c ? `, intensity: ${round2(c.sunLux)}` : ""} }`)
150
199
  return out.join("\n")
151
200
  }
@@ -1,7 +1,9 @@
1
1
  // `lecodes assets terrain-pack` — a terrain's splat layers (up to four) → what `Terrain.create`'s
2
2
  // `layers`/`normals` read (docs/terrain-plan.md §1.5):
3
3
  //
4
- // <name>.ktx2 one per layer: the albedo, sRGB, square, mipmapped
4
+ // <name>.ktx2 one per layer: the albedo, sRGB, square, mipmapped — with the layer's ROUGHNESS in its ALPHA when
5
+ // the layer has a roughness source (terrain.mat: roughness = the layer's factor x albedo alpha; the
6
+ // one channel the sampler budget leaves free). No source = no alpha = the factor alone.
5
7
  // normals01.ktx2 layer 0's normal XY in RG + layer 1's in BA (linear)
6
8
  // normals23.ktx2 layers 2 + 3 likewise (only when there are more than two layers)
7
9
  //
@@ -12,30 +14,42 @@
12
14
  // Inputs: an albedo file per layer (PNG/JPEG/WebP/TIFF via sharp, TGA/BMP decoded here) with the
13
15
  // normal map given by `--normals`, guessed from the albedo's name (`<x>_Albedo.png` → `<x>_Normal.png`),
14
16
  // or — the Unity-pack shape — a FOLDER per layer holding `*_Albedo.*` + `*_Normal.*`.
17
+ // Roughness: `--roughness` per layer, guessed from the name (`<x>_Roughness.*`), or a folder's `*Roughness*` /
18
+ // `*AlbedoSmoothness*` (Unity: smoothness in the ALPHA, inverted here). A roughness image is read from its GREEN channel:
19
+ // a grey map has it in every channel, a glTF metallicRoughness / ORM map has roughness there.
15
20
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"
16
21
  import { basename, extname, join, resolve } from "node:path"
17
22
  import { spawnSync } from "node:child_process"
18
23
  import { decodeBmp, decodeTga, encodePng, sniffMime } from "./textures/resolveImage"
19
- import { findToktx, checkToktx } from "./doctor/transcodeKtx2"
24
+ import { findKtxEncoder, ktxEncodeArgs, KTX_ENCODER_MISSING, type KtxEncoder } from "./doctor/transcodeKtx2"
20
25
 
21
- export type TerrainPackLayer = { name: string, albedo: string, normal?: string }
26
+ export type TerrainPackLayer = {
27
+ name: string, albedo: string, normal?: string
28
+ /** a roughness image (read from its green channel) … */
29
+ roughness?: string
30
+ /** … or a Unity `*_AlbedoSmoothness` image: roughness = 1 − its alpha */
31
+ smoothnessAlpha?: string
32
+ /** A per-channel scale on the albedo, applied in LINEAR light (Unity's TerrainLayer `m_DiffuseRemapMax`: a tint
33
+ * the artist dialled on the layer, which the packed texture must carry since a layer has no tint of its own). */
34
+ albedoScale?: [number, number, number]
35
+ }
22
36
 
23
37
  export type TerrainPackOptions = {
24
38
  /** Where the files land. */
25
39
  outDir: string
26
40
  /** Output edge in pixels — every texture is resampled square (default 1024). */
27
41
  size?: number
28
- /** Write PNGs instead of KTX2 (no toktx needed; larger, uncompressed on the GPU). */
42
+ /** Write PNGs instead of KTX2 (no KTX-Software needed; larger, uncompressed on the GPU). */
29
43
  png?: boolean
30
44
  /** Invert the normal maps' green channel (DirectX-style sources; Unity's are OpenGL-style, the default). */
31
45
  flipY?: boolean
32
- /** toktx UASTC quality 0–4 (default 1). */
46
+ /** UASTC quality 0–4 (default 1). */
33
47
  quality?: number
34
48
  log?: (msg: string) => void
35
49
  }
36
50
 
37
51
  export type TerrainPackResult = {
38
- layers: { name: string, file: string, albedo: string, normal: string | null, sourceSize: [number, number] }[]
52
+ layers: { name: string, file: string, albedo: string, normal: string | null, roughness: string | null, sourceSize: [number, number] }[]
39
53
  normals: string[]
40
54
  /** The matching `Terrain.create` fragment. */
41
55
  snippet: string
@@ -44,7 +58,7 @@ export type TerrainPackResult = {
44
58
  const IMAGE_EXT = /\.(png|jpe?g|webp|tiff?|tga|bmp)$/i
45
59
 
46
60
  /** A layer from a file (+ optional normal) or a folder (`*Albedo*` + `*Normal*` inside). */
47
- export const resolveTerrainLayer = (input: string, normal?: string, name?: string): TerrainPackLayer => {
61
+ export const resolveTerrainLayer = (input: string, normal?: string, name?: string, roughness?: string): TerrainPackLayer => {
48
62
  const p = resolve(input)
49
63
  if (!existsSync(p)) throw new Error(`not found: ${input}`)
50
64
  if (statSync(p).isDirectory()) {
@@ -53,7 +67,12 @@ export const resolveTerrainLayer = (input: string, normal?: string, name?: strin
53
67
  const albedo = files.find((f) => /albedo/i.test(f) && !/smooth/i.test(f)) ?? files.find((f) => /albedo|basecolor|base_color|diffuse|_d\./i.test(f))
54
68
  if (!albedo) throw new Error(`${input}: no albedo image (a file named *Albedo* / *BaseColor* / *Diffuse*)`)
55
69
  const nrm = normal ?? files.find((f) => /normal|_n\./i.test(f) && !/normalmap_?dx/i.test(f))
56
- return { name: name ?? cleanName(basename(p)), albedo: join(p, albedo), normal: nrm ? (normal ? resolve(normal) : join(p, nrm)) : undefined }
70
+ const rough = roughness ? resolve(roughness) : files.find((f) => /rough/i.test(f))
71
+ const smooth = rough ? undefined : files.find((f) => /albedo.*smooth|smooth.*albedo/i.test(f))
72
+ return {
73
+ name: name ?? cleanName(basename(p)), albedo: join(p, albedo), normal: nrm ? (normal ? resolve(normal) : join(p, nrm)) : undefined,
74
+ roughness: rough ? (roughness ? rough : join(p, rough)) : undefined, smoothnessAlpha: smooth ? join(p, smooth) : undefined,
75
+ }
57
76
  }
58
77
  if (!IMAGE_EXT.test(p)) throw new Error(`${input}: not an image (png/jpg/webp/tiff/tga/bmp)`)
59
78
  let nrm: string | undefined = normal ? resolve(normal) : undefined
@@ -65,7 +84,16 @@ export const resolveTerrainLayer = (input: string, normal?: string, name?: strin
65
84
  if (hit) { nrm = hit; break }
66
85
  }
67
86
  }
68
- return { name: name ?? cleanName(basename(p, extname(p)).replace(/[_-]?(albedo|basecolor|base_color|diffuse)$/i, "")), albedo: p, normal: nrm }
87
+ let rough: string | undefined = roughness ? resolve(roughness) : undefined
88
+ if (!rough) {
89
+ const stem = p.slice(0, -extname(p).length)
90
+ for (const cand of [ stem.replace(/albedo/i, "Roughness"), stem.replace(/albedo/i, "roughness"), `${stem}_Roughness`, `${stem}_roughness`, stem.replace(/_?(basecolor|diffuse|_d)$/i, "_Roughness") ]) {
91
+ if (cand === stem) continue
92
+ const hit = [ ".png", ".jpg", ".jpeg", ".tga", ".webp", ".tif", ".tiff", ".bmp" ].map((e) => cand + e).find((f) => existsSync(f))
93
+ if (hit) { rough = hit; break }
94
+ }
95
+ }
96
+ return { name: name ?? cleanName(basename(p, extname(p)).replace(/[_-]?(albedo|basecolor|base_color|diffuse)$/i, "")), albedo: p, normal: nrm, roughness: rough }
69
97
  }
70
98
 
71
99
  const cleanName = (s: string): string => s.trim().replace(/\s+/g, "_").replace(/[^A-Za-z0-9_-]/g, "").toLowerCase() || "layer"
@@ -88,10 +116,10 @@ const loadRgba = async (path: string, size: number): Promise<Raw & { sourceSize:
88
116
  return { data, width: info.width, height: info.height, sourceSize: [ meta.width ?? 0, meta.height ?? 0 ] }
89
117
  }
90
118
 
91
- const encodeKtx2 = (toktx: string, png: string, ktx2: string, srgb: boolean, quality: number): void => {
92
- const args = [ "--t2", "--encode", "uastc", "--uastc_quality", String(quality), "--zcmp", "18", "--genmipmap", "--assign_oetf", srgb ? "srgb" : "linear", ktx2, png ]
93
- const p = spawnSync(toktx, args, { encoding: "utf8" })
94
- if (p.status !== 0) throw new Error(`toktx failed (${p.status ?? p.error?.message ?? "?"}) on ${basename(png)}:\n${(p.stderr || p.stdout || "").trim()}`)
119
+ const encodeKtx2 = (enc: KtxEncoder, png: string, ktx2: string, channels: 3 | 4, srgb: boolean, quality: number): void => {
120
+ const args = ktxEncodeArgs(enc, { input: png, output: ktx2, codec: "uastc", oetf: srgb ? "srgb" : "linear", channels, uastcQuality: quality, zstd: 18 })
121
+ const p = spawnSync(enc.path, args, { encoding: "utf8" })
122
+ if (p.status !== 0) throw new Error(`${enc.kind === "ktx" ? "ktx create" : "toktx"} failed (${p.status ?? p.error?.message ?? "?"}) on ${basename(png)}:\n${(p.stderr || p.stdout || "").trim()}`)
95
123
  }
96
124
 
97
125
  export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: TerrainPackOptions): Promise<TerrainPackResult> => {
@@ -101,10 +129,8 @@ export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: Terr
101
129
  if (!(size >= 16 && size <= 8192) || (size & (size - 1)) !== 0) throw new Error(`--size wants a power of two in 16..8192, got ${size}`)
102
130
  const log = opts.log ?? (() => {})
103
131
  const sharp = (await import("sharp")).default
104
- const toktx = opts.png ? null : findToktx()
105
- if (toktx && !checkToktx(toktx)) {
106
- throw new Error("KTX-Software's toktx is needed for KTX2 output — install it (https://github.com/KhronosGroup/KTX-Software/releases, on PATH or KTX_TOKTX=<path>) or pass --png")
107
- }
132
+ const enc = opts.png ? null : findKtxEncoder()
133
+ if (!opts.png && !enc) throw new Error(`${KTX_ENCODER_MISSING}\n Or pass --png.`)
108
134
  const seen = new Set<string>()
109
135
  for (const l of layers) {
110
136
  if (seen.has(l.name)) throw new Error(`two layers named "${l.name}" — pass --names`)
@@ -118,8 +144,8 @@ export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: Terr
118
144
  if (channels === 3) pipe = pipe.removeAlpha()
119
145
  const png = opts.png ? out : out.slice(0, -ext.length) + ".png"
120
146
  await pipe.png({ compressionLevel: opts.png ? 9 : 1 }).toFile(png)
121
- if (toktx) {
122
- encodeKtx2(toktx, png, out, srgb, opts.quality ?? 1)
147
+ if (enc) {
148
+ encodeKtx2(enc, png, out, channels, srgb, opts.quality ?? 1)
123
149
  unlinkSync(png)
124
150
  }
125
151
  }
@@ -128,16 +154,29 @@ export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: Terr
128
154
  const normalRaws: (Raw | null)[] = []
129
155
  for (const l of layers) {
130
156
  const alb = await loadRgba(l.albedo, size)
157
+ if (l.albedoScale && l.albedoScale.some((s) => Math.abs(s - 1) > 1e-3)) {
158
+ const toLin = (v: number): number => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4))
159
+ const toSrgb = (v: number): number => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055)
160
+ const lut = [ 0, 1, 2 ].map((c) => { const t = new Uint8Array(256); for (let i = 0; i < 256; i++) t[i] = Math.round(Math.min(1, toSrgb(toLin(i / 255) * l.albedoScale![c])) * 255); return t })
161
+ for (let i = 0; i < size * size; i++) for (let c = 0; c < 3; c++) alb.data[i * 4 + c] = lut[c][alb.data[i * 4 + c]]
162
+ }
131
163
  const file = join(opts.outDir, l.name + ext)
132
- await writeImage(alb, 3, file, true)
164
+ // the layer's roughness rides the albedo's alpha (the header); without a source the albedo ships opaque
165
+ const roughSrc = l.roughness ?? l.smoothnessAlpha
166
+ if (roughSrc) {
167
+ if (!existsSync(roughSrc)) throw new Error(`${l.name}: roughness map not found: ${roughSrc}`)
168
+ const r = await loadRgba(roughSrc, size)
169
+ for (let i = 0; i < size * size; i++) alb.data[i * 4 + 3] = l.roughness ? r.data[i * 4 + 1] : 255 - r.data[i * 4 + 3]
170
+ }
171
+ await writeImage(alb, roughSrc ? 4 : 3, file, true)
133
172
  let nrm: (Raw & { sourceSize: [number, number] }) | null = null
134
173
  if (l.normal) {
135
174
  if (!existsSync(l.normal)) throw new Error(`${l.name}: normal map not found: ${l.normal}`)
136
175
  nrm = await loadRgba(l.normal, size)
137
176
  }
138
177
  normalRaws.push(nrm)
139
- result.layers.push({ name: l.name, file, albedo: l.albedo, normal: l.normal ?? null, sourceSize: alb.sourceSize })
140
- log(` ${l.name.padEnd(16)} ${alb.sourceSize[0]}×${alb.sourceSize[1]} → ${size}² ${nrm ? `+ normal ${nrm.sourceSize[0]}×${nrm.sourceSize[1]}` : "(flat normal)"}`)
178
+ result.layers.push({ name: l.name, file, albedo: l.albedo, normal: l.normal ?? null, roughness: roughSrc ?? null, sourceSize: alb.sourceSize })
179
+ log(` ${l.name.padEnd(16)} ${alb.sourceSize[0]}×${alb.sourceSize[1]} → ${size}² ${nrm ? `+ normal ${nrm.sourceSize[0]}×${nrm.sourceSize[1]}` : "(flat normal)"}${roughSrc ? ` + roughness in the alpha (${l.roughness ? basename(roughSrc) : "1 − " + basename(roughSrc) + "'s alpha"})` : ""}`)
141
180
  }
142
181
 
143
182
  // the packs: RG = layer 2k's XY, BA = layer 2k+1's; a missing map is flat