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.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- 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
|