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,581 @@
1
+ // FOOT MARKS — where a clip's feet touch the ground, stand still, and lift off; and, for a stair
2
+ // clip, the staircase its plants sit on. Measured OFFLINE and written into the GLB with the clip
3
+ // (`extras.lecodes.contacts`), so every host reads one truth and the retargeter can enforce it
4
+ // (`retarget.ts` locks each foot through its still window). The runtime keeps a copy of these rules
5
+ // (creator-anim `curves.cpp`) for clips that carry no marks.
6
+ //
7
+ // Per foot, per frame: the CONTACT bone (a humanoid's ball — the toe base) and the ANKLE. A frame is
8
+ // DOWN when the contact bone is horizontally slow AND low, or holds still for a quarter second
9
+ // whatever its height, or the whole clip is still (an idle). The thresholds are fractions of the
10
+ // clip's own speed / height range (a sprint's planted toe still rolls; an absolute gate misses it),
11
+ // FLOORED by absolute constants: a range only means something when the clip contains a swing — a
12
+ // combat idle's toe never exceeds 6.5 cm/s, and 20 % of that is noise.
13
+ //
14
+ // Inside a touch span the STILL window is the longest run under absolute stillness thresholds on
15
+ // the toe's speed (both axes), the toe's height and the ankle's height — the ankle is the heel's
16
+ // witness: it descends while the heel lowers and rises the moment the heel does, while the toe stays
17
+ // low through both. The touch is the phase anchor; the still window is what a lock may trust.
18
+ //
19
+ // STAIRS (a clip the build labels as one): every plant sits on a tread, and the treads are a regular
20
+ // staircase — one riser, one depth. Pass 1 finds provisional plants and their heights, a regular
21
+ // staircase is FITTED to them (base + k · riser, least squares), pass 2 judges "low" against the
22
+ // nearest tread instead of the clip's global bottom half, and every span gets its step index.
23
+ //
24
+ // A PLATFORM take also carries its LEDGE (`fitPlatform` → `platform`): a HANG — both hands still
25
+ // and overhead, no foot on the floor — keeps dangling feet out of the fit and, where the feet never
26
+ // stand on the top, names it from the grip (hands + `gripUnder`); the face is bounded by what is
27
+ // inside it (plants on the top, hands resting on it) and outside it (gripping wrists, the body
28
+ // below the top), its normal from the two levels' plants, else across the two gripping wrists,
29
+ // else the facing. The edge is written relative to the root at frame 0.
30
+
31
+ import type { Vec3 } from './rig'
32
+
33
+ export interface FootTrack {
34
+ /** the contact bone's world position per frame (a humanoid's ball) */
35
+ contact: Vec3[]
36
+ /** the ankle's world position per frame (the foot bone) */
37
+ ankle: Vec3[]
38
+ /** a hand: its LOWEST descendant per frame (a fingertip) — a roll lands on the fingers with the
39
+ * wrist 10–20 cm up, and the contact is theirs */
40
+ tip?: Vec3[]
41
+ }
42
+
43
+ export interface MarksInput {
44
+ /** seconds per frame */
45
+ dt: number
46
+ /** the root's world position per frame (`at` is expressed relative to its x/z) */
47
+ root: Vec3[]
48
+ /** the root's heading per frame (radians about +Y, +Z forward at 0) — lets an in-place clip's
49
+ * backward-travelling planted foot read as "slow"; optional */
50
+ heading?: number[]
51
+ feet: { l?: FootTrack; r?: FootTrack }
52
+ /** the HANDS: `contact` = the hand, `ankle` = the forearm (the wrist's witness). A hand is down when
53
+ * it is slow AND within HAND_LOW of the feet's floor — nothing else (a hand held still in the air
54
+ * is a stance, not a contact). Cartwheels, rolls, vaults, hangs. */
55
+ hands?: { l?: FootTrack; r?: FootTrack }
56
+ /** fit a staircase to the plants (a clip labeled as stairs), or a PLATFORM: exactly two levels —
57
+ * the floor and the top — and a foot slow at any other height (against the wall) is no plant */
58
+ stairs?: boolean | 'platform'
59
+ /** the GROUND's height in the tracks' space (a platform's level 0 is the ground, never "the lowest
60
+ * the feet got": feet dangling under a ledge are still and low and not planted at all) */
61
+ floor?: number
62
+ /** the pelvis per frame (default: `root`) — the HANG rule reads the hands against it */
63
+ hips?: Vec3[]
64
+ /** every joint per frame — the body's envelope bounds a platform's face (the body below the top
65
+ * is never inside the platform); optional, the fit does without */
66
+ body?: Vec3[][]
67
+ /** how far under the ledge's top a hanging hand's contact bone holds (m, default 0.15: the
68
+ * fingers over the lip, the wrist against the face) — the level a hang alone defines */
69
+ gripUnder?: number
70
+ }
71
+
72
+ /** A platform take's ledge: the top's height, and its EDGE — a point on the top edge relative to the
73
+ * root at frame 0 (world axes, like `at`), the face's outward horizontal normal — so a runtime can
74
+ * place the character against a real ledge at clip start. `width` = how tightly the edge was
75
+ * pinned (the gap between the inside and outside bounds, m). */
76
+ export interface PlatformMark {
77
+ height: number // the top over level 0 (the riser)
78
+ top: number // the top's absolute height in the tracks' space
79
+ edge: [ number, number ] // x/z of a point on the top edge, relative to the root at frame 0
80
+ normal: [ number, number ] // horizontal unit normal of the face, pointing away from the platform
81
+ width: number
82
+ from: 'both' | 'inside' | 'outside'
83
+ /** what fixed the edge: the HANDS on the ledge (a grip under it, a palm on top of it — known to a
84
+ * few cm) or the FEET and the body alone (a jump onto or off a box: an estimate, see `width`) */
85
+ pinned: 'hands' | 'feet'
86
+ }
87
+
88
+ /** One foot-on-ground: `from`/`to` = the touch (seconds), `still` = the window it is genuinely
89
+ * stationary in (null: never), `at` = the contact bone at the strike (x/z relative to the root that
90
+ * frame, y absolute), `step` = the tread index on a stair clip. */
91
+ export interface MarkSpan { from: number; to: number; still: [ number, number ] | null; at: Vec3; step?: number }
92
+
93
+ /** The regular staircase a stair clip's plants sit on: tread k is at `base + k · riser`. */
94
+ export interface Stairs {
95
+ riser: number
96
+ /** horizontal distance between successive treads (metres; 0 when it could not be measured) */
97
+ tread: number
98
+ base: number
99
+ dir: 'up' | 'down'
100
+ }
101
+
102
+ export interface ClipMarks {
103
+ feet: { l: string | null; r: string | null }
104
+ l: MarkSpan[]
105
+ r: MarkSpan[]
106
+ /** the hands' contact bones and spans (absent: not measured) */
107
+ hands?: { l: string | null; r: string | null }
108
+ hl?: MarkSpan[]
109
+ hr?: MarkSpan[]
110
+ /** planted at t = 0 */
111
+ first: 'l' | 'r' | 'both' | null
112
+ stairs?: Stairs
113
+ /** a platform take's ledge (see `PlatformMark`) */
114
+ platform?: PlatformMark
115
+ }
116
+
117
+ // ---- the constants (kept in step with creator-anim/src/curves.cpp) ----------------------------------
118
+ /** absolute floors under the clip-relative touch gates: horizontal speed (m/s), height above the
119
+ * clip's lowest foot height (m) — 2 cm: a pivot on the spot lifts its toe ~3.5 cm, a planted toe
120
+ * wanders ~1.2 cm */
121
+ const TOUCH_SPEED = 0.15, TOUCH_HEIGHT = 0.02
122
+ /** a foot under this speed on both axes for HELD_SECONDS is down whatever its height */
123
+ const STILL_SPEED = 0.12, HELD_SECONDS = 0.25
124
+ /** the still window must last this long — a velocity reversal passes every gate for 2 samples */
125
+ const STILL_MIN = 0.08
126
+ /** stairs: candidate plants shorter than this do not vote for a tread */
127
+ const CAND_MIN = 0.1
128
+ /** a hand is on the ground within this of the feet's floor (m) */
129
+ const HAND_LOW = 0.06
130
+ /** a HANG: both hands still and at least this far above the pelvis, no foot on the floor (m) */
131
+ const HANG_OVER = 0.5
132
+ /** a hanging hand's contact bone (the wrist) hangs this far in FRONT of the ledge's face (m): the
133
+ * fingers curl over the lip, the palm's heel presses the face, the wrist is outside it */
134
+ const GRIP_OUT = 0.04
135
+
136
+ const hyp2 = (a: number, b: number): number => Math.sqrt(a * a + b * b)
137
+ const median = (v: number[]): number => { const s = [ ...v ].sort((a, b) => a - b); return s.length ? s[s.length >> 1]! : 0 }
138
+
139
+ interface Series { sp: number[]; vv: number[]; vf: number[]; hs: number[]; ah: number[]; lo: number; hi: number; hLo: number; hHi: number; aLo: number; aHi: number }
140
+
141
+ const seriesOf = (ft: FootTrack, dt: number, heading: number[] | undefined): Series => {
142
+ const n = ft.contact.length
143
+ const sp: number[] = [], vv: number[] = [], vf: number[] = [], hs: number[] = [], ah: number[] = []
144
+ for (let k = 0; k < n; k++) {
145
+ const a = Math.max(0, k - 1), b = Math.min(n - 1, k + 1)
146
+ const inv = 1 / ((b - a) * dt || dt)
147
+ const d = [ (ft.contact[b]![0] - ft.contact[a]![0]) * inv, (ft.contact[b]![1] - ft.contact[a]![1]) * inv, (ft.contact[b]![2] - ft.contact[a]![2]) * inv ]
148
+ sp.push(hyp2(d[0]!, d[2]!)); vv.push(Math.abs(d[1]!)); hs.push(ft.contact[k]![1]); ah.push(ft.ankle[k]![1])
149
+ const yaw = heading?.[k] ?? 0
150
+ vf.push(d[0]! * Math.sin(yaw) + d[2]! * Math.cos(yaw))
151
+ }
152
+ return { sp, vv, vf, hs, ah, lo: Math.min(...sp), hi: Math.max(...sp), hLo: Math.min(...hs), hHi: Math.max(...hs), aLo: Math.min(...ah), aHi: Math.max(...ah) }
153
+ }
154
+
155
+ /** ±window local minimum / range of a series (the stair pass-1 height gate). */
156
+ const localMin = (v: number[], w: number): { mn: number[]; range: number[] } => {
157
+ const mn: number[] = [], range: number[] = []
158
+ for (let k = 0; k < v.length; k++) {
159
+ let lo = Infinity, hi = -Infinity
160
+ for (let q = Math.max(0, k - w); q <= Math.min(v.length - 1, k + w); q++) { lo = Math.min(lo, v[q]!); hi = Math.max(hi, v[q]!) }
161
+ mn.push(lo); range.push(hi - lo)
162
+ }
163
+ return { mn, range }
164
+ }
165
+
166
+ const runsOf = (flag: boolean[]): { start: number; end: number }[] => {
167
+ const out: { start: number; end: number }[] = []
168
+ let s = -1
169
+ for (let k = 0; k <= flag.length; k++) {
170
+ const on = k < flag.length && flag[k]!
171
+ if (on && s < 0) s = k
172
+ if (!on && s >= 0) { out.push({ start: s, end: k }); s = -1 }
173
+ }
174
+ return out
175
+ }
176
+
177
+ /**
178
+ * Fit a regular staircase to plant heights: `levels` = (height, weight). Returns null when the
179
+ * plants do not span two treads. Least squares on base + k·riser with k re-assigned each round.
180
+ */
181
+ export const fitStaircase = (levels: { y: number; w: number }[], mode: 'stairs' | 'platform' = 'stairs', floor?: number): { base: number; riser: number; steps: (y: number) => number } | null => {
182
+ if (levels.length < 2) return null
183
+ const sorted = [ ...levels ].sort((a, b) => a.y - b.y)
184
+ // initial levels: gap-based clusters (candidates within 8 cm chain into one tread)
185
+ const clusters: { y: number; w: number }[] = []
186
+ for (const l of sorted) {
187
+ const last = clusters[clusters.length - 1]
188
+ if (last && l.y - last.y < 0.08) { const w = last.w + l.w; last.y = (last.y * last.w + l.y * l.w) / w; last.w = w }
189
+ else clusters.push({ ...l })
190
+ }
191
+ if (mode === 'platform') {
192
+ // a platform has two levels: the floor (the ground when known, else the lowest cluster) and the
193
+ // top (the highest cluster); a plant is on one of them or it is not a plant (a foot pressed
194
+ // against the wall on the way up, feet dangling under a ledge)
195
+ const lo = floor ?? clusters[0]!.y, hi = clusters[clusters.length - 1]!.y, riser = hi - lo
196
+ if (riser < 0.05) return null
197
+ const eps = 0.08 // ON a level means within 8 cm of it — not a share of a 2.5 m platform's height (that let feet dangling 23 cm over the floor count as planted on it)
198
+ return { base: lo, riser, steps: (y) => (Math.abs(y - lo) < eps ? 0 : Math.abs(y - hi) < eps ? 1 : NaN) }
199
+ }
200
+ if (clusters.length < 2) return null
201
+ const gaps: number[] = []
202
+ for (let i = 1; i < clusters.length; i++) gaps.push(clusters[i]!.y - clusters[i - 1]!.y)
203
+ let riser = median(gaps), base = clusters[0]!.y
204
+ if (riser < 0.05) return null
205
+ for (let round = 0; round < 4; round++) {
206
+ // assign k, then weighted least squares for (base, riser)
207
+ let sw = 0, sk = 0, sy = 0, skk = 0, sky = 0
208
+ for (const l of levels) {
209
+ const k = Math.round((l.y - base) / riser)
210
+ sw += l.w; sk += l.w * k; sy += l.w * l.y; skk += l.w * k * k; sky += l.w * k * l.y
211
+ }
212
+ const det = sw * skk - sk * sk
213
+ if (Math.abs(det) < 1e-9) break
214
+ const nb = (sy * skk - sk * sky) / det, nr = (sw * sky - sk * sy) / det
215
+ if (nr < 0.05) break
216
+ base = nb; riser = nr
217
+ }
218
+ // k = 0 at the lowest plant
219
+ const kMin = Math.min(...levels.map((l) => Math.round((l.y - base) / riser)))
220
+ base += kMin * riser
221
+ return { base, riser, steps: (y) => Math.round((y - base) / riser) }
222
+ }
223
+
224
+ /** Detect one clip's marks from its foot tracks. */
225
+ export const detectMarks = (input: MarksInput): { l: MarkSpan[]; r: MarkSpan[]; hl: MarkSpan[]; hr: MarkSpan[]; first: ClipMarks['first']; stairs?: Stairs; platform?: PlatformMark; hang: boolean[]; warnings: string[] } => {
226
+ const { dt, root } = input
227
+ const n = root.length
228
+ const warnings: string[] = []
229
+ const sides: ('l' | 'r')[] = [ 'l', 'r' ]
230
+ const series: Partial<Record<'l' | 'r', Series>> = {}
231
+ for (const s of sides) { const ft = input.feet[s]; if (ft && ft.contact.length === n) series[s] = seriesOf(ft, dt, input.heading) }
232
+ // in place = the root barely travels: a treadmill clip's planted foot travels backward at gait speed
233
+ let travel = 0
234
+ for (let k = 1; k < n; k++) travel += hyp2(root[k]![0] - root[k - 1]![0], root[k]![2] - root[k - 1]![2])
235
+ const inPlace = travel < 0.05
236
+
237
+ // ---- a HANG: both hands still, overhead, no foot on the floor ------------------------------------
238
+ // Dangling feet are still and low and used to vote a "platform" a quarter metre high (the hang
239
+ // idles); a hang frame's feet are no plant candidates, and where the feet never reach a top the
240
+ // grip alone names the level: the hands' height plus what a hand holds under a lip.
241
+ const hangAt = new Array<boolean>(n).fill(false)
242
+ const hipsY = (k: number): number => (input.hips ?? root)[k]![1]
243
+ const floorY0 = input.floor ?? Math.min(...sides.map((s) => series[s]?.hLo ?? Infinity))
244
+ const handSeries: Partial<Record<'l' | 'r', Series>> = {}
245
+ for (const s of sides) { const ft = input.hands?.[s]; if (ft && ft.contact.length === n) handSeries[s] = seriesOf(ft, dt, undefined) }
246
+ if (handSeries.l && handSeries.r) {
247
+ const hl = handSeries.l, hr = handSeries.r
248
+ const flags: boolean[] = []
249
+ for (let k = 0; k < n; k++) {
250
+ const still = hl.sp[k]! < STILL_SPEED && hl.vv[k]! < STILL_SPEED && hr.sp[k]! < STILL_SPEED && hr.vv[k]! < STILL_SPEED
251
+ const overhead = hl.hs[k]! > hipsY(k) + HANG_OVER && hr.hs[k]! > hipsY(k) + HANG_OVER
252
+ const feetUp = Number.isFinite(floorY0) ? sides.every((s) => !series[s] || series[s]!.hs[k]! > floorY0 + 0.1) : true
253
+ flags.push(still && overhead && feetUp)
254
+ }
255
+ // a run at the clip's edge is a pose held before / after the take (a drop from a hang starts
256
+ // hanging for 0.2 s): three frames suffice there
257
+ for (const r of runsOf(flags)) if ((r.end - r.start) * dt >= HELD_SECONDS || ((r.start === 0 || r.end === n) && r.end - r.start >= 3)) for (let k = r.start; k < r.end; k++) hangAt[k] = true
258
+ }
259
+ const hangFrames: number[] = []
260
+ hangAt.forEach((h, k) => { if (h) hangFrames.push(k) })
261
+
262
+ // ---- stairs, pass 1: provisional plants → tread candidates → one regular staircase ---------------
263
+ let stairs: Stairs | undefined
264
+ let stepOf: ((y: number) => number) | null = null
265
+ let levelOf: ((k: number) => number) | null = null
266
+ if (input.stairs) {
267
+ const W = Math.max(1, Math.round(0.3 / dt))
268
+ const minCand = Math.max(2, Math.round(CAND_MIN / dt))
269
+ const cand: { y: number; w: number; t: number; x: number; z: number }[] = []
270
+ for (const s of sides) {
271
+ const se = series[s]; if (!se) continue
272
+ const ft = input.feet[s]!
273
+ const { mn, range } = localMin(se.hs, W)
274
+ const dn: boolean[] = []
275
+ for (let k = 0; k < n; k++) dn.push(!hangAt[k] && se.sp[k]! < se.lo + 0.2 * (se.hi - se.lo) && se.hs[k]! - mn[k]! < Math.max(0.04, 0.3 * range[k]!))
276
+ for (const r of runsOf(dn)) {
277
+ if (r.end - r.start < minCand) continue
278
+ const ys = se.hs.slice(r.start, r.end)
279
+ const mid = (r.start + r.end) >> 1
280
+ cand.push({ y: median(ys), w: r.end - r.start, t: mid * dt, x: ft.contact[mid]![0], z: ft.contact[mid]![2] })
281
+ }
282
+ }
283
+ let fit = fitStaircase(cand.map((c) => ({ y: c.y, w: c.w })), input.stairs === 'platform' ? 'platform' : 'stairs', input.floor)
284
+ // a PLATFORM the feet never stand on (a hang idle, a drop from a hang): the grip names the top
285
+ if (!fit && input.stairs === 'platform' && hangFrames.length && handSeries.l && handSeries.r) {
286
+ const gripY = median(hangFrames.map((k) => Math.min(handSeries.l!.hs[k]!, handSeries.r!.hs[k]!)))
287
+ const lo = input.floor ?? Math.min(...sides.map((s) => series[s]?.hLo ?? Infinity))
288
+ const hi = gripY + (input.gripUnder ?? 0.15)
289
+ if (Number.isFinite(lo) && hi - lo >= 0.3) {
290
+ const eps = 0.08
291
+ fit = { base: lo, riser: hi - lo, steps: (y) => (Math.abs(y - lo) < eps ? 0 : Math.abs(y - hi) < eps ? 1 : NaN) }
292
+ if (process.env.LECODES_MARKS_TRACE) console.log(`[marks] platform from the GRIP: hands at ${gripY.toFixed(3)} over ${hangFrames.length} hang frames → top ${hi.toFixed(3)}`)
293
+ }
294
+ }
295
+ if (process.env.LECODES_MARKS_TRACE) console.log(`[marks] stairs candidates: ${cand.map((c) => `${c.t.toFixed(2)}s y ${c.y.toFixed(3)} w ${c.w}`).join(' | ') || 'none'} → ${fit ? `base ${fit.base.toFixed(3)} riser ${fit.riser.toFixed(3)}` : 'no fit'}`)
296
+ if (!fit) warnings.push('labeled as stairs but its plants sit on one level — flat rules used')
297
+ else {
298
+ stepOf = fit.steps
299
+ levelOf = (k) => fit.base + k * fit.riser
300
+ // tread depth: the horizontal distance between plants one step apart, in time order
301
+ const byT = [ ...cand ].sort((a, b) => a.t - b.t)
302
+ const depths: number[] = []
303
+ for (let i = 1; i < byT.length; i++) {
304
+ const a = byT[i - 1]!, b = byT[i]!
305
+ if (Math.abs(fit.steps(b.y) - fit.steps(a.y)) === 1) depths.push(hyp2(b.x - a.x, b.z - a.z))
306
+ }
307
+ const first = byT[0], last = byT[byT.length - 1]
308
+ stairs = { riser: fit.riser, tread: depths.length ? median(depths) : 0, base: fit.base, dir: first && last && fit.steps(last.y) < fit.steps(first.y) ? 'down' : 'up' }
309
+ }
310
+ }
311
+ const riser = stairs?.riser ?? 0
312
+ const treadEps = Math.max(0.04, 0.3 * riser)
313
+
314
+ // ---- the spans -----------------------------------------------------------------------------
315
+ const out: { l: MarkSpan[]; r: MarkSpan[] } = { l: [], r: [] }
316
+ for (const s of sides) {
317
+ const se = series[s]; if (!se) continue
318
+ const ft = input.feet[s]!
319
+ const { sp, vv, vf, hs, ah, lo, hi, hLo, hHi, aLo, aHi } = se
320
+ const still = hi - lo < 0.05
321
+ // held still on both axes for a quarter second: down whatever the height
322
+ const held = new Array<boolean>(n).fill(false)
323
+ for (const r of runsOf(sp.map((v, k) => v < STILL_SPEED && vv[k]! < STILL_SPEED))) {
324
+ if ((r.end - r.start) * dt >= HELD_SECONDS) for (let k = r.start; k < r.end; k++) held[k] = true
325
+ }
326
+ const spThr = Math.max(TOUCH_SPEED, lo + 0.2 * (hi - lo))
327
+ const down: boolean[] = []
328
+ for (let k = 0; k < n; k++) {
329
+ const slow = sp[k]! < spThr || (inPlace && input.heading !== undefined && vf[k]! <= 0.15 * hi)
330
+ let low: boolean, vOk = true
331
+ if (levelOf && stepOf) {
332
+ // on a staircase: within reach of the nearest tread AND not moving vertically THROUGH it
333
+ // (a foot stepping down onto its tread is mostly vertical — horizontally slow and near the
334
+ // level while still falling); on a platform a height that is no level is no plant
335
+ const kk = stepOf(hs[k]!)
336
+ low = Number.isFinite(kk) && Math.abs(hs[k]! - levelOf(kk)) < treadEps
337
+ vOk = vv[k]! < 0.3
338
+ } else low = hs[k]! < hLo + Math.max(TOUCH_HEIGHT, 0.5 * (hHi - hLo))
339
+ down.push(still || held[k]! || (slow && low && vOk))
340
+ }
341
+ for (const r of runsOf(down)) {
342
+ const interior = r.start > 0 && r.end < n
343
+ const minSpan = !stairs ? 2 : interior ? Math.max(2, Math.round(0.08 / dt)) : Math.max(2, Math.round(0.06 / dt))
344
+ if (r.end - r.start < minSpan) continue
345
+ // the still window: the longest run under the absolute stillness gates
346
+ const thr = Math.max(STILL_SPEED, lo + 0.05 * (hi - lo))
347
+ let mnH = Infinity, mnA = Infinity
348
+ for (let q = r.start; q < r.end; q++) { mnH = Math.min(mnH, hs[q]!); mnA = Math.min(mnA, ah[q]!) }
349
+ const hThr = mnH + (stairs ? Math.max(0.02, 0.1 * riser) : Math.max(0.015, 0.04 * (hHi - hLo)))
350
+ const aThr = mnA + (stairs ? Math.max(0.015, 0.075 * riser) : Math.max(0.01, 0.04 * (aHi - aLo)))
351
+ let best: { start: number; end: number } | null = null
352
+ for (const w of runsOf(sp.slice(r.start, r.end).map((v, i) => { const q = r.start + i; return v < thr && vv[q]! < thr && hs[q]! < hThr && ah[q]! < aThr }))) {
353
+ if (!best || w.end - w.start > best.end - best.start) best = w
354
+ }
355
+ const stillWin: [ number, number ] | null = best && (best.end - 1 - best.start) * dt >= STILL_MIN
356
+ ? [ (r.start + best.start) * dt, (r.start + best.end - 1) * dt ] : null
357
+ const c = ft.contact[r.start]!, rt = root[r.start]!
358
+ const span: MarkSpan = { from: r.start * dt, to: (r.end - 1) * dt, still: stillWin, at: [ c[0] - rt[0], c[1], c[2] - rt[2] ] }
359
+ if (stepOf) { const kk = stepOf(median(hs.slice(r.start, r.end))); if (!Number.isFinite(kk)) continue; span.step = kk }
360
+ out[s].push(span)
361
+ }
362
+ }
363
+ const downAt0 = (s: 'l' | 'r'): boolean => out[s].some((x) => x.from <= 1e-6)
364
+ const first: ClipMarks['first'] = downAt0('l') && downAt0('r') ? 'both' : downAt0('l') ? 'l' : downAt0('r') ? 'r' : null
365
+
366
+ // ---- the hands: slow AND on the feet's floor ------------------------------------------------
367
+ const hands: { hl: MarkSpan[]; hr: MarkSpan[] } = { hl: [], hr: [] }
368
+ const floorY = Math.min(...sides.map((s) => series[s]?.hLo ?? Infinity))
369
+ // the heights a hand can be on: the floor, and every level of a fitted staircase / platform. On
370
+ // the floor a hand is down within HAND_LOW above it; on a level above the floor also in the band
371
+ // below its edge — a hand hanging from a ledge holds it 10–20 cm under the top (fingers over the
372
+ // lip, the wrist against the face), the donor's hang measured 15 cm.
373
+ const handLevels: { y: number; step: number }[] = []
374
+ if (levelOf && stepOf) {
375
+ const ks = new Set<number>()
376
+ for (const s of sides) for (const sp of out[s]) if (sp.step !== undefined) ks.add(sp.step)
377
+ if (!ks.size) ks.add(0)
378
+ for (const k of ks) handLevels.push({ y: levelOf(k), step: k })
379
+ if (stairs && input.stairs === 'platform' && !ks.has(1)) handLevels.push({ y: levelOf(1), step: 1 })
380
+ } else if (floorY < Infinity) handLevels.push({ y: floorY, step: -1 })
381
+ const handLevelAt = (y: number): { y: number; step: number } | null => {
382
+ let best: { y: number; step: number } | null = null, bd = Infinity
383
+ for (const l of handLevels) {
384
+ const above = y - l.y
385
+ const ok = above < HAND_LOW && (l.step > 0 ? above > -0.25 : above > -0.05)
386
+ if (ok && Math.abs(above) < bd) { bd = Math.abs(above); best = l }
387
+ }
388
+ return best
389
+ }
390
+ if (input.hands && handLevels.length) for (const s of sides) {
391
+ const ft = input.hands[s]
392
+ if (!ft || ft.contact.length !== n) continue
393
+ const { sp, vv, hs, ah, lo, hi } = seriesOf(ft, dt, undefined)
394
+ const spThr = Math.max(TOUCH_SPEED, lo + 0.2 * (hi - lo))
395
+ const down: boolean[] = []
396
+ const lowest = (k: number): number => (ft.tip ? Math.min(hs[k]!, ft.tip[k]![1]) : hs[k]!)
397
+ for (let k = 0; k < n; k++) down.push(sp[k]! < spThr && handLevelAt(lowest(k)) !== null)
398
+ for (const r of runsOf(down)) {
399
+ if (r.end - r.start < 2) continue
400
+ // the still window: absolute gates (a hand's height range spans the body — no relative widths)
401
+ const thr = STILL_SPEED
402
+ let mnH = Infinity, mnA = Infinity
403
+ for (let q = r.start; q < r.end; q++) { mnH = Math.min(mnH, hs[q]!); mnA = Math.min(mnA, ah[q]!) }
404
+ let best: { start: number; end: number } | null = null
405
+ for (const w of runsOf(sp.slice(r.start, r.end).map((v, i) => { const q = r.start + i; return v < thr && vv[q]! < thr && hs[q]! < mnH + 0.02 && ah[q]! < mnA + 0.03 }))) {
406
+ if (!best || w.end - w.start > best.end - best.start) best = w
407
+ }
408
+ const stillWin: [ number, number ] | null = best && (best.end - 1 - best.start) * dt >= STILL_MIN
409
+ ? [ (r.start + best.start) * dt, (r.start + best.end - 1) * dt ] : null
410
+ const c = ft.contact[r.start]!, rt = root[r.start]!
411
+ const lvl = handLevelAt(median(Array.from({ length: r.end - r.start }, (_, i) => lowest(r.start + i))))
412
+ const span: MarkSpan = { from: r.start * dt, to: (r.end - 1) * dt, still: stillWin, at: [ c[0] - rt[0], c[1], c[2] - rt[2] ] }
413
+ if (lvl && lvl.step >= 0) span.step = lvl.step
414
+ hands[s === 'l' ? 'hl' : 'hr'].push(span)
415
+ }
416
+ }
417
+ // ---- the platform's edge (a platform take with a fitted top) -------------------------------------
418
+ let platform: PlatformMark | undefined
419
+ if (stairs && input.stairs === 'platform') {
420
+ const feet = sides.filter((s) => input.feet[s] && input.feet[s]!.contact.length === n).map((s) => ({ track: input.feet[s]!, spans: out[s] }))
421
+ const hs = sides.filter((s) => input.hands?.[s] && input.hands[s]!.contact.length === n).map((s) => ({ track: input.hands![s]!, spans: hands[s === 'l' ? 'hl' : 'hr'] }))
422
+ platform = fitPlatform({ dt, root, hips: input.hips, heading: input.heading, level0: stairs.base, level1: stairs.base + stairs.riser, feet, hands: hs, hang: hangAt, body: input.body }) ?? undefined
423
+ if (!platform) warnings.push('platform take: the ledge\'s edge could not be placed (no plant, hand or body near it)')
424
+ }
425
+ return { l: out.l, r: out.r, hl: hands.hl, hr: hands.hr, first, ...(stairs ? { stairs } : {}), ...(platform ? { platform } : {}), hang: hangAt, warnings }
426
+ }
427
+
428
+ export interface PlatformFitInput {
429
+ dt: number
430
+ root: Vec3[]
431
+ hips?: Vec3[]
432
+ heading?: number[]
433
+ level0: number
434
+ level1: number
435
+ /** each foot: its track and its marked spans (`step` 1 = on the top) */
436
+ feet: { track: FootTrack; spans: MarkSpan[] }[]
437
+ /** each hand: its track and its spans (`step` 1 = on the top, or gripping under its edge) */
438
+ hands: { track: FootTrack; spans: MarkSpan[] }[]
439
+ /** per frame: a hang (both hands overhead, still, feet off the floor) */
440
+ hang: boolean[]
441
+ /** every joint per frame (optional): the body below the top is never inside the platform */
442
+ body?: Vec3[][]
443
+ }
444
+
445
+ /**
446
+ * Where a platform's FACE is. Two bounds along the face's outward normal `n`: INSIDE points — the
447
+ * balls of the plants on the top, the wrists of hands resting on the top — lie behind the face;
448
+ * OUTSIDE points — hanging hands' wrists (plus GRIP_OUT), every joint of the body while below the
449
+ * top — lie in front of it. The face is midway between the two nearest; with one bound only, a
450
+ * couple of centimetres beyond it. `n` = from the top's plants toward the ground's (a climb, a drop),
451
+ * else from a hang's grip toward the hanging pelvis, else the root's facing at a hang frame.
452
+ */
453
+ export const fitPlatform = (input: PlatformFitInput): PlatformMark | null => {
454
+ const { level0, level1, root } = input
455
+ const n = root.length
456
+ const framesOf = (sp: MarkSpan): number[] => { const a = Math.round(sp.from / input.dt), b = Math.min(n - 1, Math.round(sp.to / input.dt)); const out: number[] = []; for (let k = a; k <= b; k++) out.push(k); return out }
457
+ const meanXZ = (pts: Vec3[]): [ number, number ] | null => (pts.length ? [ pts.reduce((a, p) => a + p[0], 0) / pts.length, pts.reduce((a, p) => a + p[2], 0) / pts.length ] : null)
458
+ const topPlants: Vec3[] = [], groundPlants: Vec3[] = []
459
+ for (const f of input.feet) for (const sp of f.spans) { const dst = sp.step === 1 ? topPlants : sp.step === 0 ? groundPlants : null; if (dst) for (const k of framesOf(sp)) dst.push(f.track.contact[k]!) }
460
+ const topHands: Vec3[] = [], grips: Vec3[] = []
461
+ const gripsOf: Vec3[][] = input.hands.map(() => [])
462
+ // (a hand REST: a running arm's swing pauses for three frames at a low platform's height and
463
+ // read as a palm on the top — a real palm or grip holds for a good fraction of a second)
464
+ input.hands.forEach((h, hi) => { for (const sp of h.spans) {
465
+ if (sp.step !== 1 || sp.to - sp.from < 0.12) continue
466
+ const ks = framesOf(sp)
467
+ const y = median(ks.map((k) => h.track.contact[k]![1]))
468
+ const dst = y < level1 - 0.05 ? grips : topHands
469
+ for (const k of ks) { dst.push(h.track.contact[k]!); if (dst === grips) gripsOf[hi]!.push(h.track.contact[k]!) }
470
+ } })
471
+ const hangKs: number[] = []
472
+ input.hang.forEach((h, k) => { if (h) hangKs.push(k) })
473
+ // when the character is ON the top: between its first and last top plant (the body bound skips
474
+ // those frames — a foot dragged low over the top is not a wall), and whether it LEAVES the top
475
+ // (a drop: the last top plant's toes hang over the edge) or ARRIVES on it (a landing foot comes
476
+ // down some way past the edge)
477
+ let firstTop = Infinity, lastTop = -Infinity
478
+ for (const f of input.feet) for (const sp of f.spans) if (sp.step === 1) { const ks = framesOf(sp); firstTop = Math.min(firstTop, ks[0]!); lastTop = Math.max(lastTop, ks[ks.length - 1]!) }
479
+ let firstOut = Infinity
480
+ for (const f of input.feet) for (const sp of f.spans) if (sp.step === 0) firstOut = Math.min(firstOut, framesOf(sp)[0]!)
481
+ if (hangKs.length) firstOut = Math.min(firstOut, hangKs[0]!)
482
+ const leaving = Number.isFinite(lastTop) && Number.isFinite(firstOut) && firstOut > lastTop
483
+ // the outward normal: from the top's plants toward the ground's, else toward the grip (a top
484
+ // walked to its edge and dropped from), else from a hang's grip toward the hanging pelvis when
485
+ // that offset is readable, else the facing (a hang faces its wall; the pelvis offset signs it)
486
+ let nx = 0, nz = 0
487
+ const tp = meanXZ(topPlants), gp = meanXZ(groundPlants), gr = meanXZ(grips), th = meanXZ(topHands)
488
+ const inside = tp ?? th
489
+ const hp = hangKs.length ? meanXZ(hangKs.map((k) => (input.hips ?? root)[k]!)) : null
490
+ // plants on both levels half a metre apart: their line (a walk along the top toward the edge and
491
+ // a hang under it gave a 30° skew from the top plants' mean — a hang trusts the facing instead)
492
+ if (inside && gp && hyp2(gp[0] - inside[0], gp[1] - inside[1]) >= 0.5) { nx = gp[0] - inside[0]; nz = gp[1] - inside[1] }
493
+ else {
494
+ // two hands gripping the same edge: the line between the wrists IS the edge, the normal is
495
+ // across it, signed toward the hanging pelvis (else backward from the facing)
496
+ const gl = gripsOf.map((g) => meanXZ(g)).filter((g): g is [ number, number ] => !!g)
497
+ if (gl.length >= 2 && hyp2(gl[1]![0] - gl[0]![0], gl[1]![1] - gl[0]![1]) >= 0.15) {
498
+ const tx = gl[1]![0] - gl[0]![0], tz = gl[1]![1] - gl[0]![1]
499
+ let px = tz, pz = -tx
500
+ const ks = hangKs.length ? hangKs : [ 0 ]
501
+ let fx = 0, fz = 0
502
+ for (const k of ks) { const yaw = input.heading?.[k] ?? 0; fx += Math.sin(yaw); fz += Math.cos(yaw) }
503
+ const off = hp && gr ? [ hp[0] - gr[0], hp[1] - gr[1] ] : null
504
+ const dot = off && hyp2(off[0]!, off[1]!) >= 0.1 ? off[0]! * px + off[1]! * pz : -(fx * px + fz * pz)
505
+ if (dot < 0) { px = -px; pz = -pz }
506
+ nx = px; nz = pz
507
+ }
508
+ }
509
+ if (hyp2(nx, nz) < 0.05) {
510
+ // the facing over the hang frames (a hang faces its wall: the normal points backward), signed
511
+ // by the pelvis' offset from the grip when it reads, else at frame 0 signed by the plants
512
+ const ks = hangKs.length ? hangKs : [ 0 ]
513
+ let fx = 0, fz = 0
514
+ for (const k of ks) { const yaw = input.heading?.[k] ?? 0; fx += Math.sin(yaw); fz += Math.cos(yaw) }
515
+ const fl = hyp2(fx, fz) || 1
516
+ const fwd = [ fx / fl, fz / fl ]
517
+ const off = gr && hp ? [ hp[0] - gr[0], hp[1] - gr[1] ] : null
518
+ const dot = off && hyp2(off[0]!, off[1]!) >= 0.1 ? off[0]! * fwd[0]! + off[1]! * fwd[1]! : tp && gp ? (gp[0] - tp[0]) * fwd[0]! + (gp[1] - tp[1]) * fwd[1]! : -1
519
+ const sign = Math.sign(dot) || -1
520
+ nx = fwd[0]! * sign; nz = fwd[1]! * sign
521
+ }
522
+ const nl = hyp2(nx, nz); nx /= nl; nz /= nl
523
+ const s = (p: Vec3): number => p[0] * nx + p[2] * nz
524
+ // the bounds: the face lies ahead of the inside points by what each kind leaves over the edge,
525
+ // and never past the outside ones
526
+ let lo = -Infinity, hi = Infinity
527
+ // a LANDING on the top: the first strike's HEEL is at the edge (the foot comes down just past
528
+ // it; the heel's back sits ~3 cm behind the ankle) — the owner's rule for hand-less takes; a
529
+ // DEPARTURE: the last plant's toes hang over the edge
530
+ if (Number.isFinite(firstTop) && !leaving) {
531
+ for (const f of input.feet) for (const sp of f.spans) if (sp.step === 1 && framesOf(sp)[0] === firstTop) lo = Math.max(lo, s(f.track.ankle[firstTop]!) - 0.03)
532
+ } else for (const p of topPlants) lo = Math.max(lo, s(p) + 0.12)
533
+ for (const p of topHands) lo = Math.max(lo, s(p) + 0.08)
534
+ // a gripping wrist hangs GRIP_OUT in front of the face (the fingers over the lip): the face is
535
+ // exactly there — the grip decides, the body bound is for takes without one (a retargeted
536
+ // rig's unconstrained dangling feet reached 10 cm into the wall and pushed the face out)
537
+ for (const p of grips) hi = Math.min(hi, s(p) - GRIP_OUT)
538
+ let hiAt = ''
539
+ if (input.body && !grips.length) for (let k = 0; k < n; k++) {
540
+ if (k >= firstTop && k <= lastTop) continue
541
+ // well under the lip: a gripping hand's fingers curl over it, a mantling foot dips into the top
542
+ input.body[k]!.forEach((p, j) => { if (p[1] < level1 - 0.3 && s(p) - 0.02 < hi) { hi = s(p) - 0.02; hiAt = `body f${k} joint ${j} y ${p[1].toFixed(2)}` } })
543
+ }
544
+ if (!Number.isFinite(lo) && !Number.isFinite(hi)) return null
545
+ const from: PlatformMark['from'] = Number.isFinite(lo) && Number.isFinite(hi) ? 'both' : Number.isFinite(lo) ? 'inside' : 'outside'
546
+ const sf = from === 'both' ? Math.min(lo, hi) : from === 'inside' ? lo : hi
547
+ // a point on the edge: the reference (inside points, else the grip, else the hanging pelvis) projected onto the face
548
+ const ref = inside ?? gr ?? meanXZ(hangKs.map((k) => (input.hips ?? root)[k]!)) ?? [ root[0]![0], root[0]![2] ]
549
+ const rs = ref[0] * nx + ref[1] * nz
550
+ const ex = ref[0] + nx * (sf - rs), ez = ref[1] + nz * (sf - rs)
551
+ if (process.env.LECODES_MARKS_TRACE) console.log(`[marks] platform: normal (${nx.toFixed(2)}, ${nz.toFixed(2)}) inside ${Number.isFinite(lo) ? lo.toFixed(3) : '-'} outside ${Number.isFinite(hi) ? hi.toFixed(3) : '-'} → face ${sf.toFixed(3)} (${from}${hiAt ? `, ${hiAt}` : ''}) · top plants ${topPlants.length} ground ${groundPlants.length} grips ${grips.length} top hands ${topHands.length} hang frames ${hangKs.length}`)
552
+ return { height: level1 - level0, top: level1, edge: [ ex - root[0]![0], ez - root[0]![2] ], normal: [ nx, nz ], width: from === 'both' ? Math.max(0, hi - lo) : Infinity, from, pinned: grips.length || topHands.length ? 'hands' : 'feet' }
553
+ }
554
+
555
+ /** The marks written to / read from a GLB animation's `extras.lecodes`. */
556
+ export const readMarks = (extras: unknown): ClipMarks | null => {
557
+ const lec = (extras as { lecodes?: { contacts?: ClipMarks } } | null)?.lecodes
558
+ return lec?.contacts ?? null
559
+ }
560
+
561
+ export type LevelKind = 'stairs' | 'platform' | false
562
+ /** What kind of LEVELS a clip name labels: a staircase (`stairs` pattern, default "stair"), a platform
563
+ * (`platforms` pattern, default "platform": exactly two levels) or none. A pattern is a regex
564
+ * source, 'all' or 'none'. */
565
+ export const stairsMatcher = (stairs: string | undefined, platforms: string | undefined = undefined): ((name: string) => LevelKind) => {
566
+ const mk = (p: string | undefined, def: string): ((n: string) => boolean) => {
567
+ if (p === 'none') return () => false
568
+ if (p === 'all') return () => true
569
+ const re = new RegExp(p ?? def, 'i')
570
+ return (n) => re.test(n)
571
+ }
572
+ const isStairs = mk(stairs, 'stair'), isPlatform = mk(platforms, 'platform')
573
+ return (name) => (isPlatform(name) ? 'platform' : isStairs(name) ? 'stairs' : false)
574
+ }
575
+
576
+ /** One line per clip for a log. */
577
+ export const describeMarks = (m: { l: MarkSpan[]; r: MarkSpan[]; hl?: MarkSpan[]; hr?: MarkSpan[]; stairs?: Stairs }): string => {
578
+ const side = (s: 'l' | 'r' | 'hl' | 'hr'): string => (m[s] ?? []).map((x) => `${s.toUpperCase()} ${x.from.toFixed(2)}-${x.to.toFixed(2)}${x.still ? ` still(${x.still[0].toFixed(2)}-${x.still[1].toFixed(2)})` : ''}${x.step !== undefined ? ` k${x.step}` : ''}`).join(' | ')
579
+ const st = m.stairs ? ` stairs ${m.stairs.dir} riser ${(m.stairs.riser * 100).toFixed(1)} cm tread ${(m.stairs.tread * 100).toFixed(0)} cm` : ''
580
+ return `${[ side('l'), side('r'), side('hl'), side('hr') ].filter(Boolean).join(' | ') || 'no contacts'}${st}`
581
+ }
Binary file