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

+264
View File
@@ -0,0 +1,264 @@
/**
* Scaling, turning and mirroring vector art shapes (#291). A transformation is baked into the points:
* the model keeps no matrix, so everything that reads points – combining, the picture a sprite wears,
* a press in the tab – goes on reading them as before. This module says what a shape reads after an
* affine map, how far a selection reaches, and which map a pull on the frame's handles means.
*
* The outline's width is left alone: a line of two pixels is two pixels at every size of its shape.
*/
import type { VectorGradient, VectorPaint, VectorPoint, VectorRing, VectorShape } from './Asset';
import { apply, applyVector, type Mat } from './matrix';
import type { Vec2 } from './Properties';
import { cloneVectorPoint, handleAt } from './vectorart';
import { cloneGradient, clonePaint, type Extent } from './vectorPaint';
/** A frame is never pulled thinner than this many canvas pixels, so it can be pulled open again. */
export const MIN_FRAME_SIZE = 1;
/** A handle shorter than this is none: the point is a plain corner on that side. */
const NO_HANDLE = 1e-6;
/** What of a shape a transformation moves: its outlines and where its paints lie. */
export interface ShapeGeometry {
rings: VectorRing[];
paint?: VectorPaint;
strokePaint?: VectorGradient;
}
/** The side of a frame a handle sits on: -1, 0 or 1 on each axis, a corner has both. */
export interface FrameSide {
sx: -1 | 0 | 1;
sy: -1 | 0 | 1;
}
/**
* Canvas pixels are kept to a thousandth. A map multiplies every coordinate by sines and ratios, and
* without this a square turned by 30 degrees and back would read 9.999999999999998 in the file.
*/
function tidy(value: number): number {
const rounded = Math.round(value * 1000) / 1000;
return rounded === 0 ? 0 : rounded;
}
function tidyPoint(p: Vec2): Vec2 {
return { x: tidy(p.x), y: tidy(p.y) };
}
/** Scales by `fx` and `fy` with `anchor` staying where it is; a negative factor mirrors. */
export function scaleAbout(anchor: Vec2, fx: number, fy: number): Mat {
return { a: fx, b: 0, c: 0, d: fy, tx: anchor.x * (1 - fx), ty: anchor.y * (1 - fy) };
}
/**
* Turns by `degrees` round `centre`, clockwise on screen. The cosine of a quarter turn comes out as
* 6e-17 rather than 0; `tidy` takes that off every coordinate, so a square turned four times is
* the square it was.
*/
export function rotationAbout(centre: Vec2, degrees: number): Mat {
const r = (degrees * Math.PI) / 180;
const cos = Math.cos(r);
const sin = Math.sin(r);
return {
a: cos,
b: sin,
c: -sin,
d: cos,
tx: centre.x - cos * centre.x + sin * centre.y,
ty: centre.y - sin * centre.x - cos * centre.y,
};
}
export function extentCentre(extent: Extent): Vec2 {
return { x: (extent.x0 + extent.x1) / 2, y: (extent.y0 + extent.y1) / 2 };
}
/** The four corners of an extent taken through `m`: top left, top right, bottom right, bottom left. */
export function extentCorners(extent: Extent, m: Mat): Vec2[] {
return [
apply(m, { x: extent.x0, y: extent.y0 }),
apply(m, { x: extent.x1, y: extent.y0 }),
apply(m, { x: extent.x1, y: extent.y1 }),
apply(m, { x: extent.x0, y: extent.y1 }),
];
}
/**
* A ring under `m`. A handle is an offset from its point, so it takes the linear part only. A smooth
* or symmetric point stays one: a linear map keeps two opposite offsets opposite.
*/
export function ringTransformed(ring: VectorRing, m: Mat): VectorRing {
return {
closed: ring.closed,
points: ring.points.map((point) => {
const next: VectorPoint = { ...cloneVectorPoint(point), ...tidyPoint(apply(m, point)) };
for (const side of ['in', 'out'] as const) {
const handle = point[side];
if (!handle) continue;
const moved = tidyPoint(applyVector(m, handle));
if (Math.hypot(moved.x, moved.y) < NO_HANDLE) delete next[side];
else next[side] = moved;
}
if (!next.in && !next.out) delete next.mode;
return next;
}),
};
}
/**
* A paint under `m`. The two ends of a gradient go along, which is exact for a move, a turn and an
* even scale; a gradient squeezed in one direction keeps its bands square to its axis. A picture
* fill is a tile with upright sides: scaled or mirrored it follows exactly, turned it keeps upright
* and takes the room the turned tile reaches over – the picture itself does not turn.
*/
export function paintTransformed<P extends VectorPaint>(paint: P, m: Mat): P {
const next = clonePaint(paint);
if (paint.type !== 'texture' || (m.b === 0 && m.c === 0)) {
next.from = tidyPoint(apply(m, paint.from));
next.to = tidyPoint(apply(m, paint.to));
return next;
}
const centre = apply(m, { x: (paint.from.x + paint.to.x) / 2, y: (paint.from.y + paint.to.y) / 2 });
const hw = (paint.to.x - paint.from.x) / 2;
const hh = (paint.to.y - paint.from.y) / 2;
const halfWidth = (Math.abs(m.a * hw) + Math.abs(m.c * hh)) * (hw < 0 ? -1 : 1);
const halfHeight = (Math.abs(m.b * hw) + Math.abs(m.d * hh)) * (hh < 0 ? -1 : 1);
next.from = tidyPoint({ x: centre.x - halfWidth, y: centre.y - halfHeight });
next.to = tidyPoint({ x: centre.x + halfWidth, y: centre.y + halfHeight });
return next;
}
/** The rings are handed out as they are, not copied: nobody edits a ring in place. */
export function geometryOf(shape: VectorShape): ShapeGeometry {
return {
rings: shape.rings,
...(shape.paint ? { paint: clonePaint(shape.paint) } : {}),
...(shape.stroke?.paint ? { strokePaint: cloneGradient(shape.stroke.paint) } : {}),
};
}
export function geometryTransformed(geometry: ShapeGeometry, m: Mat): ShapeGeometry {
return {
rings: geometry.rings.map((ring) => ringTransformed(ring, m)),
...(geometry.paint ? { paint: paintTransformed(geometry.paint, m) } : {}),
...(geometry.strokePaint ? { strokePaint: paintTransformed(geometry.strokePaint, m) } : {}),
};
}
/** Writes a geometry straight into a shape – a gesture's preview, and the way back before its command. */
export function writeGeometry(shape: VectorShape, geometry: ShapeGeometry): void {
shape.rings = geometry.rings;
if (geometry.paint) shape.paint = geometry.paint;
if (geometry.strokePaint && shape.stroke) shape.stroke.paint = geometry.strokePaint;
}
/**
* How far the outlines of `shapes` reach, in canvas pixels; null when there is nothing with a size.
* A curve counts as far as it is drawn, not as far as its handles stand out – the frame round a
* circle touches the circle. A single point has no size and gets no frame.
*/
export function outlineExtent(shapes: readonly VectorShape[]): Extent | null {
const e: Extent = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
const take = (p: Vec2): void => {
e.x0 = Math.min(e.x0, p.x);
e.y0 = Math.min(e.y0, p.y);
e.x1 = Math.max(e.x1, p.x);
e.y1 = Math.max(e.y1, p.y);
};
for (const shape of shapes)
for (const ring of shape.rings) {
const count = ring.points.length;
for (const [index, from] of ring.points.entries()) {
take(from);
if (index === count - 1 && !ring.closed) continue;
const to = ring.points[(index + 1) % count]!;
if (!from.out && !to.in) continue;
const c1 = handleAt(from, 'out') ?? from;
const c2 = handleAt(to, 'in') ?? to;
for (const t of [
...turningPoints(from.x, c1.x, c2.x, to.x),
...turningPoints(from.y, c1.y, c2.y, to.y),
])
take({ x: cubic(from.x, c1.x, c2.x, to.x, t), y: cubic(from.y, c1.y, c2.y, to.y, t) });
}
}
if (!Number.isFinite(e.x0)) return null;
return e.x1 - e.x0 > 0 || e.y1 - e.y0 > 0 ? e : null;
}
function cubic(p0: number, p1: number, p2: number, p3: number, t: number): number {
const u = 1 - t;
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
}
/** Where one coordinate of a cubic stops growing or shrinking, strictly between its ends. */
function turningPoints(p0: number, p1: number, p2: number, p3: number): number[] {
const a = -p0 + 3 * p1 - 3 * p2 + p3;
const b = 2 * (p0 - 2 * p1 + p2);
const c = p1 - p0;
const roots: number[] = [];
if (Math.abs(a) < 1e-12) {
if (Math.abs(b) > 1e-12) roots.push(-c / b);
} else {
const disc = b * b - 4 * a * c;
if (disc >= 0) {
const root = Math.sqrt(disc);
roots.push((-b + root) / (2 * a), (-b - root) / (2 * a));
}
}
return roots.filter((t) => t > 0 && t < 1);
}
/**
* The map a pull on a frame handle means: the handle on `side` of `extent` is taken to `to`, the
* opposite side stays put. `proportional` keeps the shape of the frame – a corner follows the
* farther of its two axes, an edge takes the other axis along about its middle. `centred` scales
* about the middle instead of the opposite side. A side without an extent has nothing to scale and
* keeps its factor at one; no side is ever pulled through its opposite.
*/
export function frameScale(
extent: Extent,
side: FrameSide,
to: Vec2,
options: { proportional?: boolean; centred?: boolean } = {},
): Mat {
const centre = extentCentre(extent);
const axis = (s: -1 | 0 | 1, low: number, high: number, mid: number, reach: number) => {
const anchor = s === 0 || options.centred ? mid : s > 0 ? low : high;
if (s === 0) return { anchor, factor: 1 };
const size = high - low;
const span = (s > 0 ? high : low) - anchor;
if (Math.abs(span) < 1e-9) return { anchor, factor: 1 };
return { anchor, factor: Math.max((reach - anchor) / span, Math.min(1, MIN_FRAME_SIZE / size)) };
};
const x = axis(side.sx, extent.x0, extent.x1, centre.x, to.x);
const y = axis(side.sy, extent.y0, extent.y1, centre.y, to.y);
if (options.proportional) {
const factor = side.sx === 0 ? y.factor : side.sy === 0 ? x.factor : Math.max(x.factor, y.factor);
x.factor = factor;
y.factor = factor;
}
return scaleAbout({ x: x.anchor, y: y.anchor }, x.factor, y.factor);
}
/** The map that puts the frame's left or top edge at `value`. */
export function frameMovedTo(extent: Extent, axis: 'x' | 'y', value: number): Mat {
const dx = axis === 'x' ? value - extent.x0 : 0;
const dy = axis === 'y' ? value - extent.y0 : 0;
return { a: 1, b: 0, c: 0, d: 1, tx: dx, ty: dy };
}
/**
* The map that makes the frame `value` wide or high, its left top corner staying put; null where
* there is nothing to stretch (a frame without that extent) or the size is not one.
*/
export function frameSizedTo(extent: Extent, axis: 'x' | 'y', value: number): Mat | null {
const size = axis === 'x' ? extent.x1 - extent.x0 : extent.y1 - extent.y0;
if (!(size > 0) || !Number.isFinite(value)) return null;
const factor = Math.max(value, MIN_FRAME_SIZE) / size;
return scaleAbout({ x: extent.x0, y: extent.y0 }, axis === 'x' ? factor : 1, axis === 'y' ? factor : 1);
}
/** Mirrors about the middle of the frame: `x` swaps left and right, `y` top and bottom. */
export function frameFlipped(extent: Extent, axis: 'x' | 'y'): Mat {
return scaleAbout(extentCentre(extent), axis === 'x' ? -1 : 1, axis === 'y' ? -1 : 1);
}