lecodes-assets 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli.js +7757 -683
  3. package/dist/index.js +7866 -675
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,171 @@
1
+ // Unity `.anim` (AnimationClip) reader — the clips that never made it into an FBX.
2
+ //
3
+ // Asset packs routinely ship a rig as FBX but its animations as Unity's own AnimationClip assets:
4
+ // the polyperfect cat and all three dogs have EMPTY FBX files and eight `.anim` clips each (Sit,
5
+ // Sleep, StandUp, …) that no glTF tool can see. When the package was serialised as text those are
6
+ // plain YAML, so the curves are readable without Unity.
7
+ //
8
+ // Only what a retarget needs is parsed — rotation, position and scale curves keyed by transform
9
+ // PATH — with a hand-rolled line scanner rather than a YAML library: these files run to tens of
10
+ // thousands of lines (a 2.6 MB Sleep clip) and the shape is fixed and shallow.
11
+ //
12
+ // Two traps in the format:
13
+ // * `m_EditorCurves` / `m_EulerEditorCurves` repeat every curve component-by-component for the
14
+ // editor's graph view, and make up most of the file. Parsing past them double-counts everything,
15
+ // so the scan stops at the first section that is not one of the three curve lists.
16
+ // * `path` is the transform's FULL hierarchy path from the animator root
17
+ // (`actor:skin_Root_M/actor:skin_Spine1_M/…`), which only matches a rig by its LAST segment.
18
+ import type { Clip, Pose, Quat, Rig, Vec3 } from './rig'
19
+ import { qnorm, qslerp } from './rig'
20
+
21
+ export interface UnityCurve { path: string; bone: string; times: number[]; values: number[][] }
22
+ export interface UnityClip {
23
+ name: string
24
+ duration: number
25
+ sampleRate: number
26
+ rotation: UnityCurve[]
27
+ position: UnityCurve[]
28
+ scale: UnityCurve[]
29
+ }
30
+
31
+ const SECTIONS: Record<string, keyof Pick<UnityClip, 'rotation' | 'position' | 'scale'>> = {
32
+ m_RotationCurves: 'rotation',
33
+ m_PositionCurves: 'position',
34
+ m_ScaleCurves: 'scale',
35
+ }
36
+
37
+ const numbersIn = (s: string): number[] => {
38
+ const out: number[] = []
39
+ for (const m of s.matchAll(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g)) out.push(Number(m[0]))
40
+ return out
41
+ }
42
+
43
+ /** Parse one `.anim` file's text. Throws when it is Unity's binary form rather than YAML. */
44
+ export function parseUnityAnim(text: string, fallbackName = 'clip'): UnityClip {
45
+ if (!text.startsWith('%YAML')) {
46
+ throw new Error('not a text-serialised Unity asset — re-export the project with Asset Serialization = Force Text')
47
+ }
48
+ const clip: UnityClip = { name: fallbackName, duration: 0, sampleRate: 30, rotation: [], position: [], scale: [] }
49
+ const lines = text.split('\n')
50
+
51
+ let section: UnityCurve[] | null = null
52
+ let cur: UnityCurve | null = null
53
+ let pending: number | null = null // time of a keyframe whose value line has not arrived
54
+ let stopTime: number | null = null
55
+ let inSettings = false
56
+
57
+ const flush = (): void => {
58
+ if (cur && section && cur.times.length) section.push(cur)
59
+ cur = null
60
+ pending = null
61
+ }
62
+
63
+ for (const raw of lines) {
64
+ const line = raw.replace(/\r$/, '')
65
+ // Top-level key (two-space indent): switches or ends a section.
66
+ const top = /^ (m_[A-Za-z]+):(.*)$/.exec(line)
67
+ if (top) {
68
+ flush()
69
+ const key = top[1]
70
+ inSettings = key === 'm_AnimationClipSettings'
71
+ if (key === 'm_Name') { clip.name = top[2].trim() || fallbackName; section = null; continue }
72
+ if (key === 'm_SampleRate') { clip.sampleRate = Number(top[2].trim()) || 30; section = null; continue }
73
+ const which = SECTIONS[key]
74
+ if (which) {
75
+ // `m_RotationCurves: []` — present but empty.
76
+ section = top[2].trim() === '[]' ? null : clip[which]
77
+ continue
78
+ }
79
+ // Anything else ends curve parsing; the editor mirrors below must not be read.
80
+ section = null
81
+ continue
82
+ }
83
+ if (inSettings) {
84
+ const st = /^ m_StopTime:\s*(\S+)/.exec(line)
85
+ if (st) stopTime = Number(st[1])
86
+ continue
87
+ }
88
+ if (!section) continue
89
+
90
+ if (/^ - curve:/.test(line)) { flush(); cur = { path: '', bone: '', times: [], values: [] }; continue }
91
+ if (!cur) continue
92
+
93
+ const p = /^ path:\s*(.*)$/.exec(line)
94
+ if (p) {
95
+ cur.path = p[1].trim()
96
+ cur.bone = cur.path.split('/').pop() ?? cur.path
97
+ flush()
98
+ continue
99
+ }
100
+ const t = /^\s+time:\s*(\S+)\s*$/.exec(line)
101
+ if (t) { pending = Number(t[1]); continue }
102
+ const v = /^\s+value:\s*\{(.*)\}\s*$/.exec(line)
103
+ if (v && pending !== null) {
104
+ cur.times.push(pending)
105
+ cur.values.push(numbersIn(v[1]))
106
+ pending = null
107
+ }
108
+ }
109
+ flush()
110
+
111
+ let maxT = 0
112
+ for (const list of [ clip.rotation, clip.position, clip.scale ]) {
113
+ for (const c of list) maxT = Math.max(maxT, c.times[c.times.length - 1] ?? 0)
114
+ }
115
+ clip.duration = stopTime !== null && stopTime > 0 ? stopTime : maxT
116
+ return clip
117
+ }
118
+
119
+ const sample = (c: UnityCurve, time: number, n: number): number[] => {
120
+ const last = c.times.length - 1
121
+ if (time <= c.times[0]) return c.values[0].slice(0, n)
122
+ if (time >= c.times[last]) return c.values[last].slice(0, n)
123
+ let i = 0
124
+ while (i < last && c.times[i + 1] < time) i++
125
+ const u = (time - c.times[i]) / (c.times[i + 1] - c.times[i] || 1)
126
+ const a = c.values[i], b = c.values[i + 1]
127
+ // Unity writes Hermite tangents too; linear/slerp between keys is what a 24-30 fps baked curve
128
+ // reproduces to well under a degree, and a retarget resamples at its own rate anyway.
129
+ if (n === 4) return qslerp(a as unknown as Quat, b as unknown as Quat, u)
130
+ return [ 0, 1, 2 ].map((k) => a[k] + (b[k] - a[k]) * u)
131
+ }
132
+
133
+ export interface AttachReport { name: string; bound: number; total: number; unbound: string[] }
134
+
135
+ /**
136
+ * Turn parsed `.anim` clips into `Clip`s driving `rig`, binding each curve's LAST path segment to a
137
+ * bone name. Curves that bind nothing (a mesh group, a control rig) are counted and reported.
138
+ */
139
+ export function attachUnityClips(rig: Rig, clips: UnityClip[]): { clips: Clip[]; report: AttachReport[] } {
140
+ const out: Clip[] = []
141
+ const report: AttachReport[] = []
142
+ for (const uc of clips) {
143
+ const bind = (list: UnityCurve[], n: number): { node: number; c: UnityCurve; n: number }[] => {
144
+ const res: { node: number; c: UnityCurve; n: number }[] = []
145
+ for (const c of list) {
146
+ const node = rig.index.get(c.bone)
147
+ if (node !== undefined) res.push({ node, c, n })
148
+ }
149
+ return res
150
+ }
151
+ const rot = bind(uc.rotation, 4), pos = bind(uc.position, 3), scl = bind(uc.scale, 3)
152
+ const total = uc.rotation.length + uc.position.length + uc.scale.length
153
+ const bound = rot.length + pos.length + scl.length
154
+ const unbound = [ ...uc.rotation, ...uc.position, ...uc.scale ]
155
+ .filter((c) => !rig.index.has(c.bone)).map((c) => c.bone)
156
+ report.push({ name: uc.name, bound, total, unbound: [ ...new Set(unbound) ] })
157
+ const base = rig.bind.local
158
+ out.push({
159
+ name: uc.name,
160
+ duration: uc.duration,
161
+ sample: (time: number): Pose => {
162
+ const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
163
+ for (const { node, c } of rot) pose.r[node] = qnorm(sample(c, time, 4) as Quat)
164
+ for (const { node, c } of pos) pose.t[node] = sample(c, time, 3) as Vec3
165
+ for (const { node, c } of scl) pose.s[node] = sample(c, time, 3) as Vec3
166
+ return pose
167
+ },
168
+ })
169
+ }
170
+ return { clips: out, report }
171
+ }
@@ -0,0 +1,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
+ /** 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
+ }
@@ -0,0 +1,136 @@
1
+ // Reading back a KTX1 cubemap so `sky` can report what the environment actually contains — above
2
+ // all WHERE ITS SUN IS. A scene lights itself with a directional light that the artist aims by hand;
3
+ // if that direction disagrees with the sun visible in the sky, every shadow in the level points
4
+ // somewhere the picture says it shouldn't. Measuring it beats guessing.
5
+ //
6
+ // cmgen writes KTX1 with GL_R11F_G11F_B10F (0x8C3A): one 32-bit word per texel, 6 faces in the
7
+ // order +X -X +Y -Y +Z -Z, mip levels back to back, each level prefixed by its per-face byte size.
8
+
9
+ export type SkyAnalysis = {
10
+ size: number
11
+ faces: number
12
+ mips: number
13
+ /** Unit vector pointing TOWARD the brightest texel — where the sun is in the sky. */
14
+ toSun: [number, number, number]
15
+ /** What a `light.direction` must be to match it: the negated `toSun`. */
16
+ lightDirection: [number, number, number]
17
+ /** Degrees above the horizon; negative means the brightest point is below it. */
18
+ elevation: number
19
+ /** Colour of the bright core (every texel within a decade of the peak), normalized so the
20
+ * brightest channel is 1 — the light source's tint, not one noisy texel's. */
21
+ sunTint: string
22
+ /** Peak luminance. A real sun disc reads in the thousands; a few units means an overcast or
23
+ * post-sunset sky with no disc at all, where a strong directional light would be wrong. */
24
+ peakLuminance: number
25
+ /** Mean luminance over the whole sphere, and over the upper hemisphere alone. */
26
+ meanLuminance: number
27
+ upperMeanLuminance: number
28
+ }
29
+
30
+ const readU32 = (b: Uint8Array, o: number): number =>
31
+ (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0
32
+
33
+ /** One 11/11/10 packed float triple → linear RGB. */
34
+ const unpackR11G11B10 = (w: number): [number, number, number] => {
35
+ const part = (exp: number, mant: number, mbits: number): number => {
36
+ if (exp === 0) return (mant / (1 << mbits)) * 2 ** -14
37
+ if (exp === 31) return 65504
38
+ return (1 + mant / (1 << mbits)) * 2 ** (exp - 15)
39
+ }
40
+ return [
41
+ part((w >>> 6) & 0x1f, w & 0x3f, 6),
42
+ part((w >>> 17) & 0x1f, (w >>> 11) & 0x3f, 6),
43
+ part((w >>> 27) & 0x1f, (w >>> 22) & 0x1f, 5),
44
+ ]
45
+ }
46
+
47
+ /** Cubemap face + in-face uv → direction, matching filament's face order and orientation. */
48
+ const dirFor = (face: number, s: number, t: number): [number, number, number] => {
49
+ const a = 2 * s - 1
50
+ const b = 1 - 2 * t
51
+ switch (face) {
52
+ case 0: return [1, b, -a]
53
+ case 1: return [-1, b, a]
54
+ case 2: return [a, 1, -b]
55
+ case 3: return [a, -1, b]
56
+ case 4: return [a, b, 1]
57
+ default: return [-a, b, -1]
58
+ }
59
+ }
60
+
61
+ const hex2 = (v: number): string => Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, "0")
62
+
63
+ /** Scan mip 0 of a cmgen KTX1 cubemap. `step` subsamples — 4 keeps a 512 cube under a few ms. */
64
+ export const analyzeSkyKtx = (buf: Uint8Array, step = 4): SkyAnalysis => {
65
+ if (buf.length < 64 || buf[1] !== 0x4b || buf[2] !== 0x54 || buf[3] !== 0x58) {
66
+ throw new Error("not a KTX1 file")
67
+ }
68
+ const width = readU32(buf, 12 + 6 * 4)
69
+ const height = readU32(buf, 12 + 7 * 4)
70
+ const faces = readU32(buf, 12 + 10 * 4)
71
+ const mips = readU32(buf, 12 + 11 * 4)
72
+ const kvLen = readU32(buf, 12 + 12 * 4)
73
+ if (faces !== 6) throw new Error(`expected a cubemap (6 faces), got ${faces}`)
74
+
75
+ const base = 64 + kvLen
76
+ const faceBytes = readU32(buf, base)
77
+ const data = base + 4
78
+
79
+ let peak = -1
80
+ let peakDir: [number, number, number] = [0, 1, 0]
81
+ let total = 0
82
+ let count = 0
83
+ let upper = 0
84
+ let upperCount = 0
85
+
86
+ for (let face = 0; face < 6; face++) {
87
+ const start = data + face * faceBytes
88
+ for (let y = 0; y < height; y += step) {
89
+ for (let x = 0; x < width; x += step) {
90
+ const [r, g, b] = unpackR11G11B10(readU32(buf, start + (y * width + x) * 4))
91
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
92
+ total += lum
93
+ count++
94
+ const d = dirFor(face, (x + 0.5) / width, (y + 0.5) / height)
95
+ if (d[1] > 0) { upper += lum; upperCount++ }
96
+ if (lum > peak) { peak = lum; peakDir = d }
97
+ }
98
+ }
99
+ }
100
+
101
+ // Second pass for the TINT. One texel's colour is a bad answer: the single brightest sample can
102
+ // sit on the rim of a clipped sun where the sky's blue still dominates, which reported a blue sun
103
+ // for a clear-day HDRI. Average the whole bright core instead — every texel within a decade of
104
+ // the peak — so the tint describes the light source rather than one pixel of it.
105
+ const cutoff = peak * 0.1
106
+ let sr = 0, sg = 0, sb = 0, sn = 0
107
+ for (let face = 0; face < 6 && cutoff > 0; face++) {
108
+ const start = data + face * faceBytes
109
+ for (let y = 0; y < height; y += step) {
110
+ for (let x = 0; x < width; x += step) {
111
+ const [r, g, b] = unpackR11G11B10(readU32(buf, start + (y * width + x) * 4))
112
+ if (0.2126 * r + 0.7152 * g + 0.0722 * b < cutoff) continue
113
+ sr += r; sg += g; sb += b; sn++
114
+ }
115
+ }
116
+ }
117
+ const peakRgb: [number, number, number] = sn > 0 ? [sr / sn, sg / sn, sb / sn] : [1, 1, 1]
118
+
119
+ const len = Math.hypot(peakDir[0], peakDir[1], peakDir[2]) || 1
120
+ const toSun: [number, number, number] = [peakDir[0] / len, peakDir[1] / len, peakDir[2] / len]
121
+ const mx = Math.max(peakRgb[0], peakRgb[1], peakRgb[2]) || 1
122
+ const r2 = (v: number): number => Math.round(v * 100) / 100
123
+
124
+ return {
125
+ size: width,
126
+ faces,
127
+ mips,
128
+ toSun: [r2(toSun[0]), r2(toSun[1]), r2(toSun[2])],
129
+ lightDirection: [r2(-toSun[0]), r2(-toSun[1]), r2(-toSun[2])],
130
+ elevation: Math.round(Math.asin(Math.max(-1, Math.min(1, toSun[1]))) * (180 / Math.PI) * 10) / 10,
131
+ sunTint: `#${hex2(peakRgb[0] / mx)}${hex2(peakRgb[1] / mx)}${hex2(peakRgb[2] / mx)}`,
132
+ peakLuminance: Math.round(peak * 10) / 10,
133
+ meanLuminance: Math.round((total / Math.max(1, count)) * 1000) / 1000,
134
+ upperMeanLuminance: Math.round((upper / Math.max(1, upperCount)) * 1000) / 1000,
135
+ }
136
+ }
@@ -0,0 +1,71 @@
1
+ // Locating filament's cmgen — the tool that turns an equirectangular HDR into the cubemaps the
2
+ // engine wants. Same shape as findToktx (doctor/transcodeKtx2.ts): env override, then the places it
3
+ // realistically sits, then PATH.
4
+ //
5
+ // cmgen is not bundled: most projects never build an environment, and the binary is ~600 KB per
6
+ // platform. Resolution order:
7
+ // 1. LECODES_CMGEN — explicit path (CI, or a local filament build)
8
+ // 2. ~/.lecodes/cmgen/**/cmgen[.exe] — the cache `lecodes` downloads into, newest first
9
+ // 3. a sibling desktop build tree — working in this monorepo, cmgen is already built
10
+ // 4. PATH
11
+ import { existsSync, readdirSync, statSync } from "node:fs"
12
+ import { homedir } from "node:os"
13
+ import { dirname, join, resolve } from "node:path"
14
+ import { spawnSync } from "node:child_process"
15
+
16
+ const exeName = (): string => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
17
+
18
+ /** Depth-limited hunt for the binary inside a directory (a release archive may nest a folder). */
19
+ const findIn = (dir: string, depth = 3): string | null => {
20
+ if (depth < 0 || !existsSync(dir)) return null
21
+ let dirs: string[] = []
22
+ try {
23
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
24
+ const p = join(dir, e.name)
25
+ if (e.isFile() && e.name === exeName()) return p
26
+ if (e.isDirectory()) dirs.push(p)
27
+ }
28
+ } catch { return null }
29
+ // newest first: a cache holds cmgen-<version> folders and the freshest should win
30
+ dirs.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
31
+ for (const d of dirs) { const hit = findIn(d, depth - 1); if (hit) return hit }
32
+ return null
33
+ }
34
+
35
+ /** Walk up from here looking for the monorepo's desktop build, where filament's host tools land. */
36
+ const fromBuildTree = (): string | null => {
37
+ let dir = resolve(process.cwd())
38
+ for (let i = 0; i < 6; i++) {
39
+ const hit = join(dir, "packages", "desktop", "build", "creator-gl", "filament", "tools", "cmgen", exeName())
40
+ if (existsSync(hit)) return hit
41
+ const up = dirname(dir)
42
+ if (up === dir) break
43
+ dir = up
44
+ }
45
+ return null
46
+ }
47
+
48
+ export const findCmgen = (): string => {
49
+ const env = process.env.LECODES_CMGEN
50
+ if (env) {
51
+ if (!existsSync(env)) throw new Error(`LECODES_CMGEN points at "${env}" which doesn't exist.`)
52
+ return env
53
+ }
54
+ return findIn(join(homedir(), ".lecodes", "cmgen")) ?? fromBuildTree() ?? exeName()
55
+ }
56
+
57
+ /** Version string when the binary runs, else null — cmgen has no --version, so -h is the probe. */
58
+ export const checkCmgen = (cmgen: string): string | null => {
59
+ try {
60
+ const p = spawnSync(cmgen, ["-h"], { encoding: "utf8" })
61
+ // cmgen exits non-zero on -h; what matters is that it ran and printed its own banner
62
+ const out = `${p.stdout ?? ""}${p.stderr ?? ""}`
63
+ if (p.error) return null
64
+ return /cmgen/i.test(out) ? "cmgen" : null
65
+ } catch { return null }
66
+ }
67
+
68
+ export const CMGEN_HELP =
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>.`
@@ -0,0 +1,151 @@
1
+ // Equirectangular HDR (.exr / .hdr) → the two cubemaps a LeCodes scene needs.
2
+ //
3
+ // The thing everyone gets wrong, so it is worth stating plainly: an environment is TWO files and
4
+ // they are NOT interchangeable.
5
+ //
6
+ // sky.ktx — sharp, single mip. What is DRAWN behind the scene (`skybox: { texture }`).
7
+ // ibl.ktx — prefiltered per roughness + spherical harmonics. What LIGHTS the scene.
8
+ //
9
+ // Handing the IBL to the skybox is the tempting shortcut (one file, and it "works") but that file
10
+ // has been blurred on purpose, so the sky comes out visibly soft. cmgen emits both for this reason;
11
+ // this command just makes the pair reproducible and names them the way the runtime expects.
12
+ //
13
+ // The IBL is deliberately smaller: it is only ever sampled as low-frequency lighting, so 256 is
14
+ // plenty, while the sky is looked at directly and wants 512+.
15
+ import { spawnSync } from "node:child_process"
16
+ import { existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
17
+ import { tmpdir } from "node:os"
18
+ import { basename, extname, join, resolve } from "node:path"
19
+ import { findCmgen, checkCmgen, CMGEN_HELP } from "./cmgen"
20
+ import { analyzeSkyKtx, type SkyAnalysis } from "./analyze"
21
+
22
+ export type SkyOptions = {
23
+ /** Where the pair lands (default: beside the input). */
24
+ outDir?: string
25
+ /** Cubemap edge for the drawn sky (default 512). */
26
+ size?: number
27
+ /** Cubemap edge for the lighting probe (default 256). */
28
+ iblSize?: number
29
+ /** File names, without extension (default "sky" and "ibl" — what the runtime looks for). */
30
+ skyName?: string
31
+ iblName?: string
32
+ /** Skip the sky, emit only the lighting probe. */
33
+ iblOnly?: boolean
34
+ log?: (msg: string) => void
35
+ }
36
+
37
+ export type SkyResult = {
38
+ skyPath: string | null
39
+ iblPath: string | null
40
+ analysis: SkyAnalysis | null
41
+ }
42
+
43
+ const runCmgen = (cmgen: string, args: string[]): void => {
44
+ const p = spawnSync(cmgen, args, { encoding: "utf8" })
45
+ if (p.error) throw new Error(`cmgen failed to start: ${p.error.message}`)
46
+ if (p.status !== 0) {
47
+ const out = `${p.stdout ?? ""}${p.stderr ?? ""}`.trim()
48
+ throw new Error(`cmgen exited with ${p.status}${out ? `:\n${out}` : ""}`)
49
+ }
50
+ }
51
+
52
+ /** The file cmgen's --deploy wrote for a given role, whatever it chose to call the folder. */
53
+ const deployed = (dir: string, suffix: string): string | null => {
54
+ if (!existsSync(dir)) return null
55
+ const hit = readdirSync(dir).find((f) => f.endsWith(`${suffix}.ktx`))
56
+ return hit ? join(dir, hit) : null
57
+ }
58
+
59
+ export const buildSky = async (input: string, opts: SkyOptions = {}): Promise<SkyResult> => {
60
+ const log = opts.log ?? (() => {})
61
+ if (!existsSync(input)) throw new Error(`not found: ${input}`)
62
+
63
+ const ext = extname(input).toLowerCase()
64
+ if (![".exr", ".hdr", ".png", ".psd"].includes(ext)) {
65
+ throw new Error(`unsupported input "${ext}" — cmgen reads .exr, .hdr, .png or .psd (equirectangular).`)
66
+ }
67
+
68
+ const cmgen = findCmgen()
69
+ if (!checkCmgen(cmgen)) throw new Error(CMGEN_HELP)
70
+
71
+ const outDir = resolve(opts.outDir ?? resolve(input, ".."))
72
+ mkdirSync(outDir, { recursive: true })
73
+ const size = opts.size ?? 512
74
+ const iblSize = opts.iblSize ?? 256
75
+ const skyName = opts.skyName ?? "sky"
76
+ const iblName = opts.iblName ?? "ibl"
77
+
78
+ const stage = mkdtempSync(join(tmpdir(), "lecodes-sky-"))
79
+ const result: SkyResult = { skyPath: null, iblPath: null, analysis: null }
80
+ try {
81
+ // Two passes: the sky and the probe want different resolutions, and --deploy sizes both at once.
82
+ if (!opts.iblOnly) {
83
+ log(` cubemap ${size}… (sky)`)
84
+ const d = join(stage, "sky")
85
+ runCmgen(cmgen, ["-q", "--format=ktx", `--size=${size}`, `--deploy=${d}`, input])
86
+ const src = deployed(d, "_skybox")
87
+ if (!src) throw new Error("cmgen produced no _skybox.ktx")
88
+ result.skyPath = join(outDir, `${skyName}.ktx`)
89
+ copyFileSync(src, result.skyPath)
90
+ }
91
+
92
+ log(` cubemap ${iblSize}… (lighting probe + SH)`)
93
+ const d2 = join(stage, "ibl")
94
+ runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, `--deploy=${d2}`, input])
95
+ const iblSrc = deployed(d2, "_ibl")
96
+ if (!iblSrc) throw new Error("cmgen produced no _ibl.ktx")
97
+ result.iblPath = join(outDir, `${iblName}.ktx`)
98
+ copyFileSync(iblSrc, result.iblPath)
99
+
100
+ // Analyse whichever cubemap is sharpest — the sky when we made one, else the probe.
101
+ const toScan = result.skyPath ?? result.iblPath
102
+ try {
103
+ result.analysis = analyzeSkyKtx(new Uint8Array(readFileSync(toScan)))
104
+ } catch (e) {
105
+ log(` (could not analyse the environment: ${(e as Error).message})`)
106
+ }
107
+ } finally {
108
+ rmSync(stage, { recursive: true, force: true })
109
+ }
110
+ return result
111
+ }
112
+
113
+ /** The report + the scene snippet to paste. The analysis is the point of the command: it turns
114
+ * "aim the sun by eye until the shadows look right" into a number read off the environment. */
115
+ export const formatSkyReport = (r: SkyResult, name = "SKY"): string => {
116
+ const out: string[] = []
117
+ const mb = (p: string): string => `${(statSync(p).size / 1048576).toFixed(1)} MB`
118
+ if (r.skyPath) out.push(`💾 ${r.skyPath} (${mb(r.skyPath)}) — drawn as the sky`)
119
+ if (r.iblPath) out.push(`💾 ${r.iblPath} (${mb(r.iblPath)}) — lights the scene`)
120
+ const a = r.analysis
121
+ if (!a) return out.join("\n")
122
+
123
+ const [dx, dy, dz] = a.lightDirection
124
+ out.push("")
125
+ out.push(`🔆 sun at ${a.elevation}° above the horizon · tint ${a.sunTint} · peak ${a.peakLuminance}`)
126
+ if (a.peakLuminance < 100) {
127
+ out.push(
128
+ ` NOTE: that peak is low — this environment has no sun DISC (overcast, or the sun is below\n` +
129
+ ` the horizon). The direction below is just the brightest part of the sky glow, and a strong\n` +
130
+ ` white directional light under it will read as wrong as one pointing the wrong way.`,
131
+ )
132
+ }
133
+ // Sunlight is never blue. A blue-dominant core means the disc is clipped in this HDRI and the
134
+ // reading has picked up the intensely blue sky glow around it — common in the 1k/2k versions.
135
+ // The DIRECTION is still trustworthy; the colour is not, so say so instead of handing over a
136
+ // number that would light the level with blue "sunlight".
137
+ const blueSun = parseInt(a.sunTint.slice(5, 7), 16) > parseInt(a.sunTint.slice(1, 3), 16) + 8
138
+ if (blueSun && a.peakLuminance >= 100) {
139
+ out.push(
140
+ ` NOTE: that tint is BLUE-dominant, which no sun is — the disc is clipped in this file and the\n` +
141
+ ` reading caught the sky glow around it. Trust the direction, not the colour: use a neutral\n` +
142
+ ` warm white (#fff2e2 or so) and tint to taste.`,
143
+ )
144
+ }
145
+ out.push(` mean luminance ${a.meanLuminance} (upper hemisphere ${a.upperMeanLuminance}) — the brighter this is,`)
146
+ out.push(` the lower environmentIntensity wants to be.`)
147
+ 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}' }`)
150
+ return out.join("\n")
151
+ }