Let a joint glide on a rail and drive it with a motor (#154)
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:
1 parent
4716726b6b
commit
f1e3bd8378
16 files changed
+738
-34
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user