cozyclay 3.1.6 → 3.2.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/agent/agent-runner.mjs +9 -4
  3. package/bin/agent/openai-compatible-provider.mjs +24 -1
  4. package/dist/app/index.html +5 -5
  5. package/dist/assets/TaskBoard-BUjjuvm5.js +1 -0
  6. package/dist/assets/{app-CEP7jzXB.css → app-Dige7q3X.css} +1 -1
  7. package/dist/assets/app-DxoI941o.js +5144 -0
  8. package/dist/assets/compile-worker-DRWRa9pr.js +6 -0
  9. package/dist/assets/{motion3-compile-Du7UXrWn.js → motion3-compile-B4SX4TGx.js} +1 -1
  10. package/dist/assets/scene-asset-sync-LeI3eSnO.js +13 -0
  11. package/dist/assets/task-board-5PKn7ZaA.js +15 -0
  12. package/dist/assets/{workflow-DJwOUzqJ.js → workflow-Dg9jCKOq.js} +3 -3
  13. package/dist/cozyclay-package.json +1 -1
  14. package/dist/index.html +1 -1
  15. package/dist/workflow/index.html +3 -3
  16. package/package.json +2 -1
  17. package/src/App.jsx +5 -5
  18. package/src/commands/index.js +2 -1
  19. package/src/commands/physics.js +21 -0
  20. package/src/domains/motion.js +7 -9
  21. package/src/motion/center-of-mass.js +5 -23
  22. package/src/motion/contact-project.js +143 -0
  23. package/src/motion/fix-collisions.js +1 -1
  24. package/src/motion/ik-drag.js +93 -1
  25. package/src/motion/timeline.css +12 -0
  26. package/src/motion/timeline.jsx +2 -2
  27. package/src/motion3/compile-job.js +3 -2
  28. package/src/motion3/compiler/compile.js +3 -3
  29. package/src/motion3/compiler/spring.js +95 -0
  30. package/src/motion3/compiler/validate-objects.js +18 -2
  31. package/src/motion3/compiler/validate-parts.js +1 -0
  32. package/src/motion3/compiler/vocab.js +1 -1
  33. package/src/motion3/fix/index.js +8 -4
  34. package/src/motion3/fix/person.js +110 -0
  35. package/src/motion3/free-motion.js +49 -11
  36. package/src/motion3/object-pose.js +44 -2
  37. package/src/motion3/people.js +16 -1
  38. package/src/motion3/physics-frames.js +72 -0
  39. package/src/motion3/physics-trail.js +45 -0
  40. package/src/motion3/physics-view.js +79 -0
  41. package/src/motion3/pipeline.js +5 -1
  42. package/src/motion3/qa/ballistic.js +67 -0
  43. package/src/motion3/qa/com.js +140 -0
  44. package/src/motion3/qa/ground.js +10 -3
  45. package/src/motion3/qa/hints-contact.js +9 -7
  46. package/src/motion3/qa/hints-motion.js +38 -1
  47. package/src/motion3/qa/key-checks.js +5 -4
  48. package/src/motion3/qa/patch.js +28 -3
  49. package/src/motion3/qa/people-checks.js +1 -1
  50. package/src/motion3/qa/spring-pivot.js +23 -0
  51. package/src/motion3/qa/style.js +13 -30
  52. package/src/motion3/schema/fields.js +4 -2
  53. package/src/motion3/schema/topics.js +15 -11
  54. package/src/motion3/schema/types.js +13 -9
  55. package/src/physics-overlay-settings.js +41 -0
  56. package/src/physics-overlay.js +152 -0
  57. package/src/physics-overlay.jsx +54 -0
  58. package/src/physics-toggle.jsx +20 -0
  59. package/src/posestudio.jsx +1 -1
  60. package/tools/run-tests.mjs +9 -16
  61. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  62. package/dist/assets/app-BLS1Zaru.js +0 -5144
  63. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  64. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  65. package/dist/assets/task-board-CkLZGPkv.js +0 -15
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.6\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"d7b44cfa152ddb449c4e1445adeb8e3ae0a176e8eab6d431c7d9103ee12c25fc\"}","signature":"ruWnt+cS82Ajp7uv+w9ZiHLcqY5lCSNKER2sVtiLV+jnhGAzpPw1/KuZazg9lwFucoycs99uTwY8s+e5A3WQDw=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.2.0\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"6b804a847783301703e2a3b5f6b7df24949f4ec67afe0e0757342f15a57730b2\"}","signature":"kb5TnT8Rn5B5fOkGXZqgfujX5ADiLTS6LDq+SMZbBAxzeZWYmFC0ogOVS1BFyRC/hjsHI7M2YrM9i/wcSTZ7Cw=="}
package/dist/index.html CHANGED
@@ -46,7 +46,7 @@
46
46
  "applicationCategory": "DesignApplication",
47
47
  "applicationSubCategory": "3D previsualization",
48
48
  "operatingSystem": "Any (Chromium-based browser)",
49
- "softwareVersion": "3.1.6",
49
+ "softwareVersion": "3.2.0",
50
50
  "description": "CozyClay is an open-source, browser-based 3D staging studio. Block a scene, pose characters, sequence motion prompts on a timeline, and preview generated motion in one local workspace that handles like the Unity Editor.",
