Skip to content

feat(joint): promote JointComponent to public API with typed joints#8891

Merged
willeastcott merged 11 commits into
mainfrom
joint-public-api
Jun 13, 2026
Merged

feat(joint): promote JointComponent to public API with typed joints#8891
willeastcott merged 11 commits into
mainfrom
joint-public-api

Conversation

@willeastcott

@willeastcott willeastcott commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Description

Rewrites the private (@ignore) JointComponent into a public, type-based joint API, ready for editor exposure. The whole surface is tagged @alpha to signal it may still change before it stabilizes.

API

  • joint.typefixed | 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.
  • The joint entity's own transform defines the joint frame — its local X axis is the primary axis (hinge rotation, slider travel, ball twist). entityA/entityB are the constrained bodies (accepting Entity or GUID); entityB = null pins to the world.
  • Per-type properties: enableLimits/limits + motorSpeed/maxMotorForce (hinge/slider — the motor engages whenever maxMotorForce > 0), swingLimitY/swingLimitZ/twistLimit (ball), and per-axis linearMotionX/Y/Z-style enums (MOTION_LOCKED/LIMITED/FREE), linearLimitsX/Y/Z Vec2 pairs and Vec3 linearStiffness/linearDamping/linearEquilibrium spring families (6dof; a spring acts on an axis when its stiffness component is > 0). Angular families mirror linear, in degrees.
  • breakImpulse with a break event and read-only isBroken; refreshFrames() re-captures frames from current transforms and re-arms broken joints. The raw Ammo constraint is intentionally not exposed (kept @ignore, like RigidBodyComponent.body) so the public surface stays backend-agnostic for a future Jolt/PhysX backend.
  • No redundant enable* booleans: a spring acts when its stiffness is > 0 and a motor when its maxMotorForce is > 0, so on/off is derived from the magnitude rather than a separate flag.

Implementation notes for reviewers

  • World pinning routes through a shared static fixed body (JointComponentSystem.getFixedBody(), Bullet's own pattern) because the single-body btConeTwistConstraint constructor does not world-convert its frame origin — verified empirically: world-pinned ball joints anchored at the world origin and flew toward it.
  • Break detection is three-tier: 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).
  • Self-healing lifecycle: joints wait for rigid bodies to enter the simulation (pending set drained pre-step) and tear down synchronously before any btRigidBody is destroyed, via two new internal RigidBodyComponent events (simulationenabled/simulationdisabled) fired from enableSimulation/disableSimulation — every body-destruction path passes through the latter.
  • Frames are derived from unscaled world position + rotation, matching how rigid bodies treat entity scale.
  • Live property changes apply to existing constraints where Bullet allows and activate the constrained bodies (sleeping islands otherwise ignore motor/limit changes).
  • Entity references track destruction, clone with full fidelity and remap on entity duplication (button-style resolveDuplicatedEntityReferenceProperties).
  • Fixes several latent bugs in the old component, including !enableCollision being passed as Bullet's useLinearReferenceFrameA constructor 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: type now exists and defaults to fixed (old behavior = type: '6dof'), enableCollision default flipped truefalse (Unity/Godot convention), breakForce renamed breakImpulse (it always was an impulse threshold), and the 6dof spring booleans are gone (stiffness > 0 enables a spring).

Verification

  • 19 new ammo-less unit tests (test/framework/components/joint/); full suite, lint, build:types/test:types all pass.
  • Two new examples, used as live verification rigs: physics/joints (door hinge with limits, motorized windmill, ball chain with asymmetric swing limits, motorized slider patrol, 6dof spring, breakable wall firing break events) 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).
  • Behaviors verified by stepping the simulation programmatically: hinge motor runs at exactly the requested speed, door slams clamp at the configured limit, ball swing anisotropy matches the documented Y/Z mapping, slider respects travel limits, the motor free/drive/brake states all behave (force 0 = free, force > 0 + speed 0 = brake), welds break with events on impact, rigidbody disable/enable destroys and resurrects constraints, and refreshFrames() re-attaches broken joints.

Follow-ups (separate repos)

  • editor: joint inspector, schema entry, component menu, reference docs.
  • playcanvas/ammo.js: bind btTypedConstraint::isEnabled() (one-line idl change) for exact break detection — the engine feature-detects and upgrades automatically; optionally bind btGeneric6DofSpring2Constraint for physical spring damping.

Checklist

  • I have read the contributing guidelines
  • My code follows the project's coding standards
  • This PR focuses on a single change

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

Public API report

This 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.

@willeastcott willeastcott self-assigned this Jun 12, 2026
@willeastcott willeastcott added enhancement Request for a new feature area: physics Physics related issue labels Jun 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/framework/components/joint/component.js
Comment thread src/framework/components/joint/system.js Outdated
Comment thread src/framework/components/rigid-body/component.js Outdated
- 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>
Comment thread src/framework/components/joint/component.js
Comment thread test/framework/components/joint/component.test.mjs Outdated
@willeastcott

Copy link
Copy Markdown
Contributor Author

@LeXXik I think I'm happy with this PR now. Any further concerns from you about it?

@LeXXik

LeXXik commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Looks good! I like your approach with multi-joints. Looking forward to see Editor's support for it.

@willeastcott

Copy link
Copy Markdown
Contributor Author

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! 😄

@willeastcott willeastcott merged commit 42c93f1 into main Jun 13, 2026
9 checks passed
@willeastcott willeastcott deleted the joint-public-api branch June 13, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: physics Physics related issue enhancement Request for a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants