# Research: two-handed grab, held-rotation stabilization, hand snap-back vs grip, and what Box3D joints really are Fourth research pass for VR Messs, after the user playtest of grab v5 (PinJoint3D per hand): two-handed hold feels "jointed-on" not welded, held-object rotation gets out of control, and PhysicsHands' stuck-safety teleports the hand out from under a live joint. Label: [S] = sourced (verified against source/docs this pass), [I] = inference. Companion to `vr-grabbing.md` — that report's taxonomy still holds; this one covers what it didn't. ## Sources verified this pass ### Box3D / godot-box3d (all read directly — the most load-bearing section) - [S] **Local addon has no source** — `/home/foxyz/Projects/Games/vr-messs/addons/godot-box3d/` contains only LICENSE, `.gdextension`, and 3 binaries. Class surface extracted from `bin/libgodot-box3d.so` via `strings`: registered joint impls are **Box3DPinJointImpl3D, Box3DHingeJointImpl3D, Box3DSliderJointImpl3D, Box3DFilterJointImpl3D** (+ base Box3DJointImpl3D). Strings also carry the rejections: "ConeTwistJoint3D is not supported in this version", "Generic6DOFJoint3D is not supported; use PinJoint3D, HingeJoint3D, or SliderJoint3D instead", "PinJoint3D's IMPULSE_CLAMP parameter has no Box3D equivalent and is ignored", HingeJoint BIAS/LIMIT_BIAS/LIMIT_RELAXATION/LIMIT_SOFTNESS ignored. - [S] **Upstream bearlikelion/godot-box3d** (https://github.com/bearlikelion/godot-box3d, tag v0.2.6 — `src/joints/box3d_pin_joint_impl_3d.cpp` diffed IDENTICAL to main): PinJoint3D maps to a Box3D **spherical joint with `enableSpring = true` HARDCODED, `hertz = bias * 30.0f`, `dampingRatio = damping`**. IMPULSE_CLAMP = warn + no-op. Defaults: bias 0.3 → **9 Hz rotational spring**, damping 1.0 → critically damped. So Godot's bias/damping DO apply — but to the joint's ROTATIONAL spring, not to the positional constraint. - [S] **Box3D = Erin Catto's engine** (https://github.com/erincatto/box3d), Rubikon/Source-2 lineage (bearlikelion README, https://bearlikelion.github.io/godot-box3d/). The wrapper README admits it is "behind box3d-godot on: joint types, ConeTwist and 6DOF" — PhysicsServer3D has no entry points for Box3D's other joints, so a drop-in backend *can't* surface them. - [S] **The underlying spherical joint is a near-cone-twist** (`include/box3d/types.h`, `src/spherical_joint.c`): b3SphericalJointDef has `enableSpring/hertz/dampingRatio` (rotational spring toward `targetRotation`, captured at creation), `targetRotation` (b3Quat), `enableConeLimit`/`coneAngle` [0, π] (cone around frameA z-axis), `enableTwistLimit`/ lower/upperTwistAngle (±0.99π around frameB z-axis), `enableMotor`/`motorVelocity` (relative angular velocity, world space)/`maxMotorTorque`. **None of this is reachable through the wrapper** — `_joint_make_pin` passes identity-basis frames and `_pin_joint_set_local_a/b` rewrite the frame ORIGIN only (src/servers/box3d_physics_server_3d.cpp), so the spring target is permanently the relative rotation at grab time. - [S] **The positional (point-to-point) part of the pin joint is tuned separately and the wrapper never touches it** (`src/joint.c`): `b3DefaultJointDef` sets `constraintHertz = 60`, `constraintDampingRatio = 2.0`, and `b3PrepareJoint` clamps hertz to **0.25 × tick rate** → at our 90 Hz physics the positional constraint runs at **22.5 Hz, overdamped (ζ 2.0)**. This is the sag/drift in the playtest and it is NOT tunable via PinJoint3D.Bias/Damping. - [S] **b3Joint_SetLocalFrameA/B apply to the live joint without rebuilding** (warm start kept) — `src/joints/box3d_joint_impl_3d.cpp` — this is the mechanism behind our hand-following pivot. - [S] **Box3D also has** (public API, all unreachable from the wrapper): weld joint ("connects two bodies together rigidly", linearHertz 0 = max stiffness, with the note "the approximate solver in Box3D cannot hold many bodies together rigidly"), **motor joint** (desired relative linear+angular velocity with max velocity force/torque, plus spring hertz/dampingRatio with max spring force/torque — b3MotorJointDef, include/box3d/types.h), distance/parallel/wheel joints. The separate project **box3d-godot** (Stink-O) exposes 8 joint types but as its own node hierarchy (no PhysicsServer3D replacement) — not usable without abandoning RigidBody3D. - [S] **HingeJoint3D DOES expose its motor** through the wrapper (`box3d_hinge_joint_impl_3d.cpp` → b3RevoluteJoint_EnableMotor/SetMotorSpeed/ SetMaxMotorTorque) — usable later for cranks/wheels, irrelevant to grab. ### Prior art — two-handed hold - [S] **Unity Discussions, "Need help with two Configurable Joints! (Two hands grab in VR)"** (https://discussions.unity.com/t/need-help-with-two-configurable-joints-two-hands-grab-in-vr/220913, found via search; scheme corroborated by search-result synthesis): two joints hand→object; BOTH lock position always; only the FIRST hand's joint locks rotation; the working scheme is "primary owns rotation, secondary is position-only" — giving both joints rotation locks made the weapon flip (the two rotational authorities fight). - [I] **Blade & Sorcery** (decompile-derived, consistent with the wiki we verified in `vr-grabbing.md`): the second hand does NOT create a second joint — Handle.jointType "Rigid" is a fully-locked ConfigurableJoint on the primary hand only, and going two-handed adjusts the PRIMARY joint's limits. Secondary hand contributes pose/limits, not an independent constraint. - [S] **Meta ISDK** (https://developers.meta.com/horizon/documentation/unity/unity-isdk-two-handed-grab-interaction/): kinematic transformers — position = centroid of both grab points, rotation "computed from relative hand movement" (TwoGrabRotateTransformer, axis-constrained with min/max degrees). This is the documented kinematic form of "orient from the two hands", no physics. - [S] **HurricaneVR** (release notes read from a public mirror, https://code.fbi.h-da.de/sttijahnk/Relax/-/blob/ae2026c6e21d8215cbfd7c05e80a2c4c00bea5f2/Relax/Assets/HurricaneVR/Documentation/ReleaseNotes.txt): joint-based physics hands; separate JointSettings for one- vs two-handed grabbing; "Two hand grabs are much more stable with the physics hand updates"; `FinalJointMaxAngle` 15° + `FinalJointTimeout` 0.25 s (snap the last rotation error after a timeout); `PerpendicularThreshold` breaks the joint on stretch. - [I] **Rigid-body theory**: two ideal point constraints remove 5 of 6 DOF and leave rotation about the axis through the two anchors free. It is killed by (a) a third non-collinear point constraint (over-constrains; soft solvers leak), (b) an angular spring/motor about that axis, or (c) aim-the-anchor-axis-at-the-hand-hand-vector + roll. **Box3D's pin joints already contain (b) implicitly** — every pin joint carries a rotational spring toward the grab-time relative rotation — so the playtest's visible axial rotation/drift is the 9 Hz DEFAULT spring being too weak, not a missing constraint. ### Prior art — held-object rotation stabilization - [S] **Box3D manual** (https://github.com/erincatto/box3d, docs/simulation.md): joint motors control position "by specifying a joint velocity that is proportional to the difference between the actual and desired position" with a torque cap (servo); friction = zero target velocity + small max torque; joint spring hertz "should be less than half the frequency of the time step" (60 Hz step → < 30 Hz). Same doctrine as ODE (already cited in `vr-grabbing.md`): let the SOLVER integrate the correction, never per-step velocity override. - [S] **Unity ConfigurableJoint reference** (https://docs.unity3d.com/Manual/class-ConfigurableJoint.html): rotation control = drive spring + damper + **Maximum Force clamp** — clamping the drive force is the standard stabilizer. - [I] Community fix patterns (Unity/Unreal threads found via search): clamp |angularVelocity| each tick while held as a *safety net*, raise angular damping while held, apply counter-torque ∝ angular error rather than hard velocity sets. No public writeup gives tuned gain values for 90 Hz — the Box3D spring hertz limits above are the only hard numbers. - [S] **The current code's oscillation source is now precisely identifiable**: PropGrabber.cs sets `body.AngularVelocity` every tick (cap 20 rad/s) while the primary pin joint's own 9 Hz rotational spring pulls toward the grab rotation — two authorities on the same DOF, the documented fight. ### Prior art — hand snap-back safety vs a live grip - [S] **HurricaneVR**: joints carry JointBreakForce/JointBreakTorque (default infinite) and a stretch break (`PerpendicularThreshold`); the hand is never teleported while jointed — the GRIP breaks instead. - [S] **Blade & Sorcery** (BasSDK wiki, verified in `vr-grabbing.md`): hand colliders disabled while gripping; grips break by distance/timeout. - [S] **godot-xr-tools** (pickable.gd re-read): kinematic drive (freeze + move-to-hand each tick) has NO joint, so a lagging hand is impossible by construction — the snap-back conflict only exists in joint-based grabs. - [S] **Unreal forum, "Picking up object in VR gets stuck in hand"** (https://forums.unrealengine.com/t/picking-up-object-in-vr-gets-stuck-in-hand-when-letting-go/658445): teleport while holding must move the held object with the rig, else yank/stuck — the joint has to go (or move) when the reference frame jumps. ## Comparison table | Scheme (who) | Rotation authority | Two-handed behavior | Hand-safety conflict | Usable in Box3D wrapper today | |---|---|---|---|---| | Two ball joints, both default (our v5) | Primary script velocity-SET + two weak 9 Hz springs (fight) | Free-ish axial DOF, anchor drift | Stuck-snap yanks joint | Yes — this is the bug list | | Primary-owns-rotation two-joint scheme (Unity thread) | Primary joint's rotational lock | Secondary = position only | Same conflict, solved by break-first | Yes — stiffen primary spring, weaken secondary | | Primary joint only, secondary pose-only (B&S [I]) | Fully-locked primary joint | Primary joint limits adjust | Grips break by distance | Partially (no locked joint; stiff spring approximates) | | Kinematic centroid + relative-hand rotation (Meta ISDK) | Transformer math, no physics | Native | None (no joint) | Yes (freeze fallback) but loses weight feel | | Box3D spherical joint full surface (cone+twist limits, motor, targetRotation) | Solver servo, torque-capped | Cone/twist limits = true weld feel | Joint break first | **No — needs a small wrapper patch** | ## Recommended architecture for VR Messs Everything below assumes 90 Hz physics (dt ≈ 11.1 ms), Box3D backend, and no wrapper changes unless marked Tier B. Constants are starting values for in-headset tuning, not gospel. ### (a) Two-handed constraint scheme (wrapper-level, works today) 1. **Keep two PinJoint3D hand→prop** (position coupling stays solver-owned — correct in v5). 2. **PRIMARY hand's joint: `PinJoint3D.Bias = 0.75`, `Damping = 1.0`** → its implicit rotational spring runs at 0.75×30 = **22.5 Hz** — exactly Box3D's own 0.25×tick stability clamp at 90 Hz; critically damped. This single change welds orientation to the primary hand solver-side and kills the free axial rotation (the axial DOF was never truly free — it was held by a 9 Hz default spring). Tuning range: 0.6–1.0 (never above 1.5 = 45 Hz = half tick rate; Box3D clamps only the positional tuning, the spring hertz is unclamped in spherical_joint.c and CAN go unstable). 3. **SECONDARY hand's joint: `Bias = 0.3` (default), `Damping = 1.0`** → 9 Hz weak spring: contributes position + a whisper of rotation, and cannot fight the primary's 22.5 Hz spring (the Unity-thread fight). Do NOT add a third anchor point/joint — redundant constraints in Box3D's soft solver just leak (weld-joint note), and B&S ships two-handed without any. 4. Anchor drift between hands: leave it to the existing break logic; optionally (cheap spike) re-capture the SECONDARY joint's anchors (destroy + recreate the joint node at the current pose, the same configure-before-AddChild ordering as grab) if its anchor separation exceeds ~0.15 m while both hands hold. [I] optional, only if drift still reads wrong after (2). 5. **Tier B (enabling change, ask before doing): fork/PR godot-box3d to expose** on pin joints: `b3Joint_SetConstraintTuning` (positional stiffness — fixes the 22.5 Hz/ζ2.0 sag), `b3SphericalJoint_SetTargetRotation` + `EnableMotor`/`SetMotorVelocity`/`SetMaxMotorTorque` (true solver-side orientation servo), and `EnableConeLimit`/`EnableTwistLimit` (true weld feel: cone 0 + twist [0,0] on the primary joint). Each is a one-line b3 call behind the existing joint_impl plumbing; upstream README already concedes these are unreached. ### (b) Per-tick rotation control (replaces the velocity-SET) 1. **DELETE the per-tick `body.AngularVelocity` set in PropGrabber.cs** (the spin = ... clamp + assign block). Two authorities on one DOF (velocity override vs the joint's rotational spring) is the documented oscillation source; the spring IS the orientation servo now. 2. **Safety clamp only**: each tick while held, if prop |angular velocity| > **30 rad/s** (`MaxHeldSpin`), scale it to 30. This is a guard, not a controller — it should almost never fire once (1) is gone. 3. Optional stabilization while held [I, starting values]: raise the prop's `AngularDamp` to **2.0** on grab, restore on release. Cheap; test with and without. 4. Tier B servo (after the patch): retarget the primary joint's `targetRotation` from the controller each tick, or enable the spherical motor with `SetMotorVelocity ≈ 15 × rotation error (rad/s per rad)` and `SetMaxMotorTorque ≈ 8 N·m` for props ≤ 5 kg (scale ∝ mass); keep the spring enabled at 10–20 Hz as the position term. Servo-with-torque-cap is the Box3D manual's own recipe. ### (c) Hand safety policy while gripping 1. **While a hand holds a live grab joint, SUSPEND both safeties for that hand** in PhysicsHands.cs: the stuck-snap (StuckDistance 0.2 m / StuckTime 1.0 s) and the SnapDistance 2 m teleport. A joint legitimately holds the hand back — that's weight feel, not a bug; teleporting a jointed hand out from under itself is the yank. (Prior art: HVR and B&S break the GRIP instead of moving the hand; kinematic systems have no joint to yank.) 2. **Grip break always runs first** and is unchanged in spirit: existing BreakDistance 0.25 m / BreakTime 0.25 s, both-hands 0.4 m. Add a hard rip: separation > **`GripRipDistance` 0.8 m → immediate release, no timer** (analog of HVR's PerpendicularThreshold stretch break) so a genuinely stuck hand recovers in one tick instead of one second. 3. After release, the normal safety logic resumes next tick; the one-frame arm jump on snap stays a known cosmetic issue (already in HANDOFF §5). 4. Do NOT teleport the prop with the hand. Move-object-with-teleport applies to kinematic holds; here the joint must break first, then everything is ordinary again. ### (d) Box3D joint facts (evidence for the above) - Wrapper joint classes (local .so strings + upstream `src/joints/`): Pin → b3SphericalJoint (spring hertz = bias×30, damping = dampingRatio, spring ALWAYS on, targetRotation = grab-time relative rotation, unreachable), Hinge (limits + motor reachable), Slider, Filter (collision exception). Cone-twist/6DOF explicitly rejected by design; weld/motor/distance/parallel/wheel joints exist in Box3D but have no wrapper path. - PinJoint3D.IMPULSE_CLAMP is a warned no-op; HingeJoint3D's BIAS/LIMIT_* params are ignored. - Positional constraint tuning: world default 60 Hz/ζ2.0, clamped to 0.25×tick (22.5 Hz at 90 Hz physics), not exposed by the wrapper — the floor under all "softness" complaints. - Local evidence paths: `/home/foxyz/Projects/Games/vr-messs/addons/godot-box3d/bin/` (strings of `libgodot-box3d.so`); upstream `src/joints/box3d_pin_joint_impl_3d.{hpp,cpp}`, `src/joints/box3d_joint_impl_3d.cpp`, `src/servers/box3d_physics_server_3d.cpp` (bearlikelion/godot-box3d v0.2.6 = main); `include/box3d/types.h`, `src/joint.c`, `src/spherical_joint.c`, `docs/simulation.md` (erincatto/box3d main). - Jolt-fallback caveat (existing): on Jolt, PinJoint3D reverts to vanilla origin-pinning and bias/damping semantics — grab v5.1 is even more Box3D-specific than v5; the freeze+drive fallback in `vr-grabbing.md` remains the lossless escape hatch. **Order of attack for the implementing agent**: (b1)+(b2) first (delete velocity-SET, add clamp — one file, likely fixes "out of control"), then (a2)/(a3) bias split (second file, likely fixes "jointed-on"), then (c) safety gating (third file), then decide on Tier B only after a playtest of those three. --- ## CORRECTION + verification (2026-10-01) — the spring's target is IDENTITY, not "grab-time relative rotation" The original claim above — "the spring target is permanently the relative rotation at grab time" (and in (d): "targetRotation = grab-time relative rotation") — was **WRONG**. It was an invalid inference from the wrapper passing identity-basis frames. Re-verification against actual source (below) shows the target is **IDENTITY**, i.e. the spring drives the two bodies to the SAME WORLD ORIENTATION (prop springs to the hand's orientation). This exactly explains playtest observation 1 (~180° forced rotation on grab) and reframes 2–4. Everything else in the original sections (spring always on, hertz = bias×30, positional tuning unreachable, wrapper API surface, hand-safety prior art) re-verified correct. ### What was verified, with quotes **Pinned version note [S]**: godot-box3d v0.2.6 (tag `791ed422`) vendors box3d as a git submodule pinned to commit `3fc20f5b` (2026-07-30). box3d `main` is 11 commits ahead, 0 behind; `src/spherical_joint.c` differs from main by ONE COMMENT LINE, and the `joint.c` / `solver.h` diffs are unrelated (contact-destroy refactor, restitution). All quotes below are identical in the pinned commit and main. **A1. targetRotation at creation = the def default = identity; nothing re-captures the current relative rotation.** [S] - `src/joint.c:86-92` — `b3DefaultSphericalJointDef()`: `b3SphericalJointDef def = { 0 };` `def.base = b3DefaultJointDef(); def.targetRotation = b3Quat_identity;` - `src/joint.c:645` — `b3CreateSphericalJoint()` just copies it: `joint->sphericalJoint.targetRotation = def->targetRotation;` — there is NO code anywhere that initializes targetRotation from the bodies' current qA/qB. The ONLY recompute from live relative rotation (`base->sphericalJoint.targetRotation = relQ;`, `joint.c:1472`) sits inside `#if 0 ... #endif` (disabled helper `b3Joint_SetSpringRotationTarget`). - The wrapper never calls `b3SphericalJoint_SetTargetRotation` (`b3SphericalJoint_SetTargetRotation` exists at `src/spherical_joint.c:154-161` but has no wrapper path), and `b3DefaultSphericalJointDef()` is what the wrapper starts from — see B below. - Consequence [I]: with identity local frames (wrapper), the solver's error `relQ = b3InvMulQuat(quatA, quatB)` (where `quatA/B = body rotation × localFrame.q`, `spherical_joint.c:452-455, 326-329`) is the pure hand↔prop relative rotation, and the spring pulls it toward `targetRotation = identity` ⇒ **prop world orientation is driven to the hand's world orientation**, from the first solver tick after grab. Observation 1 CONFIRMED at source level. **A2. Spring "torque" formula: soft-constraint PD, inertia-scaled, NO torque cap.** [S] - `src/spherical_joint.c:457-475` (`b3SolveSphericalJoint`): ``` b3Vec3 deltaRotation = b3DeltaQuatToRotation( relQ, joint->targetRotation ); b3Vec3 c = b3Neg( b3RotateVector( quatA, deltaRotation ) ); // position error (rad) b3Vec3 bias = b3MulSV( joint->springSoftness.biasRate, c ); // velocity bias ∝ error impulse = -massScale * rotationMass * ( wB - wA + bias ) - impulseScale * springImpulse; ``` where `rotationMass = b3InvertMatrix( invInertiaSum )` (`spherical_joint.c:362-369`) — the combined inverse-inertia tensor, so torque auto-scales with the paired rotational inertia; and `springSoftness = b3MakeSoft( joint->hertz, joint->dampingRatio, context->h )` (line 371). It is a PD in Box3D's soft-constraint form (stiffness via `biasRate`, damping via the `cdot` term), NOT a literal k·θ torque — hence no explicit cap: **the rotational spring has no max-torque clamp**; only the motor is capped (`spherical_joint.c:483-489`, `maxImpulse = joint->maxMotorTorque * context->h`). - Error convention: `b3DeltaQuatToRotation(q, target) = 2·vec((target − ±q)·conj(q))` (`src/math_internal.h:221-232`) — shortest-arc (polarity-corrected) error toward `target`. **A3. hertz = 0 (wrapper bias = 0) kills ONLY the spring; position is untouched.** [S] - `src/solver.h:265-273` — `b3MakeSoft( hertz, zeta, h )`: `if ( hertz == 0.0f ) return { biasRate = 0.0f, massScale = 0.0f, impulseScale = 0.0f };` ⇒ every term of the spring impulse is multiplied by 0 ⇒ **spring impulse is exactly zero** (not merely weak — the solve block at `spherical_joint.c:458` still runs but contributes nothing, not even angular-velocity coupling). - The point-to-point block (`spherical_joint.c:605-647`) uses `base->constraintSoftness` only, which `b3PrepareJoint` sets independently (`src/joint.c:1484-1486`): `hertz = b3MinFloat( joint->constraintHertz, 0.25f * context->inv_h )` with `b3DefaultJointDef()` defaults `constraintHertz = 60.0f; constraintDampingRatio = 2.0f;` (`joint.c:30-31`) — completely independent of the spring's `hertz/dampingRatio`. So **PinJoint3D.Bias = 0.0 ⇒ pure ball joint (position-only), no rotational behavior at all**. **A4. The spring torques BOTH bodies, equal and opposite.** [S] - `spherical_joint.c:473-474`: `wA = b3Sub( wA, b3MulMV( iA, impulse ) );` `wB = b3Add( wB, b3MulMV( iB, impulse ) );` — the hand (dynamic) receives the reaction, and the identity target means the hand is ALSO being pulled toward the prop's orientation. This is the source of observation 3 ("hand can rotate around the grab joint" — visibly dragged by the spring) and part of observation 4. **B. Wrapper confirmation.** [S] - `src/joints/box3d_pin_joint_impl_3d.cpp` `_create_joint_id()`: `b3SphericalJointDef def = b3DefaultSphericalJointDef(); ... def.enableSpring = true; def.hertz = (float)bias * 30.0f; def.dampingRatio = (float)damping;` — BIAS feeds ONLY the spring hertz (nothing positional; the positional constraint is never touched by the wrapper). `set_param` mirrors it live via `b3SphericalJoint_SetSpringHertz`. - `src/servers/box3d_physics_server_3d.cpp:941-955` `_joint_make_pin()`: passes `Transform3D(Basis(), p_local_a)` / `Transform3D(Basis(), p_local_b)` — **identity basis** ⇒ identity frame quats; Box3D then computes world frame rotations as `transform.q × localFrame.q` at prepare (`spherical_joint.c:326-329`) and recomputes NOTHING into targetRotation at creation. Target stays identity forever. **Reframe of playtest observations 2–4 [I]**: a spring pulling toward the WRONG target (identity = hand's orientation) reads as "weak hold": the 3.5 kg brick is simultaneously sprung toward the hand's orientation (which changes as the wrist turns) and yanked by gameplay forces, so it never settles where the player wants it. Two hands = two springs pulling toward two different orientations (each hand's) at 9 Hz and 22.5 Hz — the lower-rate secondary spring is simply the loser, and re-creating the primary joint at bias 0.75 cannot help because the target is still identity and still fights the secondary's 9 Hz identity target from a different hand pose. The original recommendation to RAISE primary bias to 0.75 would have made observation 1 WORSE (stronger snap-to-hand-orientation), not better. The "(a2)/(a3) bias split" and "order of attack" in the original sections are retracted; (b1) (delete velocity-SET) and (c) (safety gating) stand unchanged. ### C. Replacement design evidence — script-side orientation servo **Godot 4.7 C# torque APIs [S]** (pinned docs `/home/foxyz/Projects/Games/.shared/godot-docs-4.7/classes/`): - `RigidBody3D`: `ApplyTorque(Vector3)` (class_rigidbody3d.rst:886-890), `ApplyTorqueImpulse(Vector3)` (:900-904), `AddConstantTorque(Vector3)` (:814-820) + `ConstantTorque` property (:447-456). (The hypothesis's "RigidBody3D has no ApplyTorque" was wrong — it has had it since 4.x.) - `PhysicsDirectBodyState3D`: `ApplyTorque` (:521+), `ApplyTorqueImpulse`, `InverseInertia` (Vector3, :235-243), `InverseInertiaTensor` (Basis, :251-259) — usable in `_IntegrateForces`. - `PhysicsServer3D`: `body_apply_torque` / `body_apply_torque_impulse` / `body_add_constant_torque` all exist (class_physicsserver3d.rst:107-121). **Box3D wrapper honors all of them [S]** (`bearlikelion/godot-box3d` main): - `src/servers/box3d_physics_server_3d.hpp:131-143` overrides `_body_apply_torque/_body_apply_torque_impulse/_body_add_constant_torque/...`; `src/objects/box3d_body_impl_3d.cpp:325-329` `apply_torque_impulse → b3Body_ApplyAngularImpulse(..., true)`; `:346-351` `apply_torque` accumulates `applied_torque` and wakes the body; `:454-460` `pre_step()` applies the accumulated sum via `b3Body_ApplyTorque(body_id, ..., false)` each physics tick (then clears it — so call it every tick from `_IntegrateForces`/`_PhysicsProcess`). - `src/objects/box3d_physics_direct_body_state_3d.cpp:57-70` implements `inverse_inertia`/`inverse_inertia_tensor` (real values from b3, not stubs); `:114-127` forwards state `ApplyTorque*` to the body. **Prior art for the gains [S]** — David Wu's backward-PD ("Controlling Objects With Forces and Torques", digitalopus.ca/site/pd-controllers/): gains parameterized by frequency f ("1/f seconds to ~95% convergence at damping 1") and damping d, inertia-tensor-scaled so one gain set fits any mass: `kp = (6f)² × 0.25 = 9f²`, `kd = 4.5·f·d`, `g = 1/(1 + kd·dt + kp·dt²)`, `ksg = kp·g`, `kdg = (kd + kp·dt)·g`, `torque = ksg·(axis·angle_rad) − kdg·ω`, then transformed through the inertia tensor. The canonical community form (Unity forums/Reddit, e.g. discussions.unity.com/t/rigidbody-lookat-torque/483110) is the same shape without inertia scaling: `torque = axis·angle·kp − ω·kd` — works only per-object, which is why Wu's form is preferred here. Unity ConfigurableJoint practice (docs.unity3d.com class-ConfigurableJoint) corroborates the hard part: always cap the drive (Maximum Force), which the Box3D spring lacks and the script servo must add. **Servo vs raising bias — the verdict [I]**: raising bias cannot be right even in principle because the target is identity (wrong by construction) and unreachable without a wrapper patch. The script servo can be re-targeted every tick (target = hand's CURRENT world basis, optionally offset by the grab-pose delta), is inertia-scaled (one gain set for brick → rifle), torque-capped, and numerically safe: with Wu's form at 90 Hz (dt = 1/90) and f = 6 Hz the effective natural frequency 3f = 18 rad/s gives ω_n·dt ≈ 0.2 ≪ 1 — far inside the stability Box3D's own manual demands of solver springs ("< half the frequency of the time step"). Holding the 3.5 kg brick (principal inertias ≈ 0.004–0.015 kg·m² for a 0.2×0.1×0.05 m block) level at a 0.5 rad error needs τ ≈ I·9f²·θ ≈ 1–3 N·m — well under any sane cap. Instability risk lives in the HAND (light body, big reaction), so the cap and the existing hand-follow logic absorb it; this is the same authority split HVR/Unity-thread prior art uses (one rotational authority, position stays solver-owned). ### Recommended grab v5.2 architecture (supersedes (a)/(b) rotation parts above) 1. **PinJoint3D per hand stays, but both at `Bias = 0.0`, `Damping` irrelevant** → spring impulse is EXACTLY zero (A3) ⇒ pure ball joints: position coupling stays solver-owned, all rotational behavior is now script-owned. This kills observations 1, 3 (spring part), and the 4 fight in one line each. 2. **Script-side orientation servo on the prop (one authority: the primary hand)** in the prop's `_IntegrateForces` (or a fixed-tick controller using `RigidBody3D.ApplyTorque`): `q_err = q_hand_world × Quaternion(q_prop_world).Inverse()`; `q_err.ToAngleAxis(out a, out axis)` (wrap a to (−π, π]); gains via Wu's backward-PD with `f = 6.0`, `damping = 1.0`, `dt = 1/90`; torque `τ = ksg·axis·a − kdg·ω_prop` applied through the inertia tensor (`InverseInertiaTensor`); **hard cap `|τ| ≤ τ_max`** before applying. 3. **Starting constants [I]**: `f = 6.0 Hz`, `damping = 1.0`, `τ_max = 20 N·m` for props ≤ 5 kg (scale `τ_max` ∝ mass for heavier props; the inertia scaling inside the formula already handles shape). Keep the v5 (b2) safety spin clamp (30 rad/s). Keep prop `AngularDamp` untouched (the kd term IS the damping; two dampers fight). 4. **Secondary hand**: joint at Bias 0 (position-only). On second grab, record `q_offset = q_secondary_hand_world⁻¹ × q_target_world` and keep the servo target at `q_secondary_hand_world × q_offset` while the primary holds — secondary hand drags the servo target (Blade & Sorcery's "secondary adjusts pose", no second rotational authority). On primary release, promote the secondary: new servo owner, new `q_offset` captured from ITS grab pose — orientation continuity without any joint recreation semantics. 5. **(c) hand-safety policy unchanged** (suspend stuck-snap/snap-teleport while a live joint exists; grip break first; rip threshold). Tier B (wrapper patch to expose `SetTargetRotation`/motor/cone-twist) is now OPTIONAL polish, not the fix path — the script servo needs no patch and is retargetable, which the joint spring is not.