Files
feuerwerk/tests/editor/size-gizmo.test.ts
T
adminandClaude Opus 5.5 32cb5b4ba5
game-maker / gate (push) Successful in 2m54s
game-maker / suite (push) Successful in 9m14s
Size, turn and scale a selected sprite or collider in the scene (#223)
A sprite or collider could only be sized, turned and scaled through
the inspector. The scene view now draws a size gizmo on the selection,
so the common edits happen where the object is.

- Edges and corners set Width and Height. The opposite side stays
  where it was, so the position moves by half the change. Sizes land
  on whole units, at least 1.
- A circle collider has edge handles only and stays round. A polygon
  or frame collider keeps its own corner editing and gets no size
  handles.
- One knob on a stem above the top edge turns or scales. The first
  5 px of the drag decide which: out from the centre scales, sideways
  turns. The choice holds until the release, so one drag never does
  both. Turning and scaling share one handle by the user's choice.
- Shift snaps a turn to 15 degrees. The turn adds up the swept angle,
  so it goes on past a half circle instead of jumping back.
- The values are overrides of this placement, like "Fit to sprite".
  A value equal to the asset's own clears the override. One drag is
  one undo step.
- A handle reaches 7 px outwards but only half that into the object.
  Otherwise a press near the edge of a small sprite sizes it instead
  of moving it.
- The rules live in src/editor/sizeGizmo.ts without Pixi. The handles
  are drawn and driven by src/editor/viewport/sizeGizmoEditor.ts. The
  test hook __gm.sizeGizmo() reports them in screen pixels.

Found on the way:
- The first e2e run failed the play-mode drag check in 41b. It grabs
  the 40 px crate 6 px inside its bottom edge, and the full grip zone
  took that as a resize. The half zone inside fixes it.
- A first gizmo check failed because the sprite's right edge lay
  outside the scene view (x 940, view ends at 925). The test was
  wrong, the sprite now sits further left.

Tests:
- ./check.sh: 156 files, 2283 tests, CHECK EXIT 0 (before 2273).
- 10 unit tests in tests/editor/size-gizmo.test.ts.
- 6 new e2e checks in 29j: handles present, edge resize with the
  opposite edge fixed, undo, knob turn, knob scale that ignores a
  later sideways pull, circle radius.
- e2e 1-16,29,39-43 on the final state: ALL PASSED, 663 checks.
- Counter-checks: deciding the knob mode on every move fails the
  "does not turn it" check. Not moving the centre on an edge drag
  fails both edge checks. A full grip zone inside fails the new unit
  test. Originals restored.
- Check total 1357 + 6 = 1363, computed. CI measured 1357 at d257298.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:37:17 +02:00

122 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { 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);
});
});