# Research: patched godot-box3d — solver-owned rigid weld via ConeTwistJoint3D The "Tier B" enabling change from `vr-two-handed-grab.md`, implemented and proven. The addon at `addons/godot-box3d` (bearlikelion/godot-box3d v0.2.6) now maps **ConeTwistJoint3D → Box3D's b3WeldJoint**, giving grab a solver-owned rigid weld (swing_span 0 + twist_span 0) instead of the pin-joint + script-servo arrangement. Label: [S] = verified against source on this machine, [I] = inference/judgment. ## Why the weld joint (recap from the playtest evidence) The grab is a PinJoint3D (point constraint) + script orientation servo; the playtest read it as "hanging an object on a winch that magically aligns it" — a ping pong ball orbits the palm until settling, an anvil's grab surface drifts against the palm. The pose relative to the hand must be RIGID and owned by the solver, while still loading the hand's force budget. Box3D upstream has exactly this joint; only the wrapper path was missing. ## b3WeldJointDef — the full field list [S] `box3d/include/box3d/types.h` (submodule, pinned commit `3fc20f5b`), verbatim: ```c typedef struct b3WeldJointDef { /// Base joint definition b3JointDef base; /// Linear stiffness expressed as Hertz (cycles per second). Use zero for maximum stiffness. float linearHertz; /// Angular stiffness as Hertz (cycles per second). Use zero for maximum stiffness. float angularHertz; /// Linear damping ratio, non-dimensional. Use 1 for critical damping. float linearDampingRatio; /// Linear damping ratio, non-dimensional. Use 1 for critical damping. float angularDampingRatio; } b3WeldJointDef; ``` `b3DefaultWeldJointDef()` zeroes everything except the base defaults (`src/joint.c:94-99`), i.e. **hertz 0 = maximum stiffness**. Verified behavior in `box3d/src/weld_joint.c`: - `b3PrepareWeldJoint` (lines 125-141): `if (linearHertz == 0) linearSpring = base->constraintSoftness` — hertz 0 falls back to the world's engine-clamped softness (`b3PrepareJoint` clamps `constraintHertz` to **0.25 × tick rate**, the same clamp the pin joint's positional constraint lives under). So rigid weld ≠ infinitely hard: it is the engine's own stability-clamped stiffest, exactly the "0.25×tick clamp pattern" asked for. - With hertz > 0 the weld becomes a soft constraint via `b3MakeSoft(hertz, zeta, h)` (`src/solver.h:264-293`) — the softness path for "stretch before something explodes". - The angular constraint drives `relQ` toward `targetQuat = b3Quat_identity` **relative to the two local frames** (`b3SolveWeldJoint`, lines 223-247). Frames are computed at prepare as `bodyRotation × localFrame` (lines 117-120), and the joint enforces zero relative motion from creation onward — so the frames must capture the grab-time relative rotation. - Live setters exist and warm-start-preserving: `b3WeldJoint_SetLinearHertz / _SetAngularHertz / _SetLinearDampingRatio / _SetAngularDampingRatio` (`box3d.h:1588-1609`). ## The mapping implemented [S] **CONE_TWIST_JOINT → b3WeldJoint.** Chosen over the b3SphericalJoint-with-limits alternative because the weld is the engine's purpose-built "connects two bodies rigidly" and its hertz tuning directly implements the wanted soft-rigid spectrum; the spherical route (cone 0 + twist [0,0]) would instead repurpose limit constraints never designed as a weld and drag along the spherical spring machinery (identity target — the v5 bug). Nonzero swing/twist spans: the weld has NO limits, so they are **warned + treated as fully rigid**. ### Files (all in `.vendor/godot-box3d`) | File | Change | |---|---| | `src/joints/box3d_weld_joint_impl_3d.hpp/.cpp` | NEW — `Box3DWeldJointImpl3D` (mirrors the pin impl): `_create_joint_id` builds the `b3WeldJointDef`; `get_type()` returns `JOINT_TYPE_CONE_TWIST`; `set_param`/`get_param` dispatch `ConeTwistJointParam` | | `src/servers/box3d_physics_server_3d.cpp` | `_joint_make_cone_twist` now constructs `Box3DWeldJointImpl3D` (was `ERR_FAIL_MSG` "not supported"); `_cone_twist_joint_set_param/_get_param` dispatch to it; include added | ### Frame capture — the load-bearing detail [S] Godot 4.7 core (`scene/3d/physics/joints/cone_twist_joint_3d.cpp`, `_configure_joint`) passes `local_a = bodyA_global⁻¹ × jointGT`, `local_b = bodyB_global⁻¹ × jointGT` (orthonormalized). Fed straight in, the weld would drive the bodies to the JOINT NODE's relative orientation — the identity-target bug all over again. `_create_joint_id` therefore pre-corrects frame B with the bodies' **current** relative rotation: ```c const b3Quat quat_a = b3Body_GetRotation(p_body_a); const b3Quat quat_b = b3Body_GetRotation(p_body_b); const b3Quat world_frame_a = b3MulQuat(quat_a, p_local_frame_a.q); def.base.localFrameB.q = b3InvMulQuat(quat_b, world_frame_a); ``` so the weld locks the **relative pose at creation time** no matter how the joint node was oriented/rotated. Consequence for the C# side: the weld freezes the pose the bodies had when the joint entered the tree — grab placement and configure-before-AddChild ordering matter exactly as with the pin joint. Joint3D (4.7) has NO transform-notification recreation for ConeTwist (only POST_ENTER_TREE/EXIT_TREE), so moving the joint node afterwards does nothing — unlike PinJoint3D's every-tick anchor rewrite; a weld is NOT a hand-following pivot. ### Param mapping table [S] | Godot ConeTwistJoint3D | default | Box3D weld | Notes | |---|---|---|---| | `swing_span` | 45° | warn + ignored | no limits in a weld; treated rigid | | `twist_span` | 180° | warn + ignored | same | | `bias` | 0.3 | linear+angular Hertz = bias × 30, live via `b3WeldJoint_Set*Hertz` | **0 = MAX stiffness** (engine-clamped world softness); larger = softer/stretchier. ×30 mirrors the wrapper's pin-joint convention (bias→spring hertz). Damping ratios fixed at 1.0 (critical) | | `softness` | 0.8 | warn + ignored | use `bias` to soften | | `relaxation` | 1.0 | warn + ignored | no equivalent | | `exclude_nodes_from_collision` | true | `b3Joint_SetCollideConnected` | via base class, unchanged | **Bias semantics inversion to remember**: for the PIN joint bias 0 = spring OFF (pure ball joint); for the WELD bias 0 = MAX STIFFNESS (rigid). Grab tuning: rigid weld = bias 0; "slightly soft" = small bias (0.1 → 3 Hz, 0.3 → 9 Hz, 0.5 → 15 Hz). At the default 0.3 the weld is a 9 Hz soft weld. Pin/hinge/slider behavior untouched (only the two cone-twist server methods changed; the old rejection string is gone from the binary, everything else byte-identical behavior). ## Build recipe [S] Reality differed from the original plan: the repo builds with **CMake, not scons**, and godot-cpp is fetched by CMake (ExternalProject, pinned `godot-4.3-stable` — GDExtension ABI is forward-compatible, this is what the shipped binary was built as too); box3d is the only git submodule. No venv/scons needed. ```sh cd /home/foxyz/Projects/Games/vr-messs/.vendor/godot-box3d # clone of v0.2.6, .vendor has .gdignore git checkout v0.2.6 && git submodule update --init --recursive cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_GENERATOR=Ninja cmake --build build --parallel 12 # ~4 min on 12 cores; godot-cpp dominates # → bin/libgodot-box3d.so (serves BOTH linux.debug and linux.release .gdextension entries) ``` Apply the patch after a future addon update (rebase onto the new tag, resolve, re-verify): `git apply .vendor/box3d-weld.patch` (paths are repo-relative). Then swap `bin/libgodot-box3d.so` into `addons/godot-box3d/bin/` — stock binaries are preserved in `addons/godot-box3d/bin-stock/` (unpatched v0.2.6 linux .so + win/mac binaries, never touched). The `.gdextension` file resolves unchanged (same filename). ## Proof (headless, Godot 4.7.2 mono, `godot-mono --headless --path . scenes/...`) [S] - Regression: `physics_spike.tscn` → "SPIKE: overall PASS"; `grab_spike.tscn` → "GRAB: overall PASS" — pin/hinge/slider (and the whole v5.2 grab path) work identically on the patched binary. - New regression `scenes/weld_spike.tscn` + `scripts/WeldSpike.cs` (kept as the permanent weld regression): 1 kg box + 0.05 kg box welded via ConeTwistJoint3D (swing 0, twist 0), server joint type verified `ConeTwist`. Phase 1 rigid (bias 0): 0.5 N·m torque on A + 0.5 N force on B for 1.5 s — max relative-rotation drift **0.80°** (limit 2°), max anchor separation **4.2 mm** (limit 10 mm), max speed 8.86 m/s. Phase 2 soft (bias 0.5 = 15 Hz), re-welded at the current pose, sustained ±4 N pull on the 0.05 kg body (~84 m/s²): excursion stays bounded (max 72° / 26 cm) with speeds ≤ 5.91 m/s, and after load release it **fully recovers** in ~1 s (settle 0.41° / 2.0 mm). "WELD: overall PASS" 3× runs, bit-identical numbers (deterministic). - Notes from bring-up: a 15 Hz weld under that load visibly pumps (the excursion above) — in grab terms the stretch-break logic will fire long before that; bias closer to 0 for rigid grabs. ## Risks / limitations [S unless noted] - A weld is NOT a hand-following pivot: moving the joint node post-creation does nothing (no anchor rewrite like PinJoint3D). The grabber must NOT re-set its GlobalPosition every tick expecting the pin behavior. [I] likely wants one weld per grab, created at the palm. - Recreating the joint recaptures the pose at recreation (used by phase 2 of the spike; the grabber's takeover can exploit this deliberately). - Godot-side ConeTwistJoint3D docs still describe swing/twist semantics that the Box3D backend does not implement (warned, rigid) — C# code must not read meaning into nonzero spans on Box3D. [S] warning strings in the patch. - The patched .so is Linux-only; the shipped Windows/macOS binaries in `bin/` (and the stock backups) do NOT have the weld — ConeTwist on those still errors. Rebuilding those platforms requires their toolchains (CI or mingw for Windows). - Generic6DOFJoint3D remains rejected (unchanged). - `.vendor/` carries `.gdignore` so the Godot importer never scans the source tree; the patch lives at `.vendor/box3d-weld.patch`. ## C# usage sketch for the grab (orchestrator's next step) [S] ```csharp var weld = new ConeTwistJoint3D { ExcludeNodesFromCollision = true, Position = palmPoint, // world pose captured; node basis irrelevant (frame normalized) NodeA = handBody.GetPath(), // configure EVERYTHING before AddChild (same as pin) NodeB = prop.GetPath(), SwingSpan = 0f, TwistSpan = 0f, // rigid; nonzero only warns Bias = grabSoftness, // 0 = rigid; 0.1–0.3 = slight give (3–9 Hz) }; AddChild(weld); // do NOT move weld.GlobalPosition afterwards — it is not a hand-following pivot ```