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>
103 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
});
|