Files
feuerwerk/tests/viewport/joint-links.test.ts
T
adminandClaude Opus 5.5 f1e3bd8378
game-maker / gate (push) Successful in 3m4s
game-maker / suite (push) Successful in 14m2s
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>
2026-10-03 10:09:45 +02:00

103 lines
4.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AddJointCommand } from '../../src/core/commands/colliderCommands';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import {
MoveSceneObjectCommand,
PlaceAssetCommand,
SetOverrideCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { objectReference } from '../../src/core/model/objectRefs';
import type { PropertyValue } from '../../src/core/model/Properties';
import { collectHolderItems } from '../../src/viewport/items';
import { jointLinkOf } from '../../src/viewport/jointLinks';
import { makeSpriteScene, place } from '../helpers';
/** A box at (0, 0) with a joint 10 to its right, and a hook at (200, -100) turned by a quarter. */
function scene(values: Record<string, PropertyValue>, connect = true) {
const f = makeSpriteScene('Box');
const box = place(f, f.spriteId, { x: 0, y: 0 });
const hook = place(f, f.spriteId, { x: 200, y: -100 });
f.stack.execute(new SetOverrideCommand(f.sceneId, hook, 'rotation', 90));
const add = new AddJointCommand(f.sceneId, box);
f.stack.execute(add);
const joint = add.objectId;
f.stack.execute(new MoveSceneObjectCommand(f.sceneId, joint, { x: 10, y: 0, z: 0 }));
if (connect) f.stack.execute(new SetOverrideCommand(f.sceneId, joint, 'connected', objectReference(hook)));
for (const [key, value] of Object.entries(values))
f.stack.execute(new SetOverrideCommand(f.sceneId, joint, key, value));
const links = () => {
const { items } = collectHolderItems(f.project, f.sceneOf(), 0);
const byId = new Map(items.map((i) => [i.id, i]));
return items.flatMap((item) => jointLinkOf(item, byId) ?? []);
};
return { f, box, hook, joint, links };
}
describe('what the scene view draws of a joint', () => {
it('leads a hinge from where it sits to the connected object', () => {
const s = scene({ jointType: 'hinge' });
expect(s.links()).toEqual([
expect.objectContaining({
id: s.joint,
from: { x: 10, y: 0 },
to: { x: 200, y: -100 },
connectedId: s.hook,
anchored: false,
}),
]);
});
it('leads a rope to its connected anchor, counted in the connected object', () => {
const s = scene({ jointType: 'rope', connectedAnchorX: 50, connectedAnchorY: 0 });
const [link] = s.links();
// The hook is turned by a quarter, so 50 along its x is 50 down.
expect(link!.to.x).toBeCloseTo(200);
expect(link!.to.y).toBeCloseTo(-50);
expect(link!.anchored).toBe(true);
expect(link!.frame).not.toBeNull();
});
it('counts the anchor of a rope held to the scene in the scene', () => {
const s = scene({ jointType: 'spring', connectedAnchorX: 50, connectedAnchorY: -70 }, false);
expect(s.links()[0]).toMatchObject({ to: { x: 50, y: -70 }, connectedId: '', frame: null });
});
it('gives a hinge held to the scene no line: both ends are where it sits', () => {
const s = scene({ jointType: 'hinge' }, false);
expect(s.links()[0]).toMatchObject({ from: { x: 10, y: 0 }, to: { x: 10, y: 0 }, connectedId: '' });
});
it('lays a slider rail along the joint as it is turned, between its limits once it has some', () => {
const open = scene({ jointType: 'slider', rotation: 90 });
const [link] = open.links();
expect(link!.to).toEqual({ x: 200, y: -100 });
expect(link!.rail!.limited).toBe(false);
expect(link!.rail!.from.x).toBeCloseTo(10);
expect(link!.rail!.from.y).toBeCloseTo(-60);
expect(link!.rail!.to.y).toBeCloseTo(60);
const limited = scene({ jointType: 'slider', limitDistance: true, minDistance: -20, maxDistance: 80 });
expect(limited.links()[0]!.rail).toEqual({ from: { x: -10, y: 0 }, to: { x: 90, y: 0 }, limited: true });
expect(scene({ jointType: 'hinge' }).links()[0]!.rail).toBeNull();
});
it('has no link for anything but a joint', () => {
const s = scene({});
const { items } = collectHolderItems(s.f.project, s.f.sceneOf(), 0);
const byId = new Map(items.map((i) => [i.id, i]));
expect(jointLinkOf(byId.get(s.box)!, byId)).toBeNull();
});
it("finds, inside each placement of a container, that placement's own copy", () => {
const s = scene({ jointType: 'hinge' });
const create = new CreateContainerCommand(s.f.sceneId, [s.box, s.hook]);
s.f.stack.execute(create);
const first = s.f.object(create.objectId).position;
s.f.stack.execute(new PlaceAssetCommand(s.f.sceneId, create.assetId, { x: first.x + 500, y: first.y }));
const links = s.links().sort((a, b) => a.from.x - b.from.x);
expect(links).toHaveLength(2);
expect(links[1]!.from.x - links[0]!.from.x).toBeCloseTo(500);
expect(links[1]!.to.x - links[0]!.to.x).toBeCloseTo(500);
expect(links[0]!.connectedId).not.toBe(links[1]!.connectedId);
});
});