Files
feuerwerk/tests/core/vector-transform.test.ts
adminandClaude Opus 5.5 345289982b
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 14m25s
Scale, turn and mirror the shapes of a vector drawing (#291)
A shape could only be moved and reshaped point by point. Whoever
wanted it larger or turned pulled every point alone, and for several
shapes or a group there was no way at all. The answers in the issue
settle the rest: the change is baked into the points, nothing of it
is remembered, and the line width stays.

- With Select the selection stands in a frame of eight squares and a
  knob, drawn like the one of the scene view. It holds one shape,
  several shapes or a group, the same selection that is dragged as
  one.
- A corner square scales from the opposite corner, a square on an
  edge in its own direction. Shift keeps the proportions, Alt scales
  about the middle.
- The knob turns round the middle of the frame, with Shift in steps
  of 15 degrees. After the pull the frame stands upright again.
- The inspector has a band TRANSFORM with X, Y, Width, Height and
  Rotate by, and the buttons Flip horizontal and Flip vertical.
- Points, handles, gradients and texture tiles are taken through the
  same matrix. The rule is a pure function in vectorTransform.ts,
  transformShapesCommand makes one undo step of any number of
  shapes.
- A press goes to a point or a handle first, then to the frame, then
  to a line. shapeFrame.ts holds where the handles are and what a
  pull of each means.

Decided here:

- The frame stands 15 pixels off the shapes. The points of a shape
  lie on its bounds, so a square on the bounds would cover them.
- The knob only turns. In the scene view it scales as well.
- A side cannot be pulled through the opposite one, the frame keeps
  at least 1 pixel.
- Coordinates are rounded to a thousandth of a pixel, so a quarter
  turn is exact.
- A texture tile stays upright under a turn and grows to the bounds
  of the turned tile. The bands of a gradient stay square to its
  axis under an uneven scale.
- The switch Scale stroke of the proposal is not built.

Found on the way:

- The inspector of a shape is now taller than the window. Two checks
  of module 47 no longer found their row or button on the screen and
  use the new hook inspectorButton, which scrolls there first.
- Not solved: with a shape at the top edge of the view the knob lies
  outside the tab. The wiki names it under the limits.

Taken over from #217: the e2e check for two selected shapes without
a group, and unit tests for the snap of the common offset and for
the gradients of the shapes dragged along.

Tests:
- Gate on melektaus-atx: 209 files, 3176 tests (3121 + 55), exit 0
- e2e 47,48 on melektaus-atx: 112 checks, all pass
- No full run and no smokes. The sum of 1664 (1660 + 4) is reckoned,
  1660 is what CI counted for 6d79cf5.
- 55 unit tests: 25 in tests/core/vector-transform.test.ts and 30 in
  tests/editor/shape-frame.test.ts
- Four e2e checks in block 47k of module 47: pull a corner, turn
  with Shift, type width and rotation, drag two shapes without a
  group
- 47 unit counter-probes and four browser counter-probes all fail
  their tests. Four of the unit ones did not at first: two showed
  dead code, which is gone, two got tests.
- Not checked in the browser: the edge squares, Alt, the cursors,
  the flip buttons and the rows X, Y and Height

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:20:55 +02:00

379 lines
14 KiB
TypeScript

/**
* Scaling, turning and mirroring vector art shapes (#291): what a ring, a paint and a whole
* selection read after an affine map, how far a selection reaches, and the maps a frame handle makes.
*/
import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import { transformShapesCommand } from '../../src/core/commands/vectorArtCommands';
import type {
VectorArtAsset,
VectorGradient,
VectorRing,
VectorShape,
VectorTextureFill,
} from '../../src/core/model/Asset';
import { translation } from '../../src/core/model/matrix';
import { createEmptyProject } from '../../src/core/model/Project';
import {
frameFlipped,
frameMovedTo,
frameScale,
frameSizedTo,
geometryOf,
geometryTransformed,
outlineExtent,
paintTransformed,
ringTransformed,
rotationAbout,
scaleAbout,
} from '../../src/core/model/vectorTransform';
import { allShapes } from '../../src/core/model/vectorTree';
const BOX = { x0: 10, y0: 20, x1: 50, y1: 40 };
function square(id: string, x: number, y: number, size = 20): VectorShape {
return {
id,
name: id,
fill: 0x336699,
opacity: 1,
rings: [
{
closed: true,
points: [
{ x, y },
{ x: x + size, y },
{ x: x + size, y: y + size },
{ x, y: y + size },
],
},
],
};
}
/** A circle of radius `r` round `cx`, `cy`, drawn as four symmetric points. */
function circle(cx: number, cy: number, r: number): VectorRing {
// To a thousandth, which is what a map keeps: mirrored twice the circle must read the same.
const k = Math.round(r * 552.2847498) / 1000;
return {
closed: true,
points: [
{ x: cx + r, y: cy, in: { x: 0, y: -k }, out: { x: 0, y: k }, mode: 'symmetric' },
{ x: cx, y: cy + r, in: { x: k, y: 0 }, out: { x: -k, y: 0 }, mode: 'symmetric' },
{ x: cx - r, y: cy, in: { x: 0, y: k }, out: { x: 0, y: -k }, mode: 'symmetric' },
{ x: cx, y: cy - r, in: { x: -k, y: 0 }, out: { x: k, y: 0 }, mode: 'symmetric' },
],
};
}
const LINEAR: VectorGradient = {
type: 'linear',
from: { x: 10, y: 30 },
to: { x: 50, y: 30 },
stops: [
{ offset: 0, color: 0xff0000, alpha: 1 },
{ offset: 1, color: 0x0000ff, alpha: 1 },
],
};
const TILE: VectorTextureFill = {
type: 'texture',
from: { x: 10, y: 20 },
to: { x: 50, y: 40 },
texture: 't',
};
describe('a ring under a map', () => {
it('scales points about the anchor and handles by the factors alone', () => {
const ring: VectorRing = {
closed: false,
points: [
{ x: 10, y: 20, out: { x: 6, y: 4 } },
{ x: 50, y: 40, in: { x: -6, y: -2 }, mode: 'smooth' },
],
};
const scaled = ringTransformed(ring, scaleAbout({ x: 10, y: 20 }, 2, 0.5));
expect(scaled.points).toEqual([
{ x: 10, y: 20, out: { x: 12, y: 2 } },
{ x: 90, y: 30, in: { x: -12, y: -1 }, mode: 'smooth' },
]);
// The ring that went in is not the one that came out.
expect(ring.points[1]).toMatchObject({ x: 50, y: 40 });
});
it('turns by a quarter exactly, points and handles, and four quarters are the ring it was', () => {
const ring: VectorRing = {
closed: true,
points: [
{ x: 10, y: 20, out: { x: 5, y: 0 } },
{ x: 50, y: 20 },
{ x: 50, y: 40, in: { x: 0, y: -3 }, mode: 'smooth', out: { x: 0, y: 3 } },
],
};
const centre = { x: 30, y: 30 };
const quarter = ringTransformed(ring, rotationAbout(centre, 90));
// Clockwise on screen: the top left corner goes to the top right.
expect(quarter.points[0]).toEqual({ x: 40, y: 10, out: { x: 0, y: 5 } });
expect(quarter.points[1]).toEqual({ x: 40, y: 50 });
expect(quarter.points[2]).toEqual({
x: 20,
y: 50,
in: { x: 3, y: 0 },
out: { x: -3, y: 0 },
mode: 'smooth',
});
let round = ring;
for (let i = 0; i < 4; i++) round = ringTransformed(round, rotationAbout(centre, 90));
expect(round).toEqual(ring);
expect(ringTransformed(ring, rotationAbout(centre, -270))).toEqual(quarter);
});
it('keeps a turn by any other angle to a thousandth of a pixel', () => {
const ring: VectorRing = {
closed: false,
points: [
{ x: 10, y: 0 },
{ x: 0, y: 0 },
],
};
const turned = ringTransformed(ring, rotationAbout({ x: 0, y: 0 }, 30));
expect(turned.points[0]).toEqual({ x: 8.66, y: 5 });
expect(Object.is(turned.points[1]!.x, 0)).toBe(true);
});
it('mirrors about the middle and keeps a symmetric point symmetric', () => {
const ring = circle(30, 30, 10);
const flipped = ringTransformed(
ring,
frameFlipped(outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])!, 'x'),
);
expect(flipped.points[0]).toMatchObject({ x: 20, y: 30, mode: 'symmetric' });
const { in: a, out: b } = flipped.points[1]!;
expect(a!.x).toBeCloseTo(-b!.x, 9);
expect(a!.y).toBeCloseTo(-b!.y, 9);
// Mirrored twice it is the ring it was.
const box = outlineExtent([{ ...square('c', 0, 0), rings: [flipped] }])!;
expect(ringTransformed(flipped, frameFlipped(box, 'x'))).toEqual(ring);
});
it('drops a handle a scale squeezes to nothing, and the mode with the last one', () => {
const ring: VectorRing = {
closed: false,
points: [
{ x: 0, y: 0, out: { x: 0, y: 8 }, mode: 'smooth' },
{ x: 10, y: 0 },
],
};
const flat = ringTransformed(ring, scaleAbout({ x: 0, y: 0 }, 1, 0));
expect(flat.points[0]).toEqual({ x: 0, y: 0 });
});
});
describe('a paint under a map', () => {
it('takes both ends of a gradient along and leaves the stops alone', () => {
const moved = paintTransformed(LINEAR, rotationAbout({ x: 30, y: 30 }, 90));
expect(moved.from).toEqual({ x: 30, y: 10 });
expect(moved.to).toEqual({ x: 30, y: 50 });
expect(moved.stops).toEqual(LINEAR.stops);
expect(moved.stops).not.toBe(LINEAR.stops);
expect(paintTransformed(LINEAR, translation(5, -5))).toMatchObject({
from: { x: 15, y: 25 },
to: { x: 55, y: 25 },
});
});
it('scales and mirrors a picture tile exactly', () => {
expect(paintTransformed(TILE, scaleAbout({ x: 10, y: 20 }, 2, 3))).toMatchObject({
from: { x: 10, y: 20 },
to: { x: 90, y: 80 },
texture: 't',
});
// Mirrored, the tile's ends swap sides: the picture is drawn the other way round.
expect(paintTransformed(TILE, scaleAbout({ x: 30, y: 30 }, -1, 1))).toMatchObject({
from: { x: 50, y: 20 },
to: { x: 10, y: 40 },
});
});
it('keeps a turned picture tile upright, over the room the turned tile reaches', () => {
const quarter = paintTransformed(TILE, rotationAbout({ x: 30, y: 30 }, 90));
expect(quarter).toMatchObject({ from: { x: 20, y: 10 }, to: { x: 40, y: 50 } });
const eighth = paintTransformed(TILE, rotationAbout({ x: 30, y: 30 }, 45));
const half = (20 + 10) * Math.SQRT1_2;
expect(eighth.from.x).toBeCloseTo(30 - half, 2);
expect(eighth.to.y).toBeCloseTo(30 + half, 2);
});
});
describe('how far a selection reaches', () => {
it('spans all the shapes handed in', () => {
expect(outlineExtent([square('a', 0, 0), square('b', 40, 10)])).toEqual({ x0: 0, y0: 0, x1: 60, y1: 30 });
});
it('touches a circle where it is drawn, not where its handles stand out', () => {
const e = outlineExtent([{ ...square('c', 0, 0), rings: [circle(30, 30, 10)] }])!;
expect(e.x0).toBeCloseTo(20, 6);
expect(e.x1).toBeCloseTo(40, 6);
expect(e.y0).toBeCloseTo(20, 6);
expect(e.y1).toBeCloseTo(40, 6);
});
it('follows a curve that bulges past its own two ends', () => {
const ring: VectorRing = {
closed: false,
points: [
{ x: 0, y: 0, out: { x: 0, y: -40 } },
{ x: 30, y: 0, in: { x: 0, y: -40 } },
],
};
const e = outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])!;
// Both control points stand 40 above; the curve between them reaches three quarters of that.
expect(e.y0).toBeCloseTo(-30, 6);
expect(e.y1).toBe(0);
});
it('does not walk back from the last point of an open line', () => {
const ring: VectorRing = {
closed: false,
points: [
{ x: 0, y: 0, in: { x: 0, y: 50 } },
{ x: 30, y: 0, out: { x: 0, y: 50 } },
],
};
expect(outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])).toEqual({ x0: 0, y0: 0, x1: 30, y1: 0 });
});
it('has nothing to frame for no shape, an empty one and a single point', () => {
expect(outlineExtent([])).toBeNull();
expect(outlineExtent([{ ...square('a', 0, 0), rings: [] }])).toBeNull();
expect(
outlineExtent([{ ...square('a', 0, 0), rings: [{ closed: false, points: [{ x: 4, y: 4 }] }] }]),
).toBeNull();
});
});
describe('the map of a handle', () => {
const corner = { sx: 1, sy: 1 } as const;
it('scales from the opposite corner when a corner is pulled', () => {
expect(frameScale(BOX, corner, { x: 90, y: 50 })).toEqual(scaleAbout({ x: 10, y: 20 }, 2, 1.5));
expect(frameScale(BOX, { sx: -1, sy: -1 }, { x: -10, y: 10 })).toEqual(
scaleAbout({ x: 50, y: 40 }, 1.5, 1.5),
);
});
it('scales one direction only from an edge', () => {
expect(frameScale(BOX, { sx: 1, sy: 0 }, { x: 30, y: 999 })).toEqual(
scaleAbout({ x: 10, y: 30 }, 0.5, 1),
);
expect(frameScale(BOX, { sx: 0, sy: -1 }, { x: 999, y: 0 })).toEqual(scaleAbout({ x: 30, y: 40 }, 1, 2));
});
it('keeps the proportions with Shift: a corner follows its farther axis, an edge takes the other along', () => {
expect(frameScale(BOX, corner, { x: 90, y: 50 }, { proportional: true })).toEqual(
scaleAbout({ x: 10, y: 20 }, 2, 2),
);
// The edge pulled out to double width doubles the height about the middle line.
expect(frameScale(BOX, { sx: 1, sy: 0 }, { x: 90, y: 0 }, { proportional: true })).toEqual(
scaleAbout({ x: 10, y: 30 }, 2, 2),
);
});
it('scales about the middle with Alt', () => {
expect(frameScale(BOX, corner, { x: 70, y: 40 }, { centred: true })).toEqual(
scaleAbout({ x: 30, y: 30 }, 2, 1),
);
});
it('never pulls a side through its opposite: the frame stays a pixel wide', () => {
const m = frameScale(BOX, corner, { x: -100, y: -100 });
expect(m.a).toBeCloseTo(1 / 40, 9);
expect(m.d).toBeCloseTo(1 / 20, 9);
});
it('leaves a direction alone in which the frame has no extent', () => {
const line = { x0: 0, y0: 5, x1: 40, y1: 5 };
expect(frameScale(line, corner, { x: 80, y: 60 })).toEqual(scaleAbout({ x: 0, y: 5 }, 2, 1));
});
it('moves, sizes and mirrors the frame by a number', () => {
expect(frameMovedTo(BOX, 'x', 0)).toEqual(translation(-10, 0));
expect(frameMovedTo(BOX, 'y', 25)).toEqual(translation(0, 5));
expect(frameSizedTo(BOX, 'x', 80)).toEqual(scaleAbout({ x: 10, y: 20 }, 2, 1));
expect(frameSizedTo(BOX, 'y', 0)).toEqual(scaleAbout({ x: 10, y: 20 }, 1, 1 / 20));
expect(frameSizedTo({ ...BOX, x1: 10 }, 'x', 80)).toBeNull();
expect(frameFlipped(BOX, 'y')).toEqual(scaleAbout({ x: 30, y: 30 }, 1, -1));
});
});
describe('the command', () => {
function rig() {
const project = createEmptyProject('p', 'P');
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
const a = square('a', 0, 0);
const b: VectorShape = { ...square('b', 40, 0), paint: { ...LINEAR }, stroke: { color: 0, width: 3 } };
const c = square('c', 80, 0);
asset.layers[0]!.items.push(a, { id: 'g', name: 'Group', items: [b] }, c);
asset.layers.push({ id: 'upper', name: 'Layer 2', items: [square('d', 0, 40)] });
project.assets.push(asset);
const corner = (id: string) => allShapes(asset).find((s) => s.id === id)!.rings[0]!.points[0];
return { project, asset, corner };
}
it('takes several shapes, one in a group and one on another layer, in one step and back', () => {
const r = rig();
const command = transformShapesCommand(
r.asset,
['a', 'b', 'd'],
scaleAbout({ x: 0, y: 0 }, 2, 2),
'Scale shapes',
)!;
command.execute(r.project);
expect(r.corner('a')).toEqual({ x: 0, y: 0 });
expect(allShapes(r.asset).find((s) => s.id === 'a')!.rings[0]!.points[2]).toEqual({ x: 40, y: 40 });
expect(r.corner('b')).toEqual({ x: 80, y: 0 });
expect(r.corner('d')).toEqual({ x: 0, y: 80 });
expect(r.corner('c')).toEqual({ x: 80, y: 0 });
command.undo(r.project);
expect(r.corner('b')).toEqual({ x: 40, y: 0 });
expect(r.corner('d')).toEqual({ x: 0, y: 40 });
});
it('takes the fill paint along and leaves the line as wide as it was', () => {
const r = rig();
const b = allShapes(r.asset).find((s) => s.id === 'b')!;
const command = transformShapesCommand(r.asset, ['b'], scaleAbout({ x: 0, y: 0 }, 3, 3), 'Scale shape')!;
command.execute(r.project);
expect(b.paint).toMatchObject({ from: { x: 30, y: 90 }, to: { x: 150, y: 90 } });
expect(b.stroke).toEqual({ color: 0, width: 3 });
command.undo(r.project);
expect(b.paint).toMatchObject({ from: { x: 10, y: 30 }, to: { x: 50, y: 30 } });
});
it('takes the gradient of the line along as well', () => {
const r = rig();
const b = allShapes(r.asset).find((s) => s.id === 'b')!;
b.stroke = { color: 0, width: 3, paint: { ...LINEAR } };
transformShapesCommand(r.asset, ['b'], translation(5, 7), 'Move shape')!.execute(r.project);
expect(b.stroke).toMatchObject({ width: 3, paint: { from: { x: 15, y: 37 }, to: { x: 55, y: 37 } } });
});
it('is no step at all when nothing would read differently', () => {
const r = rig();
expect(
transformShapesCommand(r.asset, ['a'], rotationAbout({ x: 10, y: 10 }, 360), 'Rotate shape'),
).toBeNull();
expect(transformShapesCommand(r.asset, ['nobody'], translation(5, 5), 'Move shape')).toBeNull();
});
it('hands the rings on untouched when a geometry is read, and copies the paints', () => {
const r = rig();
const b = allShapes(r.asset).find((s) => s.id === 'b')!;
const geometry = geometryOf(b);
expect(geometry.rings).toBe(b.rings);
expect(geometry.paint).toEqual(b.paint);
expect(geometry.paint).not.toBe(b.paint);
expect(geometryTransformed(geometry, translation(1, 1)).rings).not.toBe(b.rings);
});
});