The knob on its stem turned and scaled only sprites and colliders. Every other type with Scale and Rotation had to be typed into the inspector. - Text, tilemap and particle source get the knob above their box. Edge and corner grips stay with sprite and collider, the only types with a width and a height of their own. - A container gets a thin frame round all of its content, turned and scaled with it, and the knob sits above that frame. It turns and scales round the container's origin, so the content swings with it. A container with no content gets the knob above its symbol. - contentFrame in sizeGizmo.ts is the pure rule for that frame. sizeGizmoEditor.ts wires it in. - One pull is one undo step, as for sprites. Not clicked in the browser: scaling (as opposed to turning) of a container by the knob. Only the shared code path covers it. Tests: - ./check.sh: 178 files, 2573 tests, CHECK EXIT 0 - 2 new unit tests for contentFrame - e2e 29k, 4 checks: knob on text, tilemap and particles, frame round the container's content, a real pull turns the container by 20.9 degrees round its origin with the content on the computed pixel, one undo takes it back - 4 counter-probes over #242 and #243 bite (2 unit, 2 browser) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
6.4 KiB
TypeScript
155 lines
6.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { apply, type Mat } from '../../src/core/model/matrix';
|
||
import {
|
||
contentFrame,
|
||
knobAt,
|
||
knobMode,
|
||
resized,
|
||
scaled,
|
||
sizeGripAt,
|
||
swept,
|
||
turned,
|
||
} from '../../src/editor/sizeGizmo';
|
||
|
||
// A 100 × 40 box centred on (0, 0), corners as `boxCorners` lists them.
|
||
const BOX = [
|
||
{ x: -50, y: -20 },
|
||
{ x: 50, y: -20 },
|
||
{ x: 50, y: 20 },
|
||
{ x: -50, y: 20 },
|
||
];
|
||
const ALL = { sizable: true, corners: true };
|
||
|
||
describe('size gizmo (#223)', () => {
|
||
it('puts the knob out from the middle of the top edge, turned with the box', () => {
|
||
expect(knobAt(BOX, 26)).toEqual({ base: { x: 0, y: -20 }, knob: { x: 0, y: -46 } });
|
||
// Turned a quarter clockwise: the top edge is now the right-hand one.
|
||
const turnedBox = [
|
||
{ x: 20, y: -50 },
|
||
{ x: 20, y: 50 },
|
||
{ x: -20, y: 50 },
|
||
{ x: -20, y: -50 },
|
||
];
|
||
const { knob } = knobAt(turnedBox, 26);
|
||
expect(knob.x).toBeCloseTo(46);
|
||
expect(knob.y).toBeCloseTo(0);
|
||
});
|
||
|
||
it('finds the knob first, then a corner, then an edge', () => {
|
||
const knob = { x: 0, y: -46 };
|
||
expect(sizeGripAt(BOX, knob, { x: 1, y: -45 }, 7, ALL)).toEqual({ kind: 'knob' });
|
||
expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } });
|
||
expect(sizeGripAt(BOX, knob, { x: 10, y: 22 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } });
|
||
expect(sizeGripAt(BOX, knob, { x: -53, y: 0 }, 7, ALL)).toEqual({
|
||
kind: 'size',
|
||
side: { sx: -1, sy: 0 },
|
||
});
|
||
expect(sizeGripAt(BOX, knob, { x: 0, y: 0 }, 7, ALL)).toBeNull();
|
||
});
|
||
|
||
it('reaches only half as far into the box, so a small sprite can still be grabbed off its middle', () => {
|
||
const knob = { x: 0, y: -46 };
|
||
expect(sizeGripAt(BOX, knob, { x: 10, y: 26 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } });
|
||
expect(sizeGripAt(BOX, knob, { x: 10, y: 17 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } });
|
||
expect(sizeGripAt(BOX, knob, { x: 10, y: 14 }, 7, ALL)).toBeNull();
|
||
expect(sizeGripAt(BOX, knob, { x: 45, y: 15 }, 7, ALL)).toBeNull();
|
||
expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } });
|
||
});
|
||
|
||
it('leaves the corners to the edges on a circle, and a polygon only its knob', () => {
|
||
const knob = { x: 0, y: -46 };
|
||
expect(sizeGripAt(BOX, knob, { x: 49, y: 17 }, 7, { sizable: true, corners: false })).toEqual({
|
||
kind: 'size',
|
||
side: { sx: 1, sy: 0 },
|
||
});
|
||
expect(sizeGripAt(BOX, knob, { x: 50, y: 0 }, 7, { sizable: false, corners: true })).toBeNull();
|
||
expect(sizeGripAt(BOX, knob, { x: 0, y: -46 }, 7, { sizable: false, corners: true })).toEqual({
|
||
kind: 'knob',
|
||
});
|
||
});
|
||
|
||
it('sizes from the dragged side and keeps the opposite one where it was', () => {
|
||
// Right edge from 50 out to 80: 30 wider, the centre 15 to the right, the left edge stays at -50.
|
||
expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 80, y: 3 }, false)).toEqual({
|
||
w: 130,
|
||
h: 40,
|
||
shift: { x: 15, y: 0 },
|
||
});
|
||
// Top left corner in by 10 and 5.
|
||
expect(resized({ w: 100, h: 40 }, { sx: -1, sy: -1 }, { x: -40, y: -15 }, false)).toEqual({
|
||
w: 90,
|
||
h: 35,
|
||
shift: { x: 5, y: 2.5 },
|
||
});
|
||
});
|
||
|
||
it('rounds to whole units, never below one, and keeps a circle round', () => {
|
||
expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 60.4, y: 0 }, false).w).toBe(110);
|
||
expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: -500, y: 0 }, false).w).toBe(1);
|
||
const circle = resized({ w: 40, h: 40 }, { sx: 0, sy: 1 }, { x: 0, y: 40 }, true);
|
||
expect(circle).toEqual({ w: 60, h: 60, shift: { x: 0, y: 10 } });
|
||
});
|
||
|
||
it('decides once between turning and scaling by the way the knob leaves the press', () => {
|
||
const centre = { x: 0, y: 0 };
|
||
const press = { x: 0, y: -46 };
|
||
expect(knobMode(centre, press, { x: 2, y: -46 }, 5)).toBeNull();
|
||
expect(knobMode(centre, press, { x: 12, y: -44 }, 5)).toBe('turn');
|
||
expect(knobMode(centre, press, { x: 3, y: -60 }, 5)).toBe('scale');
|
||
expect(knobMode(centre, press, { x: -2, y: -30 }, 5)).toBe('scale');
|
||
});
|
||
|
||
it('turns by the angle swept round the centre, the other way under a mirror, snapped with Shift', () => {
|
||
const centre = { x: 0, y: 0 };
|
||
const press = { x: 0, y: -46 };
|
||
const quarter = swept(centre, press, { x: 46, y: 0 });
|
||
expect(quarter).toBeCloseTo(90);
|
||
expect(turned(10, quarter, 1)).toBeCloseTo(100);
|
||
expect(turned(10, quarter, -1)).toBeCloseTo(-80);
|
||
expect(turned(0, swept(centre, press, { x: 46, y: -40 }), 1, 15)).toBe(45);
|
||
});
|
||
|
||
it('adds the sweeps of a drag up, so a turn past the half circle goes on instead of jumping back', () => {
|
||
const centre = { x: 0, y: 0 };
|
||
const path = [
|
||
{ x: 0, y: -46 },
|
||
{ x: 46, y: 0 },
|
||
{ x: 0, y: 46 },
|
||
{ x: -46, y: 1 },
|
||
];
|
||
let sweep = 0;
|
||
for (let i = 1; i < path.length; i++) sweep += swept(centre, path[i - 1]!, path[i]!);
|
||
expect(turned(0, sweep, 1)).toBeCloseTo(268.8, 1);
|
||
});
|
||
|
||
it('scales by how much farther from the centre the knob is pulled', () => {
|
||
const centre = { x: 0, y: 0 };
|
||
expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: -80 })).toBe(2);
|
||
expect(scaled(1.5, centre, { x: 0, y: -40 }, { x: 0, y: -20 })).toBe(0.75);
|
||
expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: 0 })).toBe(0.01);
|
||
});
|
||
});
|
||
|
||
describe('the frame round a container (#242)', () => {
|
||
// Turned a quarter to the right and twice the size, standing at (100, 50).
|
||
const m: Mat = { a: 0, b: 2, c: -2, d: 0, tx: 100, ty: 50 };
|
||
const own = (x: number, y: number) => apply(m, { x, y });
|
||
|
||
it("boxes the content in the container's own axes, so it turns and grows with it", () => {
|
||
const content = [own(10, -20), own(30, -20), own(30, 0), own(10, 0), own(20, -10)];
|
||
const frame = contentFrame(m, content)!;
|
||
const expected = [own(10, -20), own(30, -20), own(30, 0), own(10, 0)];
|
||
frame.forEach((corner, i) => {
|
||
expect(corner.x).toBeCloseTo(expected[i]!.x);
|
||
expect(corner.y).toBeCloseTo(expected[i]!.y);
|
||
});
|
||
// The top edge in its own axes, where the knob stands, is on the world's right side now.
|
||
expect(knobAt(frame, 26).knob.x).toBeGreaterThan(Math.max(...frame.map((c) => c.x)));
|
||
});
|
||
|
||
it('has none without content or at no size', () => {
|
||
expect(contentFrame(m, [])).toBeNull();
|
||
expect(contentFrame({ ...m, b: 0, c: 0 }, [own(0, 0)])).toBeNull();
|
||
});
|
||
});
|