feat(joint): promote JointComponent to public API with typed joints#8891
Conversation
Rewrites the private JointComponent into a public, type-based joint API. joint.type selects fixed, ball, hinge, slider or 6dof, each backed by the most appropriate Bullet constraint (btFixedConstraint, btConeTwistConstraint, btHingeConstraint, btSliderConstraint, btGeneric6DofSpringConstraint). The joint entity's own transform defines the joint frame - its local X axis is the primary axis - and entityA and entityB are the constrained bodies, with a null entityB pinning to the world. Highlights: - Per-type limits, hinge/slider motors, ball swing/twist cones and 6dof per-axis motion/limits/springs (Vec3 stiffness/damping/equilibrium, springs active when stiffness > 0). - breakImpulse with a break event and isBroken, detected via a three-tier strategy ending in anchor-separation measurement, since the stock ammo build exposes no constraint state. - World pinning routes through a shared static fixed body, as the single-body btConeTwistConstraint constructor does not world-convert its frame. - Self-healing lifecycle: joints wait for rigid bodies to enter the simulation and tear down synchronously before bodies are destroyed, via new internal rigidbody simulationenabled/simulationdisabled events. - Entity references accept Entity or GUID, track destruction, clone with full fidelity and remap on entity duplication. - refreshFrames() re-captures frames from current transforms and re-arms broken joints. - Frames are derived from unscaled world position and rotation, matching how rigid bodies treat entity scale. Adds 19 ammo-less unit tests, a six-rig joints example (door hinge, motorized windmill, ball chain, slider patrol, 6dof spring, breakable wall) and a ragdoll example with three cloned ragdolls and pointer grab-and-drag via a ball joint to a kinematic anchor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Public API reportThis PR changes the public API surface (+98 / −0), per the docs' rules (@ignore / @Private / undocumented are excluded). Show API diff+ComponentSystemRegistry.readonly joint: JointComponentSystem | undefined
+JointComponent.entity: Entity
+JointComponent.fire(name: string, arg1?: any, arg2?: any, arg3?: any, arg4?: any, arg5?: any, arg6?: any, arg7?: any, arg8?: any): EventHandler
+JointComponent.get angularDamping(): Vec3
+JointComponent.get angularEquilibrium(): Vec3
+JointComponent.get angularLimitsX(): Vec2
+JointComponent.get angularLimitsY(): Vec2
+JointComponent.get angularLimitsZ(): Vec2
+JointComponent.get angularMotionX(): "free" | "limited" | "locked"
+JointComponent.get angularMotionY(): "free" | "limited" | "locked"
+JointComponent.get angularMotionZ(): "free" | "limited" | "locked"
+JointComponent.get angularStiffness(): Vec3
+JointComponent.get breakImpulse(): number
+JointComponent.get enableCollision(): boolean
+JointComponent.get enableLimits(): boolean
+JointComponent.get enabled(): boolean
+JointComponent.get entityA(): Entity | null
+JointComponent.get entityB(): Entity | null
+JointComponent.get isBroken(): boolean
+JointComponent.get limits(): Vec2
+JointComponent.get linearDamping(): Vec3
+JointComponent.get linearEquilibrium(): Vec3
+JointComponent.get linearLimitsX(): Vec2
+JointComponent.get linearLimitsY(): Vec2
+JointComponent.get linearLimitsZ(): Vec2
+JointComponent.get linearMotionX(): "free" | "limited" | "locked"
+JointComponent.get linearMotionY(): "free" | "limited" | "locked"
+JointComponent.get linearMotionZ(): "free" | "limited" | "locked"
+JointComponent.get linearStiffness(): Vec3
+JointComponent.get maxMotorForce(): number
+JointComponent.get motorSpeed(): number
+JointComponent.get swingLimitY(): number
+JointComponent.get swingLimitZ(): number
+JointComponent.get twistLimit(): number
+JointComponent.get type(): "fixed" | "ball" | "hinge" | "slider" | "6dof"
+JointComponent.hasEvent(name: string): boolean
+JointComponent.off(name?: string, callback?: HandleEventCallback, scope?: any): EventHandler
+JointComponent.on(name: string, callback: HandleEventCallback, scope?: any): EventHandle
+JointComponent.onBeforeRemove(): void
+JointComponent.once(name: string, callback: HandleEventCallback, scope?: any): EventHandle
+JointComponent.refreshFrames(): void
+JointComponent.set angularDamping(arg: Vec3)
+JointComponent.set angularEquilibrium(arg: Vec3)
+JointComponent.set angularLimitsX(arg: Vec2)
+JointComponent.set angularLimitsY(arg: Vec2)
+JointComponent.set angularLimitsZ(arg: Vec2)
+JointComponent.set angularMotionX(arg: "free" | "limited" | "locked")
+JointComponent.set angularMotionY(arg: "free" | "limited" | "locked")
+JointComponent.set angularMotionZ(arg: "free" | "limited" | "locked")
+JointComponent.set angularStiffness(arg: Vec3)
+JointComponent.set breakImpulse(impulse: number)
+JointComponent.set enableCollision(enableCollision: boolean)
+JointComponent.set enableLimits(arg: boolean)
+JointComponent.set enabled(value: boolean)
+JointComponent.set entityA(arg: Entity | null)
+JointComponent.set entityB(arg: Entity | null)
+JointComponent.set limits(arg: Vec2)
+JointComponent.set linearDamping(arg: Vec3)
+JointComponent.set linearEquilibrium(arg: Vec3)
+JointComponent.set linearLimitsX(arg: Vec2)
+JointComponent.set linearLimitsY(arg: Vec2)
+JointComponent.set linearLimitsZ(arg: Vec2)
+JointComponent.set linearMotionX(arg: "free" | "limited" | "locked")
+JointComponent.set linearMotionY(arg: "free" | "limited" | "locked")
+JointComponent.set linearMotionZ(arg: "free" | "limited" | "locked")
+JointComponent.set linearStiffness(arg: Vec3)
+JointComponent.set maxMotorForce(arg: number)
+JointComponent.set motorSpeed(arg: number)
+JointComponent.set swingLimitY(arg: number)
+JointComponent.set swingLimitZ(arg: number)
+JointComponent.set twistLimit(arg: number)
+JointComponent.set type(type: "fixed" | "ball" | "hinge" | "slider" | "6dof")
+JointComponent.static EVENT_BREAK: string
+JointComponent.system: ComponentSystem
+JointComponentSystem.ComponentType: typeof JointComponent
+JointComponentSystem.app: AppBase
+JointComponentSystem.destroy(): void
+JointComponentSystem.fire(name: string, arg1?: any, arg2?: any, arg3?: any, arg4?: any, arg5?: any, arg6?: any, arg7?: any, arg8?: any): EventHandler
+JointComponentSystem.hasEvent(name: string): boolean
+JointComponentSystem.id: string
+JointComponentSystem.off(name?: string, callback?: HandleEventCallback, scope?: any): EventHandler
+JointComponentSystem.on(name: string, callback: HandleEventCallback, scope?: any): EventHandle
+JointComponentSystem.onBeforeRemove(entity: any, component: any): void
+JointComponentSystem.onPostUpdate(dt: any): void
+JointComponentSystem.onUpdate(dt: any): void
+JointComponentSystem.once(name: string, callback: HandleEventCallback, scope?: any): EventHandle
+JointComponentSystem.schema: any[]
+JointComponentSystem.store: object
+class JointComponent extends Component
+class JointComponentSystem extends ComponentSystem
+const JOINTTYPE_6DOF: "6dof"
+const JOINTTYPE_BALL: "ball"
+const JOINTTYPE_FIXED: "fixed"
+const JOINTTYPE_HINGE: "hinge"
+const JOINTTYPE_SLIDER: "slider"
+const MOTION_FREE: "free"
+const MOTION_LIMITED: "limited"
+const MOTION_LOCKED: "locked"Informational only — this never fails the build. |
There was a problem hiding this comment.
Pull request overview
This PR promotes the previously private JointComponent into a public, typed joint API intended for editor exposure, with per-type configuration, frame handling, and break detection integrated into the engine physics lifecycle.
Changes:
- Replaces the old joint implementation with a type-driven JointComponent + JointComponentSystem that create and manage Ammo/Bullet constraints (including world-pinning and break handling).
- Adds unit tests covering data initialization, property round-tripping, entity reference resolution, cloning/duplication remapping, and lifecycle safety without requiring Ammo.
- Adds new physics examples (
physics/joints,physics/ragdoll) to demonstrate and validate the new API in live scenes, plus integrates duplication remapping and rigidbody simulation lifecycle events needed by joints.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/framework/components/joint/component.test.mjs | New unit tests validating initialization, property behavior, entity refs, cloning, and lifecycle safety. |
| src/framework/entity.js | Ensures joint entity references are remapped correctly during subtree duplication. |
| src/framework/components/rigid-body/component.js | Emits internal simulation lifecycle events used by joints to create/tear down constraints safely. |
| src/framework/components/registry.js | Makes app.systems.joint part of the public API surface (removes @ignore). |
| src/framework/components/joint/system.js | New/updated joint system managing pending creation, break tracking, and a shared fixed body for world pinning. |
| src/framework/components/joint/constants.js | Exposes joint/motion constants as public physics API with docs/categories. |
| src/framework/components/joint/component.js | Full rewrite of JointComponent public API, constraint creation, live updates, break detection, and duplication remapping. |
| examples/src/examples/physics/ragdoll.example.mjs | New ragdoll example exercising ball/hinge joints, cloning remap, and pointer grab via joints. |
| examples/src/examples/physics/joints.example.mjs | New joints showcase example covering hinge/slider/ball/6dof/fixed + motors/limits/break events. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Mark JointComponentSystem.getFixedBody as internal (_getFixedBody, @ignore) so the shared Ammo body is not part of the public surface. - Correct the simulationdisabled comment: the body has already been removed from the dynamics world when the event fires; the teardown guards against referencing it once it is later destroyed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The underscore claims class privacy, but getFixedBody is part of the component/system contract (called from JointComponent). Name it plainly and rely on @ignore to keep it off the public docs/API surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The constraint getter returned a raw Ammo (Bullet) object as documented public API, baking the current physics backend into the permanent contract - awkward ahead of multi-backend support. Mark it @ignore so it is excluded from the public API surface, matching RigidBodyComponent.body (which is likewise undocumented). It remains reachable at runtime as an unsupported escape hatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove the redundant enableMotor boolean. A motor with a zero force budget does nothing, so a positive maxMotorForce is the natural "motor active" signal - mirroring how a spring acts when its stiffness is positive. This drops another boolean from the public surface and removes a redundant state without losing expressiveness: free (zero force), drive (positive force, non-zero speed) and brake/hold (positive force, zero speed) all remain reachable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@LeXXik I think I'm happy with this PR now. Any further concerns from you about it? |
|
Looks good! I like your approach with multi-joints. Looking forward to see Editor's support for it. |
Yeah, I tried to shape the API to map well onto an Editor component UI. It should work pretty well! 😄 |
Description
Rewrites the private (
@ignore) JointComponent into a public, type-based joint API, ready for editor exposure. The whole surface is tagged@alphato signal it may still change before it stabilizes.API
joint.type∈fixed | ball | hinge | slider | 6dof(JOINTTYPE_*constants), each backed by the most appropriate Bullet constraint (btFixedConstraint,btConeTwistConstraint,btHingeConstraint,btSliderConstraint,btGeneric6DofSpringConstraint). Per-type backends are plain module-scope dispatch tables next to the component, not system-hosted impl classes.entityA/entityBare the constrained bodies (acceptingEntityor GUID);entityB = nullpins to the world.enableLimits/limits+motorSpeed/maxMotorForce(hinge/slider — the motor engages whenevermaxMotorForce > 0),swingLimitY/swingLimitZ/twistLimit(ball), and per-axislinearMotionX/Y/Z-style enums (MOTION_LOCKED/LIMITED/FREE),linearLimitsX/Y/ZVec2 pairs and Vec3linearStiffness/linearDamping/linearEquilibriumspring families (6dof; a spring acts on an axis when its stiffness component is > 0). Angular families mirror linear, in degrees.breakImpulsewith abreakevent and read-onlyisBroken;refreshFrames()re-captures frames from current transforms and re-arms broken joints. The raw Ammo constraint is intentionally not exposed (kept@ignore, likeRigidBodyComponent.body) so the public surface stays backend-agnostic for a future Jolt/PhysX backend.enable*booleans: a spring acts when its stiffness is > 0 and a motor when itsmaxMotorForceis > 0, so on/off is derived from the magnitude rather than a separate flag.Implementation notes for reviewers
JointComponentSystem.getFixedBody(), Bullet's own pattern) because the single-bodybtConeTwistConstraintconstructor does not world-convert its frame origin — verified empirically: world-pinned ball joints anchored at the world origin and flew toward it.isEnabled()→getAppliedImpulse()→ anchor-separation measurement. The bundled ammo build binds neither of the first two on constraints, so the stock build relies on anchor separation (works for fixed/ball/hinge/slider; 6dof warns once and still breaks physically, just without the event).btRigidBodyis destroyed, via two new internalRigidBodyComponentevents (simulationenabled/simulationdisabled) fired fromenableSimulation/disableSimulation— every body-destruction path passes through the latter.resolveDuplicatedEntityReferenceProperties).!enableCollisionbeing passed as Bullet'suseLinearReferenceFrameAconstructor argument, angular equilibrium applied in radians while limits were converted from degrees, setter crashes before constraint creation, and scene-data initialization never having worked (GUIDs were written to backing fields unresolved).Breaking changes
The component was
@ignore'd, so no public API breaks. For anyone using it via forum recipes:typenow exists and defaults tofixed(old behavior =type: '6dof'),enableCollisiondefault flippedtrue→false(Unity/Godot convention),breakForcerenamedbreakImpulse(it always was an impulse threshold), and the 6dof spring booleans are gone (stiffness > 0 enables a spring).Verification
test/framework/components/joint/); full suite, lint,build:types/test:typesall pass.breakevents) and physics/ragdoll (three ragdolls cloned from one template — exercising duplication remap — with mouse/touch grab-and-drag via a ball joint to a kinematic anchor, and a 1/120 fixed timestep to prevent limb tunnelling).refreshFrames()re-attaches broken joints.Follow-ups (separate repos)
btTypedConstraint::isEnabled()(one-line idl change) for exact break detection — the engine feature-detects and upgrades automatically; optionally bindbtGeneric6DofSpring2Constraintfor physical spring damping.Checklist
🤖 Generated with Claude Code