Scale, turn and mirror the shapes of a vector drawing (#291)
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 14m25s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 11:20:55 +02:00
1 parent 6d79cf59a3
commit 345289982b
14 files changed
+2026 -28

No files matched your search

+19 -5
View File
@@ -98,13 +98,25 @@ function widgets<T extends Widget>(host: InspectorHost, type: new (...args: neve
return found;
}
/** The TRANSFORM rows (#291) stand over the shape's own, five number fields ahead of every other. */
const FRAME_KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn'];
const numberFields = (host: InspectorHost): NumberField[] =>
widgets(host, NumberField).slice(FRAME_KEYS.length);
const texts = (host: InspectorHost): string[] => widgets(host, Label).map((label) => label.text);
describe('shape rows', () => {
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect([...host.fields.keys()]).toEqual(['name', 'fillType', 'fill', 'opacity', 'outline']);
expect([...host.fields.keys()]).toEqual([
...FRAME_KEYS,
'name',
'fillType',
'fill',
'opacity',
'outline',
]);
expect(texts(host)).toContain('Without an outline only the fill is drawn.');
});
@@ -125,7 +137,7 @@ describe('shape rows', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
const opacity = widgets(host, NumberField)[0]!;
const opacity = numberFields(host)[0]!;
opacity.onChange!('0.4');
expect(shapeOf(host, assetId).opacity).toBeCloseTo(0.4);
opacity.onChange!('5');
@@ -146,6 +158,7 @@ describe('shape rows', () => {
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect([...host.fields.keys()]).toEqual([
...FRAME_KEYS,
'name',
'fillType',
'fill',
@@ -155,7 +168,7 @@ describe('shape rows', () => {
'strokeColor',
'strokeWidth',
]);
const width = widgets(host, NumberField)[1]!;
const width = numberFields(host)[1]!;
// The command refuses a line of no width, so the row never sends one.
width.onChange!('0');
expect(shapeOf(host, assetId).stroke?.width).toBe(0.1);
@@ -189,7 +202,7 @@ describe('shape rows', () => {
shape.name = 'Cloud';
refreshShapeSection(host, assetId, ids[0]!);
// The name field first, then the hex text inside the fill row – a name is a field, not a label.
const fields = widgets(host, TextField);
const fields = widgets(host, TextField).slice(FRAME_KEYS.length);
expect(fields[0]!.value).toBe('Cloud');
expect(fields[1]!.value).toBe(toHex(0x123456));
});
@@ -283,6 +296,7 @@ describe('fill and line types (#186)', () => {
expect(signature).toContain('linear/none');
rebuild(host, assetId, ids[0]!);
expect([...host.fields.keys()]).toEqual([
...FRAME_KEYS,
'name',
'fillType',
'fillStopColor',
@@ -302,7 +316,7 @@ describe('fill and line types (#186)', () => {
rebuild(host, assetId, ids[0]!);
// The stop's colour is the only colour row left; opacity of the stop is the first number row.
widgets(host, ColorField)[0]!.onChange!(0xff0000);
widgets(host, NumberField)[0]!.onChange!('0.5');
numberFields(host)[0]!.onChange!('0.5');
const paint = shapeOf(host, assetId).paint as VectorGradient;
expect(paint.stops[0]).toEqual({ offset: 0, color: 0xff0000, alpha: 0.5 });
expect(button(host, 'Remove stop').eventMode).toBe('none');