51
51
  "image": "https://cozyclay.org/media/cozyclay-truck-demo-poster.jpg",
52
52
  "screenshot": "https://cozyclay.org/media/cozyclay-truck-demo-poster.jpg",
@@ -8,11 +8,11 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-DJwOUzqJ.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-Dg9jCKOq.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
- <link rel="modulepreload" crossorigin href="/assets/task-board-CkLZGPkv.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/task-board-5PKn7ZaA.js">
14
14
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
15
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-t45pWCyq.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-LeI3eSnO.js">
16
16
  <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-BEjjhNEo.css">
17
17
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
18
18
  </head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.1.6",
3
+ "version": "3.2.0",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
@@ -75,6 +75,7 @@
75
75
  "eslint": "^10.12.0",
76
76
  "eslint-plugin-react-hooks": "^7.1.1",
77
77
  "globals": "^17.13.0",
78
+ "jpeg-js": "0.4.4",
78
79
  "knip": "^6.41.0",
79
80
  "mediabunny": "^1.55.2",
80
81
  "posthog-js": "^1.418.12",
package/src/App.jsx CHANGED
@@ -140,7 +140,7 @@ import {
140
140
  pathMetrics,
141
141
  strokeToPathPoints,
142
142
  } from "./object-path.js";
143
- import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx"; import { SculptSketch } from "./sculpt-sketch.jsx";
143
+ import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx"; import { SculptSketch } from "./sculpt-sketch.jsx"; import { PhysicsOverlayLayer } from "./physics-overlay.jsx"; import { syncPhysicsOverlays } from "./physics-overlay.js";
144
144
  import { routeOwnerFor } from "./route-owner.js";
145
145
  import {
146
146
  exportFailureCode,
@@ -2874,7 +2874,7 @@ export default function App() {
2874
2874
  // pass is the recorder's stand-in for the useFrame the preview gets.
2875
2875
  // Riding characters move with their object first, then the props.
2876
2876
  syncCarriers();
2877
- propSyncRef.current?.();
2877
+ propSyncRef.current?.(); syncPhysicsOverlays(frame);
2878
2878
  const sampled = sampleAt(context?.playbackScene ?? playbackScene, shotAtFrame(context?.shots ?? shots, frame), frame);
2879
2879
  const framing = sampled.camera ?? context?.framing;
2880
2880
  const cam = shotCamRef.current;
@@ -2959,7 +2959,7 @@ export default function App() {
2959
2959
  look.current.pitch = cameraSnapshot.pitch;
2960
2960
  propFrameRef.current = propFrame;
2961
2961
  syncCarriers();
2962
- propSyncRef.current?.();
2962
+ propSyncRef.current?.(); syncPhysicsOverlays();
2963
2963
  }
2964
2964
  }
2965
2965
 
@@ -3657,7 +3657,7 @@ export default function App() {
3657
3657
  const point = new THREE.Vector3(); bone.getWorldPosition(point);
3658
3658
  return point.toArray();
3659
3659
  },
3660
- committedIkEdits, waypoints,
3660
+ committedIkEdits, waypoints, rigs, characters, // rigs/characters: every cast member (QA reads a second rig)
3661
3661
  // Scene props as the stage sees them, for QA that places a box and
3662
3662
  // waits for its transform to land before fitting to it.
3663
3663
  objects: sceneObjects,
@@ -4762,7 +4762,7 @@ export default function App() {
4762
4762
  />
4763
4763
  </ObjectCarrier>
4764
4764
  ))}
4765
- <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} /><SculptSketch objects={sceneObjects} />
4765
+ <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} /><SculptSketch objects={sceneObjects} /><PhysicsOverlayLayer rig={rigs[activeChar.id]} take={motion} objects={sceneObjects} />
4766
4766
 
4767
4767
  {/* Selection marker: XYZ tripod + ring on the picked cast
4768
4768
  member; the X/Z arrows drag it across the deck. */}
@@ -17,8 +17,9 @@ import * as exporting from "./export.js";
17
17
  import * as ai from "./ai.js";
18
18
  import * as stage from "./stage.js";
19
19
  import * as kits from "./kits.js";
20
+ import * as physics from "./physics.js";
20
21
 
21
- export const COMMAND_MODULES = Object.freeze({ shot, cast, motion, motion3, objects, kits, view, scene, project, export: exporting, ai, stage });
22
+ export const COMMAND_MODULES = Object.freeze({ shot, cast, motion, motion3, physics, objects, kits, view, scene, project, export: exporting, ai, stage });
22
23
 
