cozyclay 2.1.1 → 3.0.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.
- package/CHANGELOG.md +88 -0
- package/README.md +12 -30
- package/bin/agent/agent-routes.mjs +43 -116
- package/bin/agent/agent-runner.mjs +14 -15
- package/bin/agent/cliproxy-provider.mjs +135 -0
- package/bin/agent/credential-store.mjs +30 -7
- package/bin/agent/pi-tools.mjs +4 -1
- package/bin/agent/provider-keys.mjs +32 -0
- package/bin/agent/provider-login.mjs +110 -0
- package/bin/agent/provider-routes.mjs +99 -0
- package/bin/agent/providers.mjs +19 -97
- package/bin/agent/script-tool.mjs +17 -4
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-context-budget.mjs +93 -0
- package/bin/agent/studio-motion-reference.mjs +23 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase-text.mjs +32 -0
- package/bin/agent/studio-rebase.mjs +26 -39
- package/bin/agent/studio-tools.mjs +9 -28
- package/bin/agent/tool-arguments.mjs +49 -0
- package/bin/agent/turn-frames.mjs +66 -0
- package/bin/codex-auth.mjs +9 -2
- package/bin/cozyclay.mjs +10 -116
- package/bin/live/cli.mjs +3 -3
- package/bin/mcp-runtime.mjs +2 -12
- package/bin/package-signature.mjs +0 -4
- package/dist/app/index.html +7 -6
- package/dist/assets/EXRLoader-CvVIvGVE.js +1 -0
- package/dist/assets/analytics-BWCqdgWA.js +1 -0
- package/dist/assets/app-DfTCxaKk.js +5087 -0
- package/dist/assets/app-f-fkV5uP.css +1 -0
- package/dist/assets/compile-worker-BxKXh07s.js +5 -0
- package/dist/assets/fflate.module-KbGj5-H6.js +5 -0
- package/dist/assets/{first-shot-handoff-msLI71g1.js → first-shot-handoff-DNO8kv_N.js} +1 -1
- package/dist/assets/{landing-ChUvB_zL.js → landing-yfZaJkWc.js} +1 -1
- package/dist/assets/scene-asset-sync-U3kS2rvK.js +20 -0
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +1 -0
- package/dist/assets/workflow-D78eiKTa.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +3 -3
- package/dist/sw.js +3 -6
- package/dist/workflow/index.html +5 -4
- package/mcp/LIVE-PROTOCOL.md +7 -47
- package/mcp/README.md +16 -26
- package/mcp/live-hub.mjs +6 -10
- package/mcp/package.json +1 -3
- package/mcp/tool-handlers.mjs +4 -168
- package/package.json +3 -22
- package/src/App.jsx +151 -2625
- package/src/agent-progress.js +80 -0
- package/src/agent-turn-mark.js +30 -0
- package/src/analytics.js +1 -19
- package/src/app-context.js +12 -2
- package/src/app-stage.jsx +2 -34
- package/src/asset-pane.jsx +6 -5
- package/src/camera-block.js +21 -0
- package/src/camera-direction.js +120 -0
- package/src/character-kind.js +3 -9
- package/src/command-bus.js +10 -8
- package/src/commands/cast.js +5 -34
- package/src/commands/elements/character.js +1 -1
- package/src/commands/elements/motion.js +1 -6
- package/src/commands/elements/object.js +6 -2
- package/src/commands/elements/shot.js +4 -1
- package/src/commands/elements.js +38 -14
- package/src/commands/index.js +3 -1
- package/src/commands/kits.js +37 -0
- package/src/commands/motion-timing.js +57 -0
- package/src/commands/motion.js +9 -152
- package/src/commands/motion3-compile.js +65 -0
- package/src/commands/motion3.js +214 -0
- package/src/commands/objects.js +6 -4
- package/src/commands/shot.js +55 -13
- package/src/commands/stage.js +14 -1
- package/src/commands/view.js +4 -1
- package/src/document-mirrors.js +42 -0
- package/src/document-store.js +4 -0
- package/src/domains/cast.js +19 -194
- package/src/domains/motion/motion-range-pin-rules.js +1 -1
- package/src/domains/motion/take-source.js +53 -0
- package/src/domains/motion.js +60 -1838
- package/src/domains/scenes.js +12 -55
- package/src/domains/shots.js +2 -2
- package/src/domains/stage.js +4 -2
- package/src/draft-ghost.js +47 -0
- package/src/draft-ghost.jsx +129 -0
- package/src/dualview.jsx +4 -3
- package/src/effects/EffectsLayer.jsx +112 -0
- package/src/effects/effect-meshes.js +232 -0
- package/src/effects/effect-record.js +103 -0
- package/src/effects/emitter.js +59 -0
- package/src/effects/simulate.js +261 -0
- package/src/effects/state.js +59 -0
- package/src/execution-telemetry.js +2 -3
- package/src/first-success-guide.jsx +5 -5
- package/src/ghost-set.js +257 -0
- package/src/held-object-edit.jsx +113 -0
- package/src/held-object-pose.js +175 -0
- package/src/hierarchy-model.js +1 -1
- package/src/hierarchy-panel.jsx +1 -1
- package/src/imported-mesh.jsx +103 -0
- package/src/kits/held.js +84 -0
- package/src/kits/index.js +70 -0
- package/src/kits/smoking.js +57 -0
- package/src/light-blockers.js +93 -0
- package/src/motion/beat-board-model.js +84 -0
- package/src/motion/beat-board.css +124 -0
- package/src/motion/beat-board.jsx +60 -0
- package/src/motion/center-of-mass.js +66 -0
- package/src/motion/fix-collisions.js +850 -0
- package/src/motion/physics-review.js +118 -0
- package/src/{ardy → motion}/physics-support.js +1 -76
- package/src/motion/rig-snapshot.js +22 -0
- package/src/motion/take-segments.jsx +140 -0
- package/src/{ardy → motion}/timeline-coordinates.js +0 -21
- package/src/{ardy → motion}/timeline.css +1 -2
- package/src/{ardy → motion}/timeline.jsx +42 -413
- package/src/motion/waypoints.js +76 -0
- package/src/motion3/beats.js +83 -0
- package/src/motion3/body.js +158 -0
- package/src/motion3/bones.js +47 -0
- package/src/motion3/carried.js +47 -0
- package/src/motion3/compile-job.js +79 -0
- package/src/motion3/compile-worker.js +10 -0
- package/src/motion3/compiler/anchors.js +113 -0
- package/src/motion3/compiler/arm-orient.js +128 -0
- package/src/motion3/compiler/channels.js +125 -0
- package/src/motion3/compiler/compile.js +205 -0
- package/src/motion3/compiler/gait-rhythm.js +97 -0
- package/src/motion3/compiler/grip.js +171 -0
- package/src/motion3/compiler/head-steady.js +48 -0
- package/src/motion3/compiler/hold.js +134 -0
- package/src/motion3/compiler/idle.js +113 -0
- package/src/motion3/compiler/limbs.js +197 -0
- package/src/motion3/compiler/math.js +72 -0
- package/src/motion3/compiler/pose.js +177 -0
- package/src/motion3/compiler/quat.js +49 -0
- package/src/motion3/compiler/skeleton.js +74 -0
- package/src/motion3/compiler/validate-common.js +32 -0
- package/src/motion3/compiler/validate-goals.js +118 -0
- package/src/motion3/compiler/validate-objects.js +144 -0
- package/src/motion3/compiler/validate-parts.js +118 -0
- package/src/motion3/compiler/validate.js +122 -0
- package/src/motion3/compiler/vocab.js +74 -0
- package/src/motion3/compiler/walk-pelvis.js +275 -0
- package/src/motion3/compiler/walk.js +207 -0
- package/src/motion3/compiler/wrist-clamp.js +32 -0
- package/src/motion3/delta.js +47 -0
- package/src/motion3/describe.js +177 -0
- package/src/motion3/docs.js +27 -0
- package/src/motion3/edit-carry.js +21 -0
- package/src/motion3/fix/ease.js +16 -0
- package/src/motion3/fix/ground.js +37 -0
- package/src/motion3/fix/index.js +52 -0
- package/src/motion3/fix/push.js +71 -0
- package/src/motion3/fix/reach.js +46 -0
- package/src/motion3/fix/retime.js +79 -0
- package/src/motion3/fix/self.js +211 -0
- package/src/motion3/fix/slide.js +70 -0
- package/src/motion3/fix/turn.js +51 -0
- package/src/motion3/follow.js +39 -0
- package/src/motion3/free-motion.js +204 -0
- package/src/motion3/grip-choice.js +130 -0
- package/src/motion3/hold-plan.js +157 -0
- package/src/motion3/json-patch.js +77 -0
- package/src/motion3/key-aim.js +105 -0
- package/src/motion3/key-edit.js +82 -0
- package/src/motion3/lenient.js +127 -0
- package/src/motion3/object-keys.js +236 -0
- package/src/motion3/object-pose.js +182 -0
- package/src/motion3/object-tracks.js +12 -0
- package/src/motion3/people.js +82 -0
- package/src/motion3/pipeline.js +102 -0
- package/src/motion3/playback.js +51 -0
- package/src/motion3/probes.js +91 -0
- package/src/motion3/qa/body-checks.js +34 -0
- package/src/motion3/qa/check-names.js +14 -0
- package/src/motion3/qa/checks.js +125 -0
- package/src/motion3/qa/contact-checks.js +51 -0
- package/src/motion3/qa/explain.js +80 -0
- package/src/motion3/qa/fmt.js +5 -0
- package/src/motion3/qa/foot-checks.js +72 -0
- package/src/motion3/qa/geometry.js +77 -0
- package/src/motion3/qa/goals.js +191 -0
- package/src/motion3/qa/ground.js +60 -0
- package/src/motion3/qa/hints-contact.js +195 -0
- package/src/motion3/qa/hints-goal.js +160 -0
- package/src/motion3/qa/hints-knee.js +24 -0
- package/src/motion3/qa/hints-motion.js +209 -0
- package/src/motion3/qa/hints-object.js +91 -0
- package/src/motion3/qa/index.js +44 -0
- package/src/motion3/qa/key-checks.js +82 -0
- package/src/motion3/qa/near.js +96 -0
- package/src/motion3/qa/object-checks.js +135 -0
- package/src/motion3/qa/patch.js +161 -0
- package/src/motion3/qa/people-checks.js +37 -0
- package/src/motion3/qa/severity.js +38 -0
- package/src/motion3/qa/spec-checks.js +65 -0
- package/src/motion3/qa/style.js +228 -0
- package/src/motion3/qa/wrist-checks.js +69 -0
- package/src/motion3/qa/wrist-obs.js +60 -0
- package/src/motion3/refs.js +129 -0
- package/src/motion3/repair-tune.js +121 -0
- package/src/motion3/repair.js +83 -0
- package/src/motion3/report.js +67 -0
- package/src/motion3/rigs/index.js +14 -0
- package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
- package/src/motion3/rigs/x-bot-tpose.json +1 -0
- package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
- package/src/motion3/rigs/y-bot-tpose.json +1 -0
- package/src/motion3/scene.js +60 -0
- package/src/motion3/schema/brief.js +89 -0
- package/src/motion3/schema/fields.js +44 -0
- package/src/motion3/schema/spec-examples.js +46 -0
- package/src/motion3/schema/topics.js +113 -0
- package/src/motion3/schema/types.js +149 -0
- package/src/motion3/search.js +117 -0
- package/src/motion3/second-hand.js +112 -0
- package/src/motion3/settle.js +136 -0
- package/src/motion3/skills/basic.js +83 -0
- package/src/motion3/skills/book.js +76 -0
- package/src/motion3/skills/car.js +145 -0
- package/src/motion3/skills/flip.js +200 -0
- package/src/motion3/skills/frames-auto.js +29 -0
- package/src/motion3/skills/impact.js +109 -0
- package/src/motion3/skills/index.js +85 -0
- package/src/motion3/skills/info.js +80 -0
- package/src/motion3/skills/smoke-kit.js +109 -0
- package/src/motion3/skills/smoke.js +160 -0
- package/src/motion3/spec-store.js +59 -0
- package/src/motion3/take-edit.js +155 -0
- package/src/motion3/take.js +63 -0
- package/src/motion3/timeline.js +127 -0
- package/src/motion3/views.js +121 -0
- package/src/motion3/walk-plan.js +119 -0
- package/src/object-catalog.jsx +7 -1
- package/src/object-colors.js +98 -0
- package/src/object-gizmo-parts.js +115 -0
- package/src/object-gizmo.jsx +37 -122
- package/src/object-glow.js +139 -0
- package/src/object-hidden.js +33 -0
- package/src/object-look.js +196 -0
- package/src/object-size.js +10 -0
- package/src/object-travel.js +71 -6
- package/src/panels/CameraPanel.jsx +7 -7
- package/src/panels/HdrEnvironmentControls.jsx +59 -0
- package/src/panels/LightPanel.jsx +25 -4
- package/src/panels/ObjectLookPanel.jsx +88 -0
- package/src/panels/PosePanel.jsx +2 -17
- package/src/panels/RigControlPanel.jsx +5 -53
- package/src/panels/SubjectBox.jsx +4 -1
- package/src/panels/details.css +14 -0
- package/src/posestudio.jsx +4 -27
- package/src/project-browser.jsx +3 -4
- package/src/project-resources.js +18 -31
- package/src/project.js +13 -104
- package/src/props.jsx +25 -280
- package/src/qa-label.js +25 -0
- package/src/rail-dot-drag.js +23 -0
- package/src/render-passes.js +10 -0
- package/src/resource-status.jsx +1 -0
- package/src/result-modal.jsx +4 -19
- package/src/room.jsx +20 -12
- package/src/scene-assets.js +8 -6
- package/src/scene-light-place.js +72 -0
- package/src/scene-lights.jsx +148 -0
- package/src/scene-objects.js +31 -139
- package/src/scenes.js +15 -105
- package/src/semantic-edit.js +2 -5
- package/src/set-pieces.jsx +183 -0
- package/src/settings-menu.jsx +3 -11
- package/src/shell/BottomDock.jsx +9 -51
- package/src/shell/DetailsSlot.jsx +16 -200
- package/src/shell/GhostsMenuGroup.jsx +74 -0
- package/src/shell/LibrarySlot.jsx +1 -3
- package/src/shell/MenuBar.jsx +3 -3
- package/src/shell/PreferencesDialog.jsx +15 -264
- package/src/shell/PreferencesSlot.jsx +2 -8
- package/src/shell/TopBar.jsx +5 -84
- package/src/shell/ViewportToolbar.jsx +10 -70
- package/src/shell/agent-glass.css +19 -6
- package/src/shell/details-selection.js +42 -0
- package/src/shell/ghosts-menu.css +22 -0
- package/src/shell/glass-regions.css +29 -3
- package/src/shell/preferences.css +6 -1
- package/src/shot-authoring.js +4 -7
- package/src/soft-shadows.js +37 -0
- package/src/stage-attach-frames.js +12 -12
- package/src/stage-canvas-rigs.jsx +3 -3
- package/src/stage-capture.jsx +42 -8
- package/src/stage-characters.jsx +22 -216
- package/src/stage-editor-gizmos.jsx +6 -6
- package/src/stage-environment.js +93 -0
- package/src/stage-hdr-builtins.js +107 -0
- package/src/stage-hdr-image.js +116 -0
- package/src/stage-hdr-library.js +70 -0
- package/src/stage-hdr-map.js +94 -0
- package/src/stage-hdr.js +130 -0
- package/src/stage-lighting.js +149 -0
- package/src/stage-occlusion.js +210 -0
- package/src/stage-sky.jsx +159 -0
- package/src/stage-startup-defaults.js +5 -9
- package/src/studio-actions.js +16 -14
- package/src/studio-agent-commands.js +19 -16
- package/src/studio-agent-motion.js +9 -9
- package/src/studio-agent-protocol.js +47 -77
- package/src/studio-app-binding.js +27 -47
- package/src/studio-asset-list.js +17 -0
- package/src/studio-contact-sheet.js +37 -5
- package/src/studio-elements.js +27 -7
- package/src/studio-entity-bounds.js +18 -0
- package/src/studio-error-codes.js +15 -0
- package/src/studio-export-framing.js +152 -0
- package/src/studio-lighting-check.js +103 -0
- package/src/studio-motion-visual.js +150 -0
- package/src/studio-schema-errors.js +80 -0
- package/src/studio-support-gap.js +74 -0
- package/src/styles.css +1 -19
- package/src/timeline-defaults.js +5 -0
- package/src/timeline-extent.js +8 -20
- package/src/tutorial-walk.js +17 -0
- package/src/ui-scrub.js +1 -1
- package/src/use-shot-camera-tracks.js +12 -0
- package/src/view-ghosts.js +97 -0
- package/src/view-ghosts.jsx +49 -0
- package/src/workflow/AgentPanel.jsx +47 -166
- package/src/workflow/AgentProgressCard.jsx +109 -0
- package/src/workflow/AgentReplyText.jsx +37 -0
- package/src/workflow/AgentTurnSteps.jsx +84 -0
- package/src/workflow/LoginPanel.jsx +140 -0
- package/src/workflow/WorkflowBuilder.jsx +7 -23
- package/src/workflow/agent-client.js +54 -51
- package/src/workflow/agent-panel.css +71 -1
- package/src/workflow/agent-progress-card.css +167 -0
- package/src/workflow/agent-progress-view.js +47 -0
- package/src/workflow/agent-turn-steps.css +113 -0
- package/src/workflow/agent-turn.js +191 -0
- package/src/workflow/login-client.js +34 -0
- package/src/workflow/motion-input.js +18 -31
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/reply-markdown.js +69 -0
- package/src/workflow/scene-asset-sync.js +8 -8
- package/tools/agent-session-stats.mjs +61 -0
- package/tools/dev-full.mjs +5 -56
- package/tools/eslint-baseline.json +12 -13
- package/tools/knip-baseline.json +0 -179
- package/tools/loc-baseline.json +31 -81
- package/tools/motion3/extract-body.mjs +103 -0
- package/tools/motion3/extract-rig.mjs +85 -0
- package/tools/process-supervisor.mjs +0 -88
- package/tools/run-tests.mjs +9 -125
- package/bin/agent/motion-runtime.mjs +0 -76
- package/dist/ardy/cskel27-rest.json +0 -557
- package/dist/assets/analytics-C9pMpW7s.js +0 -1
- package/dist/assets/app-B_2XB9rw.css +0 -1
- package/dist/assets/app-DAuvKPP8.js +0 -4920
- package/dist/assets/config-Bx7pYsD9.js +0 -1
- package/dist/assets/config-DeoFTYzb.css +0 -1
- package/dist/assets/demo-CvKuBgy5.js +0 -1
- package/dist/assets/scene-asset-sync-DKkpjKBV.js +0 -19
- package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
- package/dist/assets/ticket-BPk2Ni95.js +0 -1
- package/dist/assets/workflow-jJRBR94Z.js +0 -179
- package/dist/demo/walk-then-stop.npz +0 -0
- package/mcp/ardy-prompts.mjs +0 -218
- package/src/ardy/auto-fix-panel.css +0 -108
- package/src/ardy/auto-physics.js +0 -1194
- package/src/ardy/client.js +0 -153
- package/src/ardy/convert.js +0 -313
- package/src/ardy/cskel27-neutral.js +0 -39
- package/src/ardy/cskel27.js +0 -77
- package/src/ardy/export.js +0 -165
- package/src/ardy/fix-collisions.js +0 -2257
- package/src/ardy/foot-lock.js +0 -230
- package/src/ardy/motion-calibration.js +0 -118
- package/src/ardy/motion-edit.js +0 -213
- package/src/ardy/motion-url.js +0 -55
- package/src/ardy/mp4-duration.js +0 -102
- package/src/ardy/npz.js +0 -595
- package/src/ardy/physics-panel.css +0 -21
- package/src/ardy/physics-panel.jsx +0 -89
- package/src/ardy/physics-refine.js +0 -111
- package/src/ardy/physics-review.js +0 -567
- package/src/ardy/physics-temporal.js +0 -54
- package/src/ardy/platform-fit-panel.css +0 -64
- package/src/ardy/platform-fit-panel.jsx +0 -41
- package/src/ardy/platform-fit.js +0 -326
- package/src/ardy/playback.js +0 -711
- package/src/ardy/pose-pin.js +0 -97
- package/src/ardy/prompt-clips.js +0 -30
- package/src/ardy/retime.js +0 -211
- package/src/ardy/root-drop.js +0 -369
- package/src/ardy/timeline-resize.js +0 -13
- package/src/ardy/to-cskel27.js +0 -225
- package/src/ardy/trim.js +0 -33
- package/src/ardy/waypoints.js +0 -369
- package/src/ardy-bridge-contract.js +0 -281
- package/src/domains/motion/motion-video-capture.js +0 -664
- package/src/i2v-motion-client.js +0 -77
- package/src/i2v-motion-studio.jsx +0 -180
- package/src/line-edit.js +0 -1348
- package/src/motion/generation.js +0 -105
- package/src/motion-readiness-ui.jsx +0 -67
- package/src/motion-readiness.js +0 -40
- package/src/motion-resources.js +0 -177
- package/src/motion-store.js +0 -65
- package/src/motion-trail.js +0 -601
- package/src/multimodel-ingest.js +0 -394
- package/src/panels/PromptBlocksPanel.jsx +0 -444
- package/src/panels/VideoCapturePanel.jsx +0 -181
- package/src/pose-extract/browser.js +0 -42
- package/src/pose-extract/detector.js +0 -100
- package/src/pose-extract/fit.js +0 -227
- package/src/pose-extract/image-frame.js +0 -51
- package/src/pose-extract/index.js +0 -58
- package/src/pose-extract/keyframes.js +0 -74
- package/src/pose-extract/landmarks.js +0 -162
- package/src/pose-extract/mirror.js +0 -153
- package/src/pose-extract/take.js +0 -131
- package/src/pose-extract/video-frames.js +0 -91
- package/src/take-recipe.js +0 -165
- package/src/trail-key-conflicts.js +0 -39
- package/src/trail-pick.js +0 -59
- package/src/use-i2v-capture-modal.js +0 -15
- package/tools/ardy/BRIDGE.md +0 -37
- package/tools/ardy/GVHMR-NPZ.md +0 -68
- package/tools/ardy/README.md +0 -124
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/artifacts.mjs +0 -34
- package/tools/ardy/bridge.mjs +0 -1732
- package/tools/ardy/bvh-cskel27.mjs +0 -1265
- package/tools/ardy/cclay_constrained_generate.py +0 -561
- package/tools/ardy/cclay_gvhmr_worker.py +0 -264
- package/tools/ardy/cclay_motion_edit.py +0 -510
- package/tools/ardy/cclay_sequence_generate.py +0 -705
- package/tools/ardy/cclay_worker.py +0 -304
- package/tools/ardy/dump-npz.py +0 -205
- package/tools/ardy/extract-rest.mjs +0 -299
- package/tools/ardy/extract.mjs +0 -507
- package/tools/ardy/footage.mjs +0 -467
- package/tools/ardy/gvhmr-floor.mjs +0 -102
- package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
- package/tools/ardy/gvhmr_fastpath.py +0 -168
- package/tools/ardy/gvhmr_trajectory.py +0 -240
- package/tools/ardy/merge-text-encoder.py +0 -160
- package/tools/ardy/mocap-metrics.mjs +0 -200
- package/tools/ardy/mocap-quality-gate.mjs +0 -110
- package/tools/ardy/motion-stabilize.mjs +0 -348
- package/tools/ardy/npz.mjs +0 -414
- package/tools/ardy/pose-to-npz.mjs +0 -106
- package/tools/ardy/prompt-limits.mjs +0 -5
- package/tools/ardy/run-edit-on-box.sh +0 -73
- package/tools/ardy/run-local.mjs +0 -233
- package/tools/ardy/run-on-box.sh +0 -593
- package/tools/ardy/run-sequence-on-box.sh +0 -177
- package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
- package/tools/ardy/runners/index.mjs +0 -18
- package/tools/ardy/runners/local.mjs +0 -455
- package/tools/ardy/runners/proc.mjs +0 -125
- package/tools/ardy/runners/remote.mjs +0 -252
- package/tools/ardy/setup-local.mjs +0 -189
- package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
- package/tools/ardy/setup-text-encoder.py +0 -306
- package/tools/ardy/smpl-cskel27.mjs +0 -178
- package/tools/ardy/visual-qa.mjs +0 -196
- package/tools/ardy/vq-car.mjs +0 -52
- package/tools/bench/EXP3.md +0 -61
- package/tools/bench/cclay_bench_extract_incam.py +0 -125
- package/tools/bench/cclay_bench_extract_obs.py +0 -204
- package/tools/bench/cclay_bench_runner.py +0 -42
- package/tools/bench/cube-contact.mjs +0 -162
- package/tools/bench/exp3.mjs +0 -152
- package/tools/bench/extract-bench-lib.mjs +0 -216
- package/tools/bench/extract-bench.mjs +0 -302
- package/tools/bench/fal-generate.mjs +0 -88
- package/tools/bench/fit/README.md +0 -189
- package/tools/bench/fit/camera.mjs +0 -59
- package/tools/bench/fit/contact.mjs +0 -419
- package/tools/bench/fit/footlock.mjs +0 -234
- package/tools/bench/fit/motion.mjs +0 -85
- package/tools/bench/fit/pin.mjs +0 -27
- package/tools/bench/fit/remote.mjs +0 -105
- package/tools/bench/fit-bench.mjs +0 -126
- package/tools/bench/fit-sanity.mjs +0 -45
- package/tools/bench/metrics.mjs +0 -313
- package/tools/bench/obs/depth.mjs +0 -258
- package/tools/bench/obs/extrinsics.mjs +0 -175
- package/tools/bench/obs/fit_mannequin_betas.py +0 -340
- package/tools/bench/obs/ground.mjs +0 -294
- package/tools/bench/obs/heading.mjs +0 -151
- package/tools/bench/obs/ladder.mjs +0 -555
- package/tools/bench/obs/mannequin-betas.json +0 -124
- package/tools/bench/obs/remote.mjs +0 -83
- package/tools/bench/obs/rest_joints.py +0 -52
- package/tools/bench/obs/ybot-targets.mjs +0 -49
- package/tools/bench/obs-bench.mjs +0 -520
- package/tools/bench/score.mjs +0 -343
- package/tools/bench/summarize.mjs +0 -86
- package/tools/demo-worker/README.md +0 -137
- package/tools/demo-worker/api-client.mjs +0 -233
- package/tools/demo-worker/generate.mjs +0 -301
- package/tools/demo-worker/index.mjs +0 -466
- package/tools/demo-worker/sign-core.mjs +0 -112
- package/tools/demo-worker/sign.mjs +0 -161
- package/tools/gt-render/browser.mjs +0 -182
- package/tools/gt-render/camera-math.mjs +0 -276
- package/tools/gt-render/page.mjs +0 -267
- package/tools/gt-render/render.mjs +0 -514
- package/tools/gt-render/scene-box.mjs +0 -31
- package/tools/gt-render/take-transform.mjs +0 -87
- package/tools/kimodo/compare-seam.mjs +0 -108
- package/tools/kimodo/constraints.mjs +0 -244
- package/tools/kimodo/edit.mjs +0 -259
- package/tools/kimodo/effector-constraints.mjs +0 -200
- package/tools/kimodo/generate.mjs +0 -646
- package/tools/kimodo/local-output.mjs +0 -265
- package/tools/kimodo/measure-pose.mjs +0 -65
- package/tools/kimodo/measure-preserve.mjs +0 -899
- package/tools/kimodo/measure-waypoints.mjs +0 -55
- package/tools/kimodo/mlx-generate.py +0 -111
- package/tools/kimodo/pose-constraints.mjs +0 -248
- package/tools/kimodo/preserve-mask.mjs +0 -543
- package/tools/kimodo/read-npz.mjs +0 -189
- package/tools/kimodo/run-edit-on-box.mjs +0 -155
- package/tools/kimodo/run-sequence-on-box.mjs +0 -178
- package/tools/kimodo/runner.mjs +0 -284
- package/tools/kimodo/seam-batch.mjs +0 -77
- package/tools/kimodo/setup-local.mjs +0 -30
- package/tools/kimodo/setup-on-box.sh +0 -339
- package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
- package/tools/morphgs/assets/gen_truth.py +0 -39
- package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
- package/tools/morphgs/assets/playback-check.mjs +0 -30
- package/tools/morphgs/demo-gate.sh +0 -26
- package/tools/morphgs/fbx2morphgs.mjs +0 -68
- package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
- package/tools/morphgs/setup-on-cluster.sh +0 -126
- package/tools/projflow/driver.py +0 -1069
- package/tools/projflow/generate.mjs +0 -805
- package/tools/projflow/hml22-to-cskel27.mjs +0 -735
- package/tools/projflow/line-edit-job.mjs +0 -547
- package/tools/projflow/replay.mjs +0 -452
- package/tools/projflow/runner.mjs +0 -269
- package/tools/projflow/service.mjs +0 -742
- package/tools/track/DESIGN.md +0 -513
- package/tools/track/backfill-provenance.mjs +0 -116
- package/tools/track/budget.mjs +0 -8
- package/tools/track/check-rig.mjs +0 -209
- package/tools/track/diagnostics.schema.json +0 -60
- package/tools/track/export-rig.mjs +0 -256
- package/tools/track/fallback.mjs +0 -7
- package/tools/track/fk-parity-fixture.mjs +0 -161
- package/tools/track/gate.mjs +0 -349
- package/tools/track/masks.mjs +0 -318
- package/tools/track/metrics.mjs +0 -201
- package/tools/track/publish-obs.mjs +0 -108
- package/tools/track/py/check_env.py +0 -61
- package/tools/track/py/eval_lr.py +0 -229
- package/tools/track/py/lr_viterbi.py +0 -294
- package/tools/track/py/masks.py +0 -405
- package/tools/track/py/objective.py +0 -382
- package/tools/track/py/rig.py +0 -508
- package/tools/track/py/scene.py +0 -326
- package/tools/track/py/test_joint_indices.py +0 -123
- package/tools/track/py/test_lr_viterbi.py +0 -195
- package/tools/track/py/test_masks.py +0 -229
- package/tools/track/py/test_rig.py +0 -288
- package/tools/track/py/test_scene.py +0 -354
- package/tools/track/py/test_track.py +0 -328
- package/tools/track/py/track.py +0 -411
- package/tools/track/remote.mjs +0 -140
- package/tools/track/rig-dump.mjs +0 -227
- package/tools/track/run-box-tests.mjs +0 -14
- package/tools/track/run-box.mjs +0 -168
- package/tools/track/setup-box.sh +0 -59
- package/tools/track/study-2d.mjs +0 -728
- /package/src/{ardy → motion}/collision-blockers.js +0 -0
- /package/src/{ardy → motion}/ground.js +0 -0
- /package/src/{ardy → motion}/ik-drag.js +0 -0
- /package/src/{ardy → motion}/ik-key-json.js +0 -0
- /package/src/{ardy → motion}/ik.js +0 -0
- /package/src/{ardy → motion}/physics-surface.js +0 -0
- /package/src/{ardy → motion}/playback-clock.js +0 -0
- /package/src/{ardy → motion}/range-pin.js +0 -0
package/src/ardy/auto-physics.js
DELETED
|
@@ -1,1194 +0,0 @@
|
|
|
1
|
-
import * as THREE from "three";
|
|
2
|
-
import { findBone, ikTouch, ikBakeKeyframe, solveHipsTranslate, solveIk } from "./ik.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* AutoPhysics: a physical-plausibility pass for generated motion, modelled on
|
|
6
|
-
* Cascadeur's ballistic-trajectory tool.
|
|
7
|
-
*
|
|
8
|
-
* While a character is in the air nothing but gravity acts on it, so its
|
|
9
|
-
* CENTRE OF MASS must trace a parabola: a vertical velocity that falls off at
|
|
10
|
-
* exactly g. Generated clips routinely break that — the character floats at
|
|
11
|
-
* the apex, hovers for a few frames, or decelerates on the way down — and the
|
|
12
|
-
* eye reads it instantly as "fake", even when every limb pose is fine.
|
|
13
|
-
*
|
|
14
|
-
* v1 fixes the ROOT height during airborne spans, which is both the cheapest
|
|
15
|
-
* and the most effective correction available:
|
|
16
|
-
*
|
|
17
|
-
* - The CoM is a mass-weighted average of body-segment midpoints (Dempster's
|
|
18
|
-
* anthropometric fractions), so it already accounts for a swinging arm or a
|
|
19
|
-
* tucked leg without needing to touch either.
|
|
20
|
-
* - Translating the hips rigidly moves EVERY segment by the same vector, so
|
|
21
|
-
* the CoM moves by exactly that vector too. The correction needed at a
|
|
22
|
-
* frame is therefore literally `fittedY − actualY` — no iteration, no
|
|
23
|
-
* solver.
|
|
24
|
-
* - Only airborne spans are touched. A grounded frame has a foot carrying
|
|
25
|
-
* load, gravity is not the only force, and no parabola applies.
|
|
26
|
-
*
|
|
27
|
-
* Three rules earn the pass its right to run unattended on real clips, and
|
|
28
|
-
* each exists because the naive version failed on one:
|
|
29
|
-
*
|
|
30
|
-
* - VERTICAL ONLY. Floating is a height artefact. Line-fitting x/z as well
|
|
31
|
-
* turned a mis-detected span into metres of horizontal displacement, so
|
|
32
|
-
* the lateral track is now read-only: fitted positions keep each sample's
|
|
33
|
-
* own x and z.
|
|
34
|
-
* - CLIP-RELATIVE GROUND. Absolute floor thresholds (bone height vs. a
|
|
35
|
-
* measured mesh drop) misclassify standing as flight on any rig whose
|
|
36
|
-
* soles rest a couple of centimetres above y = 0 — which is most of them.
|
|
37
|
-
* The planted height of each foot marker is instead MEASURED from the clip
|
|
38
|
-
* itself, as that marker's minimum over the range.
|
|
39
|
-
* - REFUSE RATHER THAN WRECK. A span whose fit is wild is not a flight
|
|
40
|
-
* phase, it is a detection error; those spans are reported and skipped
|
|
41
|
-
* instead of being "corrected" by half a body length.
|
|
42
|
-
* - ANCHORED ARCS. The parabola is pinned to the take-off and landing
|
|
43
|
-
* samples rather than least-squares fitted, so the correction is zero
|
|
44
|
-
* where the pass hands back to the clip and largest where the floating
|
|
45
|
-
* actually is. That single choice is what makes the hand-off seamless and
|
|
46
|
-
* the whole pass a fixed point; see fitBallisticArc.
|
|
47
|
-
*
|
|
48
|
-
* Corrections are written through ikTouch/ikBakeKeyframe, so an AutoPhysics
|
|
49
|
-
* pass is an ordinary IK correction key: scrubbable, undoable, and eased back
|
|
50
|
-
* into the clip outside its keyed range by ikEvaluate's blend window.
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Segment mass fractions of total body mass, from Dempster's cadaver study
|
|
55
|
-
* (Dempster, W.T., "Space Requirements of the Seated Operator", WADC TR
|
|
56
|
-
* 55-159, 1955), in the tabulation popularised by Winter, "Biomechanics and
|
|
57
|
-
* Motor Control of Human Movement". The trunk (0.497) is split into pelvis
|
|
58
|
-
* (0.142) and thorax+abdomen (0.355); distal pairs are merged because their
|
|
59
|
-
* combined centroid still lands near the segment midpoint and Mixamo's hand
|
|
60
|
-
* and toe bones are too short to place reliably:
|
|
61
|
-
*
|
|
62
|
-
* head+neck 0.081 · trunk 0.497 · upper arm 0.028 · forearm 0.016 +
|
|
63
|
-
* hand 0.006 = 0.022 · thigh 0.100 · shank 0.0465 + foot 0.0145 = 0.061
|
|
64
|
-
*
|
|
65
|
-
* Each entry names the two Mixamo bones whose MIDPOINT carries the fraction.
|
|
66
|
-
* Dempster's true centres of mass sit ~43% down each segment rather than at
|
|
67
|
-
* 50%, but the difference is a couple of centimetres on a limb and cancels
|
|
68
|
-
* almost exactly between the left and right sides — while the midpoint keeps
|
|
69
|
-
* the table readable and needs no extra per-segment constant.
|
|
70
|
-
*
|
|
71
|
-
* The fractions sum to 1, so the weighted average below is a true CoM.
|
|
72
|
-
*/
|
|
73
|
-
export const SEGMENT_MASSES = [
|
|
74
|
-
{ id: "pelvis", start: "mixamorigHips", end: "mixamorigSpine1", mass: 0.142 },
|
|
75
|
-
{ id: "torso", start: "mixamorigSpine1", end: "mixamorigNeck", mass: 0.355 },
|
|
76
|
-
{ id: "head", start: "mixamorigNeck", end: "mixamorigHead", mass: 0.081 },
|
|
77
|
-
{ id: "leftUpperArm", start: "mixamorigLeftArm", end: "mixamorigLeftForeArm", mass: 0.028 },
|
|
78
|
-
{ id: "leftForeArmHand", start: "mixamorigLeftForeArm", end: "mixamorigLeftHand", mass: 0.022 },
|
|
79
|
-
{ id: "rightUpperArm", start: "mixamorigRightArm", end: "mixamorigRightForeArm", mass: 0.028 },
|
|
80
|
-
{ id: "rightForeArmHand", start: "mixamorigRightForeArm", end: "mixamorigRightHand", mass: 0.022 },
|
|
81
|
-
{ id: "leftThigh", start: "mixamorigLeftUpLeg", end: "mixamorigLeftLeg", mass: 0.100 },
|
|
82
|
-
{ id: "leftShankFoot", start: "mixamorigLeftLeg", end: "mixamorigLeftFoot", mass: 0.061 },
|
|
83
|
-
{ id: "rightThigh", start: "mixamorigRightUpLeg", end: "mixamorigRightLeg", mass: 0.100 },
|
|
84
|
-
{ id: "rightShankFoot", start: "mixamorigRightLeg", end: "mixamorigRightFoot", mass: 0.061 },
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* World-space centre of mass of the rig's CURRENT pose: the mass-weighted
|
|
89
|
-
* average of every segment's midpoint. Returns null when any segment bone is
|
|
90
|
-
* missing — a partial CoM would silently drift the correction toward
|
|
91
|
-
* whichever half of the body did resolve, so the caller must instead hide the
|
|
92
|
-
* tool (the same all-or-nothing policy resolveIkRig uses).
|
|
93
|
-
*/
|
|
94
|
-
export function computeCenterOfMass(rig) {
|
|
95
|
-
if (!rig) return null;
|
|
96
|
-
rig.updateMatrixWorld(true);
|
|
97
|
-
const com = new THREE.Vector3();
|
|
98
|
-
const start = new THREE.Vector3();
|
|
99
|
-
const end = new THREE.Vector3();
|
|
100
|
-
let total = 0;
|
|
101
|
-
for (const segment of SEGMENT_MASSES) {
|
|
102
|
-
const a = findBone(rig, segment.start);
|
|
103
|
-
const b = findBone(rig, segment.end);
|
|
104
|
-
if (!a || !b) return null;
|
|
105
|
-
a.getWorldPosition(start);
|
|
106
|
-
b.getWorldPosition(end);
|
|
107
|
-
com.addScaledVector(start.add(end).multiplyScalar(0.5), segment.mass);
|
|
108
|
-
total += segment.mass;
|
|
109
|
-
}
|
|
110
|
-
if (total <= 0) return null;
|
|
111
|
-
return com.divideScalar(total);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Shortest run of airborne frames worth correcting. Anything briefer is a
|
|
115
|
-
* one- or two-frame detection blip from a foot skimming the floor, not a
|
|
116
|
-
* flight phase, and fitting a parabola to it would amplify the noise. */
|
|
117
|
-
export const MIN_AIRBORNE_FRAMES = 4;
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* How far above its own PLANTED height a foot marker must rise before the
|
|
121
|
-
* frame counts as airborne.
|
|
122
|
-
*
|
|
123
|
-
* This replaces the absolute floor test the first version used, which was
|
|
124
|
-
* wrong in practice on every rig measured: characters rest with their soles a
|
|
125
|
-
* couple of centimetres above y = 0, ankles sit 12 cm up, and measured mesh
|
|
126
|
-
* drops disagree with both — so a plain STANDING frame cleared the absolute
|
|
127
|
-
* thresholds and a whole clip was declared airborne. The planted height is
|
|
128
|
-
* therefore taken from the clip (see `plantedFloor`), and this constant is
|
|
129
|
-
* the only absolute number left: 6 cm is above the few centimetres a heel
|
|
130
|
-
* lifts during a normal stride or a weight shift, and far below the tens of
|
|
131
|
-
* centimetres of a real jump.
|
|
132
|
-
*/
|
|
133
|
-
export const AIRBORNE_LIFT = 0.06;
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The floor markers, matching ik.js's contact model: the ankle bones plus the
|
|
137
|
-
* toe bases, because a foot rolling onto its toe keeps the ankle high while
|
|
138
|
-
* the character is still very much on the ground.
|
|
139
|
-
*/
|
|
140
|
-
export const FOOT_MARKERS = ["mixamorigLeftFoot", "mixamorigRightFoot", "mixamorigLeftToeBase", "mixamorigRightToeBase"];
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* World Y of every foot marker in the rig's CURRENT pose, keyed by bone name.
|
|
144
|
-
* Returns null when a marker is missing — the caller then cannot judge
|
|
145
|
-
* contact at all and must decline to correct anything.
|
|
146
|
-
*/
|
|
147
|
-
export function markerHeights(rig) {
|
|
148
|
-
if (!rig) return null;
|
|
149
|
-
rig.updateMatrixWorld(true);
|
|
150
|
-
const position = new THREE.Vector3();
|
|
151
|
-
const out = {};
|
|
152
|
-
for (const name of FOOT_MARKERS) {
|
|
153
|
-
const bone = findBone(rig, name);
|
|
154
|
-
if (!bone) return null;
|
|
155
|
-
bone.getWorldPosition(position);
|
|
156
|
-
out[name] = position.y;
|
|
157
|
-
}
|
|
158
|
-
return out;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Each marker's PLANTED height for this clip: its minimum over every sampled
|
|
163
|
-
* frame. That minimum is by definition a frame where the marker was as low as
|
|
164
|
-
* this performance ever puts it, which is the only trustworthy definition of
|
|
165
|
-
* "on the ground" available without trusting the export's floor — and it
|
|
166
|
-
* absorbs sole float, mesh-drop measurement error and a mis-set floorY all at
|
|
167
|
-
* once. Returns null for an empty sample list.
|
|
168
|
-
*/
|
|
169
|
-
export function plantedFloor(perFrameHeights) {
|
|
170
|
-
if (!perFrameHeights?.length) return null;
|
|
171
|
-
const out = {};
|
|
172
|
-
for (const name of FOOT_MARKERS) {
|
|
173
|
-
let min = Infinity;
|
|
174
|
-
for (const heights of perFrameHeights) {
|
|
175
|
-
const y = heights?.[name];
|
|
176
|
-
if (Number.isFinite(y) && y < min) min = y;
|
|
177
|
-
}
|
|
178
|
-
if (!Number.isFinite(min)) return null;
|
|
179
|
-
out[name] = min;
|
|
180
|
-
}
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** The airborne rule itself, over PRE-MEASURED heights: every marker must
|
|
185
|
-
* clear its own planted height by `lift`. Exported both so the driver never
|
|
186
|
-
* re-poses the rig to re-ask a question it already has the answer to, and so
|
|
187
|
-
* the rule can be checked against heights measured off a real character
|
|
188
|
-
* without needing that character present. */
|
|
189
|
-
export function heightsAirborne(heights, planted, lift = AIRBORNE_LIFT) {
|
|
190
|
-
if (!heights || !planted) return false;
|
|
191
|
-
for (const name of FOOT_MARKERS) {
|
|
192
|
-
if (!(heights[name] > planted[name] + lift)) return false;
|
|
193
|
-
}
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Is the rig's CURRENT pose airborne, relative to this clip's planted floor?
|
|
199
|
-
* True only when EVERY foot marker — both ankles and both toe bases — rises
|
|
200
|
-
* more than `lift` above its own planted height. A single low toe makes the
|
|
201
|
-
* frame grounded, which is the conservative direction: mistaking flight for
|
|
202
|
-
* contact merely skips a correction, while mistaking contact for flight drags
|
|
203
|
-
* a planted foot off the floor (or, as the first version managed, buries a
|
|
204
|
-
* standing character to the chest).
|
|
205
|
-
*
|
|
206
|
-
* `planted` comes from `plantedFloor`. Returns false when it is missing or a
|
|
207
|
-
* marker bone cannot be found — an unprovable claim of flight, again the safe
|
|
208
|
-
* answer.
|
|
209
|
-
*/
|
|
210
|
-
export function frameAirborne(rig, planted, lift = AIRBORNE_LIFT) {
|
|
211
|
-
return heightsAirborne(markerHeights(rig), planted, lift);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** Contact must stay close to its clip-relative low and vertically settled.
|
|
215
|
-
* The final run gate below is stricter still: an ankle that wanders more than
|
|
216
|
-
* three centimetres was walking, not planted, and is never snapped backward. */
|
|
217
|
-
export const GROUND_CONTACT_LIFT = 0.06;
|
|
218
|
-
const GROUND_CONTACT_VERTICAL_SPEED = 0.6;
|
|
219
|
-
export const GROUND_CONTACT_HORIZONTAL_SPEED = 0.12;
|
|
220
|
-
const GROUND_CONTACT_MIN_SECONDS = 0.10;
|
|
221
|
-
export const GROUND_LOCK_MAX_PULL = 0.03;
|
|
222
|
-
export const GROUND_LOCK_EPSILON = 0.005;
|
|
223
|
-
export const GROUND_LOCK_REACH_LIFT = 0.005;
|
|
224
|
-
const GROUND_FLOOR_MAX_LIFT = 0.15;
|
|
225
|
-
const GROUND_FLOOR_EPSILON = 0.005;
|
|
226
|
-
|
|
227
|
-
const FOOT_SIDES = [
|
|
228
|
-
{ side: "left", chainId: "leftFoot", ankle: "mixamorigLeftFoot", toe: "mixamorigLeftToeBase", height: "LeftFoot" },
|
|
229
|
-
{ side: "right", chainId: "rightFoot", ankle: "mixamorigRightFoot", toe: "mixamorigRightToeBase", height: "RightFoot" },
|
|
230
|
-
];
|
|
231
|
-
|
|
232
|
-
/** World positions of the same four markers markerHeights reads. */
|
|
233
|
-
export function markerPositions(rig) {
|
|
234
|
-
if (!rig) return null;
|
|
235
|
-
rig.updateMatrixWorld(true);
|
|
236
|
-
const out = {};
|
|
237
|
-
for (const name of FOOT_MARKERS) {
|
|
238
|
-
const bone = findBone(rig, name);
|
|
239
|
-
if (!bone) return null;
|
|
240
|
-
out[name] = bone.getWorldPosition(new THREE.Vector3());
|
|
241
|
-
}
|
|
242
|
-
return out;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function median(values) {
|
|
246
|
-
if (!values.length) return 0;
|
|
247
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
248
|
-
const middle = Math.floor(sorted.length / 2);
|
|
249
|
-
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) * 0.5;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function trueRuns(flags, minFrames) {
|
|
253
|
-
const runs = [];
|
|
254
|
-
let open = -1;
|
|
255
|
-
for (let index = 0; index <= flags.length; index += 1) {
|
|
256
|
-
const on = index < flags.length && flags[index];
|
|
257
|
-
if (on && open < 0) open = index;
|
|
258
|
-
if (on || open < 0) continue;
|
|
259
|
-
if (index - open >= minFrames) runs.push({ start: open, end: index - 1, length: index - open });
|
|
260
|
-
open = -1;
|
|
261
|
-
}
|
|
262
|
-
return runs;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Find contact runs that are safe to lock. Height and vertical speed find a
|
|
267
|
-
* stance candidate; total XZ wander then separates a planted foot from a slow
|
|
268
|
-
* step. Runs outside the pull limit are reported as rejected instead of being
|
|
269
|
-
* chopped into artificial mini-plants with visible jumps between anchors.
|
|
270
|
-
*/
|
|
271
|
-
export function detectGroundContactSpans({
|
|
272
|
-
positions,
|
|
273
|
-
planted,
|
|
274
|
-
fps = 30,
|
|
275
|
-
startFrame = 0,
|
|
276
|
-
contactLift = GROUND_CONTACT_LIFT,
|
|
277
|
-
verticalSpeed = GROUND_CONTACT_VERTICAL_SPEED,
|
|
278
|
-
horizontalSpeed = GROUND_CONTACT_HORIZONTAL_SPEED,
|
|
279
|
-
minSeconds = GROUND_CONTACT_MIN_SECONDS,
|
|
280
|
-
maxPull = GROUND_LOCK_MAX_PULL,
|
|
281
|
-
} = {}) {
|
|
282
|
-
if (!positions?.length || !planted) return { spans: [], rejected: [] };
|
|
283
|
-
const rate = Number.isFinite(fps) && fps > 0 ? fps : 30;
|
|
284
|
-
const minFrames = Math.max(3, Math.round(minSeconds * rate));
|
|
285
|
-
const half = Math.max(1, Math.round((2 / 30) * rate));
|
|
286
|
-
const spans = [];
|
|
287
|
-
const rejected = [];
|
|
288
|
-
for (const foot of FOOT_SIDES) {
|
|
289
|
-
const flags = positions.map((frame, index) => {
|
|
290
|
-
const ankle = frame?.[foot.ankle];
|
|
291
|
-
const toe = frame?.[foot.toe];
|
|
292
|
-
if (!ankle || !toe) return false;
|
|
293
|
-
const before = positions[Math.max(0, index - half)]?.[foot.ankle];
|
|
294
|
-
const after = positions[Math.min(positions.length - 1, index + half)]?.[foot.ankle];
|
|
295
|
-
if (!before || !after) return false;
|
|
296
|
-
const seconds = Math.max(1 / rate, (Math.min(positions.length - 1, index + half) - Math.max(0, index - half)) / rate);
|
|
297
|
-
const vy = Math.abs(after.y - before.y) / seconds;
|
|
298
|
-
const vxz = Math.hypot(after.x - before.x, after.z - before.z) / seconds;
|
|
299
|
-
const low = Math.min(ankle.y - planted[foot.ankle], toe.y - planted[foot.toe]);
|
|
300
|
-
// A low foot can still be in the sliding/swinging part of a step.
|
|
301
|
-
// Horizontal speed breaks a long low run into its real plant phases;
|
|
302
|
-
// maxPull below remains the second guard against slow large drifts.
|
|
303
|
-
return low <= contactLift && vy <= verticalSpeed && vxz <= horizontalSpeed;
|
|
304
|
-
});
|
|
305
|
-
for (const run of trueRuns(flags, minFrames)) {
|
|
306
|
-
const ankles = positions.slice(run.start, run.end + 1).map((frame) => frame[foot.ankle]);
|
|
307
|
-
const anchor = new THREE.Vector3(
|
|
308
|
-
median(ankles.map((point) => point.x)),
|
|
309
|
-
median(ankles.map((point) => point.y)),
|
|
310
|
-
median(ankles.map((point) => point.z)),
|
|
311
|
-
);
|
|
312
|
-
const pull = Math.max(...ankles.map((point) => Math.hypot(point.x - anchor.x, point.z - anchor.z)));
|
|
313
|
-
const span = {
|
|
314
|
-
side: foot.side,
|
|
315
|
-
chainId: foot.chainId,
|
|
316
|
-
start: startFrame + run.start,
|
|
317
|
-
end: startFrame + run.end,
|
|
318
|
-
length: run.length,
|
|
319
|
-
anchor,
|
|
320
|
-
maxPull: pull,
|
|
321
|
-
};
|
|
322
|
-
if (pull <= maxPull + 1e-9) spans.push(span);
|
|
323
|
-
else rejected.push({ ...span, reason: "foot-moving" });
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return { spans, rejected };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Group per-frame airborne flags into contiguous spans of at least
|
|
331
|
-
* `minLength` frames. `isAirborne` is either an array indexed from
|
|
332
|
-
* `startFrame` or a predicate called with the absolute frame number.
|
|
333
|
-
* Returned spans carry ABSOLUTE frame numbers, inclusive at both ends.
|
|
334
|
-
*/
|
|
335
|
-
export function detectAirborneSpans({ frames, isAirborne, startFrame = 0, minLength = MIN_AIRBORNE_FRAMES } = {}) {
|
|
336
|
-
const count = Math.max(0, Math.round(frames) || 0);
|
|
337
|
-
const flagAt = typeof isAirborne === "function"
|
|
338
|
-
? (index) => Boolean(isAirborne(startFrame + index))
|
|
339
|
-
: (index) => Boolean(isAirborne?.[index]);
|
|
340
|
-
const spans = [];
|
|
341
|
-
let open = -1;
|
|
342
|
-
for (let index = 0; index <= count; index += 1) {
|
|
343
|
-
const flag = index < count && flagAt(index);
|
|
344
|
-
if (flag && open < 0) open = index;
|
|
345
|
-
if (flag || open < 0) continue;
|
|
346
|
-
const length = index - open;
|
|
347
|
-
if (length >= minLength) spans.push({ start: startFrame + open, end: startFrame + index - 1, length });
|
|
348
|
-
open = -1;
|
|
349
|
-
}
|
|
350
|
-
return spans;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* The clearance a marker needs to STAY inside a span it is already part of —
|
|
355
|
-
* the low half of a hysteresis pair with AIRBORNE_LIFT.
|
|
356
|
-
*
|
|
357
|
-
* A single threshold cannot both reject a stride and accept a take-off. The
|
|
358
|
-
* 6 cm entry lift is set high enough to ignore a heel lift, which means the
|
|
359
|
-
* first and last frames of a real jump — the ones where the foot has only
|
|
360
|
-
* just left the ground — fall below it and the span is truncated at both
|
|
361
|
-
* ends. A truncated span then gets a zero pin one frame from a full-strength
|
|
362
|
-
* correction, and the result is a one-frame snap: on the QA clip the CoM's
|
|
363
|
-
* vertical acceleration jumped from −8.6 to −36.5 m/s², roughly 4 g, at the
|
|
364
|
-
* frame after the pin. Growing each span back out at 2 cm recovers exactly
|
|
365
|
-
* those take-off and landing frames without letting a stride start one.
|
|
366
|
-
*/
|
|
367
|
-
export const EXIT_LIFT = 0.02;
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* How deep an interior dip in the centre of mass must be, measured against
|
|
371
|
-
* the lower of the two peaks around it, before it is read as a landing
|
|
372
|
-
* between two separate arcs rather than noise inside one.
|
|
373
|
-
*
|
|
374
|
-
* Two hops in quick succession never plant a foot low enough to break the
|
|
375
|
-
* span, so detection hands over one span holding two parabolas. Fitting a
|
|
376
|
-
* single arc to that asks for 73 cm corrections at a 42 cm RMS — the sanity
|
|
377
|
-
* gate then refuses the whole thing and the clip's actual jump goes
|
|
378
|
-
* uncorrected. 8 cm of prominence is far more than a CoM ripple inside one
|
|
379
|
-
* flight and far less than the trough between two real hops.
|
|
380
|
-
*/
|
|
381
|
-
export const SPLIT_DEPTH = 0.08;
|
|
382
|
-
|
|
383
|
-
/** Frames at each end of an arc over which the correction eases in and out.
|
|
384
|
-
* See `edgeTaperWeight`. */
|
|
385
|
-
export const EDGE_TAPER = 2;
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Widen each span outward while every marker still clears its planted height
|
|
389
|
-
* by `exitLift`, without ever reaching into a neighbouring span.
|
|
390
|
-
*
|
|
391
|
-
* `canExtend` is a per-frame boolean array indexed from `startFrame` — the
|
|
392
|
-
* same airborne test run at the lower threshold. Spans are processed in
|
|
393
|
-
* order, and each one's left growth stops at the previous span's ALREADY
|
|
394
|
-
* EXPANDED end, so two spans can end up adjacent but never overlapping or
|
|
395
|
-
* merging.
|
|
396
|
-
*/
|
|
397
|
-
export function expandAirborneSpans({ spans, canExtend, startFrame = 0 } = {}) {
|
|
398
|
-
const list = spans ?? [];
|
|
399
|
-
const flags = canExtend ?? [];
|
|
400
|
-
const flagAt = (frame) => Boolean(flags[frame - startFrame]);
|
|
401
|
-
const lastFrame = startFrame + flags.length - 1;
|
|
402
|
-
const out = [];
|
|
403
|
-
for (let index = 0; index < list.length; index += 1) {
|
|
404
|
-
const span = list[index];
|
|
405
|
-
const floorLimit = index > 0 ? out[index - 1].end + 1 : startFrame;
|
|
406
|
-
const ceilLimit = index + 1 < list.length ? list[index + 1].start - 1 : lastFrame;
|
|
407
|
-
let from = span.start;
|
|
408
|
-
let to = span.end;
|
|
409
|
-
while (from - 1 >= floorLimit && flagAt(from - 1)) from -= 1;
|
|
410
|
-
while (to + 1 <= ceilLimit && flagAt(to + 1)) to += 1;
|
|
411
|
-
out.push({ start: from, end: to, length: to - from + 1 });
|
|
412
|
-
}
|
|
413
|
-
return out;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/** The deepest interior dip in `values[from..to]` whose prominence — its depth
|
|
417
|
-
* below the LOWER of the highest point on each side — clears `splitDepth`, or
|
|
418
|
-
* −1 when the range holds no such dip. Prominence rather than a bare local
|
|
419
|
-
* minimum is what distinguishes a landing from a wobble: a 2 mm ripple at the
|
|
420
|
-
* apex is a local minimum too. */
|
|
421
|
-
function deepestTrough(values, from, to, splitDepth) {
|
|
422
|
-
const span = to - from + 1;
|
|
423
|
-
if (span < 3) return -1;
|
|
424
|
-
const leftMax = new Array(span);
|
|
425
|
-
const rightMax = new Array(span);
|
|
426
|
-
leftMax[0] = values[from];
|
|
427
|
-
for (let index = 1; index < span; index += 1) leftMax[index] = Math.max(leftMax[index - 1], values[from + index]);
|
|
428
|
-
rightMax[span - 1] = values[to];
|
|
429
|
-
for (let index = span - 2; index >= 0; index -= 1) rightMax[index] = Math.max(rightMax[index + 1], values[from + index]);
|
|
430
|
-
let best = -1;
|
|
431
|
-
let bestProminence = splitDepth;
|
|
432
|
-
for (let index = 1; index < span - 1; index += 1) {
|
|
433
|
-
const prominence = Math.min(leftMax[index], rightMax[index]) - values[from + index];
|
|
434
|
-
if (prominence > bestProminence) {
|
|
435
|
-
bestProminence = prominence;
|
|
436
|
-
best = from + index;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
return best;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Cut a span's centre-of-mass height track into single ballistic arcs at its
|
|
444
|
-
* interior troughs, recursively, and return the resulting index ranges as
|
|
445
|
-
* inclusive `[from, to]` pairs (relative to the input array).
|
|
446
|
-
*
|
|
447
|
-
* The trough frame ENDS its left arc: it is the lowest point of the descent
|
|
448
|
-
* that precedes it, and the arc after it launches from the next frame. No
|
|
449
|
-
* range is discarded here — a caller that needs a minimum length filters the
|
|
450
|
-
* result, because "too short to fit" and "not an arc" are different verdicts
|
|
451
|
-
* worth reporting differently.
|
|
452
|
-
*/
|
|
453
|
-
export function splitAtTroughs(heights, { splitDepth = SPLIT_DEPTH } = {}) {
|
|
454
|
-
const values = heights ?? [];
|
|
455
|
-
const out = [];
|
|
456
|
-
const recurse = (from, to) => {
|
|
457
|
-
const cut = deepestTrough(values, from, to, splitDepth);
|
|
458
|
-
if (cut < 0) {
|
|
459
|
-
out.push([from, to]);
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
recurse(from, cut);
|
|
463
|
-
recurse(cut + 1, to);
|
|
464
|
-
};
|
|
465
|
-
if (values.length) recurse(0, values.length - 1);
|
|
466
|
-
return out;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Correction strength at `index` within an arc of `length` frames: 1 across
|
|
471
|
-
* the middle, easing to a fraction over the first and last `taper` frames.
|
|
472
|
-
*
|
|
473
|
-
* Applying a correction at full strength on the very first frame of an arc,
|
|
474
|
-
* one frame after a zero pin, is a step change in position — which is an
|
|
475
|
-
* impulse in acceleration, and reads as a one-frame snap however correct the
|
|
476
|
-
* arc either side of it is. Ramping in over two frames spreads that step
|
|
477
|
-
* across three, and the smoothstep shape means the ramp meets the flat middle
|
|
478
|
-
* without a corner of its own. The interior — where the physics claim
|
|
479
|
-
* actually lives — is untouched at weight 1.
|
|
480
|
-
*/
|
|
481
|
-
export function edgeTaperWeight(index, length, taper = EDGE_TAPER) {
|
|
482
|
-
if (!(taper > 0) || length <= 2) return 1;
|
|
483
|
-
const ramp = Math.min(Math.round(taper), Math.floor((length - 1) / 2));
|
|
484
|
-
if (ramp <= 0) return 1;
|
|
485
|
-
const steps = Math.min(index, length - 1 - index);
|
|
486
|
-
if (steps >= ramp) return 1;
|
|
487
|
-
const x = (steps + 1) / (ramp + 1);
|
|
488
|
-
return x * x * (3 - 2 * x);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/** Standard gravity (m/s²). */
|
|
492
|
-
export const GRAVITY = 9.81;
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Least-squares line through (t, value) pairs, solved from the 2×2 normal
|
|
496
|
-
* equations directly — two unknowns need no matrix library, and writing the
|
|
497
|
-
* closed form keeps the module dependency-free.
|
|
498
|
-
*
|
|
499
|
-
* [ n Σt ] [ a ] [ Σv ]
|
|
500
|
-
* [ Σt Σt² ] [ b ] = [ Σt·v ]
|
|
501
|
-
*
|
|
502
|
-
* Returns { a, b } for value ≈ a + b·t. A single sample (or a degenerate
|
|
503
|
-
* determinant, i.e. every t identical) yields the mean with zero slope,
|
|
504
|
-
* which is the correct limit rather than a divide-by-zero.
|
|
505
|
-
*/
|
|
506
|
-
function fitLine(times, values) {
|
|
507
|
-
const n = times.length;
|
|
508
|
-
if (n === 0) return { a: 0, b: 0 };
|
|
509
|
-
let sumT = 0;
|
|
510
|
-
let sumTT = 0;
|
|
511
|
-
let sumV = 0;
|
|
512
|
-
let sumTV = 0;
|
|
513
|
-
for (let index = 0; index < n; index += 1) {
|
|
514
|
-
const t = times[index];
|
|
515
|
-
const v = values[index];
|
|
516
|
-
sumT += t;
|
|
517
|
-
sumTT += t * t;
|
|
518
|
-
sumV += v;
|
|
519
|
-
sumTV += t * v;
|
|
520
|
-
}
|
|
521
|
-
const det = n * sumTT - sumT * sumT;
|
|
522
|
-
if (Math.abs(det) < 1e-12) return { a: sumV / n, b: 0 };
|
|
523
|
-
return {
|
|
524
|
-
a: (sumTT * sumV - sumT * sumTV) / det,
|
|
525
|
-
b: (n * sumTV - sumT * sumV) / det,
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Fit a ballistic HEIGHT curve to consecutive CoM samples.
|
|
531
|
-
*
|
|
532
|
-
* Gravity is not negotiable, so it is FIXED at `g` and the only free
|
|
533
|
-
* parameters are the launch height y0 and the launch velocity v0.
|
|
534
|
-
* Substituting d = y + ½·g·t² turns y = y0 + v0·t − ½·g·t² into the straight
|
|
535
|
-
* line d = y0 + v0·t, which the 2×2 normal equations above solve. Leaving g
|
|
536
|
-
* free would let the fitter "explain" a hover as weak gravity — exactly the
|
|
537
|
-
* artefact this pass exists to remove.
|
|
538
|
-
*
|
|
539
|
-
* The horizontal track is deliberately NOT fitted. In principle x and z are
|
|
540
|
-
* straight lines in flight; in practice fitting them meant that any span the
|
|
541
|
-
* detector got wrong came back with metres of sideways displacement, while
|
|
542
|
-
* the artefact users actually see — floating — is purely vertical. Each
|
|
543
|
-
* fitted position therefore carries its own sample's x and z unchanged, so
|
|
544
|
-
* `fitted − actual` is a pure Y vector by construction.
|
|
545
|
-
*
|
|
546
|
-
* `samples` are CoM positions at CONSECUTIVE frames; times come from `fps`.
|
|
547
|
-
*
|
|
548
|
-
* `fitFrom`/`fitTo` restrict WHICH samples steer the solve, while positions
|
|
549
|
-
* are still produced for every sample by evaluating the curve. The driver uses
|
|
550
|
-
* this to exclude the frames it intends to taper: those frames keep part of
|
|
551
|
-
* the clip's own trajectory on purpose, so letting them pull the fit would
|
|
552
|
-
* both bias the arc and — because the next run would then fit a slightly
|
|
553
|
-
* different curve — cost the pass its idempotence.
|
|
554
|
-
*
|
|
555
|
-
* Returns the fitted per-frame positions, the recovered launch state, and the
|
|
556
|
-
* RMS vertical residual OVER THE FITTED SUBSET (the honest "how ballistic was
|
|
557
|
-
* this already?" number the span gate reads).
|
|
558
|
-
*/
|
|
559
|
-
export function fitBallistic(samples, fps, g = GRAVITY, { fitFrom = 0, fitTo = null } = {}) {
|
|
560
|
-
const list = samples ?? [];
|
|
561
|
-
const rate = Number.isFinite(fps) && fps > 0 ? fps : 30;
|
|
562
|
-
const times = list.map((_, index) => index / rate);
|
|
563
|
-
const from = Math.max(0, Math.min(fitFrom, list.length - 1));
|
|
564
|
-
const to = Math.min(list.length - 1, Number.isFinite(fitTo) ? fitTo : list.length - 1);
|
|
565
|
-
const used = [];
|
|
566
|
-
for (let index = from; index <= to; index += 1) used.push(index);
|
|
567
|
-
// d = y + ½·g·t² is linear in t with slope v0 and intercept y0.
|
|
568
|
-
const vertical = fitLine(
|
|
569
|
-
used.map((index) => times[index]),
|
|
570
|
-
used.map((index) => list[index].y + 0.5 * g * times[index] * times[index]),
|
|
571
|
-
);
|
|
572
|
-
const positions = list.map((sample, index) => {
|
|
573
|
-
const t = times[index];
|
|
574
|
-
return new THREE.Vector3(sample.x ?? 0, vertical.a + vertical.b * t - 0.5 * g * t * t, sample.z ?? 0);
|
|
575
|
-
});
|
|
576
|
-
let sumSq = 0;
|
|
577
|
-
for (const index of used) {
|
|
578
|
-
const error = list[index].y - positions[index].y;
|
|
579
|
-
sumSq += error * error;
|
|
580
|
-
}
|
|
581
|
-
return {
|
|
582
|
-
positions,
|
|
583
|
-
y0: vertical.a,
|
|
584
|
-
v0: vertical.b,
|
|
585
|
-
g,
|
|
586
|
-
fitFrom: used.length ? used[0] : 0,
|
|
587
|
-
fitTo: used.length ? used[used.length - 1] : 0,
|
|
588
|
-
residual: used.length ? Math.sqrt(sumSq / used.length) : 0,
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* The parabola a flight between two known contacts MUST follow: gravity is
|
|
594
|
-
* fixed at `g` and the curve is pinned to the first and last sample, so the
|
|
595
|
-
* launch state is determined outright rather than fitted.
|
|
596
|
-
*
|
|
597
|
-
* y(0) = yA, y(T) = yB, y(t) = yA + v0·t − ½·g·t² ⟹ v0 = (yB − yA + ½gT²)/T
|
|
598
|
-
*
|
|
599
|
-
* This is the right model for an ARC — as opposed to `fitBallistic`, which
|
|
600
|
-
* answers the different question "what parabola best explains these samples".
|
|
601
|
-
* Least squares spreads its error evenly, which sounds fairer and behaves
|
|
602
|
-
* worse in every way that matters here:
|
|
603
|
-
*
|
|
604
|
-
* - Its correction is LARGEST at the arc's ends, exactly where the pass has
|
|
605
|
-
* to hand back to the clip, so the frames beside a grounded pin take the
|
|
606
|
-
* biggest step. Anchoring makes the correction identically zero there, and
|
|
607
|
-
* the hand-off seamless.
|
|
608
|
-
* - The take-off and landing frames are the trustworthy ones: the feet were
|
|
609
|
-
* on the ground a frame ago, so the clip's height there is constrained by
|
|
610
|
-
* something real. The floating is in the middle. Averaging the reliable
|
|
611
|
-
* ends together with the unreliable middle throws that information away.
|
|
612
|
-
* - It makes the pass a FIXED POINT. The endpoints are never moved, so a
|
|
613
|
-
* second run derives the identical curve and finds nothing left to do.
|
|
614
|
-
*
|
|
615
|
-
* The trade is deliberate: an arc whose take-off or landing height is itself
|
|
616
|
-
* wrong keeps that error, because this model treats contact frames as ground
|
|
617
|
-
* truth. The sanity gate in the driver is what catches the case where they
|
|
618
|
-
* are wrong enough to matter.
|
|
619
|
-
*
|
|
620
|
-
* `residual` is the RMS distance of the samples from that curve — how
|
|
621
|
-
* un-ballistic the arc was to begin with.
|
|
622
|
-
*/
|
|
623
|
-
function fitBallisticArc(samples, fps, g = GRAVITY) {
|
|
624
|
-
const list = samples ?? [];
|
|
625
|
-
const rate = Number.isFinite(fps) && fps > 0 ? fps : 30;
|
|
626
|
-
const times = list.map((_, index) => index / rate);
|
|
627
|
-
if (list.length === 0) return { positions: [], y0: 0, v0: 0, g, residual: 0 };
|
|
628
|
-
const y0 = list[0].y;
|
|
629
|
-
const duration = times[times.length - 1];
|
|
630
|
-
const v0 = duration > 0
|
|
631
|
-
? (list[list.length - 1].y - y0 + 0.5 * g * duration * duration) / duration
|
|
632
|
-
: 0;
|
|
633
|
-
const positions = list.map((sample, index) => {
|
|
634
|
-
const t = times[index];
|
|
635
|
-
return new THREE.Vector3(sample.x ?? 0, y0 + v0 * t - 0.5 * g * t * t, sample.z ?? 0);
|
|
636
|
-
});
|
|
637
|
-
let sumSq = 0;
|
|
638
|
-
for (let index = 0; index < list.length; index += 1) {
|
|
639
|
-
const error = list[index].y - positions[index].y;
|
|
640
|
-
sumSq += error * error;
|
|
641
|
-
}
|
|
642
|
-
return { positions, y0, v0, g, residual: Math.sqrt(sumSq / list.length) };
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/** Deviation from the fitted parabola a span must reach before it is worth
|
|
646
|
-
* correcting. 5 mm is well under the eye's threshold on a character-sized
|
|
647
|
-
* silhouette and comfortably above the noise in a generated root track. */
|
|
648
|
-
export const AUTO_PHYSICS_EPSILON = 0.005;
|
|
649
|
-
|
|
650
|
-
/** Largest root correction the pass will ever apply. Half a metre is already
|
|
651
|
-
* a hip-height move; anything beyond it means the span is not a flight phase
|
|
652
|
-
* and the "fix" would be a catastrophe, so such a span is skipped and
|
|
653
|
-
* reported instead. */
|
|
654
|
-
export const MAX_CORRECTION = 0.5;
|
|
655
|
-
|
|
656
|
-
/** Worst RMS a fit may have and still be believed. A real flight phase fits a
|
|
657
|
-
* parabola to a couple of centimetres; a mis-detected span (a whole walk
|
|
658
|
-
* misread as one jump) fits to a metre or more. */
|
|
659
|
-
const MAX_FIT_RESIDUAL = 0.25;
|
|
660
|
-
|
|
661
|
-
/** Clearance kept between the lowest foot marker and the floor when a
|
|
662
|
-
* downward correction is applied. */
|
|
663
|
-
export const FLOOR_CLEARANCE = 0.005;
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Run AutoPhysics over a frame range: find the airborne spans and force each
|
|
667
|
-
* one's centre-of-mass HEIGHT onto its best-fit ballistic curve.
|
|
668
|
-
*
|
|
669
|
-
* `applyFrame(frame)` must pose the rig (motion apply + ikEvaluate) exactly
|
|
670
|
-
* as the fix-collisions range driver expects — App owns that plumbing, this
|
|
671
|
-
* owns the loop. The pass runs in two sweeps, and the split is what makes it
|
|
672
|
-
* repeatable:
|
|
673
|
-
*
|
|
674
|
-
* 1. Sample sweep. Every frame is posed once and its CoM, foot-marker
|
|
675
|
-
* heights and CLEAN hips transform are recorded. Nothing is written, so
|
|
676
|
-
* this is the clip as authored plus whatever IK keys the user already
|
|
677
|
-
* made — never a trajectory this pass is halfway through editing.
|
|
678
|
-
* 2. Correction sweep. Each span is fitted from those samples. Before every
|
|
679
|
-
* write the hips are RESET to the sweep-1 transform, so the correction
|
|
680
|
-
* applied at a frame never depends on the keys written at earlier frames.
|
|
681
|
-
* Without that reset a second button press re-measured its own blended
|
|
682
|
-
* output and ratcheted the correction upward run after run.
|
|
683
|
-
*
|
|
684
|
-
* Between the two sweeps the raw detection is turned into ARCS, because a
|
|
685
|
-
* span and a ballistic arc are not the same thing:
|
|
686
|
-
*
|
|
687
|
-
* - HYSTERESIS. Spans are found at `lift` and then grown outward at the much
|
|
688
|
-
* lower `exitLift` (see expandAirborneSpans), recovering the take-off and
|
|
689
|
-
* landing frames a stride-proof entry threshold necessarily clips off.
|
|
690
|
-
* - SPLITTING. A span that never plants a foot can still hold two hops; it is
|
|
691
|
-
* cut at its interior centre-of-mass troughs (see splitAtTroughs) so each
|
|
692
|
-
* arc gets its own parabola. One fit spanning two hops needs corrections
|
|
693
|
-
* big enough to trip the sanity gate, which used to throw away the clip's
|
|
694
|
-
* only real jump.
|
|
695
|
-
* - TAPERING. Each arc's correction eases in and out over `edgeTaper` frames
|
|
696
|
-
* (see edgeTaperWeight), so it meets the clip's own trajectory instead of
|
|
697
|
-
* stepping onto it beside a zero pin.
|
|
698
|
-
*
|
|
699
|
-
* An arc is corrected only when its FULL-WEIGHT core misses the parabola
|
|
700
|
-
* vertically by more than `epsilon`; an arc whose worst correction exceeds
|
|
701
|
-
* `correctionLimit`, or whose fit residual exceeds `maxFitResidual`, is
|
|
702
|
-
* refused outright and reported in `skippedSpans`. Every applied ΔY is
|
|
703
|
-
* clamped so no foot marker is pushed below `floorY + floorClearance`.
|
|
704
|
-
*
|
|
705
|
-
* `spans` reports the ARCS that were considered, after expansion and
|
|
706
|
-
* splitting — the units this pass actually reasons about.
|
|
707
|
-
*
|
|
708
|
-
* Every frame of a corrected span is keyed. So is every frame BETWEEN the
|
|
709
|
-
* first and last of them that is not itself corrected — the boundary frames
|
|
710
|
-
* either side of a span, and any grounded gap between two spans. Those pins
|
|
711
|
-
* carry the clip's own pose and exist because ikEvaluate evaluates the whole
|
|
712
|
-
* keyed range at full weight: without them the layer interpolates the hips
|
|
713
|
-
* straight across the gaps and drags grounded frames along with it. A pin
|
|
714
|
-
* changes nothing visually; it states that nothing changes.
|
|
715
|
-
*
|
|
716
|
-
* Returns { supported, reason, spans, keyedFrames, maxCorrection, corrected,
|
|
717
|
-
* pinnedFrames, skippedSpans, planted }.
|
|
718
|
-
*
|
|
719
|
-
* `supported` separates "ran and found nothing to do" from "could not run".
|
|
720
|
-
* Every refusal below — a rig with no hips joint, a range too short to hold a
|
|
721
|
-
* span, a skeleton whose centre of mass or foot markers cannot be measured —
|
|
722
|
-
* used to return the same empty result as a perfectly good clip that simply
|
|
723
|
-
* never leaves the ground, and the UI read all of them as "no airborne frames
|
|
724
|
-
* in the clip". That sentence is a lie for four of the five, and the one it
|
|
725
|
-
* fits is the only one the user can do nothing about. `reason` names which.
|
|
726
|
-
*/
|
|
727
|
-
export function autoPhysicsRange({
|
|
728
|
-
rig,
|
|
729
|
-
motion = null,
|
|
730
|
-
chains,
|
|
731
|
-
fkJoints = null,
|
|
732
|
-
ikState,
|
|
733
|
-
applyFrame,
|
|
734
|
-
floorY = 0,
|
|
735
|
-
fps = 30,
|
|
736
|
-
gravity = GRAVITY,
|
|
737
|
-
lift = AIRBORNE_LIFT,
|
|
738
|
-
exitLift = EXIT_LIFT,
|
|
739
|
-
splitDepth = SPLIT_DEPTH,
|
|
740
|
-
edgeTaper = EDGE_TAPER,
|
|
741
|
-
margin = null,
|
|
742
|
-
minSpanFrames = MIN_AIRBORNE_FRAMES,
|
|
743
|
-
epsilon = AUTO_PHYSICS_EPSILON,
|
|
744
|
-
correctionLimit = MAX_CORRECTION,
|
|
745
|
-
maxFitResidual = MAX_FIT_RESIDUAL,
|
|
746
|
-
floorClearance = FLOOR_CLEARANCE,
|
|
747
|
-
contactHeights = null,
|
|
748
|
-
supportFrames = null,
|
|
749
|
-
grounding = false,
|
|
750
|
-
startFrame = 0,
|
|
751
|
-
endFrame = null,
|
|
752
|
-
} = {}) {
|
|
753
|
-
const refuse = (reason) => ({
|
|
754
|
-
supported: false, reason,
|
|
755
|
-
spans: [], keyedFrames: [], maxCorrection: 0, corrected: 0, pinnedFrames: [], skippedSpans: [], planted: null,
|
|
756
|
-
groundContactSpans: [], rejectedGroundSpans: [], groundedKeyedFrames: [], maxFootCorrection: 0,
|
|
757
|
-
maxFootSlideBefore: 0, maxFootSlideAfter: 0, meanFootSlideBefore: 0, meanFootSlideAfter: 0,
|
|
758
|
-
floorPenetrationBefore: 0, floorPenetrationAfter: 0, skippedFloorFrames: [],
|
|
759
|
-
});
|
|
760
|
-
if (!rig || !chains || !ikState || typeof applyFrame !== "function") return refuse("no-driver");
|
|
761
|
-
// The whole correction is a root translation, so without the hips FK joint
|
|
762
|
-
// (the body-root control that owns the hips' local position) there is
|
|
763
|
-
// nothing this pass can write.
|
|
764
|
-
const hips = fkJoints?.get?.("hips");
|
|
765
|
-
if (!hips) return refuse("no-hips-joint");
|
|
766
|
-
// `margin` is the old option name from the absolute-threshold version; it
|
|
767
|
-
// meant the same thing (clearance a foot must have) against a different
|
|
768
|
-
// reference, so honouring it as an alias keeps old callers working.
|
|
769
|
-
const liftThreshold = Number.isFinite(margin) ? margin : lift;
|
|
770
|
-
void contactHeights; // accepted for API compatibility; the clip-relative
|
|
771
|
-
// planted floor below is strictly better and must dominate, so measured
|
|
772
|
-
// mesh drops no longer take part in detection at all.
|
|
773
|
-
const start = Math.max(0, Math.round(startFrame) || 0);
|
|
774
|
-
const lastFrame = Number.isFinite(endFrame) ? Math.round(endFrame) : (motion?.frames ?? 0) - 1;
|
|
775
|
-
const end = Math.max(start, Math.round(lastFrame));
|
|
776
|
-
const count = end - start + 1;
|
|
777
|
-
if (count < minSpanFrames) return refuse("range-too-short");
|
|
778
|
-
|
|
779
|
-
/* --- sweep 1: measure the clip, touching nothing ----------------------- */
|
|
780
|
-
const samples = [];
|
|
781
|
-
const heights = [];
|
|
782
|
-
const footPositions = [];
|
|
783
|
-
const clipFootPositions = [];
|
|
784
|
-
const basePose = [];
|
|
785
|
-
const footIds = FOOT_SIDES.map((foot) => foot.chainId).filter((id) => chains.has(id));
|
|
786
|
-
const readFootQuats = () => new Map(footIds.map((id) => [id, chains.get(id).bones.map((bone) => bone.quaternion.clone())]));
|
|
787
|
-
const layerFootQuats = [];
|
|
788
|
-
const clipFootQuats = [];
|
|
789
|
-
const layerActive = ikState.tracked.size > 0;
|
|
790
|
-
for (let frame = start; frame <= end; frame += 1) {
|
|
791
|
-
if (layerActive) {
|
|
792
|
-
const tracked = ikState.tracked;
|
|
793
|
-
ikState.tracked = new Set();
|
|
794
|
-
try {
|
|
795
|
-
applyFrame(frame);
|
|
796
|
-
clipFootQuats.push(readFootQuats());
|
|
797
|
-
clipFootPositions.push(markerPositions(rig));
|
|
798
|
-
} finally {
|
|
799
|
-
ikState.tracked = tracked;
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
applyFrame(frame);
|
|
803
|
-
const com = computeCenterOfMass(rig);
|
|
804
|
-
const marks = markerHeights(rig);
|
|
805
|
-
const points = markerPositions(rig);
|
|
806
|
-
if (!com || !marks || !points) return refuse("rig-not-measurable"); // cannot be judged — decline
|
|
807
|
-
samples.push(com);
|
|
808
|
-
heights.push(marks);
|
|
809
|
-
footPositions.push(points);
|
|
810
|
-
basePose.push({ position: hips.bone.position.clone(), quaternion: hips.bone.quaternion.clone() });
|
|
811
|
-
layerFootQuats.push(readFootQuats());
|
|
812
|
-
if (!layerActive) {
|
|
813
|
-
clipFootQuats.push(readFootQuats());
|
|
814
|
-
clipFootPositions.push(points);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
const planted = plantedFloor(heights);
|
|
818
|
-
const ground = grounding
|
|
819
|
-
? detectGroundContactSpans({
|
|
820
|
-
positions: clipFootPositions,
|
|
821
|
-
planted: plantedFloor(clipFootPositions.map((frame) => Object.fromEntries(FOOT_MARKERS.map((name) => [name, frame[name].y])))),
|
|
822
|
-
fps,
|
|
823
|
-
startFrame: start,
|
|
824
|
-
})
|
|
825
|
-
: { spans: [], rejected: [] };
|
|
826
|
-
const flags = heights.map((marks, i) => !supportFrames?.has(start + i) && heightsAirborne(marks, planted, liftThreshold));
|
|
827
|
-
const lowest = heights.map((marks) => Math.min(...FOOT_MARKERS.map((name) => marks[name])));
|
|
828
|
-
// Detect strictly, then grow: the entry lift has to be high enough to
|
|
829
|
-
// ignore a stride, which necessarily clips the take-off and landing frames
|
|
830
|
-
// off a real jump. The exit lift puts them back.
|
|
831
|
-
const detected = detectAirborneSpans({ frames: count, isAirborne: flags, startFrame: start, minLength: minSpanFrames });
|
|
832
|
-
const exitFlags = heights.map((marks, i) => !supportFrames?.has(start + i) && heightsAirborne(marks, planted, Math.min(exitLift, liftThreshold)));
|
|
833
|
-
const expanded = expandAirborneSpans({ spans: detected, canExtend: exitFlags, startFrame: start });
|
|
834
|
-
|
|
835
|
-
/* --- split each span into single arcs ---------------------------------- */
|
|
836
|
-
const skippedSpans = [];
|
|
837
|
-
const spans = [];
|
|
838
|
-
for (const span of expanded) {
|
|
839
|
-
const first = span.start - start;
|
|
840
|
-
const track = samples.slice(first, first + span.length).map((sample) => sample.y);
|
|
841
|
-
for (const [from, to] of splitAtTroughs(track, { splitDepth })) {
|
|
842
|
-
const arc = { start: span.start + from, end: span.start + to, length: to - from + 1 };
|
|
843
|
-
// A sub-arc too short to fit is not evidence of anything; report it
|
|
844
|
-
// rather than fitting two points and calling the result physics.
|
|
845
|
-
if (arc.length < minSpanFrames) {
|
|
846
|
-
skippedSpans.push({ ...arc, reason: "too-short", maxDelta: 0, residual: 0 });
|
|
847
|
-
continue;
|
|
848
|
-
}
|
|
849
|
-
spans.push(arc);
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/** Restore the hips to the pose sweep 1 saw, so every write starts from
|
|
854
|
-
* the clip rather than from this pass's own partial output. */
|
|
855
|
-
const restoreBase = (frame) => {
|
|
856
|
-
const base = basePose[frame - start];
|
|
857
|
-
hips.bone.position.copy(base.position);
|
|
858
|
-
hips.bone.quaternion.copy(base.quaternion);
|
|
859
|
-
hips.bone.updateMatrixWorld(true);
|
|
860
|
-
};
|
|
861
|
-
|
|
862
|
-
/* --- sweep 2: correct the arcs that deserve it ------------------------- */
|
|
863
|
-
const keyedFrames = [];
|
|
864
|
-
const pinnedFrames = [];
|
|
865
|
-
const corrections = new Map();
|
|
866
|
-
let maxCorrection = 0;
|
|
867
|
-
for (const span of spans) {
|
|
868
|
-
const first = span.start - start;
|
|
869
|
-
const spanSamples = samples.slice(first, first + span.length);
|
|
870
|
-
const weights = spanSamples.map((_, index) => edgeTaperWeight(index, span.length, edgeTaper));
|
|
871
|
-
// Anchored to the take-off and landing samples, so the correction is
|
|
872
|
-
// zero at both ends by construction and the arc hands back to the clip
|
|
873
|
-
// without a step. See fitBallisticArc for why not least squares.
|
|
874
|
-
const fit = fitBallisticArc(spanSamples, fps, gravity);
|
|
875
|
-
const raw = fit.positions.map((fitted, index) => fitted.y - spanSamples[index].y);
|
|
876
|
-
// Only the FULL-WEIGHT core votes on whether the arc needs work. The
|
|
877
|
-
// tapered frames deliberately keep a share of the clip, so they are
|
|
878
|
-
// permanently a little off the curve and would otherwise ask for the
|
|
879
|
-
// same correction on every run for ever.
|
|
880
|
-
let coreFrom = weights.findIndex((weight) => weight >= 1);
|
|
881
|
-
let coreTo = weights.length - 1 - [...weights].reverse().findIndex((weight) => weight >= 1);
|
|
882
|
-
if (coreFrom < 0 || coreTo < coreFrom) {
|
|
883
|
-
coreFrom = 0;
|
|
884
|
-
coreTo = span.length - 1;
|
|
885
|
-
}
|
|
886
|
-
let worstCore = 0;
|
|
887
|
-
for (let index = coreFrom; index <= coreTo; index += 1) worstCore = Math.max(worstCore, Math.abs(raw[index]));
|
|
888
|
-
if (worstCore <= epsilon) continue; // already ballistic — leave it keyless
|
|
889
|
-
|
|
890
|
-
// Floor guard: never push a foot marker through the ground. Only a
|
|
891
|
-
// downward correction can, so this is a one-sided clamp, applied AFTER
|
|
892
|
-
// the taper so the clamp governs what actually lands on the bone.
|
|
893
|
-
const applied = raw.map((value, index) =>
|
|
894
|
-
Math.max(value * weights[index], floorY + floorClearance - lowest[first + index]));
|
|
895
|
-
const worstApplied = applied.reduce((max, value) => Math.max(max, Math.abs(value)), 0);
|
|
896
|
-
const worstRaw = raw.reduce((max, value) => Math.max(max, Math.abs(value)), 0);
|
|
897
|
-
// Deltas, not the clamped result, decide whether the arc is believable:
|
|
898
|
-
// a floor clamp could shrink an absurd correction into the accepted
|
|
899
|
-
// range and hide exactly the failure this gate exists to catch.
|
|
900
|
-
if (worstRaw > correctionLimit || worstApplied > correctionLimit || fit.residual > maxFitResidual) {
|
|
901
|
-
skippedSpans.push({
|
|
902
|
-
start: span.start,
|
|
903
|
-
end: span.end,
|
|
904
|
-
length: span.length,
|
|
905
|
-
reason: fit.residual > maxFitResidual ? "fit-too-poor" : "correction-too-large",
|
|
906
|
-
maxDelta: Math.max(worstRaw, worstApplied),
|
|
907
|
-
residual: fit.residual,
|
|
908
|
-
});
|
|
909
|
-
continue;
|
|
910
|
-
}
|
|
911
|
-
for (let index = 0; index < span.length; index += 1) {
|
|
912
|
-
corrections.set(span.start + index, applied[index]);
|
|
913
|
-
maxCorrection = Math.max(maxCorrection, Math.abs(applied[index]));
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/* --- write: corrections, then zero-delta pins across the keyed range --- */
|
|
918
|
-
const lift3 = new THREE.Vector3();
|
|
919
|
-
/** The pass writes exactly one thing: the hips' local position (and, as a
|
|
920
|
-
* pin, the clip's own hips rotation). Naming that in the bake keeps it from
|
|
921
|
-
* re-keying every limb the user has ever dragged — and the clip's own hips
|
|
922
|
-
* position rides along as `basePos`, so ikEvaluate applies the correction as
|
|
923
|
-
* a DELTA over the clip instead of splicing this frame's absolute height
|
|
924
|
-
* onto its neighbours. A pin's delta is then exactly zero by construction,
|
|
925
|
-
* not merely zero at the pinned frame. */
|
|
926
|
-
const writeFrame = (frame, delta) => {
|
|
927
|
-
applyFrame(frame);
|
|
928
|
-
restoreBase(frame);
|
|
929
|
-
const clipPosition = hips.bone.position.clone();
|
|
930
|
-
if (delta) {
|
|
931
|
-
lift3.set(0, delta, 0);
|
|
932
|
-
solveHipsTranslate(hips, lift3, clipPosition.clone());
|
|
933
|
-
}
|
|
934
|
-
ikTouch(ikState, "hips");
|
|
935
|
-
ikBakeKeyframe(chains, ikState, frame, fkJoints, ["hips"], new Map([["hips", clipPosition]]));
|
|
936
|
-
};
|
|
937
|
-
if (corrections.size > 0) {
|
|
938
|
-
for (const [frame, delta] of [...corrections.entries()].sort((a, b) => a[0] - b[0])) {
|
|
939
|
-
writeFrame(frame, delta);
|
|
940
|
-
keyedFrames.push(frame);
|
|
941
|
-
}
|
|
942
|
-
const pinFrom = Math.max(start, Math.min(...corrections.keys()) - 1);
|
|
943
|
-
const pinTo = Math.min(end, Math.max(...corrections.keys()) + 1);
|
|
944
|
-
for (let frame = pinFrom; frame <= pinTo; frame += 1) {
|
|
945
|
-
if (corrections.has(frame)) continue;
|
|
946
|
-
writeFrame(frame, 0);
|
|
947
|
-
pinnedFrames.push(frame);
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
/* --- grounded feet: lock safe stance runs, lift only real penetration --- */
|
|
952
|
-
const groundedKeyedFrames = [];
|
|
953
|
-
let maxFootCorrection = 0;
|
|
954
|
-
let floorPenetrationBefore = 0;
|
|
955
|
-
let floorPenetrationAfter = 0;
|
|
956
|
-
let maxFootSlideBefore = ground.spans.reduce((max, span) => Math.max(max, span.maxPull), 0);
|
|
957
|
-
let maxFootSlideAfter = 0;
|
|
958
|
-
let slideSumBefore = 0;
|
|
959
|
-
let slideSumAfter = 0;
|
|
960
|
-
let slideSampleCount = 0;
|
|
961
|
-
for (const span of ground.spans) {
|
|
962
|
-
const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
|
|
963
|
-
if (!foot) continue;
|
|
964
|
-
for (let frame = span.start; frame <= span.end; frame += 1) {
|
|
965
|
-
const point = footPositions[frame - start]?.[foot.ankle];
|
|
966
|
-
if (!point) continue;
|
|
967
|
-
slideSumBefore += Math.hypot(point.x - span.anchor.x, point.z - span.anchor.z);
|
|
968
|
-
slideSampleCount += 1;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
const contactHeight = (foot) => chains.get(foot.chainId)?.contactHeights?.[foot.height] ?? 0;
|
|
972
|
-
const penetrationAt = (points) => {
|
|
973
|
-
let worst = 0;
|
|
974
|
-
for (const foot of FOOT_SIDES) {
|
|
975
|
-
const ankle = points?.[foot.ankle];
|
|
976
|
-
if (!ankle) continue;
|
|
977
|
-
// Use the bind-pose MEASURED mesh drop below the ankle. ToeBase is a
|
|
978
|
-
// useful contact marker but not a sole: on X-Bot its bone sits about
|
|
979
|
-
// 12 cm below Y-Bot's relative to the visible foot, which made the old
|
|
980
|
-
// absolute toe test lift an otherwise grounded body by a false 12 cm.
|
|
981
|
-
worst = Math.max(worst, floorY + contactHeight(foot) - ankle.y);
|
|
982
|
-
}
|
|
983
|
-
return Math.max(0, worst);
|
|
984
|
-
};
|
|
985
|
-
const contactFrames = new Set();
|
|
986
|
-
for (const span of ground.spans) for (let frame = span.start; frame <= span.end; frame += 1) contactFrames.add(frame);
|
|
987
|
-
const floorLifts = new Map();
|
|
988
|
-
const skippedFloorFrames = new Set();
|
|
989
|
-
for (const frame of contactFrames) {
|
|
990
|
-
const penetration = penetrationAt(footPositions[frame - start]);
|
|
991
|
-
floorPenetrationBefore = Math.max(floorPenetrationBefore, penetration);
|
|
992
|
-
if (penetration > GROUND_FLOOR_MAX_LIFT) skippedFloorFrames.add(frame);
|
|
993
|
-
else if (penetration > GROUND_FLOOR_EPSILON) floorLifts.set(frame, penetration);
|
|
994
|
-
}
|
|
995
|
-
// A floor offset is a body placement error, not a bent-leg error. Lift the
|
|
996
|
-
// hips/root rigidly so the entire character clears the floor without
|
|
997
|
-
// changing either knee. The foot solve below then handles XZ only.
|
|
998
|
-
for (const [frame, lift] of floorLifts) {
|
|
999
|
-
writeFrame(frame, lift);
|
|
1000
|
-
groundedKeyedFrames.push(frame);
|
|
1001
|
-
maxCorrection = Math.max(maxCorrection, lift);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const groundWrites = new Map();
|
|
1005
|
-
for (const span of ground.spans) {
|
|
1006
|
-
const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
|
|
1007
|
-
if (!foot) continue;
|
|
1008
|
-
for (let frame = span.start; frame <= span.end; frame += 1) {
|
|
1009
|
-
const index = frame - start;
|
|
1010
|
-
const ankle = footPositions[index]?.[foot.ankle];
|
|
1011
|
-
if (!ankle) continue;
|
|
1012
|
-
const target = ankle.clone();
|
|
1013
|
-
target.x = span.anchor.x;
|
|
1014
|
-
target.z = span.anchor.z;
|
|
1015
|
-
// The parent root has already been lifted on a penetrated frame. Move
|
|
1016
|
-
// the ankle by the same amount so the leg keeps its vertical pose.
|
|
1017
|
-
target.y += floorLifts.get(frame) ?? 0;
|
|
1018
|
-
const delta = Math.hypot(target.x - ankle.x, target.z - ankle.z);
|
|
1019
|
-
if (delta <= GROUND_LOCK_EPSILON) continue;
|
|
1020
|
-
if (!groundWrites.has(frame)) groundWrites.set(frame, []);
|
|
1021
|
-
groundWrites.get(frame).push({ foot, target, delta });
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
for (const [frame, writes] of [...groundWrites.entries()].sort((a, b) => a[0] - b[0])) {
|
|
1025
|
-
applyFrame(frame);
|
|
1026
|
-
const index = frame - start;
|
|
1027
|
-
// Restore the sweep-1 layer pose before solving either leg. This keeps
|
|
1028
|
-
// a key written on frame N from becoming frame N+1's starting pose.
|
|
1029
|
-
for (const id of footIds) {
|
|
1030
|
-
const chain = chains.get(id);
|
|
1031
|
-
const quats = layerFootQuats[index]?.get(id);
|
|
1032
|
-
if (!chain || !quats) continue;
|
|
1033
|
-
chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
|
|
1034
|
-
}
|
|
1035
|
-
rig.updateMatrixWorld(true);
|
|
1036
|
-
const successful = [];
|
|
1037
|
-
for (const write of writes) {
|
|
1038
|
-
const { foot, target } = write;
|
|
1039
|
-
const chain = chains.get(foot.chainId);
|
|
1040
|
-
solveIk(chain, target);
|
|
1041
|
-
rig.updateMatrixWorld(true);
|
|
1042
|
-
let achieved = findBone(rig, foot.ankle)?.getWorldPosition(new THREE.Vector3());
|
|
1043
|
-
let residual = achieved ? Math.hypot(achieved.x - target.x, achieved.z - target.z) : Infinity;
|
|
1044
|
-
// A fully straight leg has no sideways reach even for a two-centimetre
|
|
1045
|
-
// plant. On failure only, permit five millimetres of sole clearance to
|
|
1046
|
-
// create knee bend, then retry from the untouched clip pose once.
|
|
1047
|
-
if (residual > GROUND_LOCK_EPSILON) {
|
|
1048
|
-
const quats = layerFootQuats[index]?.get(foot.chainId);
|
|
1049
|
-
if (quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
|
|
1050
|
-
rig.updateMatrixWorld(true);
|
|
1051
|
-
target.y += GROUND_LOCK_REACH_LIFT;
|
|
1052
|
-
solveIk(chain, target);
|
|
1053
|
-
rig.updateMatrixWorld(true);
|
|
1054
|
-
achieved = findBone(rig, foot.ankle)?.getWorldPosition(new THREE.Vector3());
|
|
1055
|
-
residual = achieved ? Math.hypot(achieved.x - target.x, achieved.z - target.z) : Infinity;
|
|
1056
|
-
}
|
|
1057
|
-
// A straight or fully stretched leg may not reach a sideways plant.
|
|
1058
|
-
// Do not bake a half-fix that a second run would ratchet further.
|
|
1059
|
-
if (residual <= GROUND_LOCK_EPSILON) {
|
|
1060
|
-
successful.push(write);
|
|
1061
|
-
maxFootCorrection = Math.max(maxFootCorrection, write.delta);
|
|
1062
|
-
} else {
|
|
1063
|
-
const quats = layerFootQuats[index]?.get(foot.chainId);
|
|
1064
|
-
if (quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
|
|
1065
|
-
rig.updateMatrixWorld(true);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
for (const { foot } of successful) {
|
|
1069
|
-
ikBakeKeyframe(chains, ikState, frame, fkJoints, [foot.chainId], null, clipFootQuats[index]);
|
|
1070
|
-
}
|
|
1071
|
-
if (successful.length) groundedKeyedFrames.push(frame);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// A root lift can turn a previously straight, unreachable leg into a
|
|
1075
|
-
// solvable one. Re-sample the FINISHED layer and take at most three bounded
|
|
1076
|
-
// refinement sweeps now, so pressing the button again cannot discover a
|
|
1077
|
-
// second batch and ratchet the take. Each sweep is still snapshotted before
|
|
1078
|
-
// it writes, so neighbouring keys never become one another's input.
|
|
1079
|
-
for (let refinement = 0; grounding && refinement < 3; refinement += 1) {
|
|
1080
|
-
const currentPositions = [];
|
|
1081
|
-
const currentQuats = [];
|
|
1082
|
-
for (let frame = start; frame <= end; frame += 1) {
|
|
1083
|
-
applyFrame(frame);
|
|
1084
|
-
currentPositions.push(markerPositions(rig));
|
|
1085
|
-
currentQuats.push(readFootQuats());
|
|
1086
|
-
}
|
|
1087
|
-
let wrote = 0;
|
|
1088
|
-
for (let frame = start; frame <= end; frame += 1) {
|
|
1089
|
-
const active = ground.spans.filter((span) => frame >= span.start && frame <= span.end);
|
|
1090
|
-
if (!active.length) continue;
|
|
1091
|
-
const index = frame - start;
|
|
1092
|
-
const candidates = [];
|
|
1093
|
-
for (const span of active) {
|
|
1094
|
-
const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
|
|
1095
|
-
const ankle = foot && currentPositions[index]?.[foot.ankle];
|
|
1096
|
-
if (!foot || !ankle) continue;
|
|
1097
|
-
const delta = Math.hypot(span.anchor.x - ankle.x, span.anchor.z - ankle.z);
|
|
1098
|
-
if (delta > GROUND_LOCK_EPSILON) candidates.push({ foot, delta, target: ankle.clone().setX(span.anchor.x).setZ(span.anchor.z) });
|
|
1099
|
-
}
|
|
1100
|
-
if (!candidates.length) continue;
|
|
1101
|
-
applyFrame(frame);
|
|
1102
|
-
for (const { foot } of candidates) {
|
|
1103
|
-
const chain = chains.get(foot.chainId);
|
|
1104
|
-
const quats = currentQuats[index]?.get(foot.chainId);
|
|
1105
|
-
if (chain && quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
|
|
1106
|
-
}
|
|
1107
|
-
rig.updateMatrixWorld(true);
|
|
1108
|
-
for (const candidate of candidates) {
|
|
1109
|
-
const { foot, target } = candidate;
|
|
1110
|
-
const chain = chains.get(foot.chainId);
|
|
1111
|
-
solveIk(chain, target);
|
|
1112
|
-
rig.updateMatrixWorld(true);
|
|
1113
|
-
const achieved = findBone(rig, foot.ankle)?.getWorldPosition(new THREE.Vector3());
|
|
1114
|
-
const residual = achieved ? Math.hypot(achieved.x - target.x, achieved.z - target.z) : Infinity;
|
|
1115
|
-
if (residual <= GROUND_LOCK_EPSILON) {
|
|
1116
|
-
ikBakeKeyframe(chains, ikState, frame, fkJoints, [foot.chainId], null, clipFootQuats[index]);
|
|
1117
|
-
groundedKeyedFrames.push(frame);
|
|
1118
|
-
maxFootCorrection = Math.max(maxFootCorrection, candidate.delta);
|
|
1119
|
-
wrote += 1;
|
|
1120
|
-
} else {
|
|
1121
|
-
const quats = currentQuats[index]?.get(foot.chainId);
|
|
1122
|
-
if (quats) chain.bones.forEach((bone, boneIndex) => bone.quaternion.copy(quats[boneIndex]));
|
|
1123
|
-
rig.updateMatrixWorld(true);
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
if (!wrote) break;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// Foot IK can lower an ankle slightly after the first root lift. Re-check
|
|
1131
|
-
// the evaluated layer and add only the remaining clearance to that frame's
|
|
1132
|
-
// existing root correction. Two bounded passes settle interpolation without
|
|
1133
|
-
// ever accumulating beyond the measured need or the 15 cm safety cap.
|
|
1134
|
-
if (grounding) {
|
|
1135
|
-
for (let pass = 0; pass < 2; pass += 1) {
|
|
1136
|
-
let wrote = 0;
|
|
1137
|
-
for (const frame of contactFrames) {
|
|
1138
|
-
applyFrame(frame);
|
|
1139
|
-
const residual = penetrationAt(markerPositions(rig));
|
|
1140
|
-
if (residual <= GROUND_FLOOR_EPSILON) continue;
|
|
1141
|
-
const total = (floorLifts.get(frame) ?? 0) + residual;
|
|
1142
|
-
if (total > GROUND_FLOOR_MAX_LIFT) {
|
|
1143
|
-
skippedFloorFrames.add(frame);
|
|
1144
|
-
continue;
|
|
1145
|
-
}
|
|
1146
|
-
floorLifts.set(frame, total);
|
|
1147
|
-
writeFrame(frame, total);
|
|
1148
|
-
groundedKeyedFrames.push(frame);
|
|
1149
|
-
maxCorrection = Math.max(maxCorrection, total);
|
|
1150
|
-
wrote += 1;
|
|
1151
|
-
}
|
|
1152
|
-
if (!wrote) break;
|
|
1153
|
-
}
|
|
1154
|
-
// Read back the final evaluated result, not the solver's momentary pose:
|
|
1155
|
-
// this is the same truth the viewer sees after all keys and blend ramps.
|
|
1156
|
-
const afterPositions = [];
|
|
1157
|
-
for (let frame = start; frame <= end; frame += 1) {
|
|
1158
|
-
applyFrame(frame);
|
|
1159
|
-
const points = markerPositions(rig);
|
|
1160
|
-
if (!points) continue;
|
|
1161
|
-
afterPositions.push(points);
|
|
1162
|
-
if (contactFrames.has(frame)) floorPenetrationAfter = Math.max(floorPenetrationAfter, penetrationAt(points));
|
|
1163
|
-
}
|
|
1164
|
-
for (const span of ground.spans) {
|
|
1165
|
-
const foot = FOOT_SIDES.find((item) => item.chainId === span.chainId);
|
|
1166
|
-
if (!foot) continue;
|
|
1167
|
-
for (let frame = span.start; frame <= span.end; frame += 1) {
|
|
1168
|
-
const point = afterPositions[frame - start]?.[foot.ankle];
|
|
1169
|
-
if (point) {
|
|
1170
|
-
const distance = Math.hypot(point.x - span.anchor.x, point.z - span.anchor.z);
|
|
1171
|
-
maxFootSlideAfter = Math.max(maxFootSlideAfter, distance);
|
|
1172
|
-
slideSumAfter += distance;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
const allKeyed = [...new Set([...keyedFrames, ...groundedKeyedFrames])].sort((a, b) => a - b);
|
|
1178
|
-
return {
|
|
1179
|
-
supported: true, reason: "",
|
|
1180
|
-
spans, keyedFrames: allKeyed, airborneKeyedFrames: keyedFrames, maxCorrection,
|
|
1181
|
-
corrected: allKeyed.length, pinnedFrames, skippedSpans, planted,
|
|
1182
|
-
groundContactSpans: ground.spans,
|
|
1183
|
-
rejectedGroundSpans: ground.rejected,
|
|
1184
|
-
groundedKeyedFrames: [...new Set(groundedKeyedFrames)].sort((a, b) => a - b),
|
|
1185
|
-
maxFootCorrection,
|
|
1186
|
-
maxFootSlideBefore,
|
|
1187
|
-
maxFootSlideAfter,
|
|
1188
|
-
meanFootSlideBefore: slideSampleCount ? slideSumBefore / slideSampleCount : 0,
|
|
1189
|
-
meanFootSlideAfter: slideSampleCount ? slideSumAfter / slideSampleCount : 0,
|
|
1190
|
-
floorPenetrationBefore,
|
|
1191
|
-
floorPenetrationAfter,
|
|
1192
|
-
skippedFloorFrames: [...skippedFloorFrames].sort((a, b) => a - b),
|
|
1193
|
-
};
|
|
1194
|
-
}
|