Let a joint glide on a rail and drive it with a motor (#154)
game-maker / gate (push) Successful in 3m4s
game-maker / suite (push) Successful in 14m2s

Step 2 of the joints: a door that opens by itself, a lift and a
piston need a joint that slides along one line and a motor that turns
or pushes. A graph also needs to read how far such a joint stands.

- Slider: a fifth joint type on Rapier's prismatic joint. Its rail is
  the joint's own x axis, so turning the joint turns the rail. Joints
  now carry Scale, Rotation and Mirror like a sprite.
- Limit distance with Min and Max, in world units from where the
  object was placed.
- Motor on hinge and slider: Off, Speed or Target, with Motor speed,
  Motor target and Max force (0 = no limit). Target goes there at no
  more than the speed and slows down for the last stretch.
- Changing speed, target or force keeps the engine joint. Switching
  the motor on or off builds it again.
- New node Joint position: value and speed of a hinge (degrees,
  counting past a full round) or a slider (world units).
- Scene view draws a slider's rail dashed, with a tick at each limit.
- shownFor in the schema takes a list of choices that must all hold.

Findings, measured with a probe against Rapier 0.20:
- A prismatic joint holds both bodies at one angle. Held to the scene
  or to an object without a rigidbody, the joint's own anchor body is
  turned by the placed difference, so nothing jumps. Between two
  rigidbodies the held one is turned to match, and the console says
  so once.
- Rapier reads no joint angle back. The position is worked out from
  the two bodies.
- A hinge motor is weak on a small body at the end of a long arm
  (box 20 on an arm of 100 does not lift itself). More solver
  iterations would help but cost every scene. A door of 100 x 20
  reaches -90 and holds it. This is written in the wiki.

Tests:
- Unit: 16 new in joints.test.ts, 1 in joint-links, 3 catalog cases.
- e2e 41: 27 checks (25 + 2), block 41f with a real mouse in the
  inspector and the motor in play (59.1 of 60 units in 1 s).
- 9 of 9 unit counter-probes fail as they should.
- Gate: 220 files, 3436 tests (3416 + 20), CHECK EXIT 0.
- e2e total 1701 is counted (1699 + 2), the full suite did not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 10:09:45 +02:00
1 parent 4716726b6b
commit f1e3bd8378
16 files changed
+738 -34

No files matched your search

+6 -1
View File
@@ -63,7 +63,7 @@ import {
samePose,
} from './bodyGeometry';
import { type Rapier, toMetres, toPixels } from './engine';
import { type JointHost, JointSet, type RigidOwner } from './joints';
import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints';
import { passesOneWay, reachOf } from './oneWay';
/** The engine always steps this long, whatever the frame took. */
@@ -448,6 +448,11 @@ export class PhysicsSystem implements System, CollisionSource {
return this.joints.holds(joint);
}
/** Where a hinge or a slider stands (#154): degrees or world units from how it was placed. */
jointStateOf(joint: Entity): JointState | null {
return this.joints.stateOf(joint);
}
/**
* Every body the entities ask for, keyed by the entity that stands for it. A collider belongs to
* the nearest object above it with a rigidbody switched on; without one it is a body of its own.
+149 -14
View File
@@ -1,20 +1,28 @@
import type {
ImpulseJoint,
JointData,
RevoluteImpulseJoint,
RigidBody,
UnitImpulseJoint,
World as RapierWorld,
} from '@dimforge/rapier2d-compat';
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { isEntity } from '../../core/graph/values';
import { hasConnectedAnchor, JOINT_CONNECTED_KEY } from '../../core/model/joint';
import { hasConnectedAnchor, JOINT_CONNECTED_KEY, type JointType } from '../../core/model/joint';
import { referencedId } from '../../core/model/objectRefs';
import type { PropertyValue, Vec2 } from '../../core/model/Properties';
import { hingeLimits, jointFarEnd, jointLength, jointSettings } from '../../core/properties/joint';
import {
hingeLimits,
jointFarEnd,
jointLength,
jointSettings,
type JointMotorSettings,
motorSpeed,
sliderAxis,
} from '../../core/properties/joint';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { angleBetween, matrixPose, type Pose, round, samePose } from './bodyGeometry';
import { type Rapier, toMetres } from './engine';
import { type Rapier, toMetres, toPixels } from './engine';
/** The body the engine moves for an object, and that object. */
export interface RigidOwner {
@@ -39,12 +47,53 @@ interface Held {
anchorPose: Pose | null;
/** The length and the angle the two ends were placed at, kept while the same two bodies are held. */
placed: { between: string; length: number; angle: number } | null;
/**
* How a slider's object was turned against its own anchor body when it was placed; that body
* is turned along by as much, since the engine holds a slider's two ends at one angle.
*/
turn: number | null;
/** The held object's turn against the other end, counted on past a full round. */
wound: number;
/** What a slider's position is read off: the two points as placed and its axis, in the bodies' metres. */
rail: { onOther: Vec2; onOwn: Vec2; axis: Vec2 } | null;
type: JointType;
/** The speed and the force the motor was last asked for, so a sleeping body is woken on a change. */
motorCall: string;
collide: boolean;
}
/** How a hinge or a slider stands: degrees or world units from how it was placed, and how fast that changes. */
export interface JointState {
value: number;
speed: number;
}
const NO_RIGIDBODY = 'sits on an object without a rigidbody, so it holds nothing.';
const GONE = 'lost its connected object, so it holds nothing.';
const SAME_BODY = 'is connected to the object it sits on, so it holds nothing.';
const TURNED_TO_MATCH =
'is a slider between two objects turned against each other; the engine keeps them at one angle, so it turns the object to match.';
/** How hard a motor holds its speed; the engine's own unit, measured to reach it within a frame. */
const MOTOR_FACTOR = 1000;
/** What the engine takes for a motor without a limit. */
const NO_FORCE_LIMIT = 1e30;
const DEGREES = 180 / Math.PI;
const rotate = (v: Vec2, angle: number): Vec2 => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
};
/** Where a body's local point is in the world and how fast it moves there, in metres. */
function pointOf(body: RigidBody, local: Vec2): { at: Vec2; velocity: Vec2 } {
const t = body.translation();
const r = rotate(local, body.rotation());
const v = body.linvel();
const w = body.angvel();
return { at: { x: t.x + r.x, y: t.y + r.y }, velocity: { x: v.x - w * r.y, y: v.y + w * r.x } };
}
/** The object a joint names: none (a point of the scene), one that is there, or one that is gone. */
function connectedOf(world: World, joint: Entity): Entity | null | 'gone' {
@@ -71,6 +120,7 @@ const ORIGIN: Pose = { x: 0, y: 0, angle: 0 };
* The joints of the played game (#154): one engine joint per joint entity whose object has a
* rigidbody. The other end is the connected object's body, or – where that has none, or nothing is
* connected – a body of the joint's own that goes wherever that end is and is pulled by nothing.
* The engine reads no angle or distance back, so a hinge's and a slider's are worked out here.
*/
export class JointSet {
private readonly held = new Map<Entity, Held>();
@@ -98,11 +148,62 @@ export class JointSet {
return Boolean(this.held.get(entity)?.joint?.isValid());
}
/** Where a hinge or a slider stands right now; null while it holds nothing or is of another type. */
stateOf(entity: Entity): JointState | null {
const held = this.held.get(entity);
return held?.joint?.isValid() ? this.measure(held) : null;
}
/** The engine is freed as a whole; what was held in it is only forgotten. */
clear(): void {
this.held.clear();
}
private measure(held: Held): JointState | null {
const joint = held.joint!;
const other = joint.body1();
const own = joint.body2();
if (held.type === 'hinge' && held.placed) {
held.wound += angleBetween(held.wound, angleBetween(other.rotation(), own.rotation()));
return {
value: (held.wound - held.placed.angle) * DEGREES,
speed: (own.angvel() - other.angvel()) * DEGREES,
};
}
if (held.type === 'slider' && held.rail) {
const a = pointOf(other, held.rail.onOther);
const b = pointOf(own, held.rail.onOwn);
const axis = rotate(held.rail.axis, other.rotation());
const along = (v: Vec2) => toPixels(v.x * axis.x + v.y * axis.y);
return {
value: along({ x: b.at.x - a.at.x, y: b.at.y - a.at.y }),
speed: along({ x: b.velocity.x - a.velocity.x, y: b.velocity.y - a.velocity.y }),
};
}
return null;
}
/**
* Asks the motor for this frame's speed – towards its target, that changes as it gets there – and
* wakes the bodies while it moves them or when it is asked for something new.
*/
private drive(held: Held, motor: JointMotorSettings): void {
const state = this.measure(held);
if (!state) return;
const joint = held.joint as UnitImpulseJoint;
const speed = motorSpeed(motor, state.value);
const toEngine = held.type === 'hinge' ? 1 / DEGREES : toMetres(1);
// A hinge's force pushes 100 units – one of the engine's metres – from its pin.
const force = motor.maxForce > 0 ? toMetres(motor.maxForce) : NO_FORCE_LIMIT;
const call = `${round(speed)}|${force}`;
if (call === held.motorCall && Math.abs(speed) < 1e-6) return;
joint.configureMotorVelocity(speed * toEngine, MOTOR_FACTOR);
joint.setMotorMaxForce(force);
held.motorCall = call;
joint.body1().wakeUp();
joint.body2().wakeUp();
}
private syncOne(world: World, engine: RapierWorld, host: JointHost, entity: Entity): void {
let held = this.held.get(entity);
if (!held) {
@@ -112,6 +213,11 @@ export class JointSet {
anchor: null,
anchorPose: null,
placed: null,
turn: null,
wound: 0,
rail: null,
type: 'hinge',
motorCall: '',
collide: false,
};
this.held.set(entity, held);
@@ -131,12 +237,16 @@ export class JointSet {
if (!own.body.isEnabled() || (rigid && !rigid.body.isEnabled())) return idle();
const settings = jointSettings(entity.props);
const slider = settings.type === 'slider';
const ownPose = matrixPose(worldMatrixOf(own.entity));
const otherPose = rigid
const connectedPose = rigid
? matrixPose(worldMatrixOf(rigid.entity))
: connected
? matrixPose(worldMatrixOf(connected))
: ORIGIN;
if (!slider || rigid) held.turn = null;
else held.turn ??= angleBetween(connectedPose.angle, own.body.rotation());
const otherPose = { ...connectedPose, angle: connectedPose.angle + (held.turn ?? 0) };
const other = rigid ? rigid.body : this.anchorAt(engine, held, otherPose);
if (rigid) this.dropAnchor(engine, held);
@@ -145,44 +255,65 @@ export class JointSet {
const far = jointFarEnd(settings, at, connected ? worldMatrixOf(connected) : null);
const onOwn = inBody(ownPose, at);
const twoEnds = hasConnectedAnchor(settings.type);
// A hinge's and a fixed joint's point on the other body is wherever the joint was when it was
// made, so it is no part of the key – the bodies move, and the point with them.
// Counted in the held object, which the joint sits on: the rail turns with it, not as it glides.
const axis = slider ? rotate(sliderAxis(m), -ownPose.angle) : null;
// A hinge's, a fixed joint's and a slider's point on the other body is wherever the joint was
// when it was made, so it is no part of the key – the bodies move, and the point with them.
const key = JSON.stringify([
settings.type,
own.body.handle,
other.handle,
[round(onOwn.x), round(onOwn.y)],
twoEnds ? [round(inBody(otherPose, far).x), round(inBody(otherPose, far).y)] : 0,
axis ? [round(axis.x), round(axis.y)] : 0,
settings.length,
settings.stiffness,
settings.damping,
settings.limits,
settings.distanceLimits,
// Switching the motor off is a joint without one; its speed and target change on the way.
Boolean(settings.motor),
]);
if (!held.joint || key !== held.key) {
this.release(engine, held);
const between = `${settings.type}|${own.body.handle}|${other.handle}`;
if (held.placed?.between !== between)
if (held.placed?.between !== between) {
held.placed = {
between,
length: Math.hypot(far.x - at.x, far.y - at.y),
angle: angleBetween(other.rotation(), own.body.rotation()),
};
const data = this.dataOf(settings, held.placed, inBody(otherPose, far), onOwn, other, own.body);
held.wound = held.placed.angle;
if (slider && rigid && Math.abs(held.placed.angle) > 1e-3) host.hint(world, entity, TURNED_TO_MATCH);
}
const onOther = inBody(otherPose, far);
// The engine reads a slider's axis in the other body; its turn is the held one's while it holds.
const railAxis = axis ? rotate(axis, ownPose.angle - otherPose.angle) : null;
held.rail = railAxis ? { onOther, onOwn, axis: railAxis } : null;
held.type = settings.type;
const data = this.dataOf(settings, held.placed, onOther, onOwn, railAxis, other, own.body);
// The other end first: a hinge's angle is then the held object's turn against it.
held.joint = engine.createImpulseJoint(data, other, own.body, true);
// Only a hinge has limits, and a hinge is the engine's revolute joint.
// Only a hinge and a slider have limits; they are the engine's revolute and prismatic joints.
if (settings.limits)
(held.joint as RevoluteImpulseJoint).setLimits(...hingeLimits(settings.limits, held.placed.angle));
(held.joint as UnitImpulseJoint).setLimits(...hingeLimits(settings.limits, held.placed.angle));
if (settings.distanceLimits)
(held.joint as UnitImpulseJoint).setLimits(
toMetres(settings.distanceLimits.min),
toMetres(settings.distanceLimits.max),
);
held.joint.setContactsEnabled(settings.collideConnected);
held.key = key;
held.collide = settings.collideConnected;
return;
}
if (held.collide !== settings.collideConnected) {
held.motorCall = '';
} else if (held.collide !== settings.collideConnected) {
held.collide = settings.collideConnected;
held.joint.setContactsEnabled(held.collide);
own.body.wakeUp();
}
if (settings.motor) this.drive(held, settings.motor);
// A hinge is followed every frame, so a wheel that turns on counts its rounds.
else if (held.type === 'hinge') this.measure(held);
}
private dataOf(
@@ -190,6 +321,7 @@ export class JointSet {
placed: NonNullable<Held['placed']>,
onOther: Vec2,
onOwn: Vec2,
axis: Vec2 | null,
other: RigidBody,
own: RigidBody,
): JointData {
@@ -197,6 +329,8 @@ export class JointSet {
switch (settings.type) {
case 'hinge':
return JointData.revolute(onOther, onOwn);
case 'slider':
return JointData.prismatic(onOther, onOwn, axis!);
case 'spring':
return JointData.spring(
toMetres(jointLength(settings, placed.length)),
@@ -247,5 +381,6 @@ export class JointSet {
engine.removeRigidBody(held.anchor);
held.anchor = null;
held.anchorPose = null;
held.turn = null;
}
}
+4
View File
@@ -244,5 +244,9 @@ function physicsControl(
setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)),
applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })),
setGravity: (x, y) => physics.setGravity(world, { x, y }),
jointStateOf: (handle) => {
const found = resolve(handle);
return found?.type === 'joint' ? physics.jointStateOf(found) : null;
},
};
}