cozyclay 2.1.0 → 3.0.0
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 +65 -0
- package/README.md +12 -30
- package/bin/agent/agent-routes.mjs +40 -113
- package/bin/agent/agent-runner.mjs +9 -10
- 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/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 +22 -6
- 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-C9sUnndd.js +1 -0
- package/dist/assets/app-CCh--1Hc.js +5087 -0
- package/dist/assets/app-f-fkV5uP.css +1 -0
- package/dist/assets/compile-worker-CkxiD6lH.js +5 -0
- package/dist/assets/fflate.module-KbGj5-H6.js +5 -0
- package/dist/assets/{first-shot-handoff-BlT33CGf.js → first-shot-handoff-CC4xe4HM.js} +1 -1
- package/dist/assets/{landing-BSesrCpL.js → landing-DjHD8u7k.js} +1 -1
- package/dist/assets/scene-asset-sync-Hx_FzdOH.js +20 -0
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +1 -0
- package/dist/assets/workflow-C4yKH1p4.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +3 -3
- package/dist/llms.txt +14 -0
- 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 +174 -2639
- package/src/agent-progress.js +79 -0
- package/src/agent-turn-mark.js +30 -0
- package/src/analytics.js +1 -19
- package/src/app-context.js +5 -0
- 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 +8 -6
- 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 +207 -0
- package/src/commands/objects.js +6 -4
- package/src/commands/shot.js +49 -11
- 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 +51 -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 +46 -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 +59 -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 +48 -419
- 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 +75 -0
- package/src/motion3/compile-worker.js +10 -0
- package/src/motion3/compiler/anchors.js +105 -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 +170 -0
- package/src/motion3/compiler/math.js +72 -0
- package/src/motion3/compiler/pose.js +162 -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 +143 -0
- package/src/motion3/compiler/validate-parts.js +116 -0
- package/src/motion3/compiler/validate.js +122 -0
- package/src/motion3/compiler/vocab.js +73 -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 +174 -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 +173 -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/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 +121 -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 +179 -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 +42 -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/severity.js +38 -0
- package/src/motion3/qa/spec-checks.js +65 -0
- package/src/motion3/qa/style.js +218 -0
- package/src/motion3/qa/wrist-checks.js +69 -0
- package/src/motion3/qa/wrist-obs.js +60 -0
- package/src/motion3/refs.js +120 -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 +111 -0
- package/src/motion3/schema/types.js +147 -0
- package/src/motion3/search.js +117 -0
- package/src/motion3/second-hand.js +112 -0
- package/src/motion3/skills/basic.js +82 -0
- package/src/motion3/skills/book.js +76 -0
- package/src/motion3/skills/car.js +145 -0
- package/src/motion3/skills/frames-auto.js +29 -0
- package/src/motion3/skills/index.js +83 -0
- package/src/motion3/skills/info.js +65 -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/PoseImagePanel.jsx +1 -1
- 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/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 +11 -53
- package/src/shell/CompositionGuideButton.jsx +20 -0
- 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/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +5 -83
- package/src/shell/ViewportToolbar.jsx +23 -93
- 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/shell/viewport.css +12 -0
- 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 +12 -14
- package/src/studio-agent-commands.js +46 -16
- package/src/studio-agent-motion.js +9 -9
- package/src/studio-agent-protocol.js +46 -73
- package/src/studio-app-binding.js +29 -45
- 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 +8 -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 +107 -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/headless-studio.mjs +85 -0
- package/tools/knip-baseline.json +0 -179
- package/tools/loc-baseline.json +33 -83
- 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 -124
- package/bin/agent/motion-runtime.mjs +0 -76
- package/dist/ardy/cskel27-rest.json +0 -557
- package/dist/assets/analytics-B41FmPYI.js +0 -1
- package/dist/assets/app-Do-Og5Pu.css +0 -1
- package/dist/assets/app-DwP-tl2x.js +0 -4920
- package/dist/assets/config-Bx7pYsD9.js +0 -1
- package/dist/assets/config-DeoFTYzb.css +0 -1
- package/dist/assets/demo-Dt0qZfS7.js +0 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +0 -19
- package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
- package/dist/assets/ticket-jqMhqL88.js +0 -1
- package/dist/assets/workflow-Br-K7Srs.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 -159
- 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
|
@@ -1,2257 +0,0 @@
|
|
|
1
|
-
import * as THREE from "three";
|
|
2
|
-
import {
|
|
3
|
-
findBone,
|
|
4
|
-
solveIk,
|
|
5
|
-
solveMidJoint,
|
|
6
|
-
solveSwingAngle,
|
|
7
|
-
ikTouch,
|
|
8
|
-
ikBakeKeyframe,
|
|
9
|
-
measureContactRadii,
|
|
10
|
-
hasBindPose,
|
|
11
|
-
bindWorldPosition,
|
|
12
|
-
restWorldPosition,
|
|
13
|
-
} from "./ik.js";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Self-collision cleanup for generated motion: detect body parts that pass
|
|
17
|
-
* through each other (a hand through the thigh, a forearm through the
|
|
18
|
-
* chest) and push them apart with the existing two-bone IK solver.
|
|
19
|
-
*
|
|
20
|
-
* Standard character-cleanup technique (capsule proxies + iterative
|
|
21
|
-
* projection, as in classic mocap-clean literature and DCC plugins), built
|
|
22
|
-
* entirely on CozyClay's own pieces:
|
|
23
|
-
*
|
|
24
|
-
* - Each body segment gets a CAPSULE proxy (bone → child bone axis, radius
|
|
25
|
-
* measured from the bind-pose skinned mesh by measureContactRadii).
|
|
26
|
-
* - Torso/head capsules are STATIC blockers; limb capsules are MOVABLE.
|
|
27
|
-
* - A penetration is resolved by moving the limb — mid joint (elbow/knee)
|
|
28
|
-
* for the upper segment, effector (wrist/ankle) for the lower — out by
|
|
29
|
-
* (depth + offset) along the separation normal BIASED TOWARD THE REST POSE,
|
|
30
|
-
* so a deep hit escapes to the side the limb belongs on instead of taking
|
|
31
|
-
* the shortest way out (see REST-BIASED ESCAPE). solveIk's bend continuity
|
|
32
|
-
* keeps the elbow/knee on its own side, so the fix never flips a hinge.
|
|
33
|
-
* - Passes repeat until nothing penetrates or maxIterations is hit — the
|
|
34
|
-
* same projected-gauss-seidel loop physics engines use, but with IK as
|
|
35
|
-
* the projection step.
|
|
36
|
-
*
|
|
37
|
-
* The result is baked through ikBakeKeyframe, so a fix is an ordinary IK
|
|
38
|
-
* correction key: undoable, scrubbable, and blended back to the clip
|
|
39
|
-
* outside its keyed range by ikEvaluate's blend window.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Body capsules. `movable.chain`/`movable.joint` name the IK chain and the
|
|
44
|
-
* joint of that chain to drive ("mid" = elbow/knee, "effector" = wrist/
|
|
45
|
-
* ankle). Torso, chest and head have no chain: they are blockers the limbs
|
|
46
|
-
* get pushed out of. `radiusJoint` keys into the measured contact radii.
|
|
47
|
-
*
|
|
48
|
-
* `endOptional` marks an end bone the rig is allowed not to have: the hands
|
|
49
|
-
* span wrist → middle-finger base so a fingertip driven into a thigh is
|
|
50
|
-
* judged at the FINGERS, not at the wrist a palm's length behind them (a
|
|
51
|
-
* wrist-only sphere reads that pose as clean). Rigs exported without finger
|
|
52
|
-
* bones simply fall back to the wrist sphere instead of disqualifying the
|
|
53
|
-
* whole tool.
|
|
54
|
-
*/
|
|
55
|
-
// `maxRadius` caps the measured radius where the mesh extent is not a
|
|
56
|
-
// thickness: the head measurement runs crown-to-chin (a length), the foot
|
|
57
|
-
// measurement follows the toe (also a length). Uncapped, those fat capsules
|
|
58
|
-
// flag permanent false contacts with the torso and with the other foot at
|
|
59
|
-
// any normal stance.
|
|
60
|
-
const CAPSULE_DEFS = [
|
|
61
|
-
{ id: "torso", start: "mixamorigHips", end: "mixamorigSpine1", radiusJoint: "Spine", maxRadius: 0.17, movable: null },
|
|
62
|
-
{ id: "chest", start: "mixamorigSpine1", end: "mixamorigSpine2", radiusJoint: "Spine", maxRadius: 0.16, movable: null },
|
|
63
|
-
{ id: "head", start: "mixamorigNeck", end: "mixamorigHead", radiusJoint: "Head", maxRadius: 0.11, movable: null },
|
|
64
|
-
{ id: "leftUpperArm", start: "mixamorigLeftArm", end: "mixamorigLeftForeArm", radiusJoint: "LeftArm", movable: { chain: "leftHand", joint: "mid" }, priority: 2 },
|
|
65
|
-
{ id: "leftForeArm", start: "mixamorigLeftForeArm", end: "mixamorigLeftHand", radiusJoint: "LeftForeArm", movable: { chain: "leftHand", joint: "effector" }, priority: 2 },
|
|
66
|
-
{ id: "leftHand", start: "mixamorigLeftHand", end: "mixamorigLeftHandMiddle1", endOptional: true, radiusJoint: "LeftHand", maxRadius: 0.06, movable: { chain: "leftHand", joint: "effector" }, priority: 2 },
|
|
67
|
-
{ id: "rightUpperArm", start: "mixamorigRightArm", end: "mixamorigRightForeArm", radiusJoint: "RightArm", movable: { chain: "rightHand", joint: "mid" }, priority: 2 },
|
|
68
|
-
{ id: "rightForeArm", start: "mixamorigRightForeArm", end: "mixamorigRightHand", radiusJoint: "RightForeArm", movable: { chain: "rightHand", joint: "effector" }, priority: 2 },
|
|
69
|
-
{ id: "rightHand", start: "mixamorigRightHand", end: "mixamorigRightHandMiddle1", endOptional: true, radiusJoint: "RightHand", maxRadius: 0.06, movable: { chain: "rightHand", joint: "effector" }, priority: 2 },
|
|
70
|
-
{ id: "leftThigh", start: "mixamorigLeftUpLeg", end: "mixamorigLeftLeg", radiusJoint: "LeftUpLeg", movable: { chain: "leftFoot", joint: "mid" }, priority: 1 },
|
|
71
|
-
{ id: "leftShin", start: "mixamorigLeftLeg", end: "mixamorigLeftFoot", radiusJoint: "LeftLeg", movable: { chain: "leftFoot", joint: "effector" }, priority: 1 },
|
|
72
|
-
{ id: "leftFoot", start: "mixamorigLeftFoot", end: "mixamorigLeftToeBase", radiusJoint: "LeftFoot", maxRadius: 0.07, movable: { chain: "leftFoot", joint: "effector" }, priority: 1 },
|
|
73
|
-
{ id: "rightThigh", start: "mixamorigRightUpLeg", end: "mixamorigRightLeg", radiusJoint: "RightUpLeg", movable: { chain: "rightFoot", joint: "mid" }, priority: 1 },
|
|
74
|
-
{ id: "rightShin", start: "mixamorigRightLeg", end: "mixamorigRightFoot", radiusJoint: "RightLeg", movable: { chain: "rightFoot", joint: "effector" }, priority: 1 },
|
|
75
|
-
{ id: "rightFoot", start: "mixamorigRightFoot", end: "mixamorigRightToeBase", radiusJoint: "RightFoot", maxRadius: 0.07, movable: { chain: "rightFoot", joint: "effector" }, priority: 1 },
|
|
76
|
-
];
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* FINGER CAPSULES. The hand capsule spans wrist → middle-finger base, which is
|
|
80
|
-
* a palm: it still reads a pose where the FINGERS are buried in a thigh and the
|
|
81
|
-
* palm is not as clean, because the fingers stick out another 8 cm past its end
|
|
82
|
-
* cap. Mixamo hands carry mixamorig{Side}Hand{Finger}1..4, so each finger gets
|
|
83
|
-
* its own thin capsule from the base joint to the tip.
|
|
84
|
-
*
|
|
85
|
-
* OPTIONAL, ONE FINGER AT A TIME. Game rigs ship every mixture of these bones:
|
|
86
|
-
* all four joints, three, a thumb only, none at all. A finger falls back from
|
|
87
|
-
* 1→4 to 1→3 and, failing both, is simply left out — `optional` means a MISSING
|
|
88
|
-
* finger is not a missing capsule but a missing entry, so a fingerless rig is
|
|
89
|
-
* still fully supported (supportsCollisionCleanup ignores optional defs) and
|
|
90
|
-
* every other proxy is built exactly as before.
|
|
91
|
-
*
|
|
92
|
-
* RADIUS is a constant. measureContactRadii's CONTACT_JOINTS carry no finger
|
|
93
|
-
* entries — there is nothing to look up — and a finger is the one body part
|
|
94
|
-
* whose thickness barely varies between characters.
|
|
95
|
-
*/
|
|
96
|
-
export const FINGER_RADIUS = 0.011;
|
|
97
|
-
|
|
98
|
-
const FINGER_NAMES = ["Thumb", "Index", "Middle", "Ring", "Pinky"];
|
|
99
|
-
|
|
100
|
-
for (const side of ["Left", "Right"]) {
|
|
101
|
-
const lower = side === "Left" ? "left" : "right";
|
|
102
|
-
for (const finger of FINGER_NAMES) {
|
|
103
|
-
CAPSULE_DEFS.push({
|
|
104
|
-
id: `${lower}Hand${finger}`,
|
|
105
|
-
start: `mixamorig${side}Hand${finger}1`,
|
|
106
|
-
// First spelling that resolves wins: the tip, else the last knuckle.
|
|
107
|
-
ends: [`mixamorig${side}Hand${finger}4`, `mixamorig${side}Hand${finger}3`],
|
|
108
|
-
optional: true,
|
|
109
|
-
fixedRadius: FINGER_RADIUS,
|
|
110
|
-
// The two ids a finger must never be tested against beyond its own
|
|
111
|
-
// kind — see pairExcluded.
|
|
112
|
-
handId: `${lower}Hand`,
|
|
113
|
-
foreArmId: `${lower}ForeArm`,
|
|
114
|
-
movable: { chain: `${lower}Hand`, joint: "effector" },
|
|
115
|
-
priority: 2,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* A pair whose two capsules ride the SAME two-bone chain: the elbow folded shut,
|
|
122
|
-
* the knee folded shut. Every push moves both ends together, so no solver run
|
|
123
|
-
* can open the gap — the fixer must never act on one (it spends its passes
|
|
124
|
-
* rearranging the limb, and on QA's yawed box it chased a fold the fix itself
|
|
125
|
-
* had created and drove the hand 574 mm, 55 mm INTO the box). Detection still
|
|
126
|
-
* REPORTS it, because a viewer can see a folded limb; what it must not do is
|
|
127
|
-
* count as a frame the tool failed to clean.
|
|
128
|
-
*/
|
|
129
|
-
export function isHingeFold(pen) {
|
|
130
|
-
const a = pen?.a?.def?.movable?.chain;
|
|
131
|
-
const b = pen?.b?.def?.movable?.chain;
|
|
132
|
-
return Boolean(a && b && a === b);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Fallback radius when no measurement exists (matches ik.js's floor). */
|
|
136
|
-
const RADIUS_FALLBACK = 0.01;
|
|
137
|
-
|
|
138
|
-
/** Soft-tissue compression allowance, as a fraction of the thinner capsule.
|
|
139
|
-
* See detectPenetrations for why this is 0.25 and not something roomier. */
|
|
140
|
-
export const DEFAULT_SLACK_FACTOR = 0.25;
|
|
141
|
-
|
|
142
|
-
/** How much closer than its BIND pose a pair must come before the extra
|
|
143
|
-
* closeness counts as a collision. See restPairOverlaps. 5 mm is under the
|
|
144
|
-
* amplitude of the proxy error itself and far under any visible interpenetration. */
|
|
145
|
-
export const PAIR_TOLERANCE = 0.005;
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* REPORTING FLOOR. A capsule proxy is a coarse stand-in for a specific mesh, and
|
|
149
|
-
* its agreement with that mesh is worth a few millimetres at best; the pushes it
|
|
150
|
-
* takes to resolve one are not small (a 0.2 mm thigh × thigh reading during a
|
|
151
|
-
* normal walk stride moved the pose 16 mm and keyed the frame). So a pair under
|
|
152
|
-
* this depth is not reported at all — not by detection, not to the fixer, not to
|
|
153
|
-
* the readout — and the tool spends its passes on contact a viewer can actually
|
|
154
|
-
* see.
|
|
155
|
-
*/
|
|
156
|
-
export const MIN_DEPTH = 0.004;
|
|
157
|
-
|
|
158
|
-
/** Clearance the pushed joint keeps above the floor. A separation normal has
|
|
159
|
-
* no idea the ground exists, so an otherwise correct push can drive an ankle
|
|
160
|
-
* or a knee underground; the fix trades a little residual for never solving one
|
|
161
|
-
* artefact by creating a worse one. */
|
|
162
|
-
export const PUSH_FLOOR_CLEARANCE = 0.005;
|
|
163
|
-
|
|
164
|
-
/** Foot markers the accept gate keeps above the floor. The push guard clamps
|
|
165
|
-
* the joint it DRIVES, which is the ankle; the toe hangs off the far side of
|
|
166
|
-
* the foot and goes under the deck while the ankle sits happily at its
|
|
167
|
-
* clearance. Judging the markers catches what the push guard structurally
|
|
168
|
-
* cannot. */
|
|
169
|
-
const FLOOR_MARKERS = [
|
|
170
|
-
"mixamorigLeftFoot", "mixamorigRightFoot", "mixamorigLeftToeBase", "mixamorigRightToeBase",
|
|
171
|
-
];
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* TORSO/HEAD YIELD. Torso, chest and head are static blockers, which is right
|
|
175
|
-
* for the geometry and wrong for the failure mode: when a limb is reach-limited
|
|
176
|
-
* — a leg pinned by the floor guard, an arm at full stretch — the passes run
|
|
177
|
-
* out with the penetration still there and the tool reports a residual it had no
|
|
178
|
-
* way to spend. A body leans out of the way of its own knee; the rig should be
|
|
179
|
-
* allowed to as well.
|
|
180
|
-
*
|
|
181
|
-
* So, only AFTER the limb passes have done what they can, the blocker gives a
|
|
182
|
-
* little: a small FK swing on the spine (Spine1, the `chest` FK joint) or the
|
|
183
|
-
* neck, away from the contact. Capped hard in both directions — per step and per
|
|
184
|
-
* run — because a yield that could grow without bound would turn every hard case
|
|
185
|
-
* into a bent-over character, and because the cap is what keeps the loop finite.
|
|
186
|
-
* Every yielded joint id lands in `touched`, so the caller's bake keys the spine
|
|
187
|
-
* exactly as it keys a limb.
|
|
188
|
-
*/
|
|
189
|
-
export const YIELD_STEP_RAD = (6 * Math.PI) / 180;
|
|
190
|
-
export const YIELD_TOTAL_RAD = (12 * Math.PI) / 180;
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Which FK joint carries each static blocker's yield — the joint whose rotation
|
|
194
|
-
* actually MOVES that capsule. A capsule is drawn between two bone POSITIONS,
|
|
195
|
-
* and rotating a bone never moves its own origin: swinging Spine1 turns the
|
|
196
|
-
* chest (Spine1 → Spine2) but leaves the torso capsule (Hips → Spine1) exactly
|
|
197
|
-
* where it was, so the torso's yield belongs one joint lower, at Spine, whose
|
|
198
|
-
* swing carries Spine1 — and the chest with it.
|
|
199
|
-
*/
|
|
200
|
-
const YIELD_JOINTS = { torso: "spine", chest: "chest", head: "neck" };
|
|
201
|
-
|
|
202
|
-
/** The bone behind each of those ids, for a caller that has no FK joint map to
|
|
203
|
-
* lend. Shaped exactly like resolveIkRig's entries — track id, bone, bind local
|
|
204
|
-
* position — because solveSwingAngle and ikBakeKeyframe both read them that way,
|
|
205
|
-
* and because the ids have to match the App's FK tracks for the bake to land. */
|
|
206
|
-
const YIELD_BONES = { spine: "mixamorigSpine", chest: "mixamorigSpine1", neck: "mixamorigNeck" };
|
|
207
|
-
function fallbackYieldJoints(rig) {
|
|
208
|
-
const joints = new Map();
|
|
209
|
-
for (const [id, name] of Object.entries(YIELD_BONES)) {
|
|
210
|
-
const bone = findBone(rig, name);
|
|
211
|
-
if (!bone) continue;
|
|
212
|
-
const saved = rig.userData?.poseBind?.get(bone)?.position;
|
|
213
|
-
joints.set(id, {
|
|
214
|
-
track: { id },
|
|
215
|
-
bone,
|
|
216
|
-
bindPos: saved ? new THREE.Vector3(saved.x, saved.y, saved.z) : bone.position.clone(),
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
return joints;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* True when the rig carries every bone the capsule table REQUIRES — the
|
|
224
|
-
* cheap pre-flight the UI runs to decide whether to offer the tool at all.
|
|
225
|
-
* Bone lookups only: no mesh measurement, so it is safe to call per render.
|
|
226
|
-
* Optional ends (the finger bones) never count against a rig.
|
|
227
|
-
*/
|
|
228
|
-
export function supportsCollisionCleanup(rig) {
|
|
229
|
-
if (!rig) return false;
|
|
230
|
-
for (const def of CAPSULE_DEFS) {
|
|
231
|
-
// An OPTIONAL capsule (the fingers) is an extra proxy, never a
|
|
232
|
-
// requirement: a rig without them is described just as well without them.
|
|
233
|
-
if (def.optional) continue;
|
|
234
|
-
if (!findBone(rig, def.start)) return false;
|
|
235
|
-
if (def.end && !def.endOptional && !findBone(rig, def.end)) return false;
|
|
236
|
-
}
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** The capsule table against an arbitrary source of bone positions — the
|
|
241
|
-
* current pose for detection, the bind pose for calibration. Returns null when
|
|
242
|
-
* a REQUIRED bone is missing. */
|
|
243
|
-
function buildCapsules(rig, radiiMap, positionOf) {
|
|
244
|
-
const capsules = new Map();
|
|
245
|
-
for (const def of CAPSULE_DEFS) {
|
|
246
|
-
const start = findBone(rig, def.start);
|
|
247
|
-
// An OPTIONAL capsule the rig has no bones for is left out, not fatal.
|
|
248
|
-
if (!start) {
|
|
249
|
-
if (def.optional) continue;
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
let end = null;
|
|
253
|
-
if (def.ends) {
|
|
254
|
-
for (const name of def.ends) {
|
|
255
|
-
end = findBone(rig, name);
|
|
256
|
-
if (end) break;
|
|
257
|
-
}
|
|
258
|
-
// A finger with a base joint but no further joint has no segment worth
|
|
259
|
-
// testing: skip it rather than park a knuckle-sized sphere on the hand.
|
|
260
|
-
if (!end) continue;
|
|
261
|
-
} else if (def.end) {
|
|
262
|
-
end = findBone(rig, def.end);
|
|
263
|
-
// A missing OPTIONAL end degrades to the wrist sphere; a missing
|
|
264
|
-
// required end means this is not a rig we can build proxies for.
|
|
265
|
-
if (!end && !def.endOptional) return null;
|
|
266
|
-
}
|
|
267
|
-
const a = positionOf(start);
|
|
268
|
-
const b = end ? positionOf(end) : a.clone();
|
|
269
|
-
const measured = Math.max(RADIUS_FALLBACK, radiiMap[def.radiusJoint] ?? RADIUS_FALLBACK);
|
|
270
|
-
const radius = def.fixedRadius ?? (def.maxRadius ? Math.min(measured, def.maxRadius) : measured);
|
|
271
|
-
capsules.set(def.id, {
|
|
272
|
-
def,
|
|
273
|
-
bones: end ? [start, end] : [start],
|
|
274
|
-
a,
|
|
275
|
-
b,
|
|
276
|
-
radius,
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
return capsules;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Build world-space capsules for the rig's CURRENT pose. Cheap (a bone
|
|
284
|
-
* lookup + two world positions per entry), so callers rebuild per frame.
|
|
285
|
-
* `radii` overrides the measured map (tests, or rigs without skin weights).
|
|
286
|
-
* Returns null when a REQUIRED bone is missing — a non-Mixamo rig the tool
|
|
287
|
-
* cannot describe, same policy as resolveIkRig. Callers must tell that apart
|
|
288
|
-
* from "found nothing to fix": see fixCollisions's `supported` flag.
|
|
289
|
-
*/
|
|
290
|
-
export function buildCollisionCapsules(rig, radii = null) {
|
|
291
|
-
if (!rig) return null;
|
|
292
|
-
rig.updateMatrixWorld(true);
|
|
293
|
-
const radiiMap = radii ?? measureContactRadii(rig);
|
|
294
|
-
const capsules = buildCapsules(rig, radiiMap, (bone) => bone.getWorldPosition(new THREE.Vector3()));
|
|
295
|
-
// Ride the pair calibration along on the map so detectPenetrations gets it
|
|
296
|
-
// for free — it is a property of the RIG, which detection alone cannot see.
|
|
297
|
-
if (capsules) capsules.pairAllowances = restPairOverlaps(rig, radiiMap);
|
|
298
|
-
return capsules;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Stable key for an unordered capsule pair. */
|
|
302
|
-
function pairKey(idA, idB) {
|
|
303
|
-
return idA < idB ? `${idA}|${idB}` : `${idB}|${idA}`;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// rig → (radii object identity → pair overlap map). The reference poses never
|
|
307
|
-
// change, so this is measured once per character.
|
|
308
|
-
const restOverlapCache = new WeakMap();
|
|
309
|
-
|
|
310
|
-
/** Every testable pair's overlap in one reference pose, positive entries only,
|
|
311
|
-
* merged into `into` by taking the larger of the two. Measured through the SAME
|
|
312
|
-
* pairGeometry detection uses, so a trimmed (hinged) pair is calibrated on the
|
|
313
|
-
* portions it will actually be judged on — the natural fold a character rests
|
|
314
|
-
* in is a legitimate overlap there exactly as it is anywhere else. */
|
|
315
|
-
function collectPairOverlaps(rig, radiiMap, positionOf, into) {
|
|
316
|
-
const capsules = buildCapsules(rig, radiiMap, positionOf);
|
|
317
|
-
if (!capsules) return into;
|
|
318
|
-
const list = [...capsules.values()];
|
|
319
|
-
const pa = new THREE.Vector3();
|
|
320
|
-
const pb = new THREE.Vector3();
|
|
321
|
-
for (let i = 0; i < list.length; i += 1) {
|
|
322
|
-
for (let j = i + 1; j < list.length; j += 1) {
|
|
323
|
-
const geo = pairGeometry(list[i], list[j]);
|
|
324
|
-
if (!geo) continue;
|
|
325
|
-
closestPointsSegmentSegment(geo.a0, geo.a1, geo.b0, geo.b1, pa, pb);
|
|
326
|
-
const overlap = list[i].radius + list[j].radius - pa.distanceTo(pb);
|
|
327
|
-
if (overlap <= 0) continue;
|
|
328
|
-
const key = pairKey(list[i].def.id, list[j].def.id);
|
|
329
|
-
if (overlap > (into.get(key) ?? 0)) into.set(key, overlap);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
return into;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* How much each non-adjacent capsule pair ALREADY overlaps in a pose the
|
|
337
|
-
* character legitimately holds, in metres (positive entries only; a pair that is
|
|
338
|
-
* clear in every reference pose has no entry). Returns null on a rig with no
|
|
339
|
-
* bind snapshot to compose from.
|
|
340
|
-
*
|
|
341
|
-
* Capsules are a coarse proxy for a specific mesh, and on real characters the
|
|
342
|
-
* proxy error is not small: on the shipped default rig the REST pose reports
|
|
343
|
-
* torso × upper arm at 9.2 mm of "penetration" (torso 0.17 + upper arm 0.05447
|
|
344
|
-
* + offset 0.002 against a 0.19547 axis distance, minus a 21.8 mm slack
|
|
345
|
-
* allowance). One press of Fix collisions on a brand-new project baked a phantom
|
|
346
|
-
* key and moved the rig 13.7 mm. Slack cannot fix this: the rig would need
|
|
347
|
-
* slackFactor ≥ 0.569 to read clean, and a global allowance that loose re-masks
|
|
348
|
-
* every real contact on every other pair.
|
|
349
|
-
*
|
|
350
|
-
* So the pair's own reference overlap becomes its zero point — the same
|
|
351
|
-
* clip-relative-ground move auto-physics made for the floor.
|
|
352
|
-
*
|
|
353
|
-
* TWO REFERENCE POSES, and the second is the one that matters. `poseBind` is a
|
|
354
|
-
* T-POSE: arms straight out, nowhere near the torso (LeftForeArm at x = 0.4616).
|
|
355
|
-
* The pose the app actually shows is DEFAULT_POSE — arms hanging at the sides,
|
|
356
|
-
* the same forearm at x = 0.1876 — and that is where the arm capsules graze the
|
|
357
|
-
* torso. Calibrating against bind alone therefore recorded only leftThigh ×
|
|
358
|
-
* rightThigh and left torso × upper arm at zero allowance, which is exactly the
|
|
359
|
-
* pair that false-positives. Both poses are measured and the LARGER allowance
|
|
360
|
-
* wins, so a T-posed character and a standing one both read clean, while a pair
|
|
361
|
-
* that overlaps in neither (a hand driven into a thigh) still fires at full
|
|
362
|
-
* sensitivity. Both are composed virtually through ik.js, so measuring never
|
|
363
|
-
* disturbs — or depends on — the pose currently on screen.
|
|
364
|
-
*
|
|
365
|
-
* The cost, stated plainly: contact that the rest pose already has is invisible
|
|
366
|
-
* here. An arm pressed flat against the ribs cannot be distinguished from an arm
|
|
367
|
-
* hanging beside them, because the capsule proxies genuinely cannot tell those
|
|
368
|
-
* apart. Excluding those pairs outright (the alternative) would lose the deep
|
|
369
|
-
* hits too; this keeps them.
|
|
370
|
-
*/
|
|
371
|
-
export function restPairOverlaps(rig, radii = null) {
|
|
372
|
-
if (!rig || !hasBindPose(rig)) return null;
|
|
373
|
-
const radiiMap = radii ?? measureContactRadii(rig);
|
|
374
|
-
let perRadii = restOverlapCache.get(rig);
|
|
375
|
-
if (!perRadii) restOverlapCache.set(rig, (perRadii = new Map()));
|
|
376
|
-
const cached = perRadii.get(radiiMap);
|
|
377
|
-
if (cached) return cached;
|
|
378
|
-
rig.updateMatrixWorld(true);
|
|
379
|
-
const overlaps = new Map();
|
|
380
|
-
collectPairOverlaps(rig, radiiMap, (bone) => bindWorldPosition(rig, bone, new THREE.Vector3()), overlaps);
|
|
381
|
-
collectPairOverlaps(rig, radiiMap, (bone) => restWorldPosition(rig, bone, new THREE.Vector3()), overlaps);
|
|
382
|
-
perRadii.set(radiiMap, overlaps);
|
|
383
|
-
return overlaps;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** Closest points between two segments (Ericson, Real-Time Collision
|
|
387
|
-
* Detection — the textbook segment/segment distance). Writes into outA/outB
|
|
388
|
-
* and returns the squared distance. */
|
|
389
|
-
function closestPointsSegmentSegment(p1, q1, p2, q2, outA, outB) {
|
|
390
|
-
const d1 = q1.clone().sub(p1);
|
|
391
|
-
const d2 = q2.clone().sub(p2);
|
|
392
|
-
const r = p1.clone().sub(p2);
|
|
393
|
-
const a = d1.dot(d1);
|
|
394
|
-
const e = d2.dot(d2);
|
|
395
|
-
const f = d2.dot(r);
|
|
396
|
-
let s;
|
|
397
|
-
let t;
|
|
398
|
-
if (a <= 1e-12 && e <= 1e-12) {
|
|
399
|
-
outA.copy(p1); outB.copy(p2);
|
|
400
|
-
return r.dot(r);
|
|
401
|
-
}
|
|
402
|
-
if (a <= 1e-12) {
|
|
403
|
-
s = 0;
|
|
404
|
-
t = Math.max(0, Math.min(1, f / e));
|
|
405
|
-
} else {
|
|
406
|
-
const c = d1.dot(r);
|
|
407
|
-
if (e <= 1e-12) {
|
|
408
|
-
t = 0;
|
|
409
|
-
s = Math.max(0, Math.min(1, -c / a));
|
|
410
|
-
} else {
|
|
411
|
-
const b = d1.dot(d2);
|
|
412
|
-
const denom = a * e - b * b;
|
|
413
|
-
s = denom > 1e-12 ? Math.max(0, Math.min(1, (b * f - c * e) / denom)) : 0;
|
|
414
|
-
t = (b * s + f) / e;
|
|
415
|
-
if (t < 0) { t = 0; s = Math.max(0, Math.min(1, -c / a)); }
|
|
416
|
-
else if (t > 1) { t = 1; s = Math.max(0, Math.min(1, (b - c) / a)); }
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
outA.copy(p1).addScaledVector(d1, s);
|
|
420
|
-
outB.copy(p2).addScaledVector(d2, t);
|
|
421
|
-
return outA.distanceToSquared(outB);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/** Ancestor hop count between two bones, or Infinity when neither is an
|
|
425
|
-
* ancestor of the other within `limit` hops. */
|
|
426
|
-
const HOP_SEARCH = 8;
|
|
427
|
-
function boneHops(b0, b1, limit = HOP_SEARCH) {
|
|
428
|
-
let hops = 0;
|
|
429
|
-
for (let node = b0; node && hops <= limit; node = node.parent, hops += 1) {
|
|
430
|
-
if (node === b1) return hops;
|
|
431
|
-
}
|
|
432
|
-
hops = 0;
|
|
433
|
-
for (let node = b1; node && hops <= limit; node = node.parent, hops += 1) {
|
|
434
|
-
if (node === b0) return hops;
|
|
435
|
-
}
|
|
436
|
-
return Infinity;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** Capsules whose endpoints sit within HOP_LIMIT hops on the skeleton are
|
|
440
|
-
* NEIGHBOURS (upper arm against chest at the shoulder, thigh against the
|
|
441
|
-
* pelvis, forearm against upper arm at the elbow): they always overlap AT the
|
|
442
|
-
* joint they share, so they get the trimmed test below rather than the plain
|
|
443
|
-
* one. */
|
|
444
|
-
const HOP_LIMIT = 2;
|
|
445
|
-
function bonesRelated(b0, b1) {
|
|
446
|
-
return boneHops(b0, b1, HOP_LIMIT) <= HOP_LIMIT;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function capsulesAdjacent(ca, cb) {
|
|
450
|
-
return ca.bones.some((ba) => cb.bones.some((bb) => bonesRelated(ba, bb)));
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* HINGED PAIRS. A blanket "skip everything within HOP_LIMIT" made whole classes
|
|
455
|
-
* of real interpenetration invisible: a knee driven into the belly is
|
|
456
|
-
* thigh × torso, an arm folded past its own elbow is forearm × upper arm, and
|
|
457
|
-
* neither pair was ever tested. The skip existed for a good reason — the two
|
|
458
|
-
* capsules MEET at the joint they share, so an untrimmed test reports a
|
|
459
|
-
* permanent collision at every pose and pins the fixer forever — but that
|
|
460
|
-
* reason only covers the part of each capsule NEXT TO the joint.
|
|
461
|
-
*
|
|
462
|
-
* So the pair is tested on the portions FAR from the shared joint: TRIM_FRACTION
|
|
463
|
-
* of each segment is cut away from the joint side, and the segment-segment test
|
|
464
|
-
* runs on what is left. A normally bent elbow keeps its far portions well apart;
|
|
465
|
-
* a fully folded one lays them on top of each other, which is exactly the pose
|
|
466
|
-
* that has to be caught.
|
|
467
|
-
*
|
|
468
|
-
* The joint side is the endpoint CLOSER to the other capsule in the hierarchy
|
|
469
|
-
* (fewest ancestor hops), so the trim always eats the shared end and never the
|
|
470
|
-
* free one.
|
|
471
|
-
*/
|
|
472
|
-
export const TRIM_FRACTION = 0.45;
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Compression allowance for a pair that SHARES a bone — the two segments of one
|
|
476
|
-
* hinge (elbow, knee, ankle, wrist, waist). Flesh-to-flesh contact is the normal
|
|
477
|
-
* state of a deep fold there, and the capsule proxies overlap by construction
|
|
478
|
-
* long before the pose is wrong, so the thin-capsule slack rule is the wrong
|
|
479
|
-
* ruler: this one is a fraction of the COMBINED radii, and only a fold that puts
|
|
480
|
-
* the far end of one segment inside the body of the other clears it.
|
|
481
|
-
*/
|
|
482
|
-
export const HINGE_SLACK_FACTOR = 0.65;
|
|
483
|
-
|
|
484
|
-
/** Which end of `ca` faces `cb` on the skeleton: true = the START endpoint. */
|
|
485
|
-
function jointSideIsStart(ca, cb) {
|
|
486
|
-
const hopsOf = (bone) => Math.min(...cb.bones.map((other) => boneHops(bone, other)));
|
|
487
|
-
const first = hopsOf(ca.bones[0]);
|
|
488
|
-
const last = ca.bones.length > 1 ? hopsOf(ca.bones[ca.bones.length - 1]) : first;
|
|
489
|
-
return first <= last;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** `ca` with the joint-side TRIM_FRACTION cut off, written into out0/out1. */
|
|
493
|
-
function trimTowardJoint(ca, cb, out0, out1) {
|
|
494
|
-
if (jointSideIsStart(ca, cb)) {
|
|
495
|
-
out0.copy(ca.a).lerp(ca.b, TRIM_FRACTION);
|
|
496
|
-
out1.copy(ca.b);
|
|
497
|
-
} else {
|
|
498
|
-
out0.copy(ca.a);
|
|
499
|
-
out1.copy(ca.b).lerp(ca.a, TRIM_FRACTION);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/** How many bones the two capsules have in common (0, 1 or 2). */
|
|
504
|
-
function sharedBoneCount(ca, cb) {
|
|
505
|
-
return ca.bones.filter((bone) => cb.bones.includes(bone)).length;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Pairs that must never be tested, whatever the pose.
|
|
510
|
-
*
|
|
511
|
-
* Adjacency already covers most of the finger cases (a finger base is one hop
|
|
512
|
-
* from the hand, two from the forearm), but the trimmed test above means
|
|
513
|
-
* "adjacent" no longer means "skipped", so the finger rules are spelled out
|
|
514
|
-
* rather than inherited: a finger against its own hand, its own forearm, or any
|
|
515
|
-
* other finger is a pose the proxies cannot judge — fingers touch each other and
|
|
516
|
-
* lie against the palm constantly — while a finger against a thigh, a shin, the
|
|
517
|
-
* torso, the chest, the head or the opposite arm is exactly what the finger
|
|
518
|
-
* capsules were added to catch.
|
|
519
|
-
*/
|
|
520
|
-
function pairExcluded(ca, cb) {
|
|
521
|
-
const fa = ca.def.handId;
|
|
522
|
-
const fb = cb.def.handId;
|
|
523
|
-
if (fa && fb) return true; // finger × finger, either hand
|
|
524
|
-
if (fa && (cb.def.id === fa || cb.def.id === ca.def.foreArmId)) return true;
|
|
525
|
-
if (fb && (ca.def.id === fb || ca.def.id === cb.def.foreArmId)) return true;
|
|
526
|
-
// SAME CHAIN, no shared joint: a hand against its own upper arm, a foot
|
|
527
|
-
// against its own thigh, a finger against its own forearm. Two things are
|
|
528
|
-
// true of every such pair and neither is true of a hinge: the pose is
|
|
529
|
-
// legitimate (a hand rests on its own shoulder, a heel touches its own
|
|
530
|
-
// thigh) and the fixer could not separate them anyway, because ONE solver
|
|
531
|
-
// drives both sides and each push undoes the other — measured as a fold
|
|
532
|
-
// that oscillated to maxIterations without converging. The fold that IS
|
|
533
|
-
// impossible — the segments either side of the elbow or the knee — shares a
|
|
534
|
-
// bone, and stays tested (trimmed) by the rule above.
|
|
535
|
-
const chainA = ca.def.movable?.chain;
|
|
536
|
-
const chainB = cb.def.movable?.chain;
|
|
537
|
-
if (chainA && chainA === chainB && sharedBoneCount(ca, cb) === 0) return true;
|
|
538
|
-
return false;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/** Scratch segments for the pair geometry below. One pair is measured at a time
|
|
542
|
-
* (detection is a plain double loop), so these can be module-level. */
|
|
543
|
-
const geoA0 = new THREE.Vector3();
|
|
544
|
-
const geoA1 = new THREE.Vector3();
|
|
545
|
-
const geoB0 = new THREE.Vector3();
|
|
546
|
-
const geoB1 = new THREE.Vector3();
|
|
547
|
-
const geometry = { a0: geoA0, a1: geoA1, b0: geoB0, b1: geoB1, hinge: false };
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* The segments to test for one capsule pair, or null when the pair is not
|
|
551
|
-
* testable at all. Unrelated capsules are tested whole; neighbours are tested
|
|
552
|
-
* on their trimmed portions (see HINGED PAIRS). The returned object is REUSED
|
|
553
|
-
* between calls — read it before the next call.
|
|
554
|
-
*/
|
|
555
|
-
function pairGeometry(ca, cb) {
|
|
556
|
-
if (pairExcluded(ca, cb)) return null;
|
|
557
|
-
if (!capsulesAdjacent(ca, cb)) {
|
|
558
|
-
geoA0.copy(ca.a); geoA1.copy(ca.b);
|
|
559
|
-
geoB0.copy(cb.a); geoB1.copy(cb.b);
|
|
560
|
-
geometry.hinge = false;
|
|
561
|
-
return geometry;
|
|
562
|
-
}
|
|
563
|
-
// Same segment, or two capsules over the same two bones: there is no "far
|
|
564
|
-
// portion" to compare and nothing a fixer could do about it either way.
|
|
565
|
-
const shared = sharedBoneCount(ca, cb);
|
|
566
|
-
if (shared >= 2 || ca.bones.length === 0 || cb.bones.length === 0) return null;
|
|
567
|
-
// Two static blockers meeting at the spine (torso × chest, chest × head) are
|
|
568
|
-
// permanent by construction and unfixable by anything: no limb chain drives
|
|
569
|
-
// them. Reporting them would only burn passes — the same reason
|
|
570
|
-
// fixCollisions filters static × static out of its own pass list.
|
|
571
|
-
if (!ca.def.movable && !cb.def.movable) return null;
|
|
572
|
-
trimTowardJoint(ca, cb, geoA0, geoA1);
|
|
573
|
-
trimTowardJoint(cb, ca, geoB0, geoB1);
|
|
574
|
-
geometry.hinge = shared === 1;
|
|
575
|
-
return geometry;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* EXTERNAL BLOCKERS. Everything above is the character against itself; a hand
|
|
580
|
-
* swinging through a chair is the same problem against the SET. Callers pass
|
|
581
|
-
* `blockers`: world-space static shapes, already posed for the frame being
|
|
582
|
-
* solved (the caller owns their animation — this owns the push).
|
|
583
|
-
*
|
|
584
|
-
* capsule: { id, kind: "capsule", a: Vector3, b: Vector3, radius }
|
|
585
|
-
* box: { id, kind: "box", center: Vector3, halfExtents: Vector3, yaw }
|
|
586
|
-
*
|
|
587
|
-
* `yaw` is radians about world +Y, the only rotation a prop standing on a floor
|
|
588
|
-
* normally needs and the only one the closest-point test below has to invert.
|
|
589
|
-
*
|
|
590
|
-
* They behave exactly like torso and head: never movable, never adjacent to
|
|
591
|
-
* anything, and never rest-calibrated — a character's rest pose says nothing
|
|
592
|
-
* about where the furniture is. The full slack rule applies, with one wrinkle: a
|
|
593
|
-
* box has no radius to take the min against, so the movable capsule's own slack
|
|
594
|
-
* is used (a box is a surface, not a soft body).
|
|
595
|
-
*
|
|
596
|
-
* A blocker's records carry its id VERBATIM, so the existing pair readouts
|
|
597
|
-
* ("leftHand×obj:chair") identify which prop was hit without any caller change.
|
|
598
|
-
* The id is the caller's to namespace — collision-blockers.js already ships
|
|
599
|
-
* "obj:cube" and "char:char-2:torso" — and a prefix added here would only double
|
|
600
|
-
* it up ("obj:obj:cube", as QA read out).
|
|
601
|
-
*/
|
|
602
|
-
function blockerEntry(shape) {
|
|
603
|
-
if (!shape || typeof shape.id !== "string") return null;
|
|
604
|
-
const point = (value) => (value && Number.isFinite(value.x) && Number.isFinite(value.y) && Number.isFinite(value.z)
|
|
605
|
-
? new THREE.Vector3(value.x, value.y, value.z)
|
|
606
|
-
: null);
|
|
607
|
-
const def = { id: shape.id, movable: null, blocker: true };
|
|
608
|
-
if (shape.kind === "capsule") {
|
|
609
|
-
const a = point(shape.a);
|
|
610
|
-
const b = point(shape.b);
|
|
611
|
-
if (!a || !b || !Number.isFinite(shape.radius) || shape.radius < 0) return null;
|
|
612
|
-
return { def, kind: "capsule", bones: [], a, b, radius: shape.radius };
|
|
613
|
-
}
|
|
614
|
-
if (shape.kind === "box") {
|
|
615
|
-
const center = point(shape.center);
|
|
616
|
-
const halfExtents = point(shape.halfExtents);
|
|
617
|
-
if (!center || !halfExtents) return null;
|
|
618
|
-
halfExtents.set(Math.abs(halfExtents.x), Math.abs(halfExtents.y), Math.abs(halfExtents.z));
|
|
619
|
-
return {
|
|
620
|
-
def,
|
|
621
|
-
kind: "box",
|
|
622
|
-
bones: [],
|
|
623
|
-
center,
|
|
624
|
-
halfExtents,
|
|
625
|
-
yaw: Number.isFinite(shape.yaw) ? shape.yaw : 0,
|
|
626
|
-
// The slack rule reads `radius`; a box surface has none.
|
|
627
|
-
radius: 0,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/** The capsule whose START is a chain's root joint (shoulder / hip) — the
|
|
634
|
-
* anchor a box escape aims back toward. */
|
|
635
|
-
const CHAIN_ROOT_CAPSULE = {
|
|
636
|
-
leftHand: "leftUpperArm",
|
|
637
|
-
rightHand: "rightUpperArm",
|
|
638
|
-
leftFoot: "leftThigh",
|
|
639
|
-
rightFoot: "rightThigh",
|
|
640
|
-
};
|
|
641
|
-
|
|
642
|
-
function normalizeBlockers(blockers) {
|
|
643
|
-
if (!Array.isArray(blockers) || blockers.length === 0) return [];
|
|
644
|
-
const out = [];
|
|
645
|
-
for (const shape of blockers) {
|
|
646
|
-
const entry = blockerEntry(shape);
|
|
647
|
-
if (entry) out.push(entry);
|
|
648
|
-
}
|
|
649
|
-
return out;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Signed distance from `point` to an oriented box: positive outside, NEGATIVE
|
|
654
|
-
* inside (the depth to the nearest face). Writes the closest SURFACE point into
|
|
655
|
-
* `outSurface` and the outward surface normal there into `outNormal`.
|
|
656
|
-
*
|
|
657
|
-
* The box is axis-aligned once the yaw is undone, so the whole thing is a clamp
|
|
658
|
-
* in box space plus a rotation back.
|
|
659
|
-
*/
|
|
660
|
-
const boxLocal = new THREE.Vector3();
|
|
661
|
-
const boxClamped = new THREE.Vector3();
|
|
662
|
-
const boxPrefer = new THREE.Vector3();
|
|
663
|
-
function boxSignedDistance(box, point, outSurface, outNormal, prefer = null) {
|
|
664
|
-
const cos = Math.cos(box.yaw);
|
|
665
|
-
const sin = Math.sin(box.yaw);
|
|
666
|
-
const dx = point.x - box.center.x;
|
|
667
|
-
const dy = point.y - box.center.y;
|
|
668
|
-
const dz = point.z - box.center.z;
|
|
669
|
-
// world → box: rotate by −yaw about Y.
|
|
670
|
-
boxLocal.set(cos * dx - sin * dz, dy, sin * dx + cos * dz);
|
|
671
|
-
const h = box.halfExtents;
|
|
672
|
-
const outward = (local) => {
|
|
673
|
-
// box → world.
|
|
674
|
-
outSurface.set(cos * local.x + sin * local.z, local.y, -sin * local.x + cos * local.z).add(box.center);
|
|
675
|
-
};
|
|
676
|
-
const outsideX = Math.abs(boxLocal.x) - h.x;
|
|
677
|
-
const outsideY = Math.abs(boxLocal.y) - h.y;
|
|
678
|
-
const outsideZ = Math.abs(boxLocal.z) - h.z;
|
|
679
|
-
if (outsideX > 0 || outsideY > 0 || outsideZ > 0) {
|
|
680
|
-
boxClamped.set(
|
|
681
|
-
Math.max(-h.x, Math.min(h.x, boxLocal.x)),
|
|
682
|
-
Math.max(-h.y, Math.min(h.y, boxLocal.y)),
|
|
683
|
-
Math.max(-h.z, Math.min(h.z, boxLocal.z)),
|
|
684
|
-
);
|
|
685
|
-
const distance = boxLocal.distanceTo(boxClamped);
|
|
686
|
-
outward(boxClamped);
|
|
687
|
-
if (distance > 1e-9) {
|
|
688
|
-
const nx = (boxLocal.x - boxClamped.x) / distance;
|
|
689
|
-
const ny = (boxLocal.y - boxClamped.y) / distance;
|
|
690
|
-
const nz = (boxLocal.z - boxClamped.z) / distance;
|
|
691
|
-
outNormal.set(cos * nx + sin * nz, ny, -sin * nx + cos * nz).normalize();
|
|
692
|
-
} else {
|
|
693
|
-
// Exactly ON the surface: fall through to the face normal.
|
|
694
|
-
const gaps = [h.x - Math.abs(boxLocal.x), h.y - Math.abs(boxLocal.y), h.z - Math.abs(boxLocal.z)];
|
|
695
|
-
const axis = gaps.indexOf(Math.min(...gaps));
|
|
696
|
-
const sign = [boxLocal.x, boxLocal.y, boxLocal.z][axis] < 0 ? -1 : 1;
|
|
697
|
-
const local = [0, 0, 0];
|
|
698
|
-
local[axis] = sign;
|
|
699
|
-
outNormal.set(cos * local[0] + sin * local[2], local[1], -sin * local[0] + cos * local[2]).normalize();
|
|
700
|
-
}
|
|
701
|
-
return distance;
|
|
702
|
-
}
|
|
703
|
-
// Inside: THE EXIT FACE, which is not always the nearest one.
|
|
704
|
-
//
|
|
705
|
-
// A limb buried in a crate is nearest to whichever face it happened to sink
|
|
706
|
-
// past, and for a deep hit that is regularly the FAR one — a push out through
|
|
707
|
-
// it drags the whole arm through the box (QA measured 16 bones inside after a
|
|
708
|
-
// "fix"). So of the six faces the exit prefers one the limb's own ROOT is on:
|
|
709
|
-
// the shallowest whose outward normal points back toward the shoulder or hip.
|
|
710
|
-
// With no root hint — or with the root itself inside the box — the nearest
|
|
711
|
-
// face is still the answer, which is the plain signed distance.
|
|
712
|
-
//
|
|
713
|
-
// `prefer` therefore changes the DIRECTION and the depth of the escape, never
|
|
714
|
-
// the geometry: the plain nearest-face value is what the segment search below
|
|
715
|
-
// minimises (a max of linear functions, so convex, which is what makes the
|
|
716
|
-
// ternary search exact), and the preference is applied once, to the point that
|
|
717
|
-
// search returns.
|
|
718
|
-
const components = [boxLocal.x, boxLocal.y, boxLocal.z];
|
|
719
|
-
const extents = [h.x, h.y, h.z];
|
|
720
|
-
let bestAxis = 0;
|
|
721
|
-
let bestSign = 1;
|
|
722
|
-
let bestExit = Infinity;
|
|
723
|
-
let bestAligned = false;
|
|
724
|
-
for (let axis = 0; axis < 3; axis += 1) {
|
|
725
|
-
for (const sign of [-1, 1]) {
|
|
726
|
-
const exit = extents[axis] - sign * components[axis];
|
|
727
|
-
let aligned = false;
|
|
728
|
-
if (prefer) {
|
|
729
|
-
boxPrefer.set(
|
|
730
|
-
cos * (axis === 0 ? sign : 0) + sin * (axis === 2 ? sign : 0),
|
|
731
|
-
axis === 1 ? sign : 0,
|
|
732
|
-
-sin * (axis === 0 ? sign : 0) + cos * (axis === 2 ? sign : 0),
|
|
733
|
-
);
|
|
734
|
-
aligned = boxPrefer.dot(prefer) > 0;
|
|
735
|
-
}
|
|
736
|
-
// An aligned face beats an unaligned one outright; within a class the
|
|
737
|
-
// shallower exit wins.
|
|
738
|
-
if ((aligned && !bestAligned) || (aligned === bestAligned && exit < bestExit)) {
|
|
739
|
-
bestAxis = axis;
|
|
740
|
-
bestSign = sign;
|
|
741
|
-
bestExit = exit;
|
|
742
|
-
bestAligned = aligned;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
boxClamped.copy(boxLocal);
|
|
747
|
-
if (bestAxis === 0) boxClamped.x = bestSign * h.x;
|
|
748
|
-
else if (bestAxis === 1) boxClamped.y = bestSign * h.y;
|
|
749
|
-
else boxClamped.z = bestSign * h.z;
|
|
750
|
-
outward(boxClamped);
|
|
751
|
-
outNormal.set(
|
|
752
|
-
cos * (bestAxis === 0 ? bestSign : 0) + sin * (bestAxis === 2 ? bestSign : 0),
|
|
753
|
-
bestAxis === 1 ? bestSign : 0,
|
|
754
|
-
-sin * (bestAxis === 0 ? bestSign : 0) + cos * (bestAxis === 2 ? bestSign : 0),
|
|
755
|
-
).normalize();
|
|
756
|
-
return -bestExit;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/** How many ternary-search steps the segment/box solve takes. Each step keeps
|
|
760
|
-
* 2/3 of the interval, so 48 of them pin the parameter to ~1e-8 of the segment
|
|
761
|
-
* — far under the millimetre the depths are reported in. */
|
|
762
|
-
const BOX_SEARCH_STEPS = 48;
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* Closest approach between a segment and an oriented box. The distance from a
|
|
766
|
-
* point to a convex set is convex along a line, and the SIGNED distance of a
|
|
767
|
-
* convex set is convex too (which is what makes the inside case work), so a
|
|
768
|
-
* ternary search on the parameter finds the minimum without any case analysis of
|
|
769
|
-
* edges, faces and corners.
|
|
770
|
-
*
|
|
771
|
-
* Writes the segment point into `outA` and the box's surface point into `outB`,
|
|
772
|
-
* with the outward surface normal in `outNormal`, and returns the SIGNED
|
|
773
|
-
* distance (negative when the segment runs through the box).
|
|
774
|
-
*/
|
|
775
|
-
const boxProbe = new THREE.Vector3();
|
|
776
|
-
const boxSurface = new THREE.Vector3();
|
|
777
|
-
const boxNormal = new THREE.Vector3();
|
|
778
|
-
function closestSegmentBox(p, q, box, outA, outB, outNormal, prefer = null) {
|
|
779
|
-
const at = (t) => {
|
|
780
|
-
boxProbe.copy(p).lerp(q, t);
|
|
781
|
-
return boxSignedDistance(box, boxProbe, boxSurface, boxNormal);
|
|
782
|
-
};
|
|
783
|
-
let lo = 0;
|
|
784
|
-
let hi = 1;
|
|
785
|
-
for (let step = 0; step < BOX_SEARCH_STEPS; step += 1) {
|
|
786
|
-
const third = (hi - lo) / 3;
|
|
787
|
-
const m1 = lo + third;
|
|
788
|
-
const m2 = hi - third;
|
|
789
|
-
if (at(m1) < at(m2)) hi = m2;
|
|
790
|
-
else lo = m1;
|
|
791
|
-
}
|
|
792
|
-
const t = (lo + hi) / 2;
|
|
793
|
-
let distance = at(t);
|
|
794
|
-
if (distance < 0 && prefer) {
|
|
795
|
-
// Deepest point found; now pick the face it leaves by.
|
|
796
|
-
boxProbe.copy(p).lerp(q, t);
|
|
797
|
-
distance = boxSignedDistance(box, boxProbe, boxSurface, boxNormal, prefer);
|
|
798
|
-
}
|
|
799
|
-
outA.copy(p).lerp(q, t);
|
|
800
|
-
outB.copy(boxSurface);
|
|
801
|
-
outNormal.copy(boxNormal);
|
|
802
|
-
return distance;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* One capsule against one blocker. Returns { gap, normal, pointA, pointB } with
|
|
807
|
-
* `gap` the signed distance from the capsule's AXIS to the blocker's SURFACE and
|
|
808
|
-
* `normal` pointing from the blocker toward the capsule — the same b → a
|
|
809
|
-
* convention detectPenetrations uses everywhere else.
|
|
810
|
-
*/
|
|
811
|
-
const blockNormal = new THREE.Vector3();
|
|
812
|
-
const blockPrefer = new THREE.Vector3();
|
|
813
|
-
function blockerContact(capsule, blocker, outA, outB, rootAnchor = null) {
|
|
814
|
-
if (blocker.kind === "box") {
|
|
815
|
-
// The way OUT of a box is the way the limb came in, and the limb's root is
|
|
816
|
-
// the only anchor that knows which way that was.
|
|
817
|
-
let prefer = null;
|
|
818
|
-
if (rootAnchor) {
|
|
819
|
-
blockPrefer.copy(rootAnchor).sub(capsule.a.clone().lerp(capsule.b, 0.5));
|
|
820
|
-
if (blockPrefer.lengthSq() > 1e-12) prefer = blockPrefer.normalize();
|
|
821
|
-
}
|
|
822
|
-
const gap = closestSegmentBox(capsule.a, capsule.b, blocker, outA, outB, blockNormal, prefer);
|
|
823
|
-
return { gap, normal: blockNormal.clone() };
|
|
824
|
-
}
|
|
825
|
-
closestPointsSegmentSegment(capsule.a, capsule.b, blocker.a, blocker.b, outA, outB);
|
|
826
|
-
const axisGap = outA.distanceTo(outB);
|
|
827
|
-
const normal = outA.clone().sub(outB);
|
|
828
|
-
if (normal.lengthSq() < 1e-12) {
|
|
829
|
-
normal.set(0, 0, 1).cross(capsule.b.clone().sub(capsule.a));
|
|
830
|
-
if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0);
|
|
831
|
-
}
|
|
832
|
-
normal.normalize();
|
|
833
|
-
// Report the contact ON the blocker's surface, not on its axis.
|
|
834
|
-
outB.addScaledVector(normal, blocker.radius);
|
|
835
|
-
return { gap: axisGap - blocker.radius, normal };
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/**
|
|
839
|
-
* How many capsule endpoints sit INSIDE a box blocker. The one measure of "made
|
|
840
|
-
* it worse" a box escape cannot argue with: a limb pushed out through the far
|
|
841
|
-
* face is off the surface and reports no contact while more of it than ever is
|
|
842
|
-
* in the crate. Counted over endpoints because they are the extremities the
|
|
843
|
-
* viewer sees poking through.
|
|
844
|
-
*/
|
|
845
|
-
export function blockerInsideCount(capsules, blockers) {
|
|
846
|
-
const shapes = normalizeBlockers(blockers).filter((shape) => shape.kind === "box");
|
|
847
|
-
if (!capsules || shapes.length === 0) return 0;
|
|
848
|
-
let inside = 0;
|
|
849
|
-
const surface = new THREE.Vector3();
|
|
850
|
-
const normal = new THREE.Vector3();
|
|
851
|
-
for (const capsule of capsules.values()) {
|
|
852
|
-
if (!capsule.def.movable) continue;
|
|
853
|
-
for (const point of [capsule.a, capsule.b]) {
|
|
854
|
-
for (const box of shapes) {
|
|
855
|
-
if (boxSignedDistance(box, point, surface, normal) < 0) inside += 1;
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
return inside;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* Find every penetrating capsule pair in the current pose. Returns records
|
|
864
|
-
* { a, b, depth, normal, pointA, pointB } sorted deepest-first. `normal`
|
|
865
|
-
* points from b toward a. `offset` inflates the contact distance HERE, in
|
|
866
|
-
* the test itself — a pair counts as penetrating once it is closer than
|
|
867
|
-
* rA + rB + offset — so the same gap the fixer pushes out to is the gap it
|
|
868
|
-
* checks for. Honouring offset only in the push distance would make the
|
|
869
|
-
* fixer chase a target detection never asked for, and the very gap it just
|
|
870
|
-
* opened would read as clean at exactly the surface-grazing pose it exists
|
|
871
|
-
* to avoid.
|
|
872
|
-
*
|
|
873
|
-
* SLACK: capsules are rigid proxies for soft tissue — an upper arm resting
|
|
874
|
-
* against the torso, or thighs touching, legitimately overlaps the proxies.
|
|
875
|
-
* A pair only counts once it penetrates past `slackFactor × min(radius)`,
|
|
876
|
-
* the soft-tissue compression allowance; `depth` is the amount PAST that
|
|
877
|
-
* allowance, which is also the distance the fixer pushes.
|
|
878
|
-
*
|
|
879
|
-
* The default is 0.25, and it used to be 0.4. The looser value was doing two
|
|
880
|
-
* jobs: covering genuine soft-tissue compression AND covering the overlaps a
|
|
881
|
-
* character legitimately holds at rest, which the proxies cannot tell from a
|
|
882
|
-
* real hit. The rest/bind calibration below now does the second job precisely,
|
|
883
|
-
* per pair, so the global allowance no longer has to be blunt enough for the
|
|
884
|
-
* worst pair on the rig — and at 0.4 it hid a centimetre and a half of forearm
|
|
885
|
-
* inside a thigh. (Loosening it further was never an option either: at 0.6 a
|
|
886
|
-
* hand sunk most of the way into a thigh still fit inside the allowance and the
|
|
887
|
-
* tool answered "no collisions".)
|
|
888
|
-
*
|
|
889
|
-
* HINGED PAIRS use their own, wider allowance instead — see HINGE_SLACK_FACTOR —
|
|
890
|
-
* because two segments meeting at a joint are in flesh-to-flesh contact at any
|
|
891
|
-
* deep fold. BLOCKERS use the plain rule, with a box's missing radius standing
|
|
892
|
-
* in as the movable capsule's own.
|
|
893
|
-
*
|
|
894
|
-
* BIND CALIBRATION: on top of the global slack, a pair that already overlaps in
|
|
895
|
-
* the rig's bind pose gets that overlap plus PAIR_TOLERANCE as its own
|
|
896
|
-
* allowance (see restPairOverlaps), so a rest or bind pose can never report a
|
|
897
|
-
* collision. `pairAllowances` overrides the map buildCollisionCapsules attached;
|
|
898
|
-
* pass null to measure a pose against nothing but the slack rule.
|
|
899
|
-
*
|
|
900
|
-
* `blockers` adds the world-space static shapes described under EXTERNAL
|
|
901
|
-
* BLOCKERS; they are tested against every MOVABLE capsule and named `obj:<id>`
|
|
902
|
-
* in the records.
|
|
903
|
-
*/
|
|
904
|
-
export function detectPenetrations(capsules, {
|
|
905
|
-
offset = 0.002,
|
|
906
|
-
slackFactor = DEFAULT_SLACK_FACTOR,
|
|
907
|
-
pairAllowances,
|
|
908
|
-
blockers = null,
|
|
909
|
-
} = {}) {
|
|
910
|
-
if (!capsules) return [];
|
|
911
|
-
const calibration = pairAllowances === undefined ? (capsules.pairAllowances ?? null) : pairAllowances;
|
|
912
|
-
const list = [...capsules.values()];
|
|
913
|
-
const out = [];
|
|
914
|
-
const pa = new THREE.Vector3();
|
|
915
|
-
const pb = new THREE.Vector3();
|
|
916
|
-
for (let i = 0; i < list.length; i += 1) {
|
|
917
|
-
for (let j = i + 1; j < list.length; j += 1) {
|
|
918
|
-
const ca = list[i];
|
|
919
|
-
const cb = list[j];
|
|
920
|
-
const geo = pairGeometry(ca, cb);
|
|
921
|
-
if (!geo) continue;
|
|
922
|
-
const hinge = geo.hinge;
|
|
923
|
-
closestPointsSegmentSegment(geo.a0, geo.a1, geo.b0, geo.b1, pa, pb);
|
|
924
|
-
const overlap = ca.radius + cb.radius + offset - pa.distanceTo(pb);
|
|
925
|
-
const slack = hinge
|
|
926
|
-
? HINGE_SLACK_FACTOR * (ca.radius + cb.radius)
|
|
927
|
-
: slackFactor * Math.min(ca.radius, cb.radius);
|
|
928
|
-
const bindOverlap = calibration?.get(pairKey(ca.def.id, cb.def.id)) ?? 0;
|
|
929
|
-
const allowance = Math.max(slack, bindOverlap > 0 ? bindOverlap + PAIR_TOLERANCE : 0);
|
|
930
|
-
const depth = overlap - allowance;
|
|
931
|
-
// MIN_DEPTH, not epsilon: see the constant. A pair under it is not a
|
|
932
|
-
// contact anyone can see and not one worth moving a limb for.
|
|
933
|
-
if (depth < MIN_DEPTH) continue;
|
|
934
|
-
const normal = pa.clone().sub(pb);
|
|
935
|
-
if (normal.lengthSq() < 1e-12) {
|
|
936
|
-
// Coincident axes: any perpendicular direction separates them.
|
|
937
|
-
normal.set(0, 0, 1).cross(ca.b.clone().sub(ca.a));
|
|
938
|
-
if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0);
|
|
939
|
-
}
|
|
940
|
-
normal.normalize();
|
|
941
|
-
out.push({ a: ca, b: cb, depth, normal, pointA: pa.clone(), pointB: pb.clone() });
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
// External blockers: only MOVABLE capsules are worth testing — a blocker
|
|
945
|
-
// against the torso is two immovable things touching, which no pass can fix.
|
|
946
|
-
const shapes = normalizeBlockers(blockers);
|
|
947
|
-
for (const blocker of shapes) {
|
|
948
|
-
for (const capsule of list) {
|
|
949
|
-
if (!capsule.def.movable) continue;
|
|
950
|
-
const rootId = CHAIN_ROOT_CAPSULE[capsule.def.movable.chain];
|
|
951
|
-
const rootAnchor = rootId ? capsules.get(rootId)?.a ?? null : null;
|
|
952
|
-
const contact = blockerContact(capsule, blocker, pa, pb, rootAnchor);
|
|
953
|
-
const overlap = capsule.radius + offset - contact.gap;
|
|
954
|
-
// A box has no radius to take the min against, so the limb's own
|
|
955
|
-
// slack is the whole allowance. No rest calibration: the character's
|
|
956
|
-
// reference poses say nothing about where the set dressing is.
|
|
957
|
-
const slack = slackFactor * (blocker.kind === "box"
|
|
958
|
-
? capsule.radius
|
|
959
|
-
: Math.min(capsule.radius, blocker.radius));
|
|
960
|
-
const depth = overlap - slack;
|
|
961
|
-
if (depth < MIN_DEPTH) continue;
|
|
962
|
-
out.push({
|
|
963
|
-
a: capsule,
|
|
964
|
-
b: blocker,
|
|
965
|
-
depth,
|
|
966
|
-
normal: contact.normal.clone(),
|
|
967
|
-
pointA: pa.clone(),
|
|
968
|
-
pointB: pb.clone(),
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
out.sort((x, y) => y.depth - x.depth);
|
|
973
|
-
return out;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* The penetrations a fixer is allowed to ACT on, out of everything detection
|
|
978
|
-
* reports: no static × static (two immovable things touching, which no pass can
|
|
979
|
-
* separate), no same-chain hinge fold (see isHingeFold), and, with a filter,
|
|
980
|
-
* only pairs naming a permitted chain. Shared so a caller — the range walker's
|
|
981
|
-
* continuity pass — can read a frame with exactly the eyes fixCollisions uses,
|
|
982
|
-
* rather than a second, subtly different filter.
|
|
983
|
-
*/
|
|
984
|
-
function fixablePenetrations(capsules, { offset, slackFactor, blockers, onlyChains } = {}) {
|
|
985
|
-
if (!capsules) return [];
|
|
986
|
-
return detectPenetrations(capsules, { offset, slackFactor, blockers }).filter((pen) => {
|
|
987
|
-
if (!pen.a.def.movable && !pen.b.def.movable) return false;
|
|
988
|
-
if (isHingeFold(pen)) return false;
|
|
989
|
-
if (!onlyChains) return true;
|
|
990
|
-
const chainsOf = [pen.a, pen.b].map((c) => c.def.movable?.chain).filter(Boolean);
|
|
991
|
-
return chainsOf.some((id) => onlyChains.has(id));
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/** Every chain named by a penetration list — the cast a frame's own pose puts
|
|
996
|
-
* in play, which is what the provenance gate is allowed to move. */
|
|
997
|
-
function chainsInPlay(pens) {
|
|
998
|
-
const ids = new Set();
|
|
999
|
-
for (const pen of pens) {
|
|
1000
|
-
for (const side of [pen.a, pen.b]) {
|
|
1001
|
-
if (side.def?.movable?.chain) ids.add(side.def.movable.chain);
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
return ids;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* REST-BIASED ESCAPE. The separation normal is the SHORTEST way out, which for
|
|
1009
|
-
* a deep hit is regularly the wrong side of the blocker: a forearm driven into
|
|
1010
|
-
* the chest is closest to the far side of the torso axis, so the shortest
|
|
1011
|
-
* escape carries the hand ACROSS the chest and parks it at the opposite
|
|
1012
|
-
* shoulder; a hand 10.8 cm inside the head exits forward, and "hand to face"
|
|
1013
|
-
* becomes "hand held out in front of the face".
|
|
1014
|
-
*
|
|
1015
|
-
* A limb has somewhere it belongs, and the rig already knows where: the REST
|
|
1016
|
-
* pose. So the push direction is the contact normal blended with a HOME
|
|
1017
|
-
* direction — contact point → the driven joint's rest-pose position — which
|
|
1018
|
-
* bends the escape back toward the limb's own side of the body without
|
|
1019
|
-
* abandoning the normal.
|
|
1020
|
-
*
|
|
1021
|
-
* The blend alone would under-separate: a direction at 60° to the normal that
|
|
1022
|
-
* travels (depth + offset) only clears 0.5 × that much, and the next pass would
|
|
1023
|
-
* find the pair still penetrating. So the push LENGTH is divided by the
|
|
1024
|
-
* alignment, `dot(biased, normal)`, which makes the component ALONG THE NORMAL
|
|
1025
|
-
* exactly the separation the unbiased push would have achieved — the bias
|
|
1026
|
-
* chooses the side, never the amount of clearance.
|
|
1027
|
-
*
|
|
1028
|
-
* MIN_ALIGN is the floor on that division: a home direction that opposes the
|
|
1029
|
-
* normal would demand an unbounded push, or (past 90°) push the limb DEEPER, so
|
|
1030
|
-
* below the floor the pass falls back to the plain normal. The rule is
|
|
1031
|
-
* therefore never worse than the old one — at worst it is exactly the old one.
|
|
1032
|
-
*
|
|
1033
|
-
* No bind snapshot means no rest pose to bias toward (restWorldPosition would
|
|
1034
|
-
* compose the CURRENT pose, i.e. the penetrating one), so those rigs keep the
|
|
1035
|
-
* unbiased normal.
|
|
1036
|
-
*
|
|
1037
|
-
* The home direction is a HINT, not a target, and it is deliberately the rest
|
|
1038
|
-
* pose's ABSOLUTE world position: a clip that has walked or lowered the hips a
|
|
1039
|
-
* long way from the rest pose points it less precisely (a crouch aims it a
|
|
1040
|
-
* little high). MIN_ALIGN is what makes that safe — a hint pointing the wrong
|
|
1041
|
-
* way is dropped rather than followed, so the worst case is the old rule.
|
|
1042
|
-
*/
|
|
1043
|
-
export const HOME_BIAS = 0.6;
|
|
1044
|
-
|
|
1045
|
-
/** Floor on dot(biasedDir, normal): below it the bias is dropped for that push.
|
|
1046
|
-
* 0.35 ≈ 70°, and caps the push at 1/0.35 ≈ 2.9 × the unbiased distance. */
|
|
1047
|
-
export const MIN_ALIGN = 0.35;
|
|
1048
|
-
|
|
1049
|
-
/**
|
|
1050
|
-
* ESCAPE CONSISTENCY. A deep hit usually has more than one honest way out — a
|
|
1051
|
-
* hand buried in the head can leave up, forward or across — and which one a
|
|
1052
|
-
* frame picks turns on millimetres of contact geometry. Solved frame by frame
|
|
1053
|
-
* that is a coin toss per frame, and QA measured the result on the crouch clip:
|
|
1054
|
-
* frame 115 escaped with the arm straight up, 116 with the hands in front of the
|
|
1055
|
-
* face, 117 with the arm down across the body, on source poses that barely move
|
|
1056
|
-
* (22 mm of hand travel a frame became 520 mm).
|
|
1057
|
-
*
|
|
1058
|
-
* So a chain corrected on the PREVIOUS frame carries its push direction into
|
|
1059
|
-
* this one, as a third term in the same blend the rest bias uses: the normal
|
|
1060
|
-
* still owns the amount of clearance (see the alignment division above), the
|
|
1061
|
-
* biases only choose the side. Weighted like HOME_BIAS, because it is the same
|
|
1062
|
-
* kind of hint and answers the same question — where does this limb belong.
|
|
1063
|
-
*/
|
|
1064
|
-
const CONTINUITY_BIAS = 0.6;
|
|
1065
|
-
|
|
1066
|
-
/** The weight the STEP CAP's retry uses when the plain one produced a jump: the
|
|
1067
|
-
* previous frame's direction outvotes the rest pose rather than tying with it. */
|
|
1068
|
-
const CONTINUITY_BIAS_STRONG = 1.6;
|
|
1069
|
-
|
|
1070
|
-
/**
|
|
1071
|
-
* THE STEP CAP, as a multiple of the clip's own worst adjacent step for the
|
|
1072
|
-
* effector being judged. The clip is the only honest ruler: 8 cm in a frame is
|
|
1073
|
-
* a flail on a crouch and unremarkable in a sprint, and a fixed millimetre
|
|
1074
|
-
* budget would have to be re-tuned per take. 2.5× leaves room for a correction
|
|
1075
|
-
* to lead or lag the clip's own motion by a good margin while a 20× jump — the
|
|
1076
|
-
* flail QA measured — cannot fit inside it.
|
|
1077
|
-
*/
|
|
1078
|
-
const STEP_CAP_FACTOR = 2.5;
|
|
1079
|
-
|
|
1080
|
-
/** Ceiling on that cap, in metres. A clip whose hand already crosses the frame
|
|
1081
|
-
* in one step must not thereby license a half-metre correction jump; ~24 frames
|
|
1082
|
-
* a second, so 0.25 m is 6 m/s of hand — past any human hand speed a viewer
|
|
1083
|
-
* would read as continuous. */
|
|
1084
|
-
const STEP_CAP_ABS = 0.25;
|
|
1085
|
-
|
|
1086
|
-
/** Floor on that cap, in metres. A perfectly static clip has a zero worst step,
|
|
1087
|
-
* and a cap of zero would refuse every frame that follows a corrected one. 5 mm
|
|
1088
|
-
* is the millimetre noise of the proxies themselves. */
|
|
1089
|
-
const STEP_CAP_FLOOR = 0.005;
|
|
1090
|
-
|
|
1091
|
-
/** The bone a capsule's push actually drives: mid joint (elbow/knee) for an
|
|
1092
|
-
* upper segment, effector (wrist/ankle) for a lower segment or an end sphere. */
|
|
1093
|
-
function drivenBone(capsule, chains) {
|
|
1094
|
-
const target = capsule.def.movable;
|
|
1095
|
-
if (!target) return null;
|
|
1096
|
-
const chain = chains?.get(target.chain);
|
|
1097
|
-
if (!chain) return null;
|
|
1098
|
-
return target.joint === "mid" ? chain.bones[1] : chain.bones[2];
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
/** Unit vector from the contact point toward where the driven joint sits in the
|
|
1102
|
-
* rig's REST pose (bind pose if the rest composition is unavailable), or null
|
|
1103
|
-
* when the rig carries no bind snapshot to compose either from. */
|
|
1104
|
-
function homeDirection(rig, capsule, chains, contactPoint) {
|
|
1105
|
-
if (!hasBindPose(rig)) return null;
|
|
1106
|
-
const bone = drivenBone(capsule, chains);
|
|
1107
|
-
if (!bone) return null;
|
|
1108
|
-
const home = restWorldPosition(rig, bone, new THREE.Vector3())
|
|
1109
|
-
?? bindWorldPosition(rig, bone, new THREE.Vector3());
|
|
1110
|
-
if (!home) return null;
|
|
1111
|
-
const dir = home.sub(contactPoint);
|
|
1112
|
-
if (dir.lengthSq() < 1e-12) return null;
|
|
1113
|
-
return dir.normalize();
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
/**
|
|
1117
|
-
* The world push for one side of one penetration: `normal` already points the
|
|
1118
|
-
* way THIS capsule must go, `distance` is the separation it owes along that
|
|
1119
|
-
* normal, and `weight` scales the home bias (0 disables it).
|
|
1120
|
-
*
|
|
1121
|
-
* `continuity` is the ESCAPE CONSISTENCY hint — { dirs: Map(chainId → unit
|
|
1122
|
-
* Vector3), weight } — and rides in the same blend: a chain the previous frame
|
|
1123
|
-
* pushed somewhere leans that way again. Both hints are optional and either can
|
|
1124
|
-
* be absent; with neither, this is the plain separation normal it always was.
|
|
1125
|
-
*/
|
|
1126
|
-
function escapeVector(rig, capsule, chains, normal, contactPoint, distance, weight, continuity = null) {
|
|
1127
|
-
const straight = normal.clone().multiplyScalar(distance);
|
|
1128
|
-
const carry = continuity?.weight > 0
|
|
1129
|
-
? continuity.dirs?.get(capsule.def.movable?.chain) ?? null
|
|
1130
|
-
: null;
|
|
1131
|
-
if (!(weight > 0) && !carry) return straight;
|
|
1132
|
-
const home = weight > 0 ? homeDirection(rig, capsule, chains, contactPoint) : null;
|
|
1133
|
-
if (!home && !carry) return straight;
|
|
1134
|
-
const biased = normal.clone();
|
|
1135
|
-
if (home) biased.addScaledVector(home, weight);
|
|
1136
|
-
if (carry) biased.addScaledVector(carry, continuity.weight);
|
|
1137
|
-
if (biased.lengthSq() < 1e-12) return straight;
|
|
1138
|
-
biased.normalize();
|
|
1139
|
-
const align = biased.dot(normal);
|
|
1140
|
-
// Opposing (or near-perpendicular) home direction: keep the normal rather
|
|
1141
|
-
// than trade a sideways slide for a shallower — or negative — separation.
|
|
1142
|
-
if (align < MIN_ALIGN) return straight;
|
|
1143
|
-
return biased.multiplyScalar(distance / Math.max(MIN_ALIGN, align));
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/** Chains whose end joint must not change sides of the body because of a fix. */
|
|
1147
|
-
const SIDE_CHAINS = ["leftHand", "rightHand"];
|
|
1148
|
-
|
|
1149
|
-
/** How far off the midline a wrist must be, both before and after, before a
|
|
1150
|
-
* sign change counts as a crossing. Hands legitimately work at the midline
|
|
1151
|
-
* (clasped, folded); 2 cm keeps that noise out of the guard. */
|
|
1152
|
-
const SIDE_EPSILON = 0.02;
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* Signed distance from the body's sagittal plane — positive on the character's
|
|
1156
|
-
* LEFT. The plane is the hips' own YZ plane: origin at the hips, normal = the
|
|
1157
|
-
* hips' local X axis in world space, which is the hips→spine frame's lateral
|
|
1158
|
-
* axis (ik.js's toe-derived facing helper is not exported, and the hips basis
|
|
1159
|
-
* agrees with it on any upright rig). Returns null on a rig with no hips.
|
|
1160
|
-
*/
|
|
1161
|
-
function sagittalSide(rig, point) {
|
|
1162
|
-
const hips = findBone(rig, "mixamorigHips");
|
|
1163
|
-
if (!hips) return null;
|
|
1164
|
-
const lateral = new THREE.Vector3();
|
|
1165
|
-
hips.matrixWorld.extractBasis(lateral, new THREE.Vector3(), new THREE.Vector3());
|
|
1166
|
-
if (lateral.lengthSq() < 1e-12) return null;
|
|
1167
|
-
const origin = new THREE.Vector3().setFromMatrixPosition(hips.matrixWorld);
|
|
1168
|
-
return point.clone().sub(origin).dot(lateral.normalize());
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
const NO_PUSH = Object.freeze({ ran: false, moved: false });
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Push one movable capsule by `push` (world vector). Mid joint drives the
|
|
1175
|
-
* elbow/knee (upper segment), effector drives the wrist/ankle (lower segment
|
|
1176
|
-
* and end spheres). Returns { ran, moved }: `ran` means a solver was invoked on
|
|
1177
|
-
* this chain (so the chain's pose may have changed and must be keyed), `moved`
|
|
1178
|
-
* means the driven joint actually went somewhere.
|
|
1179
|
-
*
|
|
1180
|
-
* `onlyChains` is enforced HERE, not only at the detection filter. A pair is
|
|
1181
|
-
* kept when EITHER side names a permitted chain, so a movable×movable pair with
|
|
1182
|
-
* one side listed used to push the unlisted side too (3 cm of unrequested
|
|
1183
|
-
* motion in "filter only selected" mode) — a filter honoured by the query but
|
|
1184
|
-
* not by the write.
|
|
1185
|
-
*
|
|
1186
|
-
* DIRECTION is the caller's business: `push` arrives already blended toward the
|
|
1187
|
-
* limb's rest pose by escapeVector — see the REST-BIASED ESCAPE note there for
|
|
1188
|
-
* why the raw separation normal is not enough on a deep hit.
|
|
1189
|
-
*/
|
|
1190
|
-
function pushCapsule(capsule, chains, push, ikState, { onlyChains = null, floorY = 0 } = {}) {
|
|
1191
|
-
const target = capsule.def.movable;
|
|
1192
|
-
if (!target) return NO_PUSH;
|
|
1193
|
-
if (onlyChains && !onlyChains.has(target.chain)) return NO_PUSH;
|
|
1194
|
-
const chain = chains?.get(target.chain);
|
|
1195
|
-
if (!chain) return NO_PUSH;
|
|
1196
|
-
// Detection, target and solve all use the clip's current translations.
|
|
1197
|
-
// A bind reset here would reintroduce length compensation into the key.
|
|
1198
|
-
const rotations = chain.bones.map((bone) => bone.quaternion.clone());
|
|
1199
|
-
const driven = target.joint === "mid" ? chain.bones[1] : chain.bones[2];
|
|
1200
|
-
const before = driven.getWorldPosition(new THREE.Vector3());
|
|
1201
|
-
const goal = before.clone().add(push);
|
|
1202
|
-
goal.y = Math.max(goal.y, floorY + PUSH_FLOOR_CLEARANCE);
|
|
1203
|
-
if (target.joint === "mid") solveMidJoint(chain, goal);
|
|
1204
|
-
else solveIk(chain, goal);
|
|
1205
|
-
// `ran` is "this chain's pose is no longer the one it arrived in", measured
|
|
1206
|
-
// rather than assumed: a solver that put every bone back where it found it
|
|
1207
|
-
// has nothing to key, and a run of nothing but those must report itself as
|
|
1208
|
-
// the no-op it was.
|
|
1209
|
-
const ran = chain.bones.some((bone, index) => bone.quaternion.angleTo(rotations[index]) > 1e-9);
|
|
1210
|
-
if (ran && ikState) ikTouch(ikState, target.chain);
|
|
1211
|
-
// A push the solver clamped away (unreachable target, floor guard, a limb
|
|
1212
|
-
// already at full stretch) is NOT progress. Counting it as progress kept the
|
|
1213
|
-
// pass loop alive to maxIterations re-solving a pose that cannot move.
|
|
1214
|
-
return { ran, moved: driven.getWorldPosition(new THREE.Vector3()).distanceTo(before) > 1e-6 };
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
/**
|
|
1218
|
-
* Clean self-collisions out of the CURRENT pose. Iterates detect → push
|
|
1219
|
-
* until the deepest remaining penetration fits in `epsilon` or
|
|
1220
|
-
* maxIterations passes ran. A movable-vs-static pair pushes the limb by the
|
|
1221
|
-
* full depth + offset; two movable capsules split it, weighted toward the
|
|
1222
|
-
* higher-priority limb (arms yield before legs — leg moves read heavier).
|
|
1223
|
-
*
|
|
1224
|
-
* `onlyChains` (Set of chain ids) restricts which limbs may move — the
|
|
1225
|
-
* "filter only selected" mode. `ikState`, when given, marks every touched
|
|
1226
|
-
* chain as tracked so a following ikBakeKeyframe persists the fix.
|
|
1227
|
-
* `slackFactor` rides through to detectPenetrations for callers that want a
|
|
1228
|
-
* stricter or looser reading than the default. `blockers` rides through too:
|
|
1229
|
-
* world-space props the limbs are pushed out of exactly as they are pushed out
|
|
1230
|
-
* of the torso. `fkJoints` enables the capped TORSO/HEAD YIELD — without it the
|
|
1231
|
-
* blockers stay perfectly rigid, which is what every existing caller gets.
|
|
1232
|
-
*
|
|
1233
|
-
* `continuity` ({ dirs: Map(chainId → unit Vector3), weight }) is the ESCAPE
|
|
1234
|
-
* CONSISTENCY hint — the way each chain left its collision on the frame before
|
|
1235
|
-
* — and `provenanceChains` overrides the cast the provenance gate reads off the
|
|
1236
|
-
* entry pose. Both exist for the range walker's temporal pass and are null for
|
|
1237
|
-
* every single-frame caller, which therefore behaves exactly as it did.
|
|
1238
|
-
*
|
|
1239
|
-
* Returns { supported, changed, passes, residual, touched } — residual is the
|
|
1240
|
-
* deepest remaining penetration in metres, 0 when fully clean, and `touched` is
|
|
1241
|
-
* the chain ids THIS run actually drove, plus any FK joint id a yield leaned on
|
|
1242
|
-
* (both are keyable ids: ikBakeKeyframe reads a chain map first and the FK joint
|
|
1243
|
-
* map second, so a bake naming "spine" keys the spine). `supported` is false when the rig has
|
|
1244
|
-
* no capsule proxies to build at all (a non-Mixamo skeleton): the tool did not
|
|
1245
|
-
* run, which is a DIFFERENT answer from "ran and found nothing", and the UI
|
|
1246
|
-
* must say so rather than claim a clean pose.
|
|
1247
|
-
*
|
|
1248
|
-
* `touched` exists so the bake can name what moved. ikBakeKeyframe defaults to
|
|
1249
|
-
* the whole tracked set, which after any earlier drag or pass is far more than
|
|
1250
|
-
* this fix touched — keying a leg fix at frame 40 also re-keyed the hand
|
|
1251
|
-
* dragged at frame 2, at whatever blended value the layer happened to be
|
|
1252
|
-
* showing there.
|
|
1253
|
-
*
|
|
1254
|
-
* `baseQuats` is the other half of that: the rotations the key is a DELTA from.
|
|
1255
|
-
* The returned map (chain id → the three bone quaternions BEFORE the first
|
|
1256
|
-
* solver pass) is what a caller hands to ikBakeKeyframe so the blend window
|
|
1257
|
-
* carries the correction rather than the whole pose difference. Pass the option
|
|
1258
|
-
* in when you know the RAW CLIP rotations for this frame — the range walker
|
|
1259
|
-
* does, because it samples the clip with the layer switched off — otherwise the
|
|
1260
|
-
* pose on the rig at entry is used, which is exact whenever no earlier key is
|
|
1261
|
-
* already blending into this frame.
|
|
1262
|
-
*
|
|
1263
|
-
* CLIP TRANSLATIONS THROUGHOUT. The solvers measure each frame's segment
|
|
1264
|
-
* lengths without changing its locals. Detection, pushes and warm starts must
|
|
1265
|
-
* therefore leave those translations alone too; the bake records them so the
|
|
1266
|
-
* pose measured here is the one playback reproduces.
|
|
1267
|
-
*/
|
|
1268
|
-
export function fixCollisions(rig, chains, {
|
|
1269
|
-
radii = null,
|
|
1270
|
-
offset = 0.002,
|
|
1271
|
-
epsilon = 1e-4,
|
|
1272
|
-
maxIterations = 8,
|
|
1273
|
-
onlyChains = null,
|
|
1274
|
-
ikState = null,
|
|
1275
|
-
slackFactor = DEFAULT_SLACK_FACTOR,
|
|
1276
|
-
floorY = 0,
|
|
1277
|
-
baseQuats = null,
|
|
1278
|
-
blockers = null,
|
|
1279
|
-
fkJoints = null,
|
|
1280
|
-
continuity = null,
|
|
1281
|
-
provenanceChains = null,
|
|
1282
|
-
} = {}) {
|
|
1283
|
-
if (!rig || !chains) {
|
|
1284
|
-
return { supported: false, changed: false, passes: 0, residual: 0, touched: [], baseQuats: new Map() };
|
|
1285
|
-
}
|
|
1286
|
-
const bases = baseQuats ?? new Map(
|
|
1287
|
-
[...chains].map(([id, chain]) => [id, chain.bones.map((bone) => bone.quaternion.clone())]),
|
|
1288
|
-
);
|
|
1289
|
-
const touched = new Set();
|
|
1290
|
-
let poseDirty = false;
|
|
1291
|
-
/**
|
|
1292
|
-
* PROVENANCE. The chains this frame is allowed to move, named by the FIRST
|
|
1293
|
-
* detection on the pose as handed over. Filled in below, before the first
|
|
1294
|
-
* pass.
|
|
1295
|
-
*
|
|
1296
|
-
* A solve is iterative, so the pose it detects on after pass 0 is a pose it
|
|
1297
|
-
* made. Contacts that appear there are the fix's own doing, and acting on
|
|
1298
|
-
* them recruits limbs the source frame never had a problem with: on the
|
|
1299
|
-
* crouch clip a head × hand escape flung the arm through the legs, the legs
|
|
1300
|
-
* were then "fixed" for hitting it, and the right leg came away keyed, snapped
|
|
1301
|
-
* to bind length and smeared 164.9 mm over a dozen frames on which no leg
|
|
1302
|
-
* pair had ever existed. A new contact between two chains that were already
|
|
1303
|
-
* in play is still fair game — that is the projection loop doing its job —
|
|
1304
|
-
* but the cast is closed at the door.
|
|
1305
|
-
*/
|
|
1306
|
-
let allowedChains = null;
|
|
1307
|
-
const drive = (capsule, vector) => {
|
|
1308
|
-
const chainId = capsule.def.movable?.chain;
|
|
1309
|
-
if (allowedChains && chainId && !allowedChains.has(chainId)) return false;
|
|
1310
|
-
const outcome = pushCapsule(capsule, chains, vector, ikState, {
|
|
1311
|
-
onlyChains, floorY,
|
|
1312
|
-
});
|
|
1313
|
-
if (outcome.ran) {
|
|
1314
|
-
touched.add(capsule.def.movable.chain);
|
|
1315
|
-
// A solve that changed the pose without moving the driven joint is not
|
|
1316
|
-
// progress, but it IS a change, and a run that ends with one owes the
|
|
1317
|
-
// caller a `changed: true` and a key.
|
|
1318
|
-
poseDirty = true;
|
|
1319
|
-
}
|
|
1320
|
-
return outcome.moved;
|
|
1321
|
-
};
|
|
1322
|
-
|
|
1323
|
-
// Every push of a pass is computed from the pose the pass STARTED in, so the
|
|
1324
|
-
// same penetration list can be replayed at a different weight (the side
|
|
1325
|
-
// guard below) and produce a deterministic result either way.
|
|
1326
|
-
const applyPass = (pens, weight) => {
|
|
1327
|
-
let passChanged = false;
|
|
1328
|
-
for (const pen of pens) {
|
|
1329
|
-
const ma = pen.a.def.movable;
|
|
1330
|
-
const mb = pen.b.def.movable;
|
|
1331
|
-
const distance = pen.depth + offset;
|
|
1332
|
-
// `normal` points b → a: a escapes along +normal, b along −normal.
|
|
1333
|
-
const away = pen.normal;
|
|
1334
|
-
const back = pen.normal.clone().negate();
|
|
1335
|
-
const pushA = (share) => escapeVector(rig, pen.a, chains, away, pen.pointA, distance * share, weight, continuity);
|
|
1336
|
-
const pushB = (share) => escapeVector(rig, pen.b, chains, back, pen.pointB, distance * share, weight, continuity);
|
|
1337
|
-
if (ma && !mb) passChanged = drive(pen.a, pushA(1)) || passChanged;
|
|
1338
|
-
else if (mb && !ma) passChanged = drive(pen.b, pushB(1)) || passChanged;
|
|
1339
|
-
else if (ma && mb) {
|
|
1340
|
-
// Splitting the push is only right when BOTH sides are allowed to
|
|
1341
|
-
// move. With a filter naming one of them, the permitted side owes
|
|
1342
|
-
// the whole separation — half a push leaves half a penetration.
|
|
1343
|
-
const allowA = !onlyChains || onlyChains.has(ma.chain);
|
|
1344
|
-
const allowB = !onlyChains || onlyChains.has(mb.chain);
|
|
1345
|
-
if (allowA && allowB) {
|
|
1346
|
-
const pa = pen.a.def.priority ?? 1;
|
|
1347
|
-
const pb = pen.b.def.priority ?? 1;
|
|
1348
|
-
const shareA = pa / (pa + pb); // arms (2) yield before legs (1)
|
|
1349
|
-
const movedA = drive(pen.a, pushA(shareA));
|
|
1350
|
-
const movedB = drive(pen.b, pushB(1 - shareA));
|
|
1351
|
-
passChanged = movedA || movedB || passChanged;
|
|
1352
|
-
} else if (allowA) passChanged = drive(pen.a, pushA(1)) || passChanged;
|
|
1353
|
-
else if (allowB) passChanged = drive(pen.b, pushB(1)) || passChanged;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
return passChanged;
|
|
1357
|
-
};
|
|
1358
|
-
|
|
1359
|
-
/**
|
|
1360
|
-
* A hand must not change sides of the body because of a fix. The heuristic,
|
|
1361
|
-
* deliberately blunt: measure each arm chain's WRIST against the sagittal
|
|
1362
|
-
* plane, and if a pass leaves a wrist that was clearly on one side
|
|
1363
|
-
* (> SIDE_EPSILON) clearly on the other, the escape went ACROSS the torso
|
|
1364
|
-
* rather than back out — so that pass is rewound and replayed with the home
|
|
1365
|
-
* bias at DOUBLE weight, which pulls harder toward the limb's own side. One
|
|
1366
|
-
* retry per pass, and the retry is accepted whatever it produces, so the
|
|
1367
|
-
* pass count and the output stay deterministic.
|
|
1368
|
-
*
|
|
1369
|
-
* TWO REFERENCE SIDES, and the second is the one that fires. A single pass
|
|
1370
|
-
* moves the wrist by (depth + offset), so a hand does not jump the midline
|
|
1371
|
-
* in one pass — measured over 6000 random deep poses it never once did.
|
|
1372
|
-
* Crossings are CUMULATIVE: a few passes of a few centimetres each, and the
|
|
1373
|
-
* hand has walked to the far shoulder. So a pass is judged against the side
|
|
1374
|
-
* the wrist had when the pass started AND against the side it had when the
|
|
1375
|
-
* fix started, and the pass that completes a cumulative crossing is the one
|
|
1376
|
-
* that gets replayed.
|
|
1377
|
-
*/
|
|
1378
|
-
const bias = hasBindPose(rig) ? HOME_BIAS : 0;
|
|
1379
|
-
const passBones = [];
|
|
1380
|
-
{
|
|
1381
|
-
const seen = new Set();
|
|
1382
|
-
for (const chain of chains.values()) {
|
|
1383
|
-
for (const bone of chain.bones) if (!seen.has(bone)) { seen.add(bone); passBones.push(bone); }
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
const snapshotPass = () => passBones.map((bone) => bone.quaternion.clone());
|
|
1387
|
-
const restorePass = (snapshot) => {
|
|
1388
|
-
passBones.forEach((bone, index) => bone.quaternion.copy(snapshot[index]));
|
|
1389
|
-
rig.updateMatrixWorld(true);
|
|
1390
|
-
};
|
|
1391
|
-
const wristSides = () => {
|
|
1392
|
-
const sides = new Map();
|
|
1393
|
-
for (const id of SIDE_CHAINS) {
|
|
1394
|
-
const chain = chains.get(id);
|
|
1395
|
-
if (!chain) continue;
|
|
1396
|
-
const side = sagittalSide(rig, chain.bones[2].getWorldPosition(new THREE.Vector3()));
|
|
1397
|
-
if (side !== null) sides.set(id, side);
|
|
1398
|
-
}
|
|
1399
|
-
return sides;
|
|
1400
|
-
};
|
|
1401
|
-
const crossedMidline = (before, after) => [...before].some(([id, start]) => {
|
|
1402
|
-
const end = after.get(id);
|
|
1403
|
-
return end !== undefined
|
|
1404
|
-
&& Math.abs(start) > SIDE_EPSILON && Math.abs(end) > SIDE_EPSILON
|
|
1405
|
-
&& start * end < 0;
|
|
1406
|
-
});
|
|
1407
|
-
const entrySides = bias > 0 ? wristSides() : null;
|
|
1408
|
-
|
|
1409
|
-
/** The penetrations THIS run is allowed to act on: no static × static (two
|
|
1410
|
-
* immovable things touching, which no pass can fix), no SAME-CHAIN HINGE (a
|
|
1411
|
-
* fold is a pose, not a collision — see isHingeFold), and only pairs naming a
|
|
1412
|
-
* permitted chain when a filter is in force. */
|
|
1413
|
-
const fixablePens = (capsules) => fixablePenetrations(capsules, { offset, slackFactor, blockers, onlyChains });
|
|
1414
|
-
|
|
1415
|
-
/** Every bone a fix or a yield can write, with its full local transform. */
|
|
1416
|
-
const frameSnapshot = () => {
|
|
1417
|
-
const entries = [];
|
|
1418
|
-
const seen = new Set();
|
|
1419
|
-
const add = (bone) => {
|
|
1420
|
-
if (!bone || seen.has(bone)) return;
|
|
1421
|
-
seen.add(bone);
|
|
1422
|
-
entries.push([bone, bone.quaternion.clone(), bone.position.clone()]);
|
|
1423
|
-
};
|
|
1424
|
-
for (const chain of chains.values()) for (const bone of chain.bones) add(bone);
|
|
1425
|
-
const joints = fkJoints ?? fallbackYieldJoints(rig);
|
|
1426
|
-
if (joints) for (const joint of joints.values()) add(joint.bone);
|
|
1427
|
-
return entries;
|
|
1428
|
-
};
|
|
1429
|
-
const frameRestore = (entries) => {
|
|
1430
|
-
for (const [bone, quaternion, position] of entries) {
|
|
1431
|
-
bone.quaternion.copy(quaternion);
|
|
1432
|
-
bone.position.copy(position);
|
|
1433
|
-
}
|
|
1434
|
-
rig.updateMatrixWorld(true);
|
|
1435
|
-
};
|
|
1436
|
-
/** Lowest foot marker in the current pose, or +Infinity when the rig has
|
|
1437
|
-
* none to judge. */
|
|
1438
|
-
const lowestFoot = () => {
|
|
1439
|
-
let lowest = Infinity;
|
|
1440
|
-
for (const name of FLOOR_MARKERS) {
|
|
1441
|
-
const bone = findBone(rig, name);
|
|
1442
|
-
if (!bone) continue;
|
|
1443
|
-
lowest = Math.min(lowest, bone.getWorldPosition(new THREE.Vector3()).y);
|
|
1444
|
-
}
|
|
1445
|
-
return lowest;
|
|
1446
|
-
};
|
|
1447
|
-
/** The deepest fixable penetration in the current pose, 0 when clean. */
|
|
1448
|
-
const currentWorst = () => {
|
|
1449
|
-
const capsules = buildCollisionCapsules(rig, radii);
|
|
1450
|
-
if (!capsules) return 0;
|
|
1451
|
-
const pens = fixablePens(capsules);
|
|
1452
|
-
return pens.length ? pens[0].depth : 0;
|
|
1453
|
-
};
|
|
1454
|
-
|
|
1455
|
-
const entryPose = frameSnapshot();
|
|
1456
|
-
const entryFoot = lowestFoot();
|
|
1457
|
-
let entryWorst = 0;
|
|
1458
|
-
{
|
|
1459
|
-
const capsules = buildCollisionCapsules(rig, radii);
|
|
1460
|
-
const pens = capsules ? fixablePens(capsules) : [];
|
|
1461
|
-
entryWorst = pens.length ? pens[0].depth : 0;
|
|
1462
|
-
// The cast, closed at the door. A chain that is not named by a pair on the
|
|
1463
|
-
// pose as handed over cannot be recruited by a contact the fix invents.
|
|
1464
|
-
//
|
|
1465
|
-
// `provenanceChains` lets a caller name that cast itself, and exists for
|
|
1466
|
-
// exactly one caller: the range walker's WARM START hands over a pose it
|
|
1467
|
-
// has already nudged with the previous frame's correction, so the pose at
|
|
1468
|
-
// the door is no longer the frame's own. The list it passes is read off
|
|
1469
|
-
// the frame's UNTOUCHED pose, which keeps the gate saying what it always
|
|
1470
|
-
// said — only limbs this frame's own clip pose put in play may move.
|
|
1471
|
-
allowedChains = provenanceChains ? new Set(provenanceChains) : chainsInPlay(pens);
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
let changed = false;
|
|
1475
|
-
let residual = 0;
|
|
1476
|
-
let pass = 0;
|
|
1477
|
-
for (; pass < maxIterations; pass += 1) {
|
|
1478
|
-
const capsules = buildCollisionCapsules(rig, radii);
|
|
1479
|
-
if (!capsules) {
|
|
1480
|
-
return { supported: false, changed, passes: pass, residual, touched: [...touched], baseQuats: bases };
|
|
1481
|
-
}
|
|
1482
|
-
// `changed` is cumulative across passes, so it can never report what
|
|
1483
|
-
// THIS pass did. A pass that pushes nothing — every penetration
|
|
1484
|
-
// belongs to a chain the rig lacks, or to a filtered-out chain — would
|
|
1485
|
-
// otherwise keep looping to maxIterations re-solving an unmoving pose.
|
|
1486
|
-
let passChanged = false;
|
|
1487
|
-
const pens = fixablePens(capsules);
|
|
1488
|
-
if (pens.length === 0) { residual = 0; break; }
|
|
1489
|
-
residual = pens[0].depth;
|
|
1490
|
-
const sidesBefore = bias > 0 ? wristSides() : null;
|
|
1491
|
-
const insideBefore = blockerInsideCount(capsules, blockers);
|
|
1492
|
-
const rewind = snapshotPass();
|
|
1493
|
-
const dirtyBefore = poseDirty;
|
|
1494
|
-
const touchedBefore = [...touched];
|
|
1495
|
-
passChanged = applyPass(pens, bias);
|
|
1496
|
-
if (passChanged && sidesBefore) {
|
|
1497
|
-
const sidesAfter = wristSides();
|
|
1498
|
-
if (crossedMidline(sidesBefore, sidesAfter) || crossedMidline(entrySides, sidesAfter)) {
|
|
1499
|
-
restorePass(rewind);
|
|
1500
|
-
passChanged = applyPass(pens, bias * 2);
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
// BOX GUARD. A box escape that ends with MORE of the limb inside the box
|
|
1504
|
-
// went out through the wrong face, and no residual number can see that —
|
|
1505
|
-
// the pose reports no contact precisely because it is buried. Such a pass
|
|
1506
|
-
// is rewound whole and the loop stops with the pose it had, which is the
|
|
1507
|
-
// worse-is-not-an-option rule the rest of this file already follows.
|
|
1508
|
-
if (passChanged && insideBefore >= 0) {
|
|
1509
|
-
const after = buildCollisionCapsules(rig, radii);
|
|
1510
|
-
if (after && blockerInsideCount(after, blockers) > insideBefore) {
|
|
1511
|
-
restorePass(rewind);
|
|
1512
|
-
poseDirty = dirtyBefore;
|
|
1513
|
-
touched.clear();
|
|
1514
|
-
for (const id of touchedBefore) touched.add(id);
|
|
1515
|
-
break;
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
changed = changed || passChanged;
|
|
1519
|
-
if (!passChanged) break;
|
|
1520
|
-
}
|
|
1521
|
-
/**
|
|
1522
|
-
* The last resort, and only ever a last one: the limb passes are over and a
|
|
1523
|
-
* movable × static pair is still penetrating, so the STATIC side leans away
|
|
1524
|
-
* by a few degrees.
|
|
1525
|
-
*
|
|
1526
|
-
* THE JOINTS ARE FOUND, NOT REQUIRED. The map is the caller's `fkJoints` when
|
|
1527
|
-
* there is one and a minimal one built off the rig's own bones otherwise —
|
|
1528
|
-
* because the caller that needs this most (the single-frame button) does not
|
|
1529
|
-
* pass one, and QA measured exactly that: a head × upper-arm hit at the arm's
|
|
1530
|
-
* reach limit, 88 mm left standing, spine and neck at 0.000°. The ids match
|
|
1531
|
-
* the FK track ids either way, so `touched` stays bakeable.
|
|
1532
|
-
*
|
|
1533
|
-
* THE DIRECTION IS THE SUM OF WHAT PRESSES ON IT. Leaning away from the single
|
|
1534
|
-
* deepest pair is no use when the blocker is caught between two of them — a
|
|
1535
|
-
* head between both upper arms, the measured case — because every degree away
|
|
1536
|
-
* from one is a degree into the other. The swing follows the depth-weighted
|
|
1537
|
-
* sum of every pair pressing on that capsule, which for the symmetric case
|
|
1538
|
-
* points backward, out of both.
|
|
1539
|
-
*
|
|
1540
|
-
* THE BUDGET IS SPENT IN STEPS, NOT BET ON ONE. A single 6° step cannot clear
|
|
1541
|
-
* 88 mm, so a step that merely fails to improve is not a reason to stop:
|
|
1542
|
-
* steps continue while the residual is NON-INCREASING, up to the cap. What is
|
|
1543
|
-
* refused is the outcome — if the whole yield leaves the pose no better than
|
|
1544
|
-
* it found it, every degree of it is rolled back and nothing is keyed.
|
|
1545
|
-
*/
|
|
1546
|
-
const yieldJoints = fkJoints ?? fallbackYieldJoints(rig);
|
|
1547
|
-
const yieldTargetOf = (pen) => {
|
|
1548
|
-
const staticSide = !pen.a.def.movable ? pen.a : (!pen.b.def.movable ? pen.b : null);
|
|
1549
|
-
if (!staticSide) return null;
|
|
1550
|
-
const id = YIELD_JOINTS[staticSide.def.id];
|
|
1551
|
-
const joint = id ? yieldJoints?.get(id) : null;
|
|
1552
|
-
if (!joint?.bone?.parent) return null;
|
|
1553
|
-
return { id, joint, staticSide };
|
|
1554
|
-
};
|
|
1555
|
-
/** Everything a yield can move, so a yield that does not pay for itself can
|
|
1556
|
-
* be put back exactly. */
|
|
1557
|
-
const yieldSnapshot = () => {
|
|
1558
|
-
const entries = [];
|
|
1559
|
-
const seen = new Set();
|
|
1560
|
-
const add = (bone) => {
|
|
1561
|
-
if (!bone || seen.has(bone)) return;
|
|
1562
|
-
seen.add(bone);
|
|
1563
|
-
entries.push([bone, bone.quaternion.clone(), bone.position.clone()]);
|
|
1564
|
-
};
|
|
1565
|
-
for (const chain of chains.values()) for (const bone of chain.bones) add(bone);
|
|
1566
|
-
if (yieldJoints) for (const joint of yieldJoints.values()) add(joint.bone);
|
|
1567
|
-
return entries;
|
|
1568
|
-
};
|
|
1569
|
-
const yieldRestore = (entries) => {
|
|
1570
|
-
for (const [bone, quaternion, position] of entries) {
|
|
1571
|
-
bone.quaternion.copy(quaternion);
|
|
1572
|
-
bone.position.copy(position);
|
|
1573
|
-
}
|
|
1574
|
-
rig.updateMatrixWorld(true);
|
|
1575
|
-
};
|
|
1576
|
-
/** The depth-weighted direction `capsule` is being pressed FROM, with the
|
|
1577
|
-
* contact it should pivot about and the deepest pair pressing on it. */
|
|
1578
|
-
const yieldLoad = (pens, capsule) => {
|
|
1579
|
-
const away = new THREE.Vector3();
|
|
1580
|
-
const contact = new THREE.Vector3();
|
|
1581
|
-
let weight = 0;
|
|
1582
|
-
let deepest = 0;
|
|
1583
|
-
for (const pen of pens) {
|
|
1584
|
-
const isA = pen.a === capsule;
|
|
1585
|
-
if (!isA && pen.b !== capsule) continue;
|
|
1586
|
-
// `normal` points b → a, so the static side escapes along +normal when
|
|
1587
|
-
// it is a and along −normal when it is b — the same rule applyPass uses.
|
|
1588
|
-
away.addScaledVector(isA ? pen.normal : pen.normal.clone().negate(), pen.depth);
|
|
1589
|
-
contact.addScaledVector(isA ? pen.pointA : pen.pointB, pen.depth);
|
|
1590
|
-
weight += pen.depth;
|
|
1591
|
-
deepest = Math.max(deepest, pen.depth);
|
|
1592
|
-
}
|
|
1593
|
-
if (weight <= 0 || away.lengthSq() < 1e-12) return null;
|
|
1594
|
-
return { away: away.normalize(), contact: contact.divideScalar(weight), deepest };
|
|
1595
|
-
};
|
|
1596
|
-
const yieldStatic = (pens, target, limit) => {
|
|
1597
|
-
const load = yieldLoad(pens, target.staticSide);
|
|
1598
|
-
if (!load) return 0;
|
|
1599
|
-
const pivot = target.joint.bone.getWorldPosition(new THREE.Vector3());
|
|
1600
|
-
const lever = load.contact.clone().sub(pivot);
|
|
1601
|
-
const radius = lever.length();
|
|
1602
|
-
if (radius < 1e-4) return 0;
|
|
1603
|
-
const axis = lever.clone().cross(load.away);
|
|
1604
|
-
if (axis.lengthSq() < 1e-12) return 0;
|
|
1605
|
-
const angle = Math.min(limit, (load.deepest + offset) / radius);
|
|
1606
|
-
if (!(angle > 1e-6)) return 0;
|
|
1607
|
-
axis.normalize();
|
|
1608
|
-
// WHICH WAY ROUND. `lever × away` names the plane the yield should turn
|
|
1609
|
-
// in, but not reliably its sign: `contact` is a depth-weighted mean of
|
|
1610
|
-
// several contact points, and when a blocker is pressed from both sides
|
|
1611
|
-
// that mean can land on the far side of the pivot from every individual
|
|
1612
|
-
// contact, which flips the cross product and leans INTO the deeper pair.
|
|
1613
|
-
// Both signs are cheap to try, so try both and keep the one that actually
|
|
1614
|
-
// reduces the worst depth; if neither does, spend nothing.
|
|
1615
|
-
const before = worstDepth();
|
|
1616
|
-
const start = target.joint.bone.quaternion.clone();
|
|
1617
|
-
const parentWorld = target.joint.bone.parent.getWorldQuaternion(new THREE.Quaternion());
|
|
1618
|
-
const startPosition = target.joint.bone.position.clone();
|
|
1619
|
-
let best = null;
|
|
1620
|
-
for (const sign of [1, -1]) {
|
|
1621
|
-
solveSwingAngle(target.joint, axis, sign * angle, start, parentWorld);
|
|
1622
|
-
rig.updateMatrixWorld(true);
|
|
1623
|
-
const now = worstDepth();
|
|
1624
|
-
if (best === null || now < best.depth) {
|
|
1625
|
-
best = { depth: now, quaternion: target.joint.bone.quaternion.clone() };
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
if (!best || best.depth >= before - 1e-9) {
|
|
1629
|
-
target.joint.bone.quaternion.copy(start);
|
|
1630
|
-
target.joint.bone.position.copy(startPosition);
|
|
1631
|
-
rig.updateMatrixWorld(true);
|
|
1632
|
-
return 0;
|
|
1633
|
-
}
|
|
1634
|
-
target.joint.bone.quaternion.copy(best.quaternion);
|
|
1635
|
-
rig.updateMatrixWorld(true);
|
|
1636
|
-
return angle;
|
|
1637
|
-
};
|
|
1638
|
-
/** The deepest fixable penetration right now, 0 when there is none. */
|
|
1639
|
-
const worstDepth = () => {
|
|
1640
|
-
const capsules = buildCollisionCapsules(rig, radii);
|
|
1641
|
-
if (!capsules) return 0;
|
|
1642
|
-
const pens = fixablePens(capsules);
|
|
1643
|
-
return pens.length ? pens[0].depth : 0;
|
|
1644
|
-
};
|
|
1645
|
-
if (yieldJoints?.size && residual > epsilon) {
|
|
1646
|
-
const entrySnapshot = yieldSnapshot();
|
|
1647
|
-
const entryTouched = [...touched];
|
|
1648
|
-
const entryDirty = poseDirty;
|
|
1649
|
-
const startResidual = residual;
|
|
1650
|
-
let bestResidual = residual;
|
|
1651
|
-
let bestSnapshot = null;
|
|
1652
|
-
const yielded = new Set();
|
|
1653
|
-
let budget = YIELD_TOTAL_RAD;
|
|
1654
|
-
while (budget > 1e-6) {
|
|
1655
|
-
const capsules = buildCollisionCapsules(rig, radii);
|
|
1656
|
-
if (!capsules) break;
|
|
1657
|
-
const pens = fixablePens(capsules);
|
|
1658
|
-
const worst = pens.length ? pens[0].depth : 0;
|
|
1659
|
-
if (worst <= epsilon) break;
|
|
1660
|
-
let picked = null;
|
|
1661
|
-
for (const pen of pens) {
|
|
1662
|
-
const target = yieldTargetOf(pen);
|
|
1663
|
-
if (target) { picked = { pens, target }; break; }
|
|
1664
|
-
}
|
|
1665
|
-
if (!picked) break;
|
|
1666
|
-
const spent = yieldStatic(picked.pens, picked.target, Math.min(YIELD_STEP_RAD, budget));
|
|
1667
|
-
if (!(spent > 0)) break;
|
|
1668
|
-
// The room a yield opens is only worth anything if the limb takes it.
|
|
1669
|
-
const after = buildCollisionCapsules(rig, radii);
|
|
1670
|
-
const remaining = after ? fixablePens(after) : [];
|
|
1671
|
-
if (remaining.length) applyPass(remaining, bias);
|
|
1672
|
-
const now = worstDepth();
|
|
1673
|
-
// Non-increasing keeps the budget alive (one 6° step cannot clear an
|
|
1674
|
-
// 88 mm hit); a step that makes things worse ends it there.
|
|
1675
|
-
if (now > worst + 1e-6) break;
|
|
1676
|
-
budget -= spent;
|
|
1677
|
-
yielded.add(picked.target.id);
|
|
1678
|
-
if (now < bestResidual - 1e-9) {
|
|
1679
|
-
bestResidual = now;
|
|
1680
|
-
bestSnapshot = yieldSnapshot();
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
if (bestResidual < startResidual - 1e-6) {
|
|
1684
|
-
if (bestSnapshot) yieldRestore(bestSnapshot);
|
|
1685
|
-
residual = bestResidual;
|
|
1686
|
-
changed = true;
|
|
1687
|
-
for (const id of yielded) {
|
|
1688
|
-
touched.add(id);
|
|
1689
|
-
if (ikState) ikTouch(ikState, id);
|
|
1690
|
-
}
|
|
1691
|
-
} else {
|
|
1692
|
-
// Bought nothing: no lean, no key, and the pose the limb passes reached
|
|
1693
|
-
// is what the caller gets.
|
|
1694
|
-
yieldRestore(entrySnapshot);
|
|
1695
|
-
touched.clear();
|
|
1696
|
-
for (const id of entryTouched) touched.add(id);
|
|
1697
|
-
poseDirty = entryDirty;
|
|
1698
|
-
}
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
// A solve that rearranged a chain without moving its driven joint still
|
|
1702
|
-
// changed the pose, and the caller must key it.
|
|
1703
|
-
changed = changed || poseDirty;
|
|
1704
|
-
|
|
1705
|
-
/**
|
|
1706
|
-
* A frame's fix is kept only if it is BETTER than the pose it started from.
|
|
1707
|
-
* Two ways it can fail to be, each measured against the entry pose:
|
|
1708
|
-
*
|
|
1709
|
-
* - IT DID NOT HELP. The deepest fixable pair is no shallower than it was.
|
|
1710
|
-
* Keying that is how a whole-clip run stayed non-idempotent: 19 frames
|
|
1711
|
-
* re-keyed on every press with "13 still penetrate", because a frame the
|
|
1712
|
-
* passes could not clear was baked anyway and then baked again next time.
|
|
1713
|
-
* An unreducible frame is now reported, not written.
|
|
1714
|
-
* - IT BURIED A FOOT. The push guard clamps the joint it drives, which is
|
|
1715
|
-
* the ankle; the toe hangs off the far side and went 18.7 mm under the
|
|
1716
|
-
* deck on QA's crouch. Any marker lower than it started, and lower than
|
|
1717
|
-
* its clearance, is a fix that solved one artefact by making a worse one.
|
|
1718
|
-
*
|
|
1719
|
-
* Refusal is total: the pose goes back exactly as it arrived, nothing is
|
|
1720
|
-
* touched, nothing is keyed, and `residual` reports the depth that defeated
|
|
1721
|
-
* it so the caller can say so out loud.
|
|
1722
|
-
*/
|
|
1723
|
-
if (changed) {
|
|
1724
|
-
const afterWorst = currentWorst();
|
|
1725
|
-
const afterFoot = lowestFoot();
|
|
1726
|
-
const improved = afterWorst < entryWorst - epsilon;
|
|
1727
|
-
const buried = Number.isFinite(afterFoot)
|
|
1728
|
-
&& afterFoot < Math.min(entryFoot, floorY + PUSH_FLOOR_CLEARANCE) - 1e-6;
|
|
1729
|
-
if (!improved || buried) {
|
|
1730
|
-
frameRestore(entryPose);
|
|
1731
|
-
return {
|
|
1732
|
-
supported: true,
|
|
1733
|
-
changed: false,
|
|
1734
|
-
passes: pass,
|
|
1735
|
-
residual: entryWorst,
|
|
1736
|
-
touched: [],
|
|
1737
|
-
baseQuats: bases,
|
|
1738
|
-
refused: improved ? "foot-below-floor" : "no-improvement",
|
|
1739
|
-
};
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
// Final measurement for an honest residual report.
|
|
1744
|
-
if (changed) {
|
|
1745
|
-
const capsules = buildCollisionCapsules(rig, radii);
|
|
1746
|
-
// Same-chain folds are reported by detection but never acted on, so
|
|
1747
|
-
// they must not count as residual either — otherwise a frame the fixer
|
|
1748
|
-
// has fully handled toasts "reduced (0.8 cm)" and the next press,
|
|
1749
|
-
// which finds nothing to do, toasts "no collisions" for the same pose.
|
|
1750
|
-
const pens = capsules ? detectPenetrations(capsules, { offset, slackFactor, blockers }).filter((pen) => !isHingeFold(pen)) : [];
|
|
1751
|
-
residual = pens.length ? pens[0].depth : 0;
|
|
1752
|
-
}
|
|
1753
|
-
return { supported: true, changed, passes: pass, residual, touched: [...touched], baseQuats: bases };
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
/** Every bone the IK layer can write, once each: the chain bones plus the FK
|
|
1757
|
-
* joints. These are exactly the bones a bake at one frame can change, so
|
|
1758
|
-
* snapshotting them captures everything this pass could contaminate. */
|
|
1759
|
-
function layerBones(chains, fkJoints) {
|
|
1760
|
-
const bones = [];
|
|
1761
|
-
const seen = new Set();
|
|
1762
|
-
const add = (bone) => {
|
|
1763
|
-
if (!bone || seen.has(bone)) return;
|
|
1764
|
-
seen.add(bone);
|
|
1765
|
-
bones.push(bone);
|
|
1766
|
-
};
|
|
1767
|
-
for (const chain of chains.values()) for (const bone of chain.bones) add(bone);
|
|
1768
|
-
if (fkJoints) for (const joint of fkJoints.values()) add(joint.bone);
|
|
1769
|
-
return bones;
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
function snapshotBones(bones) {
|
|
1773
|
-
return bones.map((bone) => ({ position: bone.position.clone(), quaternion: bone.quaternion.clone() }));
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
function restoreBones(rig, bones, snapshot) {
|
|
1777
|
-
for (let index = 0; index < bones.length; index += 1) {
|
|
1778
|
-
bones[index].position.copy(snapshot[index].position);
|
|
1779
|
-
bones[index].quaternion.copy(snapshot[index].quaternion);
|
|
1780
|
-
}
|
|
1781
|
-
rig.updateMatrixWorld(true);
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
/**
|
|
1785
|
-
* Clean a frame RANGE, baking every touched frame into the IK key layer.
|
|
1786
|
-
* `applyFrame(frame)` must pose the rig (motion apply + ikEvaluate) before
|
|
1787
|
-
* the fix runs — App owns that plumbing, this owns the loop. Only frames
|
|
1788
|
-
* that actually changed get a key, so a clean clip stays keyless.
|
|
1789
|
-
*
|
|
1790
|
-
* External blockers come either as a static `blockers` array (the same shapes at
|
|
1791
|
-
* every frame) or as a `blockersAt(frame)` callback, called after applyFrame for
|
|
1792
|
-
* that frame so an ANIMATED prop can report its pose for the frame being solved.
|
|
1793
|
-
* Returns the list of keyed frames — empty on a rig the tool cannot
|
|
1794
|
-
* describe, which the caller should have screened with
|
|
1795
|
-
* supportsCollisionCleanup before offering the button at all.
|
|
1796
|
-
*
|
|
1797
|
-
* TWO SWEEPS, for the same reason auto-physics needs them. `applyFrame`
|
|
1798
|
-
* evaluates the IK layer, so once this loop starts writing keys it is no longer
|
|
1799
|
-
* looking at the clip: frame 40 gets posed as clip + the blend ramp of the key
|
|
1800
|
-
* written at frame 37, that blend introduces penetrations of its own, and those
|
|
1801
|
-
* get keyed too. On the QA clip 4 of 11 keys landed on frames that were CLEAN
|
|
1802
|
-
* in the source, and 58 frames finished flagged that started clean — the pass
|
|
1803
|
-
* chasing its own tail. Sweep 1 therefore records each frame's pose with
|
|
1804
|
-
* nothing of this run's written yet; sweep 2 restores that pose before
|
|
1805
|
-
* detecting, so every frame is judged and fixed against the clip.
|
|
1806
|
-
*
|
|
1807
|
-
* Sweep 1 records TWO poses per frame, and the difference matters:
|
|
1808
|
-
*
|
|
1809
|
-
* - the LAYER pose (clip + whatever keys already existed) is what the fix is
|
|
1810
|
-
* applied to, because the user's earlier corrections are part of the pose
|
|
1811
|
-
* they are asking to clean;
|
|
1812
|
-
* - the RAW CLIP pose — sampled with `ikState.tracked` emptied, which makes
|
|
1813
|
-
* the caller's own ikEvaluate a no-op — is what each key's rotations are
|
|
1814
|
-
* stored as a DELTA FROM. It has to be the raw one: ikEvaluate finds the raw
|
|
1815
|
-
* clip on the bone when it comes to apply the key, so only a delta measured
|
|
1816
|
-
* from there collapses back to the authored pose at the keyed frame.
|
|
1817
|
-
*
|
|
1818
|
-
* With no keys yet the two are identical and the second sample is skipped.
|
|
1819
|
-
*/
|
|
1820
|
-
export function fixCollisionsRange({
|
|
1821
|
-
rig,
|
|
1822
|
-
chains,
|
|
1823
|
-
ikState,
|
|
1824
|
-
fkJoints = null,
|
|
1825
|
-
startFrame,
|
|
1826
|
-
endFrame,
|
|
1827
|
-
applyFrame,
|
|
1828
|
-
blockersAt = null,
|
|
1829
|
-
blendWindow = 6,
|
|
1830
|
-
...options
|
|
1831
|
-
} = {}) {
|
|
1832
|
-
if (!rig || !chains || !ikState || typeof applyFrame !== "function") return [];
|
|
1833
|
-
const start = Math.max(0, Math.round(startFrame));
|
|
1834
|
-
const end = Math.max(start, Math.round(endFrame));
|
|
1835
|
-
|
|
1836
|
-
const bones = layerBones(chains, fkJoints);
|
|
1837
|
-
const chainIds = [...chains.keys()];
|
|
1838
|
-
const readChainQuats = () => new Map(chainIds.map((id) => [id, chains.get(id).bones.map((b) => b.quaternion.clone())]));
|
|
1839
|
-
/** Every chain's EFFECTOR (wrist / ankle) in world space — the one point per
|
|
1840
|
-
* limb whose frame-to-frame travel a viewer reads as speed, and the thing the
|
|
1841
|
-
* step cap below is written in terms of. */
|
|
1842
|
-
const readEffectors = () => new Map(chainIds.map(
|
|
1843
|
-
(id) => [id, chains.get(id).bones[2].getWorldPosition(new THREE.Vector3())],
|
|
1844
|
-
));
|
|
1845
|
-
const layerActive = ikState.tracked.size > 0;
|
|
1846
|
-
const basePose = [];
|
|
1847
|
-
const clipQuats = [];
|
|
1848
|
-
const sourceEffectors = [];
|
|
1849
|
-
for (let frame = start; frame <= end; frame += 1) {
|
|
1850
|
-
if (layerActive) {
|
|
1851
|
-
// Silence the layer for one sample: ikEvaluate walks `tracked`, so an
|
|
1852
|
-
// empty set makes the caller's applyFrame pure clip. Restored on the
|
|
1853
|
-
// same iteration, and never observed by anyone else — this loop owns
|
|
1854
|
-
// the rig for its duration.
|
|
1855
|
-
const tracked = ikState.tracked;
|
|
1856
|
-
ikState.tracked = new Set();
|
|
1857
|
-
try {
|
|
1858
|
-
applyFrame(frame);
|
|
1859
|
-
clipQuats.push(readChainQuats());
|
|
1860
|
-
} finally {
|
|
1861
|
-
ikState.tracked = tracked;
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
applyFrame(frame);
|
|
1865
|
-
basePose.push(snapshotBones(bones));
|
|
1866
|
-
sourceEffectors.push(readEffectors());
|
|
1867
|
-
if (!layerActive) clipQuats.push(readChainQuats());
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
/**
|
|
1871
|
-
* THE STEP CAP, per chain, measured on the clip the pass was handed. A
|
|
1872
|
-
* correction is allowed to move a limb; what it is not allowed to do is move
|
|
1873
|
-
* it FASTER THAN THE CLIP EVER DOES between two frames it both corrects. So
|
|
1874
|
-
* the cap is the effector's own worst adjacent step over the range, times
|
|
1875
|
-
* STEP_CAP_FACTOR — a limb that already travels 8 cm a frame gets a roomier
|
|
1876
|
-
* allowance than one that barely moves, which is the only ruler that means
|
|
1877
|
-
* the same thing on a crouch and on a sprint.
|
|
1878
|
-
*
|
|
1879
|
-
* Clamped at both ends. STEP_CAP_ABS is the ceiling: a fast clip must not
|
|
1880
|
-
* license a half-metre teleport just because its own hand is quick. The floor
|
|
1881
|
-
* is what keeps a perfectly static clip from setting a cap of zero and
|
|
1882
|
-
* refusing every correction that follows another one.
|
|
1883
|
-
*
|
|
1884
|
-
* The cap governs the step between two CONSECUTIVE CORRECTED frames only.
|
|
1885
|
-
* The first corrected frame of a run has no corrected predecessor and is
|
|
1886
|
-
* uncapped — the correction itself is as big as the collision demands, and
|
|
1887
|
-
* easing that in is the blend window's job, not this one's.
|
|
1888
|
-
*/
|
|
1889
|
-
const stepCap = new Map();
|
|
1890
|
-
for (const id of chainIds) {
|
|
1891
|
-
let worst = 0;
|
|
1892
|
-
for (let index = 1; index < sourceEffectors.length; index += 1) {
|
|
1893
|
-
const previous = sourceEffectors[index - 1].get(id);
|
|
1894
|
-
const current = sourceEffectors[index].get(id);
|
|
1895
|
-
if (previous && current) worst = Math.max(worst, current.distanceTo(previous));
|
|
1896
|
-
}
|
|
1897
|
-
stepCap.set(id, Math.min(STEP_CAP_ABS, Math.max(STEP_CAP_FLOOR, STEP_CAP_FACTOR * worst)));
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
const keyed = [];
|
|
1901
|
-
// Frame → deepest penetration left standing. A map, not a list: a frame the
|
|
1902
|
-
// blend-window round revisits reports its LAST word, and a frame that round
|
|
1903
|
-
// cleans drops off the list rather than being reported twice.
|
|
1904
|
-
const residuals = new Map();
|
|
1905
|
-
const unresolvedList = () => [...residuals.entries()]
|
|
1906
|
-
.sort((a, b) => a[0] - b[0])
|
|
1907
|
-
.map(([frame, residual]) => ({ frame, residual }));
|
|
1908
|
-
const blockersFor = (frame) => (typeof blockersAt === "function" ? blockersAt(frame) : options.blockers);
|
|
1909
|
-
const noteResidual = (frame, residual) => {
|
|
1910
|
-
if (residual > 0) residuals.set(frame, residual);
|
|
1911
|
-
else residuals.delete(frame);
|
|
1912
|
-
};
|
|
1913
|
-
/**
|
|
1914
|
-
* WHY THIS EXISTS. Every frame above is solved on its own, from the clip,
|
|
1915
|
-
* which is what makes each one honest — and what made a RUN of them garbage.
|
|
1916
|
-
* A deep hit has several ways out, the choice between them turns on
|
|
1917
|
-
* millimetres, and QA measured the consequence on the crouch clip: frames
|
|
1918
|
-
* 104..120 each had a 112 mm head × hand hit, each frame found its own escape,
|
|
1919
|
-
* and the hand stepped 22 → 160 mm, 23 → 520 mm, 25 → 519 mm frame to frame
|
|
1920
|
-
* on source poses that barely moved. Arm up, hands to face, arm down across
|
|
1921
|
-
* the body: three frames of flail, every one of them collision-free.
|
|
1922
|
-
*
|
|
1923
|
-
* The cure is to make a run of corrected frames one correction rather than N
|
|
1924
|
-
* unrelated ones, in three pieces that all keep the per-frame guarantees:
|
|
1925
|
-
*
|
|
1926
|
-
* 1. WARM START. When the frame before was corrected, this frame's solve
|
|
1927
|
-
* starts from the pose it would have if it inherited that correction —
|
|
1928
|
-
* the previous frame's delta (baseQ⁻¹ ∘ q, its correction relative to its
|
|
1929
|
-
* OWN clip pose) applied to this frame's pose. If that already reads
|
|
1930
|
-
* clean, it is accepted as it stands: the collision is gone and the
|
|
1931
|
-
* continuity comes for free. If not, the solve carries on from there, so
|
|
1932
|
-
* it starts in the neighbourhood of the answer its neighbour found.
|
|
1933
|
-
* 2. ESCAPE CONSISTENCY. Whatever is solved gets the previous frame's push
|
|
1934
|
-
* direction as a bias (see CONTINUITY_BIAS), so two frames that must both
|
|
1935
|
-
* escape leave the same way.
|
|
1936
|
-
* 3. THE STEP CAP. If the result still moves an effector further from its
|
|
1937
|
-
* previous corrected position than the clip's own motion justifies, the
|
|
1938
|
-
* frame is re-solved with the continuity bias turned up; failing that the
|
|
1939
|
-
* closest collision-free candidate is kept, and failing THAT the frame is
|
|
1940
|
-
* left alone and reported unresolved. A flail is never the answer.
|
|
1941
|
-
*
|
|
1942
|
-
* Everything is keyed exactly as before — this frame's own raw baseQuats,
|
|
1943
|
-
* only the chains that moved — so the blend window, the pins and the delta
|
|
1944
|
-
* bake are untouched.
|
|
1945
|
-
*/
|
|
1946
|
-
/** The frame before this one, when it was corrected: what it did, where it
|
|
1947
|
-
* left each effector, and which way it pushed. Null the moment a frame is
|
|
1948
|
-
* not keyed, so continuity never reaches across a gap. */
|
|
1949
|
-
let carried = null;
|
|
1950
|
-
/** `baseQ⁻¹ ∘ q` per bone — the correction a frame applied to its own clip
|
|
1951
|
-
* pose, which is exactly the delta ikEvaluate replays for its key. */
|
|
1952
|
-
const correctionDeltas = (touched, baseQuats) => {
|
|
1953
|
-
const deltas = new Map();
|
|
1954
|
-
for (const id of touched) {
|
|
1955
|
-
const chain = chains.get(id);
|
|
1956
|
-
const bases = baseQuats?.get(id);
|
|
1957
|
-
if (!chain || !bases) continue;
|
|
1958
|
-
deltas.set(id, chain.bones.map((bone, index) => (bases[index]
|
|
1959
|
-
? bases[index].clone().invert().multiply(bone.quaternion)
|
|
1960
|
-
: new THREE.Quaternion())));
|
|
1961
|
-
}
|
|
1962
|
-
return deltas;
|
|
1963
|
-
};
|
|
1964
|
-
/** Seed `ids` with the previous frame's rotation correction on this frame's
|
|
1965
|
-
* own translations, the same solve space used by the fresh attempts. */
|
|
1966
|
-
const applyWarmStart = (ids, deltas) => {
|
|
1967
|
-
for (const id of ids) {
|
|
1968
|
-
const chain = chains.get(id);
|
|
1969
|
-
const delta = deltas.get(id);
|
|
1970
|
-
if (!chain || !delta) continue;
|
|
1971
|
-
chain.bones.forEach((bone, index) => {
|
|
1972
|
-
if (delta[index]) bone.quaternion.multiply(delta[index]);
|
|
1973
|
-
});
|
|
1974
|
-
}
|
|
1975
|
-
rig.updateMatrixWorld(true);
|
|
1976
|
-
};
|
|
1977
|
-
const lowestFootMarker = () => {
|
|
1978
|
-
let lowest = Infinity;
|
|
1979
|
-
for (const name of FLOOR_MARKERS) {
|
|
1980
|
-
const bone = findBone(rig, name);
|
|
1981
|
-
if (bone) lowest = Math.min(lowest, bone.getWorldPosition(new THREE.Vector3()).y);
|
|
1982
|
-
}
|
|
1983
|
-
return lowest;
|
|
1984
|
-
};
|
|
1985
|
-
const epsilonOf = options.epsilon ?? 1e-4;
|
|
1986
|
-
const floorOf = options.floorY ?? 0;
|
|
1987
|
-
|
|
1988
|
-
for (let frame = start; frame <= end; frame += 1) {
|
|
1989
|
-
const index = frame - start;
|
|
1990
|
-
const blockers = blockersFor(frame);
|
|
1991
|
-
const detectOptions = { offset: options.offset, slackFactor: options.slackFactor, blockers, onlyChains: options.onlyChains };
|
|
1992
|
-
// applyFrame still runs: it poses the bones outside the IK layer (fingers,
|
|
1993
|
-
// toes, spine tips) that the capsules also measure. The restore then
|
|
1994
|
-
// overwrites exactly the bones this pass could have moved.
|
|
1995
|
-
//
|
|
1996
|
-
// Blockers are WORLD-SPACE and posed for this frame: a static array is
|
|
1997
|
-
// the same set at every frame, while `blockersAt(frame)` is asked for
|
|
1998
|
-
// them AFTER applyFrame, so a caller animating a prop reports where it
|
|
1999
|
-
// is on the frame being solved rather than where it started.
|
|
2000
|
-
const restoreFrame = () => {
|
|
2001
|
-
applyFrame(frame);
|
|
2002
|
-
restoreBones(rig, bones, basePose[index]);
|
|
2003
|
-
};
|
|
2004
|
-
restoreFrame();
|
|
2005
|
-
|
|
2006
|
-
// The frame's OWN reading, before anything is seeded into it: the cast the
|
|
2007
|
-
// provenance gate may move, the depth any candidate has to beat, and the
|
|
2008
|
-
// floor no candidate may go under.
|
|
2009
|
-
let warmChains = null;
|
|
2010
|
-
let provenance = null;
|
|
2011
|
-
let baseWorst = 0;
|
|
2012
|
-
const baseEffectors = readEffectors();
|
|
2013
|
-
const baseFoot = lowestFootMarker();
|
|
2014
|
-
if (carried?.frame === frame - 1) {
|
|
2015
|
-
const capsules = buildCollisionCapsules(rig, options.radii ?? null);
|
|
2016
|
-
const pens = fixablePenetrations(capsules, detectOptions);
|
|
2017
|
-
baseWorst = pens.length ? pens[0].depth : 0;
|
|
2018
|
-
provenance = chainsInPlay(pens);
|
|
2019
|
-
// Only a chain THIS frame's own pose puts in play may be warm started:
|
|
2020
|
-
// inheriting a correction onto a limb with nothing wrong with it would
|
|
2021
|
-
// smear the fix across frames that never needed one.
|
|
2022
|
-
warmChains = [...provenance].filter((id) => carried.deltas.has(id));
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
/** One attempt at this frame, from the pose as recorded, optionally warm
|
|
2026
|
-
* started and with the continuity bias at `weight`. Returns everything the
|
|
2027
|
-
* chooser needs and leaves nothing behind: the pose is snapshotted, and
|
|
2028
|
-
* the next attempt restores the frame before it starts. */
|
|
2029
|
-
const attempt = ({ warm, weight }) => {
|
|
2030
|
-
restoreFrame();
|
|
2031
|
-
if (warm?.length) applyWarmStart(warm, carried.deltas);
|
|
2032
|
-
const continuity = weight > 0 && carried ? { dirs: carried.dirs, weight } : null;
|
|
2033
|
-
// A warm start that lands clean IS the answer: the collision is gone
|
|
2034
|
-
// and the pose is the neighbour's, which is the whole point. It still
|
|
2035
|
-
// has to clear the floor, the one guarantee a solve would have checked
|
|
2036
|
-
// and a straight inheritance would not.
|
|
2037
|
-
if (warm?.length) {
|
|
2038
|
-
const capsules = buildCollisionCapsules(rig, options.radii ?? null);
|
|
2039
|
-
if (!capsules) return { supported: false };
|
|
2040
|
-
if (fixablePenetrations(capsules, detectOptions).length === 0) {
|
|
2041
|
-
const foot = lowestFootMarker();
|
|
2042
|
-
if (!(Number.isFinite(foot) && foot < Math.min(baseFoot, floorOf + PUSH_FLOOR_CLEARANCE) - 1e-6)) {
|
|
2043
|
-
return {
|
|
2044
|
-
supported: true, keyed: true, warm: true,
|
|
2045
|
-
touched: [...warm], residual: 0,
|
|
2046
|
-
pose: snapshotBones(bones), effectors: readEffectors(),
|
|
2047
|
-
};
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
const result = fixCollisions(rig, chains, {
|
|
2052
|
-
...options,
|
|
2053
|
-
blockers,
|
|
2054
|
-
fkJoints,
|
|
2055
|
-
// The chains a DISCARDED attempt drove must not be left tracked, so
|
|
2056
|
-
// the bake below — which touches every id it keys — is what marks
|
|
2057
|
-
// the layer, not the attempts.
|
|
2058
|
-
ikState: null,
|
|
2059
|
-
baseQuats: clipQuats[index],
|
|
2060
|
-
continuity,
|
|
2061
|
-
provenanceChains: warm?.length ? provenance : null,
|
|
2062
|
-
});
|
|
2063
|
-
if (!result.supported) return { supported: false };
|
|
2064
|
-
// A warm attempt keys every chain it seeded, whether the solve moved it
|
|
2065
|
-
// again or not: the pose being measured is the seeded one, and a chain
|
|
2066
|
-
// left out of the key would be evaluated back on the clip.
|
|
2067
|
-
const touched = new Set(result.touched);
|
|
2068
|
-
if (result.changed && warm?.length) for (const id of warm) touched.add(id);
|
|
2069
|
-
return {
|
|
2070
|
-
supported: true,
|
|
2071
|
-
keyed: result.changed && touched.size > 0,
|
|
2072
|
-
warm: Boolean(warm?.length),
|
|
2073
|
-
touched: [...touched],
|
|
2074
|
-
residual: result.residual,
|
|
2075
|
-
pose: snapshotBones(bones),
|
|
2076
|
-
effectors: readEffectors(),
|
|
2077
|
-
};
|
|
2078
|
-
};
|
|
2079
|
-
|
|
2080
|
-
/** How far this attempt moves an effector away from where the previous
|
|
2081
|
-
* corrected frame left it, as a FRACTION of that chain's cap — counted
|
|
2082
|
-
* only on chains BOTH frames correct, because a chain the previous frame
|
|
2083
|
-
* corrected and this one does not is the blend window's business, not the
|
|
2084
|
-
* cap's. Over 1 is a jump; below it the frames read as one motion. The
|
|
2085
|
-
* ratio (not the raw millimetres) is also what ranks two candidates, so a
|
|
2086
|
-
* hand and a foot are compared on the same scale. */
|
|
2087
|
-
const stepRatio = (candidate) => {
|
|
2088
|
-
let worst = 0;
|
|
2089
|
-
if (!carried) return worst;
|
|
2090
|
-
for (const id of candidate.touched) {
|
|
2091
|
-
const before = carried.effectors.get(id);
|
|
2092
|
-
const after = candidate.effectors?.get(id);
|
|
2093
|
-
if (!before || !after) continue;
|
|
2094
|
-
worst = Math.max(worst, after.distanceTo(before) / (stepCap.get(id) ?? STEP_CAP_ABS));
|
|
2095
|
-
}
|
|
2096
|
-
return worst;
|
|
2097
|
-
};
|
|
2098
|
-
const overCap = (candidate) => stepRatio(candidate) > 1;
|
|
2099
|
-
// A warm attempt may not come out WORSE than the frame's own pose: the
|
|
2100
|
-
// seeded pose is not this frame's, so fixCollisions's accept gate — which
|
|
2101
|
-
// judges against the pose it was handed — cannot see that comparison.
|
|
2102
|
-
const usable = (candidate) => candidate?.keyed
|
|
2103
|
-
&& (!candidate.warm || candidate.residual <= baseWorst + 1e-9);
|
|
2104
|
-
|
|
2105
|
-
const tries = [];
|
|
2106
|
-
if (warmChains?.length) tries.push(attempt({ warm: warmChains, weight: CONTINUITY_BIAS }));
|
|
2107
|
-
if (tries[0]?.supported === false) { keyed.unresolved = unresolvedList(); return keyed; }
|
|
2108
|
-
const plain = attempt({ warm: null, weight: carried ? CONTINUITY_BIAS : 0 });
|
|
2109
|
-
// An unsupported rig is unsupported at every frame: bail on the first
|
|
2110
|
-
// one instead of re-posing the whole clip to learn the same thing.
|
|
2111
|
-
if (!plain.supported) { keyed.unresolved = unresolvedList(); return keyed; }
|
|
2112
|
-
tries.push(plain);
|
|
2113
|
-
|
|
2114
|
-
// Warm first, then plain: the inherited answer wins ties, which is what
|
|
2115
|
-
// makes a run of frames one correction.
|
|
2116
|
-
let chosen = tries.find((candidate) => usable(candidate) && !overCap(candidate)) ?? null;
|
|
2117
|
-
if (!chosen && tries.some(usable)) {
|
|
2118
|
-
// Every candidate jumped. One more go with the previous frame's
|
|
2119
|
-
// direction outvoting the rest bias...
|
|
2120
|
-
const stronger = attempt({ warm: warmChains?.length ? warmChains : null, weight: CONTINUITY_BIAS_STRONG });
|
|
2121
|
-
if (stronger.supported === false) { keyed.unresolved = unresolvedList(); return keyed; }
|
|
2122
|
-
if (usable(stronger) && !overCap(stronger)) chosen = stronger;
|
|
2123
|
-
else {
|
|
2124
|
-
// ...and failing that, the closest of the candidates that actually
|
|
2125
|
-
// clears the collision. A frame with no collision-free candidate is
|
|
2126
|
-
// left as the clip had it and reported, because a flail that happens
|
|
2127
|
-
// to be collision-free is not a fix.
|
|
2128
|
-
const free = [...tries, stronger]
|
|
2129
|
-
.filter((candidate) => usable(candidate) && candidate.residual <= epsilonOf)
|
|
2130
|
-
.sort((a, b) => stepRatio(a) - stepRatio(b));
|
|
2131
|
-
chosen = free[0] ?? null;
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
if (!chosen) {
|
|
2136
|
-
// Nothing to key: either the frame was clean (residual 0, and every
|
|
2137
|
-
// candidate reports it) or every candidate was refused. Report whichever
|
|
2138
|
-
// depth is left standing on the pose the viewer will see.
|
|
2139
|
-
restoreFrame();
|
|
2140
|
-
noteResidual(frame, Math.max(baseWorst, plain.keyed ? 0 : plain.residual));
|
|
2141
|
-
carried = null;
|
|
2142
|
-
continue;
|
|
2143
|
-
}
|
|
2144
|
-
restoreBones(rig, bones, chosen.pose);
|
|
2145
|
-
noteResidual(frame, chosen.residual);
|
|
2146
|
-
// Only the chains THIS frame moved — the pass must not re-key an
|
|
2147
|
-
// unrelated limb at the blended value it happens to be showing here —
|
|
2148
|
-
// and each one as a delta from the clip's own rotations at this frame.
|
|
2149
|
-
ikBakeKeyframe(chains, ikState, frame, fkJoints, chosen.touched, null, clipQuats[index]);
|
|
2150
|
-
keyed.push(frame);
|
|
2151
|
-
const dirs = new Map();
|
|
2152
|
-
for (const id of chosen.touched) {
|
|
2153
|
-
const before = baseEffectors.get(id);
|
|
2154
|
-
const after = chosen.effectors.get(id);
|
|
2155
|
-
if (!before || !after) continue;
|
|
2156
|
-
const dir = after.clone().sub(before);
|
|
2157
|
-
if (dir.lengthSq() > 1e-8) dirs.set(id, dir.normalize());
|
|
2158
|
-
}
|
|
2159
|
-
carried = {
|
|
2160
|
-
frame,
|
|
2161
|
-
deltas: correctionDeltas(chosen.touched, clipQuats[index]),
|
|
2162
|
-
effectors: new Map([...chosen.effectors].filter(([id]) => chosen.touched.includes(id))),
|
|
2163
|
-
dirs,
|
|
2164
|
-
};
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
|
-
/**
|
|
2168
|
-
* Sweeps 1 and 2 judge every frame against the CLIP, which is what stops the
|
|
2169
|
-
* pass chasing its own tail. The cost is that the ramp each new key runs out
|
|
2170
|
-
* over is never looked at, and a big correction drags its neighbours into
|
|
2171
|
-
* contact on the way: QA's crouch had frame 103 clean before the pass and
|
|
2172
|
-
* 28.9 mm of forearm × thumb after it, deterministically, with no frame left
|
|
2173
|
-
* to notice.
|
|
2174
|
-
*
|
|
2175
|
-
* So one bounded round, and only for frames a key can actually reach: pose
|
|
2176
|
-
* the frame WITH the layer (the pose the viewer will see), and if that pose
|
|
2177
|
-
* penetrates, fix and key it from there. The key is still stored as a delta
|
|
2178
|
-
* from the RAW CLIP rotations sweep 1 recorded, so it composes with the ramp
|
|
2179
|
-
* the same way every other key does.
|
|
2180
|
-
*
|
|
2181
|
-
* ONE ROUND. Keying these frames moves the ramp again, and chasing that would
|
|
2182
|
-
* not terminate on a clip whose corrections overlap. A frame this round
|
|
2183
|
-
* leaves dirty is reported in `unresolved` instead, which is the honest
|
|
2184
|
-
* answer and one the caller can say out loud.
|
|
2185
|
-
*/
|
|
2186
|
-
if (keyed.length && blendWindow > 0) {
|
|
2187
|
-
const keyedSet = new Set(keyed);
|
|
2188
|
-
const candidates = [];
|
|
2189
|
-
for (const frame of keyed) {
|
|
2190
|
-
for (let offset = -blendWindow; offset <= blendWindow; offset += 1) {
|
|
2191
|
-
const near = frame + offset;
|
|
2192
|
-
if (near < start || near > end || keyedSet.has(near)) continue;
|
|
2193
|
-
if (!candidates.includes(near)) candidates.push(near);
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
|
-
candidates.sort((a, b) => a - b);
|
|
2197
|
-
for (const frame of candidates) {
|
|
2198
|
-
applyFrame(frame); // the BLENDED pose, keys included — no restore here
|
|
2199
|
-
const result = fixCollisions(rig, chains, {
|
|
2200
|
-
...options,
|
|
2201
|
-
blockers: blockersFor(frame),
|
|
2202
|
-
fkJoints,
|
|
2203
|
-
ikState,
|
|
2204
|
-
baseQuats: clipQuats[frame - start],
|
|
2205
|
-
});
|
|
2206
|
-
if (!result.supported) break;
|
|
2207
|
-
noteResidual(frame, result.residual);
|
|
2208
|
-
if (!result.changed || !result.touched.length) continue;
|
|
2209
|
-
ikBakeKeyframe(chains, ikState, frame, fkJoints, result.touched, null, result.baseQuats);
|
|
2210
|
-
keyed.push(frame);
|
|
2211
|
-
}
|
|
2212
|
-
keyed.sort((a, b) => a - b);
|
|
2213
|
-
}
|
|
2214
|
-
|
|
2215
|
-
/**
|
|
2216
|
-
* Everything above judges a frame at the moment it is solved. What the viewer
|
|
2217
|
-
* gets is the EVALUATED pose — clip plus every key's blend ramp — and the last
|
|
2218
|
-
* key written can put a frame back into contact after that frame was already
|
|
2219
|
-
* measured and passed. QA's crouch ended with frame 104 at 13.5 mm, created by
|
|
2220
|
-
* a neighbouring key's ramp, and the toast said nothing at all.
|
|
2221
|
-
*
|
|
2222
|
-
* So one last read-only walk over every frame a key can reach: pose it with
|
|
2223
|
-
* the layer, measure, and let that measurement be the final word. Nothing is
|
|
2224
|
-
* written here — a frame this finds dirty is REPORTED, because writing would
|
|
2225
|
-
* move the ramps again and there would be no frame left to check that.
|
|
2226
|
-
*/
|
|
2227
|
-
if (keyed.length) {
|
|
2228
|
-
const reach = new Set();
|
|
2229
|
-
for (const frame of keyed) {
|
|
2230
|
-
for (let offset = -blendWindow; offset <= blendWindow; offset += 1) {
|
|
2231
|
-
const near = frame + offset;
|
|
2232
|
-
if (near >= start && near <= end) reach.add(near);
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
for (const frame of [...reach].sort((a, b) => a - b)) {
|
|
2236
|
-
applyFrame(frame);
|
|
2237
|
-
const capsules = buildCollisionCapsules(rig, options.radii ?? null);
|
|
2238
|
-
const pens = (capsules
|
|
2239
|
-
? detectPenetrations(capsules, {
|
|
2240
|
-
offset: options.offset,
|
|
2241
|
-
slackFactor: options.slackFactor,
|
|
2242
|
-
blockers: blockersFor(frame),
|
|
2243
|
-
})
|
|
2244
|
-
: []).filter((pen) => !isHingeFold(pen));
|
|
2245
|
-
// The final word replaces whatever the solve-time measurement said,
|
|
2246
|
-
// in both directions: a frame a later ramp dirtied is added, and one
|
|
2247
|
-
// a later key cleaned drops off.
|
|
2248
|
-
noteResidual(frame, pens.length ? pens[0].depth : 0);
|
|
2249
|
-
}
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
// Frames the passes could not clear, deepest remaining penetration and all,
|
|
2253
|
-
// so the caller can say "some frames still touch" instead of implying a clean
|
|
2254
|
-
// clip. Rides on the returned array so every existing caller keeps working.
|
|
2255
|
-
keyed.unresolved = unresolvedList();
|
|
2256
|
-
return keyed;
|
|
2257
|
-
}
|