23
24
  export function commandDeclarations(modules = COMMAND_MODULES) {
24
25
  return Object.values(modules).flatMap(module => module.declarations.map(declaration => ({
@@ -0,0 +1,21 @@
1
+ // motion.physicsOverlay: the physics overlay's viewer setting (physics-overlay-settings.js), the agent's door to the
2
+ // motion controls' Physics box. Transient: never saved, never undone.
3
+ import { describePhysicsOverlay, setPhysicsOverlay } from "../physics-overlay-settings.js";
4
+
5
+ const flag = description => ({ type: "boolean", description });
6
+
7
+ export const declarations = Object.freeze([
8
+ { id: "motion.physicsOverlay", label: "Physics overlay", kind: "transient",
9
+ input: { type: "object", required: [], additionalProperties: false, properties: {
10
+ com: flag("the centre of mass: a 4 cm ball with a line down to the ground, green inside the feet, red past the balance limit (what QA calls BALANCE), grey off the ground"),
11
+ support: flag("the support polygon: the outline of the soles on the ground"),
12
+ arc: flag("per air run: the hips' path (white, dashed) and the gravity parabola through the same takeoff and landing (orange); far apart is a FLOAT"),
13
+ trail: flag("the last 24 frames of each spring object's tip"),
14
+ all: flag("every part at once, before the named ones; { all: false } turns the overlay off"),
15
+ } },
16
+ description: "Draw the physics QA measures over the active character's installed take, in the editor views and in captures, contact sheets and exports (capture_frame, verify_result frames, export.shotVideo) until it is turned off: com, support, arc and trail (see each field). Updated every frame, the same in scrub, playback and export. No field turns every part on; { all: false } turns it off. Turn it on before a screenshot to see why QA reports BALANCE or FLOAT. A viewer setting: off by default, never saved or undone." },
17
+ ]);
18
+
19
+ export function register(registry) {
20
+ registry.register({ ...declarations[0], available: () => true, run: args => ({ affectedIds: [], summary: describePhysicsOverlay(setPhysicsOverlay(args)) }) });
21
+ }
@@ -12,10 +12,7 @@ import {
12
12
  ikKeyframes,
13
13
  ikRemoveKeyframe,
14
14
  ikSeedTargets,
15
- clampIkTargetToFloor,
16
- solveIk,
17
15
  MID_TRACKS,
18
- solveMidJoint,
19
16
  solveEffectorSwing,
20
17
  ikPlantFeet,
21
18
  solveHipsTranslateToFloor,
@@ -35,7 +32,7 @@ import { ko } from "../locale.js";
35
32
  import { isProxyFigure } from "../scenes.js";
36
33
  import { refuseRigOnly } from "../character-kind.js";
37
34
  import { DEFAULT_POSE, restoreBindPositions, applyPose, applyHipsOffset } from "../poses.js";
38
- import { bakeIkDragKey, bakeIkDragRange, chainsChangedBy, ikDragRecord, ikDragTouch } from "../motion/ik-drag.js";
35
+ import { bakeIkDragKey, bakeIkDragRange, chainsChangedBy, clampDragTarget, ikDragRecord, ikDragTouch, solveDragTarget } from "../motion/ik-drag.js";
39
36
  import { ikKeyJson, ikTrackKeyFromJson } from "../motion/ik-key-json.js";
40
37
  import { chooseIkEntryPose } from "../ik-camera.js";
41
38
  import { applyRangePin, captureRangePinTarget, normalizeRangePin, rangePinTargetWorld, rangePinTracks, removeRangePinKeys } from "../motion/range-pin.js";
@@ -741,7 +738,7 @@ export function useMotion(appContext) {
741
738
  // chain toward the target; mid joints reposition the elbow/knee with both
742
739
  // ends pinned (the handle snaps to the clamped position); FK joints swing
743
740
  // toward the pointer. The solved pose stays a preview until Apply.
744
- function ikSolve(kind, trackId, targetWorld) {
741
+ function ikSolve(kind, trackId, targetWorld, input = null) {
745
742
  if (ikDragRef.current && ikDragRef.current.characterId !== appContext.shared.activeChar.id) return;
746
743
  const state = appContext.shared.ikStateRef.current;
747
744
  const ikChains = state.chains;
@@ -762,14 +759,15 @@ export function useMotion(appContext) {
762
759
  ikDragRef.current = { frame: appContext.shared.tlFrame, ids: new Set(pending?.ids ?? []) };
763
760
  }
764
761
  ikDragRef.current = Object.assign(ikDragRecord(ikDragRef.current, appContext.shared.tlFrame), { characterId: appContext.shared.activeChar.id });
765
- const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id);
762
+ const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id), activeId = appContext.shared.activeChar.id;
763
+ const clamp = () => clampDragTarget({ kind, trackId, targetWorld, bodyContact, chains: ikChains, record: ikDragRef.current, bypass: input?.bypass, open: () => ({ rigs: appContext.shared.rigs, activeId, cast: appContext.shared.characters, allow: domain.layer(activeId).take?.source?.spec?.checks?.allow, notify: appContext.notify }) });
766
764
  if (kind === "chain") {
767
765
  const chain = appContext.shared.ikStateRef.current.chains?.get(trackId);
768
766
  if (!chain) return;
769
767
  touch(trackId);
770
- const clampedTarget = bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, ikChains?.get(trackId)?.contactHeights ?? ikChains?.values().next().value?.contactHeights) : targetWorld;
768
+ const clampedTarget = clamp();
771
769
  appContext.shared.ikStateRef.current.targets.set(trackId, clampedTarget.clone());
772
- solveIk(chain, clampedTarget);
770
+ solveDragTarget(kind, chain, clampedTarget);
773
771
  return;
774
772
  }
775
773
  if (kind === "mid") {
@@ -778,7 +776,7 @@ export function useMotion(appContext) {
778
776
  const chain = midDef ? appContext.shared.ikStateRef.current.chains?.get(midDef.chain) : null;
779
777
  if (!chain) return;
780
778
  touch(chain.track.id);
781
- solveMidJoint(chain, bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, chain.contactHeights) : targetWorld);
779
+ solveDragTarget(kind, chain, clamp());
782
780
  return;
783
781
  }
784
782
  // Effector swing: the rotation ring on a focused hand/foot. Rotates
@@ -1,20 +1,14 @@
1
1
  import * as THREE from "three";
2
2
  import { findBone } from "./ik.js";
3
+ import { SEGMENT_FRACTIONS } from "../motion3/qa/com.js";
3
4
 
4
5
  // Body segment masses and the centre of mass of a posed Mixamo rig. Read-only:
5
6
  // motion verification and the support diagnostics measure with it.
6
7
 
7
8
  /**
8
- * Segment mass fractions of total body mass, from Dempster's cadaver study
9
- * (Dempster, W.T., "Space Requirements of the Seated Operator", WADC TR
10
- * 55-159, 1955), in the tabulation popularised by Winter, "Biomechanics and
11
- * Motor Control of Human Movement". The trunk (0.497) is split into pelvis
12
- * (0.142) and thorax+abdomen (0.355); distal pairs are merged because their
13
- * combined centroid still lands near the segment midpoint and Mixamo's hand
14
- * and toe bones are too short to place reliably:
15
- *
16
- * head+neck 0.081 · trunk 0.497 · upper arm 0.028 · forearm 0.016 +
17
- * hand 0.006 = 0.022 · thigh 0.100 · shank 0.0465 + foot 0.0145 = 0.061
9
+ * Segment mass fractions of total body mass (Dempster, via Winter), on Mixamo
10
+ * bone names. The table itself lives with motion3 QA (motion3/qa/com.js
11
+ * SEGMENT_FRACTIONS), which measures balance on the same segments.
18
12
  *
19
13
  * Each entry names the two Mixamo bones whose MIDPOINT carries the fraction.
20
14
  * Dempster's true centres of mass sit ~43% down each segment rather than at
@@ -24,19 +18,7 @@ import { findBone } from "./ik.js";
24
18
  *
25
19
  * The fractions sum to 1, so the weighted average below is a true CoM.
26
20
  */
27
- export const SEGMENT_MASSES = [
28
- { id: "pelvis", start: "mixamorigHips", end: "mixamorigSpine1", mass: 0.142 },
29
- { id: "torso", start: "mixamorigSpine1", end: "mixamorigNeck", mass: 0.355 },
30
- { id: "head", start: "mixamorigNeck", end: "mixamorigHead", mass: 0.081 },
31
- { id: "leftUpperArm", start: "mixamorigLeftArm", end: "mixamorigLeftForeArm", mass: 0.028 },
32
- { id: "leftForeArmHand", start: "mixamorigLeftForeArm", end: "mixamorigLeftHand", mass: 0.022 },
33
- { id: "rightUpperArm", start: "mixamorigRightArm", end: "mixamorigRightForeArm", mass: 0.028 },
34
- { id: "rightForeArmHand", start: "mixamorigRightForeArm", end: "mixamorigRightHand", mass: 0.022 },
35
- { id: "leftThigh", start: "mixamorigLeftUpLeg", end: "mixamorigLeftLeg", mass: 0.100 },
36
- { id: "leftShankFoot", start: "mixamorigLeftLeg", end: "mixamorigLeftFoot", mass: 0.061 },
37
- { id: "rightThigh", start: "mixamorigRightUpLeg", end: "mixamorigRightLeg", mass: 0.100 },
38
- { id: "rightShankFoot", start: "mixamorigRightLeg", end: "mixamorigRightFoot", mass: 0.061 },
39
- ];
21
+ export const SEGMENT_MASSES = SEGMENT_FRACTIONS.map(({ id, start, end, mass }) => ({ id, start: `mixamorig${start}`, end: `mixamorig${end}`, mass }));
40
22
 
41
23
  /**
42
24
  * World-space centre of mass of the rig's CURRENT pose: the mass-weighted
@@ -0,0 +1,143 @@
1
+ import * as THREE from "three";
2
+ import { closestPointsSegmentSegment } from "./fix-collisions.js";
3
+
4
+ /**
5
+ * Contact projection for a Studio IK drag: the dragged target is moved out of
6
+ * the OTHER characters' capsules before the chain solves toward it, so a hand
7
+ * pushed into someone's chest stops on the chest instead of going through.
8
+ *
9
+ * One-sided by design: the blockers never move (the other character is never
10
+ * pushed; what they do is their own take). Fixed pass count, fixed order
11
+ * (probe capsules in the order given, blockers in the order given, the first
12
+ * deepest pair wins a tie), no clock, no randomness: the same target, the same
13
+ * blockers and the same `lastGood` give the same result.
14
+ *
15
+ * KEEP THE SIDE. The plain escape normal (probe point minus blocker point)
16
+ * flips once a deep target crosses the blocker's axis and then pushes the hand
17
+ * out of the BACK of the body. The escape keeps the side the hand came from:
18
+ * when the normal points away from `lastGood` (the last clean target of this
19
+ * drag), the push goes toward `lastGood` instead. Of the escape rules tried in
20
+ * the research prototype this was the only one that never tunnelled.
21
+ */
22
+
23
+ const EPS = 1e-9;
24
+
25
+ /** Push distance along unit `dir` that takes point `w` (relative to the
26
+ * blocker point) to distance `reach` from it: the far root of |w + t dir| = reach. */
27
+ function exitDistance(w, dir, reach) {
28
+ const along = w.dot(dir);
29
+ const inside = w.lengthSq() - reach * reach;
30
+ return -along + Math.sqrt(Math.max(0, along * along - inside));
31
+ }
32
+
33
+ /**
34
+ * @param {object} options
35
+ * @param {Array<{id:string,a:THREE.Vector3,b:THREE.Vector3,radius:number}>} options.blockers
36
+ * the other characters' capsules, built once per drag; never moved
37
+ * @param {number|((probeId:string, blockerId:string) => number|null)} [options.allow]
38
+ * depth (m) a probe may go into a blocker: a number for every pair, or a
39
+ * lookup (null = 0) for an intended touch (spec.checks.allow person entries)
40
+ * @param {number} [options.margin] how far outside the surface a pushed target lands
41
+ * @param {number} [options.passes] fixed number of probe/push passes
42
+ * @param {number} [options.maxSteps] cap on the sweep's substeps per event
43
+ */
44
+ export function createContactProjector({ blockers = [], allow = 0, margin = 0.004, passes = 4, maxSteps = 32 } = {}) {
45
+ // sweep spacing: no substep longer than the thinnest blocker's radius
46
+ const thinnest = Math.max(0.005, Math.min(...blockers.map((b) => b.radius)));
47
+ const allowed = typeof allow === "function" ? (p, b) => Math.max(0, allow(p, b) ?? 0) : () => Math.max(0, allow ?? 0);
48
+ const onProbe = new THREE.Vector3(), onBlocker = new THREE.Vector3(), w = new THREE.Vector3(), dir = new THREE.Vector3(), side = new THREE.Vector3();
49
+ const best = { depth: 0, reach: 0, probe: new THREE.Vector3(), blocker: new THREE.Vector3() };
50
+
51
+ /** The deepest probe/blocker pair past its allowance, written into `best`; false when none. */
52
+ function deepest(capsules) {
53
+ best.depth = 0;
54
+ for (const capsule of capsules) {
55
+ for (const blocker of blockers) {
56
+ const reach = capsule.radius + blocker.radius - allowed(capsule.id, blocker.id);
57
+ const dist = Math.sqrt(closestPointsSegmentSegment(capsule.a, capsule.b, blocker.a, blocker.b, onProbe, onBlocker));
58
+ if (reach - dist <= best.depth) continue;
59
+ best.depth = reach - dist;
60
+ best.reach = reach;
61
+ best.probe.copy(onProbe);
62
+ best.blocker.copy(onBlocker);
63
+ }
64
+ }
65
+ return best.depth > 0;
66
+ }
67
+
68
+ /**
69
+ * SWEEP. A clean endpoint is not enough: one pointer event can carry the
70
+ * limb clean across a thin blocker (a forearm, a hand). Walk from the
71
+ * handle as displayed (`from.at`) to the target in equal substeps no
72
+ * longer than the thinnest blocker's radius (at most `maxSteps`; a longer
73
+ * jump gets longer substeps) and stop at the first substep that touches
74
+ * after a clean one. Returns that substep (the keep-the-side push then runs
75
+ * there, with the clean substep before it as the side), `{ at: null }`
76
+ * when the target is clean and so was the way there, or null when the path
77
+ * never got clean (the limb as displayed is in contact, e.g. the drag
78
+ * opened touching, and a start in contact only stops once clean).
79
+ *
80
+ * The start is judged on the limb AS DISPLAYED (`from.capsules`), not by
81
+ * solving toward lastGood again: the drag's solve does not land its handle
82
+ * on the target in one call, so a second solve toward the same clean
83
+ * target moves the limb on, into the blocker it had stopped short of, and
84
+ * the whole crossing then read as a start in contact allowed to leave.
85
+ */
86
+ function firstTouch(target, probe, from) {
87
+ const steps = Math.min(maxSteps, Math.max(1, Math.ceil(from.at.distanceTo(target) / thinnest)));
88
+ let wasClean = !deepest(from.capsules);
89
+ const clean = from.at.clone();
90
+ for (let k = 1; k <= steps; k++) {
91
+ const at = k === steps ? target.clone() : from.at.clone().lerp(target, k / steps);
92
+ if (!deepest(probe(at))) {
93
+ wasClean = true;
94
+ clean.copy(at);
95
+ } else if (wasClean) return { at, clean };
96
+ }
97
+ return wasClean ? { at: null, clean } : null;
98
+ }
99
+
100
+ /**
101
+ * Move `target` out of the blockers.
102
+ * @param {THREE.Vector3} target the dragged point (not mutated)
103
+ * @param {(candidate:THREE.Vector3) => Array<{id:string,a:THREE.Vector3,b:THREE.Vector3,radius:number}>} probe
104
+ * the dragged limb's capsules when its handle sits at `candidate`
105
+ * @param {THREE.Vector3|null} lastGood the last clean target of this drag (the side to keep)
106
+ * @param {{at:THREE.Vector3, capsules:Array}|null} [from] the handle and the limb's
107
+ * capsules as displayed when the event began: where the sweep starts
108
+ * (default: lastGood, with the limb probed there)
109
+ * @returns {{ target: THREE.Vector3, contact: boolean, depth: number }}
110
+ * `contact` = a push happened; `depth` = what is left after the last pass (0 = clean)
111
+ */
112
+ function project(target, probe, lastGood = null, from = null) {
113
+ let out = target.clone();
114
+ let contact = false;
115
+ if (!blockers.length) return { target: out, contact, depth: 0 };
116
+ const hit = lastGood ? firstTouch(out, probe, from ?? { at: lastGood, capsules: probe(lastGood) }) : null;
117
+ if (hit && !hit.at) return { target: out, contact, depth: 0 };
118
+ if (hit) out = hit.at;
119
+ const keep = hit?.clean ?? lastGood;
120
+ for (let pass = 0; pass < passes; pass++) {
121
+ if (!deepest(probe(out))) return { target: out, contact, depth: 0 };
122
+ contact = true;
123
+ w.subVectors(best.probe, best.blocker);
124
+ dir.copy(w);
125
+ if (keep) {
126
+ side.subVectors(keep, best.blocker);
127
+ // a normal pointing away from the side the hand came from is the
128
+ // far side of the body: escape toward lastGood instead
129
+ if (side.lengthSq() > EPS && (dir.lengthSq() <= EPS || dir.dot(side) < 0)) dir.copy(side);
130
+ }
131
+ if (dir.lengthSq() <= EPS) dir.set(0, 1, 0);
132
+ dir.normalize();
133
+ out.addScaledVector(dir, exitDistance(w, dir, best.reach + margin));
134
+ }
135
+ const left = deepest(probe(out)) ? best.depth : 0;
136
+ // the passes did not get the probed (displayed) limb out: fall back to
137
+ // the last clean substep of the sweep, on the near side
138
+ if (left > 0 && hit) return { target: hit.clean.clone(), contact: true, depth: 0 };
139
+ return { target: out, contact, depth: left };
140
+ }
141
+
142
+ return { project };
143
+ }
@@ -287,7 +287,7 @@ function restPairOverlaps(rig, radii = null) {
287
287
  /** Closest points between two segments (Ericson, Real-Time Collision
288
288
  * Detection — the textbook segment/segment distance). Writes into outA/outB
289
289
  * and returns the squared distance. */
290
- function closestPointsSegmentSegment(p1, q1, p2, q2, outA, outB) {
290
+ export function closestPointsSegmentSegment(p1, q1, p2, q2, outA, outB) {
291
291
  const d1 = q1.clone().sub(p1);
292
292
  const d2 = q2.clone().sub(p2);
293
293
  const r = p1.clone().sub(p2);
@@ -1,4 +1,10 @@
1
- import { createIkState, ikBakeKeyframe, ikTouch } from "./ik.js";
1
+ import * as THREE from "three";
2
+ import { clampIkTargetToFloor, createIkState, ikBakeKeyframe, ikTouch, MID_TRACKS, solveIk, solveMidJoint } from "./ik.js";
3
+ import { buildCollisionCapsules } from "./fix-collisions.js";
4
+ import { blockerCharacterId, characterBlockers } from "./collision-blockers.js";
5
+ import { createContactProjector } from "./contact-project.js";
6
+ import { allowance } from "../motion3/qa/checks.js";
7
+ import { ko } from "../locale.js";
2
8
 
3
9
  /**
4
10
  * Per-drag bookkeeping for manual IK drags over a loaded motion.
@@ -16,6 +22,92 @@ export function ikDragRecord(current, frame) {
16
22
  return current && current.frame === frame ? current : { frame, ids: new Set() };
17
23
  }
18
24
 
25
+ // Told once per drag, the first time the projection moves the handle.
26
+ const contactHint = () => ko("Stopped at the other character's body. Hold Alt while dragging to pass through",
27
+ "다른 인물의 몸에 닿아 멈췄어요. Alt를 누른 채 드래그하면 통과해요", "已停在另一个角色的身体上。按住 Alt 拖动即可穿过");
28
+ // Studio capsule id -> the motion3 QA part an allow entry names (qa/geometry.js CAPSULES).
29
+ const QA_PART = { UpperArm: "uarm", ForeArm: "farm", Hand: "hand", Thigh: "thigh", Shin: "shin", Foot: "foot" };
30
+ const qaPart = (capsuleId) => capsuleId.replace(/^(left|right)(\w+)$/, (_, side, part) => `${QA_PART[part] ?? part}_${side[0]}`);
31
+
32
+ /** The contact side of a drag, built once when it opens: the other cast members'
33
+ * capsules as they stand now (they do not move during a drag), the dragged
34
+ * chain's own capsules (upper arm, forearm, hand / thigh, shin, foot; no
35
+ * fingers) and the active take's person allowances (spec.checks.allow
36
+ * `{ part, object: "person:<id>", depth }`, read as QA reads them). */
37
+ function openDragContact(kind, chain, { rigs, activeId, cast = null, allow = null, notify = null }) {
38
+ const blockers = characterBlockers(rigs, activeId, { characterIds: cast });
39
+ const own = blockers.length ? buildCollisionCapsules(chain.rig) : null;
40
+ if (!own) return null;
41
+ const capsules = [...own.values()].filter((c) => c.def.movable?.chain === chain.track.id && !c.def.handId && !c.def.optional);
42
+ const allowFor = (probeId, blockerId) => allowance(allow, qaPart(probeId), `person:${blockerCharacterId(blockerId)}`);
43
+ const reads = capsules.map((c) => ({ id: c.def.id, start: c.bones[0], end: c.bones[1] ?? c.bones[0], radius: c.radius, a: c.a.clone(), b: c.b.clone() }));
44
+ const contact = {
45
+ projector: createContactProjector({ blockers, allow: allowFor }),
46
+ pose: null, // this event's starting locals, set by clampDragTarget
47
+ // the limb's capsules as the drag will DISPLAY them with its handle at
48
+ // `candidate`: the drag's own solve, from this event's starting pose
49
+ probe: (candidate) => {
50
+ restorePose(chain, contact.pose);
51
+ solveDragTarget(kind, chain, candidate);
52
+ return contact.shown();
53
+ },
54
+ // the limb's capsules as they stand (the pose on screen at event start)
55
+ shown: () => {
56
+ for (const read of reads) { read.start.getWorldPosition(read.a); read.end.getWorldPosition(read.b); }
57
+ return reads;
58
+ },
59
+ handle: chain.bones[kind === "mid" ? 1 : 2],
60
+ lastGood: chain.bones[kind === "mid" ? 1 : 2].getWorldPosition(new THREE.Vector3()),
61
+ notify,
62
+ };
63
+ return contact;
64
+ }
65
+
66
+ function restorePose(chain, pose) {
67
+ chain.bones.forEach((bone, i) => bone.quaternion.copy(pose[i]));
68
+ chain.bones[0].updateMatrixWorld(true);
69
+ }
70
+
71
+ /** The solve a chain or mid drag applies toward its handle (domains/motion.js
72
+ * ikSolve). The contact probe runs this same call from the same starting pose,
73
+ * so the pose it checks is exactly the pose the drag displays. (A probe on a
74
+ * different solve, e.g. exactHinge, lands the elbow and hand elsewhere from a
75
+ * bent pose and passed targets whose displayed limb sat inside the other body.) */
76
+ export function solveDragTarget(kind, chain, target) {
77
+ if (kind === "mid") solveMidJoint(chain, target);
78
+ else solveIk(chain, target);
79
+ }
80
+
81
+ /**
82
+ * Where a chain or mid drag may put its handle: on the floor with Body contact,
83
+ * then out of the other characters (contact-project.js). `open()` returns
84
+ * `{ rigs, activeId, cast, allow }` and is read once, on the first event of the
85
+ * drag (`record` keeps the result). `bypass` (Alt held) skips the contact
86
+ * projection for that event and leaves the side to keep untouched.
87
+ */
88
+ export function clampDragTarget({ kind, trackId, targetWorld, bodyContact, chains, record = null, open = null, bypass = false }) {
89
+ const chain = chains?.get(kind === "mid" ? MID_TRACKS.find((t) => t.id === trackId)?.chain : trackId);
90
+ const heights = chain?.contactHeights ?? chains?.values().next().value?.contactHeights;
91
+ const floored = bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, heights) : targetWorld;
92
+ if (!chain || !record || !open || (kind !== "chain" && kind !== "mid")) return floored;
93
+ if (record.contact === undefined) record.contact = openDragContact(kind, chain, open());
94
+ const contact = record.contact;
95
+ if (!contact || bypass) return floored;
96
+ // every probe starts from this event's pose, and the limb goes back to it
97
+ // so the caller's solveDragTarget starts there too
98
+ contact.pose = chain.bones.map((bone) => bone.quaternion.clone());
99
+ // the sweep starts from the limb as displayed, not from a re-solve toward lastGood
100
+ const from = { at: contact.handle.getWorldPosition(new THREE.Vector3()), capsules: contact.shown() };
101
+ const { target, depth } = contact.projector.project(floored, contact.probe, contact.lastGood, from);
102
+ restorePose(chain, contact.pose);
103
+ if (depth === 0) contact.lastGood = target.clone();
104
+ if (!target.equals(floored) && !contact.told) {
105
+ contact.told = true;
106
+ contact.notify?.(contactHint());
107
+ }
108
+ return target;
109
+ }
110
+
19
111
  /** Mark `id` as written by this drag and touch it into the live layer. */
20
112
  export function ikDragTouch(ikState, record, id) {
21
113
  ikTouch(ikState, id);
@@ -1427,3 +1427,15 @@
1427
1427
  .v2-sequencer .objmo .sg { position: absolute; inset: 0; }
1428
1428
  .v2-sequencer .objmo .sg-head { padding: 3px 10px 2px; }
1429
1429
  .v2-sequencer .objmo .sg-body { min-height: 36px; margin: 0 0 6px; }
1430
+
1431
+ /* The motion controls' Physics box (physics-toggle.jsx) */
1432
+ .v2-sequencer .tl-btn.physics {
1433
+ display: inline-flex;
1434
+ align-items: center;
1435
+ gap: 4px;
1436
+ cursor: pointer;
1437
+ }
1438
+
1439
+ .v2-sequencer .tl-btn.physics input {
1440
+ margin: 0;
1441
+ }
@@ -10,7 +10,7 @@ import { insertPathMark, pathMarks, pathMetrics, pathProgressAt, MAX_PATH_LEAN }
10
10
  import { NumberField } from "../ui.jsx";
11
11
  import { flatTiming, timingIsFlat, envelopeDrag, insertCut, removeCut, CUT_MIN_GAP } from "../speed-envelope.js";
12
12
  import { checkShotAgainstPreset, presetById } from "../model-presets.js";
13
- import { TakeSegmentClips, TakeSegmentTools } from "./take-segments.jsx"; import { BeatBoard } from "./beat-board.jsx";
13
+ import { TakeSegmentClips, TakeSegmentTools } from "./take-segments.jsx"; import { BeatBoard } from "./beat-board.jsx"; import { PhysicsToggle } from "../physics-toggle.jsx";
14
14
  import "./timeline.css";
15
15
  export { timelineTake } from "./take-segments.jsx";
16
16
 
@@ -1708,7 +1708,7 @@ export default function Timeline({
1708
1708
  </div>
1709
1709
  {onClearMotion && (
1710
1710
  <div className="tl-head-group tl-motion-tools" role="group" aria-label={ko("Motion controls", "모션 컨트롤")}>
1711
- {motion?.segments && motionTools && <TakeSegmentTools motion={motion} frame={frame} />}
1711
+ {motion?.segments && motionTools && <TakeSegmentTools motion={motion} frame={frame} />}{motionTools && <PhysicsToggle />}
1712
1712
  <button
1713
1713
  type="button"
1714
1714
  className="tl-btn clear"
@@ -65,8 +65,9 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
65
65
  // ms.repair: the whole repair's wall time, so a sweep that repairs every variant shows what it costs
66
66
  const r = fixed.result, label = fixed.spec?.label ?? null, ms = fixed.repair ? { ...r.ms, repair: Math.round(performance.now() - t0) } : r.ms;
67
67
  const take = bake ? bakeTake(sk, r.out, { position: bake.position, yaw: bake.yaw }, { spec: r.stored, prompt: ("prompt" in bake ? bake.prompt : label) ?? "" }) : undefined;
68
- // a person anchor's joints ride with the take, so its saved source compiles the same without the others' takes (people.js)
69
- const people = take && keptPeople(scene, [r.expanded, r.stored]);
68
+ // a person anchor's joints (and the bodies fix.person pushed an arm out of) ride with the take, so its saved source
69
+ // compiles the same without the others' takes (people.js)
70
+ const people = take && keptPeople(scene, [r.expanded, r.stored], r.out);
70
71
  if (people) take.people = people;
71
72
  return { stored: r.stored, qa: r.qa, observations: r.observations, used: r.used, fixed: r.fixed, search: r.search, timeline: r.timeline,
72
73
  ...(r.probes ? { probes: r.probes } : {}), ...(r.notes || corrected.length ? { notes: [...corrected, ...(r.notes ?? [])] } : {}), ms, frameCount: r.out.frameCount, label,
@@ -20,7 +20,7 @@
20
20
  // ctx = { sk, scene: { objects: { id: { pos, R, half, ancestors } } } }; out.ctx.scene keeps the scene (object-pose.js)
21
21
  import { add, sub, scale, norm, len, dot, apply, axisRot } from "./math.js";
22
22
  import { isCarried, isFollow, hingePose, movesRecord, compose, relative } from "../carried.js";
23
- import { isFree, freePoseAt } from "../free-motion.js";
23
+ import { isFree, freeSwungAt } from "../free-motion.js";
24
24
  import { groupsOf } from "../scene.js";
25
25
  import { pose, SIDES } from "./pose.js";
26
26
  import { graspAnchor } from "./socket-grip.js";
@@ -46,7 +46,7 @@ const REACH_SLACK = 0.02;
46
46
 
47
47
  /**
48
48
  * Scene boxes by frame. A body moves with its own spec.objects entry or its nearest group's (a
49
- * switch entry moves nothing; hinge: turned; free (C17): where its keys and falls put it, under the time map `mapOf()`;
49
+ * switch entry moves nothing; hinge: turned; free (C17): where its keys and falls put it, under the time map `mapOf()`, swung by its spring;
50
50
  * carry and follow: left at its scene pose and marked `carried` with the moving id). `obstacles` drops the carried
51
51
  * ones: QA, refs and object anchors read a carry where it rides (object-pose.js objectPoseAt, qa/object-checks.js);
52
52
  * a follow entry rides the body and is no obstacle.
@@ -63,7 +63,7 @@ function objectTracks(spec, scene, baked, mapOf = () => undefined) {
63
63
  const mover = [id, ...(o.ancestors ?? [])].find(k => movesRecord(spec.objects?.[k])), entry = spec.objects?.[mover];
64
64
  if (!entry) out[id] = o;
65
65
  else if (isCarried(entry) || isFollow(entry)) out[id] = { ...o, carried: mover };
66
- else if (isFree(entry)) out[id] = { ...o, ...compose(freePoseAt({ spec, scene, timeMap: map }, mover, f), relative(scene.objects[mover] ?? groupsOf(scene)[mover], o)) };
66
+ else if (isFree(entry)) out[id] = { ...o, ...compose(freeSwungAt({ spec, scene, timeMap: map }, mover, f), relative(scene.objects[mover] ?? groupsOf(scene)[mover], o)) };
67
67
  else out[id] = { ...o, ...hingePose(entry, o, baked, f) };
68
68
  }
69
69
  cache.set(f, out);