lecodes-assets 0.2.0 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,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,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
+ /** 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
+ }
@@ -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, "hosts", "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 (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>.`