diff --git a/README.md b/README.md
index f36813c3..307e8c3b 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
-node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1660 checks (GM_STEPS=20 for one step)
+node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1664 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts
index 2a19b82b..9cc0c83e 100644
--- a/src/core/commands/vectorArtCommands.ts
+++ b/src/core/commands/vectorArtCommands.ts
@@ -10,6 +10,7 @@
*/
import { newId } from '../ids';
import type {
+ VectorArtAsset,
VectorItem,
VectorLayer,
VectorPaint,
@@ -17,6 +18,7 @@ import type {
VectorShape,
VectorStroke,
} from '../model/Asset';
+import type { Mat } from '../model/matrix';
import type { Project } from '../model/Project';
import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
@@ -39,8 +41,10 @@ import {
withLayerMoved,
withLayerRemoved,
} from '../model/vectorTree';
+import { geometryOf, geometryTransformed } from '../model/vectorTransform';
import { copyName } from '../util/names';
import { type Command, CommandError } from './Command';
+import { oneStep } from './CompoundCommand';
import { nextNumberedName } from './naming';
import { requireVectorArt, requireVectorItem, requireVectorShape } from './require';
@@ -435,6 +439,28 @@ function applyPaints(shape: VectorShape, paints: ShapePaints): void {
else delete shape.stroke.paint;
}
+/**
+ * Takes the shapes `shapeIds` through `m` (#291) – scaled, turned, mirrored or moved, their paints
+ * with them – as one undo step. Null when no shape would read differently afterwards.
+ */
+export function transformShapesCommand(
+ asset: VectorArtAsset,
+ shapeIds: readonly string[],
+ m: Mat,
+ label: string,
+): Command | null {
+ const commands: Command[] = [];
+ for (const shape of allShapes(asset)) {
+ if (!shapeIds.includes(shape.id)) continue;
+ const { rings, paint, strokePaint } = geometryTransformed(geometryOf(shape), m);
+ if (!SetVectorShapeRingsCommand.changes(shape, rings)) continue;
+ const paints =
+ paint || strokePaint ? { ...(paint ? { paint } : {}), ...(strokePaint ? { strokePaint } : {}) } : null;
+ commands.push(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, label, paints));
+ }
+ return commands.length > 0 ? oneStep(label, commands) : null;
+}
+
/**
* What `SetVectorShapeStyleCommand` writes; a field left out stays as it is, `null` removes it. A
* stroke brings its own paint along, as a whole.
diff --git a/src/core/model/vectorTransform.ts b/src/core/model/vectorTransform.ts
new file mode 100644
index 00000000..2bd29c53
--- /dev/null
+++ b/src/core/model/vectorTransform.ts
@@ -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
(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);
+}
diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts
index 4259dcd4..abbc926d 100644
--- a/src/editor/debug/uiHooks.ts
+++ b/src/editor/debug/uiHooks.ts
@@ -194,6 +194,23 @@ export function uiHooks({ app, state, editor }: HookContext) {
pressable: label.eventMode === 'static',
};
},
+ /**
+ * A button of the inspector by its text, scrolled into view first: the rows of a selection can
+ * be taller than the window (#291), and a button below the fold is on no screen pixel to find.
+ */
+ inspectorButton: (text: string) => {
+ let found: Button | null = null;
+ const walk = (node: Container): void => {
+ if (found || !node.visible) return;
+ if (node instanceof Button && node.text === text) found = node;
+ else for (const child of node.children) walk(child);
+ };
+ walk(inspector);
+ const button = found as Button | null;
+ if (!button) return null;
+ inspector.reveal(button);
+ return { ...centreOf(button), enabled: button.enabled };
+ },
/**
* The band of a group of rows (#290), by its title: where to click it clear of its controls, its
* rectangle and whether the group is folded. The first one showing, or the last with `last`.
diff --git a/src/editor/debug/vectorArtHooks.ts b/src/editor/debug/vectorArtHooks.ts
index a3d78d17..53bf2a05 100644
--- a/src/editor/debug/vectorArtHooks.ts
+++ b/src/editor/debug/vectorArtHooks.ts
@@ -85,6 +85,11 @@ export function vectorArtHooks({ state, editor }: HookContext) {
layerEyeAt: (layerId: string) => layerList()?.eyeGlobal(layerId) ?? null,
layerRemoveAt: (layerId: string) => layerList()?.removeGlobal(layerId) ?? null,
layerAddAt: () => layerList()?.addGlobal() ?? null,
+ /**
+ * The frame round the selection (#291) where it is drawn: its corners (top left first,
+ * clockwise), the middle of each edge (top first) and the knob; null while there is none.
+ */
+ frame: () => vectorArtEditor.canvas.frameInfo,
selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''),
selectedIds: () => state.selectedShapeIds,
/**
diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts
index ed6afc7b..afc16ab4 100644
--- a/src/editor/inspector/shapeSection.ts
+++ b/src/editor/inspector/shapeSection.ts
@@ -73,6 +73,7 @@ import { ShapeList } from '../vectorart/ShapeList';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
+import { addTransformRows, hasShapeFrame, refreshTransformRows } from './shapeTransformRows';
/** The outline a shape gets when one is switched on: black, two canvas pixels wide. */
const DEFAULT_STROKE: VectorStroke = { color: 0x000000, width: 2 };
@@ -260,7 +261,9 @@ export function shapeSignature(
if (!asset || !shape) return 'missing';
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`;
- return `${shapeListSignature(asset, shapeId)}|${paints}|${shapeIds.join(',')}|${blocked}`;
+ // A shape gets its TRANSFORM rows with its second point: before that it has no size to show.
+ const framed = hasShapeFrame(asset, shapeIds) ? 'framed' : 'bare';
+ return `${shapeListSignature(asset, shapeId)}|${paints}|${shapeIds.join(',')}|${blocked}|${framed}`;
}
/** The selected shapes in drawing order, the lowest first – the order they are combined in. */
@@ -314,6 +317,7 @@ export function buildShapeSection(
}
addGroupButtons(host, asset, shapeId, shapeIds);
+ addTransformRows(host, assetId, shapeIds);
addCombineButtons(host, asset, shapeIds);
addTitle(host, 'VECTOR SHAPE');
@@ -460,6 +464,7 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
host.fields.get('name')?.setValue(shape.name);
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
if (asset) host.fields.get(LAYER_KEY)?.setValue(placeOf(asset.layers, shapeId)?.layer.id ?? '');
+ refreshTransformRows(host, assetId, host.state.selectedShapeIds);
host.fields.get('fill')?.setValue(shape.fill);
host.fields.get(FILL_TYPE_DEF.key)?.setValue(fillKind(shape));
host.fields.get(LINE_TYPE_DEF.key)?.setValue(strokeKind(shape));
diff --git a/src/editor/inspector/shapeTransformRows.ts b/src/editor/inspector/shapeTransformRows.ts
new file mode 100644
index 00000000..e8120b71
--- /dev/null
+++ b/src/editor/inspector/shapeTransformRows.ts
@@ -0,0 +1,201 @@
+/**
+ * The TRANSFORM rows of a vector art selection (#291): where its frame stands and how big it is, a
+ * row that turns it, and the two buttons that mirror it. Nothing of this is stored – the numbers are
+ * read off the shapes each time, and a turn is baked into the points, so the row that turns reads 0
+ * again as soon as it has turned.
+ *
+ * The rows work on all selected shapes at once, a group included, as the frame in the tab does.
+ */
+import { transformShapesCommand } from '../../core/commands/vectorArtCommands';
+import type { VectorArtAsset, VectorShape } from '../../core/model/Asset';
+import type { Mat } from '../../core/model/matrix';
+import { findAssetOf } from '../../core/model/Project';
+import type { Extent } from '../../core/model/vectorPaint';
+import {
+ extentCentre,
+ frameFlipped,
+ frameMovedTo,
+ frameSizedTo,
+ geometryOf,
+ geometryTransformed,
+ MIN_FRAME_SIZE,
+ outlineExtent,
+ rotationAbout,
+ type ShapeGeometry,
+ writeGeometry,
+} from '../../core/model/vectorTransform';
+import { allShapes } from '../../core/model/vectorTree';
+import type { PropertyDef } from '../../core/properties/PropertySchema';
+import { Button, HStack, Theme } from '../../ui';
+import { FramePreview } from '../framePreview';
+import type { EditorState } from '../state';
+import { createFieldRow } from './fieldFactory';
+import { addTitle, type InspectorHost } from './host';
+
+/** One row: what it shows of the frame, and the map that makes the frame show `value` instead. */
+interface TransformRow {
+ def: PropertyDef;
+ read: (extent: Extent) => number;
+ map: (extent: Extent, value: number) => Mat | null;
+ label: string;
+}
+
+const number = (key: string, label: string, extra: Partial = {}): PropertyDef =>
+ ({ key, label, kind: 'number', default: 0, step: 1, decimals: 2, ...extra }) as PropertyDef;
+
+const ROWS: readonly TransformRow[] = [
+ {
+ def: number('frameX', 'X'),
+ read: (e) => e.x0,
+ map: (e, value) => frameMovedTo(e, 'x', value),
+ label: 'Move',
+ },
+ {
+ def: number('frameY', 'Y'),
+ read: (e) => e.y0,
+ map: (e, value) => frameMovedTo(e, 'y', value),
+ label: 'Move',
+ },
+ {
+ def: number('frameWidth', 'Width', { min: MIN_FRAME_SIZE }),
+ read: (e) => e.x1 - e.x0,
+ map: (e, value) => frameSizedTo(e, 'x', value),
+ label: 'Scale',
+ },
+ {
+ def: number('frameHeight', 'Height', { min: MIN_FRAME_SIZE }),
+ read: (e) => e.y1 - e.y0,
+ map: (e, value) => frameSizedTo(e, 'y', value),
+ label: 'Scale',
+ },
+ {
+ def: number('frameTurn', 'Rotate by', { decimals: 1 }),
+ read: () => 0,
+ map: (e, value) => (Number.isFinite(value) ? rotationAbout(extentCentre(e), value) : null),
+ label: 'Rotate',
+ },
+];
+
+const TURN_KEY = 'frameTurn';
+
+/** The degrees the turning row is being dragged at; null while it rests, and then it reads 0. */
+let turning: number | null = null;
+
+function selected(state: EditorState, assetId: string, shapeIds: readonly string[]) {
+ const asset = findAssetOf(state.project, assetId, 'vectorart');
+ const shapes = asset ? allShapes(asset).filter((shape) => shapeIds.includes(shape.id)) : [];
+ return { asset, shapes, extent: outlineExtent(shapes) };
+}
+
+/** Whether the selection has a frame – a shape without a size has no rows to show. */
+export function hasShapeFrame(asset: VectorArtAsset, shapeIds: readonly string[]): boolean {
+ return outlineExtent(allShapes(asset).filter((shape) => shapeIds.includes(shape.id))) !== null;
+}
+
+function stepLabel(verb: string, count: number): string {
+ return `${verb} ${count > 1 ? 'shapes' : 'shape'}`;
+}
+
+/**
+ * Takes the selected shapes through the map `toMap` makes of their frame, as one undo step. False
+ * when there is no frame or the map changes nothing. Exported for the buttons and the tests.
+ */
+export function transformSelection(
+ state: EditorState,
+ assetId: string,
+ shapeIds: readonly string[],
+ toMap: (extent: Extent) => Mat | null,
+ verb: string,
+): boolean {
+ const { asset, shapes, extent } = selected(state, assetId, shapeIds);
+ const m = extent && toMap(extent);
+ const command =
+ asset && m ? transformShapesCommand(asset, shapeIds, m, stepLabel(verb, shapes.length)) : null;
+ return command ? state.execute(command) : false;
+}
+
+/** Mirrors the selection about the middle of its frame; `x` swaps left and right. */
+export function flipSelection(
+ state: EditorState,
+ assetId: string,
+ shapeIds: readonly string[],
+ axis: 'x' | 'y',
+): boolean {
+ return transformSelection(state, assetId, shapeIds, (extent) => frameFlipped(extent, axis), 'Flip');
+}
+
+export function addTransformRows(host: InspectorHost, assetId: string, shapeIds: readonly string[]): void {
+ const { extent } = selected(host.state, assetId, shapeIds);
+ if (!extent) return;
+ addTitle(host, 'TRANSFORM');
+ for (const row of ROWS) addRow(host, assetId, shapeIds, row, extent);
+ const flips = new HStack({ gap: Theme.gap });
+ for (const [axis, label] of [
+ ['x', 'Flip horizontal'],
+ ['y', 'Flip vertical'],
+ ] as const)
+ flips.add(
+ new Button(label, {
+ variant: 'ghost',
+ onClick: () => void flipSelection(host.state, assetId, shapeIds, axis),
+ }),
+ 1,
+ );
+ host.body.add(flips);
+}
+
+/**
+ * A row previews from the shapes as its first input found them, not from the preview before: a
+ * dragged number passes through many values, and each mapped onto the last would add up their
+ * rounding. On commit the shapes are put back, so the command records what was really there.
+ */
+function addRow(
+ host: InspectorHost,
+ assetId: string,
+ shapeIds: readonly string[],
+ row: TransformRow,
+ extent: Extent,
+): void {
+ const { state } = host;
+ const perFrame = new FramePreview(state, 'shape-transform');
+ let held: { extent: Extent; parts: { shape: VectorShape; before: ShapeGeometry }[] } | null = null;
+ const field = createFieldRow(host, row.def, row.read(extent), {
+ onInput: (input) => {
+ if (!held) {
+ const now = selected(state, assetId, shapeIds);
+ if (!now.extent) return;
+ held = {
+ extent: now.extent,
+ parts: now.shapes.map((shape) => ({ shape, before: geometryOf(shape) })),
+ };
+ }
+ const m = row.map(held.extent, Number(input));
+ if (!m) return;
+ if (row.def.key === TURN_KEY) turning = Number(input);
+ for (const part of held.parts) writeGeometry(part.shape, geometryTransformed(part.before, m));
+ perFrame.request();
+ },
+ onChange: (input) => {
+ perFrame.cancel();
+ for (const part of held?.parts ?? []) writeGeometry(part.shape, part.before);
+ held = null;
+ turning = null;
+ const value = Number(input);
+ if (!transformSelection(state, assetId, shapeIds, (e) => row.map(e, value), row.label)) state.preview();
+ },
+ });
+ host.fields.set(row.def.key, field);
+ host.body.add(field.row);
+}
+
+/** Pushes the frame as it stands into the rows – after a drag in the tab, an undo, a typed number. */
+export function refreshTransformRows(
+ host: InspectorHost,
+ assetId: string,
+ shapeIds: readonly string[],
+): void {
+ const { extent } = selected(host.state, assetId, shapeIds);
+ if (!extent) return;
+ for (const row of ROWS)
+ host.fields.get(row.def.key)?.setValue(row.def.key === TURN_KEY ? (turning ?? 0) : row.read(extent));
+}
diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts
index cc5e11fe..4b1a234e 100644
--- a/src/editor/texture/SheetCanvas.ts
+++ b/src/editor/texture/SheetCanvas.ts
@@ -41,8 +41,8 @@ export abstract class SheetCanvas extends Widget {
/** What a press works on; while null every press is ignored, a pan included. */
protected abstract pressTarget(): A | null;
protected abstract press(target: A, e: FederatedPointerEvent, local: Vec2): void;
- /** The pointer moved while no pan runs. */
- protected abstract move(local: Vec2): void;
+ /** The pointer moved while no pan runs; the event says which keys are held. */
+ protected abstract move(local: Vec2, e: FederatedPointerEvent): void;
protected abstract release(): void;
/**
* Whether a Shift press here is the tab's own rather than a pan: the vector art tab takes it on a
@@ -124,7 +124,7 @@ export abstract class SheetCanvas extends Widget {
const local = this.toLocal(e.global);
const panning = this.panning;
if (!panning) {
- this.move({ x: local.x, y: local.y });
+ this.move({ x: local.x, y: local.y }, e);
return;
}
this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y));
diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts
index 0eb30c5e..598174cd 100644
--- a/src/editor/vectorart/VectorArtCanvas.ts
+++ b/src/editor/vectorart/VectorArtCanvas.ts
@@ -17,6 +17,7 @@ import {
paintsOf,
SetVectorShapeRingsCommand,
SetVectorShapeStyleCommand,
+ transformShapesCommand,
} from '../../core/commands/vectorArtCommands';
import type {
VectorArtAsset,
@@ -25,11 +26,20 @@ import type {
VectorRing,
VectorShape,
} from '../../core/model/Asset';
+import { IDENTITY, type Mat } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart';
-import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
+import { type Extent, paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
import { vectorArtDrawings } from '../../core/model/vectorPath';
+import {
+ extentCentre,
+ geometryOf,
+ geometryTransformed,
+ outlineExtent,
+ type ShapeGeometry,
+ writeGeometry,
+} from '../../core/model/vectorTransform';
import { allShapes, drawnShapes, outermostGroup, shapesIn } from '../../core/model/vectorTree';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
@@ -53,11 +63,13 @@ import { deleteTakes, pointPress } from '../pointGestures';
import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips';
import { openPointMenu } from '../pointMenu';
import { removeShapes } from '../actions';
+import { type SizeGrip, swept } from '../sizeGizmo';
import { type EditorState, shapeSelection } from '../state';
import { drawChecker } from '../texture/checker';
import { SheetCanvas } from '../texture/SheetCanvas';
import { fitView, imageToLocal, localToImage } from '../texture/sheetView';
import { activeLayerOf } from './activeLayer';
+import { type FrameBox, frameBox, frameCursor, frameGripAt, frameSizing, frameTurning } from './shapeFrame';
/**
* Which gesture a press begins: the arrow edits what is there, the pen adds to it, and the paint
@@ -109,6 +121,28 @@ interface PaintDrag {
moved: boolean;
}
+/** A handle of the selection's frame being pulled (#291): the shapes are scaled or turned as one. */
+interface FrameDrag {
+ grip: SizeGrip;
+ /** What the frame held when the press came, and how each shape read then. */
+ extent: Extent;
+ parts: { shapeId: string; before: ShapeGeometry }[];
+ pointer: Vec2;
+ at: Vec2;
+ /** Where the pointer was at the last move, and the degrees it swept round the middle since the press. */
+ last: Vec2;
+ sweep: number;
+ matrix: Mat;
+ moved: boolean;
+}
+
+/** The keys a move reads: Shift keeps proportions or snaps a turn, Alt scales about the middle. */
+type HeldKeys = Pick;
+
+/** Side of a frame handle and radius of its knob, in screen pixels – the scene view's. */
+const FRAME_HANDLE = 6;
+const FRAME_KNOB = 5;
+
/** Counts canvases so each gets its own key in the frame queue. */
let instances = 0;
@@ -119,6 +153,7 @@ export class VectorArtCanvas extends SheetCanvas {
private snap = false;
private drag: ShapeDrag | null = null;
private paintDrag: PaintDrag | null = null;
+ private frameDrag: FrameDrag | null = null;
private target: PaintTarget = 'fill';
/** Gradients live on the GPU; one repaint reuses what the one before made. */
private readonly paints = new PaintCache();
@@ -339,12 +374,47 @@ export class VectorArtCanvas extends SheetCanvas {
}
}
- /** Shift on a shape adds it to the selection; on the ground beside the shapes it still pans. */
+ /**
+ * Shift on a shape adds it to the selection, and on a handle of the frame it is that handle's
+ * (#291); on the ground beside them it still pans.
+ */
protected override shiftPresses(asset: VectorArtAsset, local: Vec2): boolean {
if (this.tool !== 'select') return false;
+ if (this.frameGrip(local)) return true;
return this.shapeAt(asset, localToImage(this.view, local.x, local.y)) !== undefined;
}
+ private selectedShapes(): VectorShape[] {
+ return this.selectedShapeIds().flatMap((id) => this.shape(id) ?? []);
+ }
+
+ /**
+ * The frame round the selection, in widget pixels; null with another tool or nothing to frame.
+ * While a handle is pulled it is the frame of the press taken through the drag, so it turns with
+ * the shapes instead of jumping to their new bounds.
+ */
+ private frame(): FrameBox | null {
+ if (this.tool !== 'select') return null;
+ const drag = this.frameDrag;
+ const extent = drag ? drag.extent : outlineExtent(this.selectedShapes());
+ if (!extent) return null;
+ return frameBox(extent, drag ? drag.matrix : IDENTITY, (x, y) => imageToLocal(this.view, x, y));
+ }
+
+ private frameGrip(local: Vec2): SizeGrip | null {
+ const box = this.frame();
+ return box ? frameGripAt(box, local) : null;
+ }
+
+ /** The frame's handles where they are drawn, in global pixels – the check scripts aim at them. */
+ get frameInfo(): { corners: Vec2[]; edges: Vec2[]; knob: Vec2 } | null {
+ const box = this.frame();
+ if (!box) return null;
+ const origin = this.getGlobalPosition();
+ const global = (p: Vec2): Vec2 => ({ x: origin.x + p.x, y: origin.y + p.y });
+ return { corners: box.corners.map(global), edges: box.edges.map(global), knob: global(box.knob) };
+ }
+
protected press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void {
const at = localToImage(this.view, local.x, local.y);
const tolerance = POINT_SIZE / this.view.zoom;
@@ -439,7 +509,8 @@ export class VectorArtCanvas extends SheetCanvas {
tolerance: number,
e: FederatedPointerEvent,
): void {
- if (e.ctrlKey || e.metaKey || e.shiftKey) {
+ const frameGrip = this.frameGrip(local);
+ if (e.ctrlKey || e.metaKey || (e.shiftKey && !frameGrip)) {
const hit = this.shapeAt(asset, at);
if (hit) {
this.pick(null);
@@ -448,13 +519,26 @@ export class VectorArtCanvas extends SheetCanvas {
return;
}
const selected = this.shape(this.selectedShapeId());
- if (selected) {
- for (const [ringIndex, ring] of selected.rings.entries()) {
- const grip = ringGripAt(ring, at, tolerance);
- if (!grip) continue;
- this.pressGrip(asset, selected, ringIndex, ring, grip, at, local, e);
- return;
+ // A point or a handle first, then the frame, then a line: the frame stands off the shape, so
+ // only a Bezier handle pulled far out can lie under one of its squares – and that one must win.
+ let onLine: { ringIndex: number; ring: VectorRing; grip: RingGrip } | null = null;
+ for (const [ringIndex, ring] of selected?.rings.entries() ?? []) {
+ const grip = ringGripAt(ring, at, tolerance);
+ if (!grip) continue;
+ if (grip.kind === 'segment') {
+ onLine ??= { ringIndex, ring, grip };
+ continue;
}
+ this.pressGrip(asset, selected!, ringIndex, ring, grip, at, local, e);
+ return;
+ }
+ if (frameGrip) {
+ this.pressFrame(frameGrip, at, local);
+ return;
+ }
+ if (selected && onLine) {
+ this.pressGrip(asset, selected, onLine.ringIndex, onLine.ring, onLine.grip, at, local, e);
+ return;
}
const shape = this.shapeAt(asset, at);
if (shape) {
@@ -470,6 +554,67 @@ export class VectorArtCanvas extends SheetCanvas {
this.redraw();
}
+ /** A press on a handle of the frame (#291): from here on the pointer scales or turns the selection. */
+ private pressFrame(grip: SizeGrip, at: Vec2, local: Vec2): void {
+ const shapes = this.selectedShapes();
+ const extent = outlineExtent(shapes);
+ if (!extent) return;
+ this.frameDrag = {
+ grip,
+ extent,
+ parts: shapes.map((shape) => ({ shapeId: shape.id, before: geometryOf(shape) })),
+ pointer: { x: local.x, y: local.y },
+ at,
+ last: at,
+ sweep: 0,
+ matrix: IDENTITY,
+ moved: false,
+ };
+ this.redraw();
+ }
+
+ /**
+ * A size handle scales with the opposite side staying put – Shift keeps the proportions, Alt
+ * scales about the middle – and the knob turns round the middle, with Shift in steps of 15°.
+ */
+ private moveFrame(drag: FrameDrag, local: Vec2, keys: HeldKeys | undefined): void {
+ if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD)
+ return;
+ drag.moved = true;
+ const now = localToImage(this.view, local.x, local.y);
+ const shift = keys?.shiftKey === true;
+ if (drag.grip.kind === 'knob') {
+ drag.sweep += swept(extentCentre(drag.extent), drag.last, now);
+ drag.last = now;
+ drag.matrix = frameTurning(drag.extent, drag.sweep, shift);
+ } else {
+ drag.matrix = frameSizing(drag.extent, drag.grip.side, drag.at, now, {
+ proportional: shift,
+ centred: keys?.altKey === true,
+ ...(this.snap ? { snap: (p: Vec2) => this.snapped(p) } : {}),
+ });
+ }
+ this.preview();
+ }
+
+ /** One pull is one undo step over all the shapes it took along. */
+ private releaseFrame(drag: FrameDrag): void {
+ this.frameDrag = null;
+ cancelFrameWork(this.frameKey);
+ for (const part of drag.parts) {
+ const shape = this.shape(part.shapeId);
+ if (shape) writeGeometry(shape, part.before);
+ }
+ const asset = this.asset();
+ const several = drag.parts.length > 1 ? 'shapes' : 'shape';
+ const label = `${drag.grip.kind === 'knob' ? 'Rotate' : 'Scale'} ${several}`;
+ const ids = drag.parts.map((part) => part.shapeId);
+ // A press that pulled nothing left the map as it was, and that makes no step.
+ const command = asset ? transformShapesCommand(asset, ids, drag.matrix, label) : null;
+ if (command) this.state.execute(command);
+ else this.state.preview();
+ }
+
/** The shape with all that shares its outermost group; alone when it stands in none. */
private withGroup(asset: VectorArtAsset, shapeId: string): string[] {
const group = outermostGroup(asset.layers, shapeId);
@@ -594,11 +739,15 @@ export class VectorArtCanvas extends SheetCanvas {
this.redraw();
}
- protected move(local: Vec2): void {
+ protected move(local: Vec2, keys?: HeldKeys): void {
if (this.paintDrag) {
this.movePaint(this.paintDrag, local);
return;
}
+ if (this.frameDrag) {
+ this.moveFrame(this.frameDrag, local, keys);
+ return;
+ }
const drag = this.drag;
if (!drag) {
this.updateCursor(local);
@@ -664,6 +813,15 @@ export class VectorArtCanvas extends SheetCanvas {
this.state.preview();
return;
}
+ const frameDrag = this.frameDrag;
+ if (frameDrag) {
+ for (const part of frameDrag.parts) {
+ const shape = this.shape(part.shapeId);
+ if (shape) writeGeometry(shape, geometryTransformed(part.before, frameDrag.matrix));
+ }
+ this.state.preview();
+ return;
+ }
const drag = this.drag;
if (!drag || !this.shape(drag.shapeId)) return;
for (const part of [drag, ...drag.others]) {
@@ -685,6 +843,10 @@ export class VectorArtCanvas extends SheetCanvas {
this.releasePaint(this.paintDrag);
return;
}
+ if (this.frameDrag) {
+ this.releaseFrame(this.frameDrag);
+ return;
+ }
const drag = this.drag;
this.drag = null;
// The gesture is over, so a preview still waiting for its frame has nothing left to write.
@@ -761,6 +923,11 @@ export class VectorArtCanvas extends SheetCanvas {
// Its line carries the selected shape as its fill does; a point or a handle is its own grip.
const onLine = grips.includes('segment');
const onGrip = grips.includes('point') || grips.includes('handle');
+ const frameGrip = onGrip ? null : this.frameGrip(local);
+ if (frameGrip) {
+ this.cursor = frameCursor(frameGrip);
+ return;
+ }
const inside = this.shapeAt(asset, at) !== undefined;
this.cursor = (inside || onLine) && !onGrip ? 'move' : Theme.hoverCursor;
}
@@ -831,6 +998,7 @@ export class VectorArtCanvas extends SheetCanvas {
this.drawOutline(this.shape(this.selectedShapeId()));
this.drawPaintHandles(this.shownPaint());
} else this.drawShape(this.shape(this.selectedShapeId()));
+ this.drawSelectionFrame();
}
this.syncHint(asset);
}
@@ -899,6 +1067,23 @@ export class VectorArtCanvas extends SheetCanvas {
}
}
+ /** The frame round the selection with its eight squares and the knob on its stem (#291). */
+ private drawSelectionFrame(): void {
+ const box = this.frame();
+ if (!box) return;
+ const color = Theme.colors.gizmo;
+ const g = this.overlay;
+ g.poly(box.corners, true).stroke({ width: 1, color, alpha: 0.6 });
+ g.moveTo(box.base.x, box.base.y).lineTo(box.knob.x, box.knob.y).stroke({ width: 1.5, color });
+ g.circle(box.knob.x, box.knob.y, FRAME_KNOB)
+ .fill(this.frameDrag?.grip.kind === 'knob' ? color : Theme.colors.accentText)
+ .stroke({ width: 1.5, color });
+ for (const p of [...box.edges, ...box.corners])
+ g.rect(p.x - FRAME_HANDLE / 2, p.y - FRAME_HANDLE / 2, FRAME_HANDLE, FRAME_HANDLE)
+ .fill(Theme.colors.accentText)
+ .stroke({ width: 1.5, color });
+ }
+
private paintHint(): string {
const shape = this.shape(this.selectedShapeId());
const part = this.target === 'fill' ? 'fill' : 'outline';
diff --git a/src/editor/vectorart/shapeFrame.ts b/src/editor/vectorart/shapeFrame.ts
new file mode 100644
index 00000000..ac4d1500
--- /dev/null
+++ b/src/editor/vectorart/shapeFrame.ts
@@ -0,0 +1,120 @@
+/**
+ * The frame round the selected shapes of a vector drawing (#291), without Pixi: where it is drawn,
+ * which of its handles a press lands on and what a pull on that handle means. The handles are the
+ * scene view's (`sizeGizmo.ts`) – eight squares that size and a knob on a stem that turns.
+ *
+ * Unlike a scene object's, the frame stands a few pixels off what it frames. A shape's own points
+ * lie on its bounds, a square's exactly on the corners, and a handle drawn there would hide the
+ * point under it; off the bounds neither is ever in the other's reach.
+ */
+import { applyVector, type Mat } from '../../core/model/matrix';
+import type { Vec2 } from '../../core/model/Properties';
+import type { Extent } from '../../core/model/vectorPaint';
+import {
+ extentCentre,
+ extentCorners,
+ type FrameSide,
+ frameScale,
+ rotationAbout,
+} from '../../core/model/vectorTransform';
+import { knobAt, type SizeGrip, sizeGripAt } from '../sizeGizmo';
+
+/** Screen pixels between what is framed and the frame. More than a point's reach plus a handle's. */
+export const FRAME_PAD = 15;
+/** Screen pixels around a handle that grab it. */
+export const FRAME_GRIP = 6;
+/** How far the knob stands out from the top edge, in screen pixels. */
+export const FRAME_STEM = 26;
+/** Degrees a turn snaps to while Shift is held. */
+export const TURN_SNAP = 15;
+
+export interface FrameBox {
+ /** Top left, top right, bottom right, bottom left – in the pixels `toLocal` hands out. */
+ corners: Vec2[];
+ /** The middle of each edge: top, right, bottom, left. */
+ edges: Vec2[];
+ base: Vec2;
+ knob: Vec2;
+}
+
+/**
+ * The frame as it is drawn: the corners of `extent` taken through `m` – the map of a drag under way,
+ * so the frame turns with what it holds – then onto the screen and `FRAME_PAD` pixels outwards.
+ */
+export function frameBox(extent: Extent, m: Mat, toLocal: (x: number, y: number) => Vec2): FrameBox {
+ const origin = toLocal(0, 0);
+ const direction = (v: Vec2): Vec2 => {
+ const moved = applyVector(m, v);
+ const tip = toLocal(moved.x, moved.y);
+ const d = { x: tip.x - origin.x, y: tip.y - origin.y };
+ const length = Math.hypot(d.x, d.y);
+ return length < 1e-9 ? v : { x: d.x / length, y: d.y / length };
+ };
+ const right = direction({ x: 1, y: 0 });
+ const down = direction({ x: 0, y: 1 });
+ const out: [number, number][] = [
+ [-1, -1],
+ [1, -1],
+ [1, 1],
+ [-1, 1],
+ ];
+ const corners = extentCorners(extent, m).map((corner, i) => {
+ const at = toLocal(corner.x, corner.y);
+ const [sx, sy] = out[i]!;
+ return {
+ x: at.x + FRAME_PAD * (sx * right.x + sy * down.x),
+ y: at.y + FRAME_PAD * (sx * right.y + sy * down.y),
+ };
+ });
+ const edges = corners.map((a, i) => {
+ const b = corners[(i + 1) % corners.length]!;
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ });
+ return { corners, edges, ...knobAt(corners, FRAME_STEM) };
+}
+
+/** The handle of the frame under `p`, the knob first; null beside all of them. */
+export function frameGripAt(box: FrameBox, p: Vec2): SizeGrip | null {
+ return sizeGripAt(box.corners, box.knob, p, FRAME_GRIP, { sizable: true, corners: true });
+}
+
+/** The arrow a handle shows: along the direction it pulls in; the knob shows a hand. */
+export function frameCursor(grip: SizeGrip): string {
+ if (grip.kind === 'knob') return 'grab';
+ const { sx, sy } = grip.side;
+ if (sx === 0) return 'ns-resize';
+ if (sy === 0) return 'ew-resize';
+ return sx === sy ? 'nwse-resize' : 'nesw-resize';
+}
+
+/** Where the handle on `side` sits on the framed extent itself, in canvas pixels. */
+export function sidePoint(extent: Extent, side: FrameSide): Vec2 {
+ const centre = extentCentre(extent);
+ return {
+ x: side.sx === 0 ? centre.x : side.sx > 0 ? extent.x1 : extent.x0,
+ y: side.sy === 0 ? centre.y : side.sy > 0 ? extent.y1 : extent.y0,
+ };
+}
+
+/**
+ * The map a size handle has reached: the pointer went from `press` to `now` (canvas pixels), and the
+ * handle went the same way from where it sat – so a press beside the handle's middle does not jump.
+ * `snap` puts the place the handle is pulled to on the grid.
+ */
+export function frameSizing(
+ extent: Extent,
+ side: FrameSide,
+ press: Vec2,
+ now: Vec2,
+ options: { proportional: boolean; centred: boolean; snap?: (p: Vec2) => Vec2 },
+): Mat {
+ const from = sidePoint(extent, side);
+ const to = { x: from.x + now.x - press.x, y: from.y + now.y - press.y };
+ return frameScale(extent, side, options.snap ? options.snap(to) : to, options);
+}
+
+/** The map of a turn by `sweep` degrees round the middle: to a tenth, or in steps of `TURN_SNAP`. */
+export function frameTurning(extent: Extent, sweep: number, snap: boolean): Mat {
+ const degrees = snap ? Math.round(sweep / TURN_SNAP) * TURN_SNAP : Math.round(sweep * 10) / 10;
+ return rotationAbout(extentCentre(extent), degrees);
+}
diff --git a/tests/core/vector-transform.test.ts b/tests/core/vector-transform.test.ts
new file mode 100644
index 00000000..78fbcec6
--- /dev/null
+++ b/tests/core/vector-transform.test.ts
@@ -0,0 +1,378 @@
+/**
+ * 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);
+ });
+});
diff --git a/tests/editor/shape-frame.test.ts b/tests/editor/shape-frame.test.ts
new file mode 100644
index 00000000..97d82ee6
--- /dev/null
+++ b/tests/editor/shape-frame.test.ts
@@ -0,0 +1,595 @@
+/**
+ * The frame round a vector art selection (#291): where it stands and which handle a press finds,
+ * what pulling a handle does in the tab, and the TRANSFORM rows of the inspector. The last block
+ * covers what #217 left without a test of its own – shapes dragged together.
+ */
+import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { buildAsset } from '../../src/core/commands/assetCommands';
+import { GroupVectorShapesCommand } from '../../src/core/commands/vectorArtCommands';
+import type { VectorArtAsset, VectorGradient, VectorShape } from '../../src/core/model/Asset';
+import { IDENTITY } from '../../src/core/model/matrix';
+import { createEmptyProject } from '../../src/core/model/Project';
+import type { Vec2 } from '../../src/core/model/Properties';
+import { rotationAbout } from '../../src/core/model/vectorTransform';
+import { allShapes } from '../../src/core/model/vectorTree';
+import type { InspectorHost } from '../../src/editor/inspector/host';
+import {
+ buildShapeSection,
+ refreshShapeSection,
+ shapeSignature,
+} from '../../src/editor/inspector/shapeSection';
+import { flipSelection } from '../../src/editor/inspector/shapeTransformRows';
+import { EditorState, shapeSelection } from '../../src/editor/state';
+import {
+ FRAME_PAD,
+ FRAME_STEM,
+ frameBox,
+ frameCursor,
+ frameGripAt,
+ frameSizing,
+ frameTurning,
+ sidePoint,
+} from '../../src/editor/vectorart/shapeFrame';
+import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
+import { Button, NumberField, VStack, type Widget } from '../../src/ui';
+import { resetRenderLoop } from '../../src/ui/render';
+import { PaintCache } from '../../src/viewport/vectorArtPicture';
+
+beforeAll(() => {
+ CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
+ PaintCache.prototype.gradient = (() => null) as never;
+});
+beforeEach(() => resetRenderLoop());
+
+const BOX = { x0: 10, y0: 20, x1: 50, y1: 40 };
+/** A view of two screen pixels per canvas pixel, moved by 100 and 50. */
+const toLocal = (x: number, y: number): Vec2 => ({ x: 100 + x * 2, y: 50 + y * 2 });
+
+describe('where the frame stands', () => {
+ it('stands a fixed number of screen pixels off the extent, at any zoom', () => {
+ const box = frameBox(BOX, IDENTITY, toLocal);
+ expect(box.corners).toEqual([
+ { x: 120 - FRAME_PAD, y: 90 - FRAME_PAD },
+ { x: 200 + FRAME_PAD, y: 90 - FRAME_PAD },
+ { x: 200 + FRAME_PAD, y: 130 + FRAME_PAD },
+ { x: 120 - FRAME_PAD, y: 130 + FRAME_PAD },
+ ]);
+ expect(box.edges[0]).toEqual({ x: 160, y: 90 - FRAME_PAD });
+ expect(box.edges[1]).toEqual({ x: 200 + FRAME_PAD, y: 110 });
+ expect(box.knob).toEqual({ x: 160, y: 90 - FRAME_PAD - FRAME_STEM });
+ });
+
+ it('turns with the map of a drag, the knob going round with it', () => {
+ const box = frameBox(BOX, rotationAbout({ x: 30, y: 30 }, 90), toLocal);
+ // The top edge is now the right one, so the knob stands out to the right of the middle.
+ expect(box.knob.x).toBeCloseTo(160 + 20 + FRAME_PAD + FRAME_STEM, 6);
+ expect(box.knob.y).toBeCloseTo(110, 6);
+ expect(box.corners[0]!.x).toBeCloseTo(180 + FRAME_PAD, 6);
+ expect(box.corners[0]!.y).toBeCloseTo(70 - FRAME_PAD, 6);
+ });
+
+ it('finds the knob, a corner and an edge, and nothing in the middle', () => {
+ const box = frameBox(BOX, IDENTITY, toLocal);
+ expect(frameGripAt(box, box.knob)).toEqual({ kind: 'knob' });
+ expect(frameGripAt(box, { x: box.corners[2]!.x + 3, y: box.corners[2]!.y + 3 })).toEqual({
+ kind: 'size',
+ side: { sx: 1, sy: 1 },
+ });
+ expect(frameGripAt(box, { x: 140, y: box.edges[2]!.y })).toEqual({
+ kind: 'size',
+ side: { sx: 0, sy: 1 },
+ });
+ expect(frameGripAt(box, { x: 160, y: 110 })).toBeNull();
+ // A point of the framed shape lies on the extent: out of every handle's reach.
+ expect(frameGripAt(box, { x: 200, y: 130 })).toBeNull();
+ expect(frameGripAt(box, { x: 200, y: 110 })).toBeNull();
+ });
+
+ it('shows the arrow along the pull of each handle', () => {
+ expect(frameCursor({ kind: 'knob' })).toBe('grab');
+ expect(frameCursor({ kind: 'size', side: { sx: 0, sy: -1 } })).toBe('ns-resize');
+ expect(frameCursor({ kind: 'size', side: { sx: 1, sy: 0 } })).toBe('ew-resize');
+ expect(frameCursor({ kind: 'size', side: { sx: -1, sy: -1 } })).toBe('nwse-resize');
+ expect(frameCursor({ kind: 'size', side: { sx: 1, sy: -1 } })).toBe('nesw-resize');
+ });
+
+ it('moves a handle as far as the pointer went, wherever on the handle the press was', () => {
+ const side = { sx: 1, sy: 1 } as const;
+ expect(sidePoint(BOX, side)).toEqual({ x: 50, y: 40 });
+ expect(sidePoint(BOX, { sx: -1, sy: 0 })).toEqual({ x: 10, y: 30 });
+ const m = frameSizing(
+ BOX,
+ side,
+ { x: 57, y: 47 },
+ { x: 97, y: 57 },
+ { proportional: false, centred: false },
+ );
+ expect(m).toMatchObject({ a: 2, d: 1.5 });
+ const snapped = frameSizing(
+ BOX,
+ side,
+ { x: 57, y: 47 },
+ { x: 95, y: 47 },
+ {
+ proportional: false,
+ centred: false,
+ snap: (p) => ({ x: Math.round(p.x / 8) * 8, y: Math.round(p.y / 8) * 8 }),
+ },
+ );
+ // 50 + 38 = 88 is on the grid already; the height stays, 40 being on it too.
+ expect(snapped).toMatchObject({ a: 78 / 40, d: 1 });
+ });
+
+ it('turns to a tenth of a degree, and in steps of fifteen with Shift', () => {
+ expect(frameTurning(BOX, 37.26, false)).toEqual(rotationAbout({ x: 30, y: 30 }, 37.3));
+ expect(frameTurning(BOX, 37.26, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 30));
+ expect(frameTurning(BOX, 38, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 45));
+ });
+});
+
+/** A square of 20 pixels with its corner at `x`, `y`. */
+function square(id: string, x: number, y = 0): VectorShape {
+ return {
+ id,
+ name: id.toUpperCase(),
+ fill: 0x336699,
+ opacity: 1,
+ rings: [
+ {
+ closed: true,
+ points: [
+ { x, y },
+ { x: x + 20, y },
+ { x: x + 20, y: y + 20 },
+ { x, y: y + 20 },
+ ],
+ },
+ ],
+ };
+}
+
+const GRADIENT: VectorGradient = {
+ type: 'linear',
+ from: { x: 80, y: 50 },
+ to: { x: 100, y: 50 },
+ stops: [
+ { offset: 0, color: 0xff0000, alpha: 1 },
+ { offset: 1, color: 0x0000ff, alpha: 1 },
+ ],
+};
+
+/** A drawing with the squares a, b and c side by side at y 40, and a host to build rows into. */
+function rig() {
+ const state = new EditorState();
+ state.loadProject(createEmptyProject('p', 'P'), 1);
+ const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
+ asset.layers[0]!.items.push(square('a', 40, 40), square('b', 80, 40), square('c', 120, 40));
+ state.project.assets.push(asset);
+ const host: InspectorHost = {
+ state,
+ body: new VStack(),
+ fields: new Map(),
+ nameField: null,
+ project: () => state.project,
+ liveVariables: () => null,
+ };
+ const build = (): void => {
+ host.body.removeChildren();
+ host.fields.clear();
+ const s = state.selection;
+ if (s?.kind === 'shape') buildShapeSection(host, asset.id, s.shapeId, s.shapeIds);
+ };
+ const widgets = (type: new (...args: never[]) => T): T[] => {
+ const found: T[] = [];
+ const walk = (widget: Widget): void => {
+ if (widget instanceof type) found.push(widget);
+ for (const child of widget.children) walk(child as Widget);
+ };
+ walk(host.body);
+ return found;
+ };
+ const shape = (id: string) => allShapes(asset).find((s) => s.id === id)!;
+ const points = (id: string) => shape(id).rings[0]!.points.map((p) => [p.x, p.y]);
+ return { state, asset, host, build, widgets, shape, points };
+}
+
+let clock = 0;
+
+function pressAt(global: Vec2, held: 'shift' | 'alt' | null = null) {
+ clock += 1000;
+ return {
+ timeStamp: clock,
+ button: 0,
+ global,
+ altKey: held === 'alt',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: held === 'shift',
+ stopPropagation: () => undefined,
+ } as unknown as FederatedPointerEvent;
+}
+
+interface Pressable {
+ shiftPresses(asset: VectorArtAsset, local: Vec2): boolean;
+ press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void;
+ move(local: Vec2, keys?: { shiftKey: boolean; altKey: boolean }): void;
+ release(): void;
+}
+
+function tab() {
+ const r = rig();
+ const canvas = new VectorArtCanvas(r.state);
+ canvas.show(r.asset.id);
+ canvas.resize(512, 512);
+ canvas.redraw();
+ const target = canvas as unknown as Pressable;
+ const at = (x: number, y: number) => canvas.imageToScreen(x, y);
+ /** Screen pixels per canvas pixel. */
+ const zoom = at(1, 0).x - at(0, 0).x;
+ const select = (...ids: string[]) => r.state.select(shapeSelection(r.asset.id, ids));
+ const frame = () => canvas.frameInfo!;
+ /** Presses at `from` and lets go `dx`, `dy` canvas pixels further, with a key held all the way. */
+ const pull = (from: Vec2, dx: number, dy: number, held: 'shift' | 'alt' | null = null) => {
+ const to = { x: from.x + dx * zoom, y: from.y + dy * zoom };
+ target.press(r.asset, pressAt(from, held), from);
+ target.move(to, { shiftKey: held === 'shift', altKey: held === 'alt' });
+ target.release();
+ };
+ return { ...r, canvas, target, at, zoom, select, frame, pull };
+}
+
+describe('the frame in the tab', () => {
+ it('frames the selection off its bounds, and nothing with another tool or no selection', () => {
+ const t = tab();
+ expect(t.canvas.frameInfo).toBeNull();
+ t.select('a');
+ const corner = t.at(40, 40);
+ expect(t.frame().corners[0]).toEqual({ x: corner.x - FRAME_PAD, y: corner.y - FRAME_PAD });
+ t.select('a', 'c');
+ const far = t.at(140, 60);
+ expect(t.frame().corners[2]).toEqual({ x: far.x + FRAME_PAD, y: far.y + FRAME_PAD });
+ t.canvas.setTool('pen');
+ expect(t.canvas.frameInfo).toBeNull();
+ t.canvas.setTool('paint');
+ expect(t.canvas.frameInfo).toBeNull();
+ });
+
+ it('scales a shape from the opposite corner when a corner is pulled, as one undo step', () => {
+ const t = tab();
+ t.select('a');
+ t.pull(t.frame().corners[2]!, 20, 10);
+ expect(t.points('a')).toEqual([
+ [40, 40],
+ [80, 40],
+ [80, 70],
+ [40, 70],
+ ]);
+ t.state.undo();
+ expect(t.points('a')[2]).toEqual([60, 60]);
+ // The step before was none of the gesture's: the drawing has no further one to take back.
+ t.state.undo();
+ expect(t.points('a')[2]).toEqual([60, 60]);
+ });
+
+ it('shows the pull while it runs, and its undo step leads back to the very rings that were there', () => {
+ const t = tab();
+ t.select('a', 'c');
+ const before = t.shape('c').rings;
+ const handle = t.frame().corners[2]!;
+ t.target.press(t.asset, pressAt(handle), handle);
+ t.target.move({ x: handle.x + 100 * t.zoom, y: handle.y });
+ // The preview waits for the frame; a test has none, so it is run by hand.
+ (t.canvas as unknown as { writePreview(): void }).writePreview();
+ expect(t.points('c')[1]).toEqual([240, 40]);
+ expect(t.points('a')[1]).toEqual([80, 40]);
+ t.target.release();
+ expect(t.points('c')[1]).toEqual([240, 40]);
+ t.state.undo();
+ expect(t.shape('c').rings).toBe(before);
+ });
+
+ it('scales in one direction from an edge', () => {
+ const t = tab();
+ t.select('a');
+ t.pull(t.frame().edges[3]!, -20, 33);
+ expect(t.points('a')).toEqual([
+ [20, 40],
+ [60, 40],
+ [60, 60],
+ [20, 60],
+ ]);
+ });
+
+ it('scales every selected shape as one, grouped or not, and one undo puts all back', () => {
+ const t = tab();
+ t.select('a', 'c');
+ // The frame is 100 wide; pulled to 200 everything doubles from the left top corner.
+ t.pull(t.frame().corners[2]!, 100, 20);
+ expect(t.points('a')[2]).toEqual([80, 80]);
+ expect(t.points('c')[0]).toEqual([200, 40]);
+ expect(t.points('c')[2]).toEqual([240, 80]);
+ expect(t.points('b')[0]).toEqual([80, 40]);
+ t.state.undo();
+ expect(t.points('a')[2]).toEqual([60, 60]);
+ expect(t.points('c')[0]).toEqual([120, 40]);
+ });
+
+ it('frames a whole group after a press on one of its shapes and scales it together', () => {
+ const t = tab();
+ t.state.execute(new GroupVectorShapesCommand(t.asset.id, ['a', 'b']));
+ const on = t.at(50, 50);
+ t.target.press(t.asset, pressAt(on), on);
+ t.target.release();
+ expect(t.state.selectedShapeIds).toEqual(['a', 'b']);
+ t.pull(t.frame().edges[1]!, 60, 0);
+ expect(t.points('a')[1]).toEqual([80, 40]);
+ expect(t.points('b')[0]).toEqual([120, 40]);
+ expect(t.points('b')[1]).toEqual([160, 40]);
+ });
+
+ it('keeps the proportions with Shift and scales about the middle with Alt', () => {
+ const t = tab();
+ t.select('a');
+ t.pull(t.frame().corners[2]!, 20, 4, 'shift');
+ expect(t.points('a')[2]).toEqual([80, 80]);
+ t.state.undo();
+ t.pull(t.frame().corners[2]!, 10, 10, 'alt');
+ expect(t.points('a')).toEqual([
+ [30, 30],
+ [70, 30],
+ [70, 70],
+ [30, 70],
+ ]);
+ });
+
+ it('takes a Shift press on a handle itself instead of leaving it to the pan or the selection', () => {
+ const t = tab();
+ t.select('a', 'c');
+ const handle = t.frame().corners[2]!;
+ expect(t.target.shiftPresses(t.asset, handle)).toBe(true);
+ // The frame is 100 wide: pulled to 200 with Shift the height doubles along.
+ t.pull(handle, 100, 0, 'shift');
+ expect(t.state.selectedShapeIds).toEqual(['a', 'c']);
+ expect(t.points('c')[2]).toEqual([240, 80]);
+ // Beside the frame and the shapes Shift is still the pan's.
+ expect(t.target.shiftPresses(t.asset, t.at(200, 200))).toBe(false);
+ });
+
+ it('turns the selection round the middle of its frame with the knob', () => {
+ const t = tab();
+ t.select('a');
+ const knob = t.frame().knob;
+ const centre = t.at(50, 50);
+ const reach = centre.y - knob.y;
+ // From straight above the middle to straight right of it: a quarter turn clockwise.
+ t.target.press(t.asset, pressAt(knob), knob);
+ t.target.move({ x: centre.x + reach, y: centre.y });
+ expect(t.frame().knob.x).toBeCloseTo(centre.x + reach, 6);
+ expect(t.frame().knob.y).toBeCloseTo(centre.y, 6);
+ t.target.release();
+ expect(t.points('a')).toEqual([
+ [60, 40],
+ [60, 60],
+ [40, 60],
+ [40, 40],
+ ]);
+ // Baked in: the frame is upright again and the knob back on top.
+ expect(t.frame().knob.x).toBeCloseTo(knob.x, 6);
+ expect(t.frame().knob.y).toBeCloseTo(knob.y, 6);
+ t.state.undo();
+ expect(t.points('a')[0]).toEqual([40, 40]);
+ });
+
+ it('snaps a turn to steps of fifteen degrees with Shift', () => {
+ const t = tab();
+ t.select('a');
+ const knob = t.frame().knob;
+ const centre = t.at(50, 50);
+ const reach = centre.y - knob.y;
+ const toward = (degrees: number): Vec2 => {
+ const r = ((degrees - 90) * Math.PI) / 180;
+ return { x: centre.x + reach * Math.cos(r), y: centre.y + reach * Math.sin(r) };
+ };
+ t.target.press(t.asset, pressAt(knob), knob);
+ t.target.move(toward(41), { shiftKey: true, altKey: false });
+ t.target.release();
+ const turned = rotationAbout({ x: 50, y: 50 }, 45);
+ expect(t.points('a')[0]![0]).toBeCloseTo(turned.a * 40 + turned.c * 40 + turned.tx, 3);
+ expect(t.points('a')[0]![1]).toBeCloseTo(turned.b * 40 + turned.d * 40 + turned.ty, 3);
+ });
+
+ it('leaves a point of the shape to itself: the frame never lies under it', () => {
+ const t = tab();
+ t.select('a');
+ const corner = t.at(60, 60);
+ t.target.press(t.asset, pressAt(corner), corner);
+ t.target.move({ x: corner.x + 10 * t.zoom, y: corner.y });
+ t.target.release();
+ expect(t.points('a')).toEqual([
+ [40, 40],
+ [60, 40],
+ [70, 60],
+ [40, 60],
+ ]);
+ expect(t.canvas.pickedAt).toEqual({ ring: 0, index: 2 });
+ });
+
+ it('puts a pulled handle on the grid while snapping is on', () => {
+ const t = tab();
+ t.select('a');
+ t.canvas.setSnapping(true);
+ t.pull(t.frame().corners[2]!, 13, 2);
+ expect(t.points('a')[2]).toEqual([72, 64]);
+ });
+
+ it('makes no undo step of a press on a handle that pulls nothing', () => {
+ const t = tab();
+ t.select('a');
+ t.pull(t.frame().corners[2]!, 20, 20);
+ const handle = t.frame().corners[0]!;
+ t.target.press(t.asset, pressAt(handle), handle);
+ t.target.release();
+ t.state.undo();
+ expect(t.points('a')[2]).toEqual([60, 60]);
+ });
+
+ it('takes the fill paint along and leaves the line as wide as it was', () => {
+ const t = tab();
+ const b = t.shape('b');
+ b.paint = { ...GRADIENT };
+ b.stroke = { color: 0, width: 3 };
+ t.select('b');
+ t.pull(t.frame().corners[2]!, 20, 20);
+ expect(b.paint).toMatchObject({ from: { x: 80, y: 60 }, to: { x: 120, y: 60 } });
+ expect(b.stroke.width).toBe(3);
+ t.state.undo();
+ expect(b.paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } });
+ });
+
+ it('shows the arrow of the handle under the pointer', () => {
+ const t = tab();
+ t.select('a');
+ t.target.move(t.frame().corners[2]!);
+ expect(t.canvas.cursor).toBe('nwse-resize');
+ t.target.move(t.frame().knob);
+ expect(t.canvas.cursor).toBe('grab');
+ t.target.move(t.at(50, 50));
+ expect(t.canvas.cursor).toBe('move');
+ });
+});
+
+describe('the TRANSFORM rows', () => {
+ const KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn'];
+ const built = (...ids: string[]) => {
+ const r = rig();
+ r.state.select(shapeSelection(r.asset.id, ids));
+ r.build();
+ const row = (key: string) => r.widgets(NumberField)[KEYS.indexOf(key)]!;
+ const button = (label: string) => r.widgets(Button).find((b) => b.text === label)!;
+ const refresh = () => refreshShapeSection(r.host, r.asset.id, ids[0]!);
+ return { ...r, row, button, refresh };
+ };
+
+ it('show where the frame of the selection stands and how big it is', () => {
+ const r = built('a', 'c');
+ expect(KEYS.map((key) => Number(r.row(key).value))).toEqual([40, 40, 100, 20, 0]);
+ expect([...r.host.fields.keys()].slice(0, 5)).toEqual(KEYS);
+ });
+
+ it('move the whole selection to a typed place', () => {
+ const r = built('a', 'c');
+ r.row('frameX').onChange!('10');
+ r.row('frameY').onChange!('0');
+ expect(r.points('a')[0]).toEqual([10, 0]);
+ expect(r.points('c')[0]).toEqual([90, 0]);
+ expect(r.points('b')[0]).toEqual([80, 40]);
+ });
+
+ it('stretch the selection to a typed size from its left top corner', () => {
+ const r = built('a', 'c');
+ r.row('frameWidth').onChange!('200');
+ expect(r.points('c')[1]).toEqual([240, 40]);
+ r.row('frameHeight').onChange!('10');
+ expect(r.points('a')[2]).toEqual([80, 50]);
+ r.refresh();
+ expect(Number(r.row('frameWidth').value)).toBe(200);
+ expect(Number(r.row('frameHeight').value)).toBe(10);
+ });
+
+ it('turn by the typed degrees, and read 0 again once they are baked in', () => {
+ const r = built('a');
+ r.row('frameTurn').onChange!('90');
+ expect(r.points('a')[0]).toEqual([60, 40]);
+ r.refresh();
+ expect(Number(r.row('frameTurn').value)).toBe(0);
+ r.state.undo();
+ expect(r.points('a')[0]).toEqual([40, 40]);
+ });
+
+ it('preview a dragged number from the shapes as they were, and commit one undo step', () => {
+ const r = built('a');
+ const before = r.shape('a').rings;
+ const turn = r.row('frameTurn');
+ turn.onInput!('30');
+ turn.onInput!('60');
+ turn.onInput!('90');
+ // Each preview starts from the square as it was: three steps of thirty would have added rounding.
+ expect(r.points('a')[0]).toEqual([60, 40]);
+ r.refresh();
+ expect(Number(turn.value)).toBe(90);
+ turn.onChange!('180');
+ expect(r.points('a')[0]).toEqual([60, 60]);
+ r.refresh();
+ expect(Number(turn.value)).toBe(0);
+ r.state.undo();
+ expect(r.shape('a').rings).toBe(before);
+ });
+
+ it('mirror the selection about the middle of its frame', () => {
+ const r = built('a', 'c');
+ r.button('Flip horizontal').onClick!();
+ expect(r.points('a')[0]).toEqual([140, 40]);
+ expect(r.points('c')[0]).toEqual([60, 40]);
+ r.button('Flip vertical').onClick!();
+ expect(r.points('a')[0]).toEqual([140, 60]);
+ r.state.undo();
+ r.state.undo();
+ expect(r.points('a')[0]).toEqual([40, 40]);
+ expect(flipSelection(r.state, r.asset.id, ['nobody'], 'x')).toBe(false);
+ });
+
+ it('are not there for a shape that has no size yet, and come with its second point', () => {
+ const r = rig();
+ r.shape('a').rings = [{ closed: false, points: [{ x: 5, y: 5 }] }];
+ r.state.select(shapeSelection(r.asset.id, ['a']));
+ r.build();
+ expect(r.host.fields.has('frameX')).toBe(false);
+ const bare = shapeSignature(r.state.project, r.asset.id, 'a', ['a']);
+ r.shape('a').rings = [
+ {
+ closed: false,
+ points: [
+ { x: 5, y: 5 },
+ { x: 9, y: 5 },
+ ],
+ },
+ ];
+ expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).not.toBe(bare);
+ });
+});
+
+describe('shapes dragged together (#217)', () => {
+ it('snaps the offset all of them share, so each keeps its place beside the others', () => {
+ const t = tab();
+ t.shape('a').rings = square('a', 43, 41).rings;
+ t.select('a', 'c');
+ t.canvas.setSnapping(true);
+ const on = t.at(130, 50);
+ t.target.press(t.asset, pressAt(on), on);
+ t.target.move({ x: on.x + 13 * t.zoom, y: on.y + 5 * t.zoom });
+ t.target.release();
+ // 13 and 5 go to the grid's 16 and 8 once, for both: a stays three pixels off the grid.
+ expect(t.points('c')[0]).toEqual([136, 48]);
+ expect(t.points('a')[0]).toEqual([59, 49]);
+ });
+
+ it('takes the paints of every dragged shape along, and one undo puts them back', () => {
+ const t = tab();
+ t.shape('b').paint = { ...GRADIENT };
+ const c = t.shape('c');
+ c.stroke = { color: 0, width: 2, paint: { ...GRADIENT, from: { x: 120, y: 50 }, to: { x: 140, y: 50 } } };
+ t.select('b', 'c');
+ // On the grid, so the offset is a whole 8 and 16 and not what a screen pixel divides to.
+ t.canvas.setSnapping(true);
+ const on = t.at(130, 50);
+ t.target.press(t.asset, pressAt(on), on);
+ t.target.move({ x: on.x + 8 * t.zoom, y: on.y + 16 * t.zoom });
+ t.target.release();
+ expect(t.shape('b').paint).toMatchObject({ from: { x: 88, y: 66 }, to: { x: 108, y: 66 } });
+ expect(c.stroke.paint).toMatchObject({ from: { x: 128, y: 66 }, to: { x: 148, y: 66 } });
+ t.state.undo();
+ expect(t.shape('b').paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } });
+ expect(c.stroke.paint).toMatchObject({ from: { x: 120, y: 50 }, to: { x: 140, y: 50 } });
+ });
+});
diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts
index 96a8ec5f..4bcf271e 100644
--- a/tests/editor/shape-rows.test.ts
+++ b/tests/editor/shape-rows.test.ts
@@ -98,13 +98,25 @@ function widgets(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');
diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs
index 3052311c..4d7bd056 100644
--- a/tools/e2e/47-vectorart.mjs
+++ b/tools/e2e/47-vectorart.mjs
@@ -82,7 +82,6 @@ export async function vectorArt(t) {
clickText,
json,
evaluate,
- locate,
mutate,
press,
settle,
@@ -187,6 +186,12 @@ export async function vectorArt(t) {
JSON.stringify({ current: await json('__gm.vectorArt.current()'), info }),
);
+ /** A button among the rows of the inspector, which may stand below the fold (#291). */
+ const clickRowButton = async (text) => {
+ const button = await json(`__gm.inspectorButton(${JSON.stringify(text)})`);
+ if (!button) throw new Error(`no button "${text}" in the inspector`);
+ await click(button.x, button.y);
+ };
await clickText('Pen');
check('the pen is the tool a press uses', (await json('__gm.vectorArt.tool()')) === 'pen');
@@ -380,14 +385,18 @@ export async function vectorArt(t) {
const outlined = await shapeOf('va-field');
check(
'the Outline switch gives the shape a line, and its rows appear with it',
- Boolean(outlined?.stroke) && outlined.stroke.width === 2 && Boolean(await locate('Line width')),
+ // By its control, which scrolls the row into view: with the TRANSFORM rows (#291) over it the
+ // inspector is taller than the window.
+ Boolean(outlined?.stroke) &&
+ outlined.stroke.width === 2 &&
+ Boolean(await json("__gm.inspectorField('Line width')")),
JSON.stringify(outlined?.stroke),
);
// The badge was drawn over the field; "To front" puts the field over the badge instead.
const covered = await json('__gm.vectorArt.pointAt(204, 60)');
const wasBadge = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
- await clickText('To front');
+ await clickRowButton('To front');
await settle();
const nowField = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
check(
@@ -416,7 +425,7 @@ export async function vectorArt(t) {
);
const before47e = (await shapeIds()).length;
- await clickText('Add shape');
+ await clickRowButton('Add shape');
await settle();
const added = await shapeIds();
check(
@@ -441,7 +450,7 @@ export async function vectorArt(t) {
// Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the
// key reaches the editor's shortcuts rather than the canvas.
- await clickText('Add shape');
+ await clickRowButton('Add shape');
await settle();
const spare = (await shapeIds()).at(-1);
const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`);
@@ -771,7 +780,7 @@ export async function vectorArt(t) {
);
await shot('47-combine-selected');
- await clickText('Subtract');
+ await clickRowButton('Subtract');
await settle();
const cut = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
const ring = cut?.[0]?.rings[0]?.points ?? [];
@@ -846,7 +855,7 @@ export async function vectorArt(t) {
await click(onDisc.x, onDisc.y);
await click(onCover.x, onCover.y, 'left', { ctrl: true });
await settle();
- await clickText('Subtract');
+ await clickRowButton('Subtract');
await settle();
const carved = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
const arc = carved?.[0]?.rings[0]?.points ?? [];
@@ -1158,7 +1167,7 @@ export async function vectorArt(t) {
await click(onOne.x, onOne.y);
await click(onTwo.x, onTwo.y, 'left', { shift: true });
await settle();
- await clickText('Group');
+ await clickRowButton('Group');
await settle();
const grouped = (await layers190())[0];
const groupId = grouped.rows.find((r) => r.group)?.id;
@@ -1275,6 +1284,185 @@ export async function vectorArt(t) {
colourDistance(await tabPixel(200, 40), GREEN) < 14,
JSON.stringify({ hiddenRed, pressedHidden, shownRed }),
);
+
+ // 47k. The frame round the selection (#291): its handles scale and turn what is selected, the
+ // rows of the inspector do the same by number, and two shapes that are not grouped go together.
+ await mutate(`
+ const art291 = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
+ art291.layers = [{ id: 'va-base', name: 'Layer 1', items: ${JSON.stringify([
+ { ...square('va-one', 'One', 0xcc3344, 10, 10), rings: [squareRing(10, 90, 60)] },
+ { ...square('va-two', 'Two', 0x3344cc, 90, 10), rings: [squareRing(90, 90, 60)] },
+ { ...square('va-three', 'Three', 0x33aa55, 170, 10), rings: [squareRing(170, 90, 60)] },
+ ])} }];
+ __gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
+ await settle();
+ const frame291 = () => json('__gm.vectorArt.frame()');
+ const pointsOf291 = async (id) =>
+ (await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`))
+ .find((s) => s.id === id)
+ ?.rings[0].points.map((p) => [p.x, p.y]);
+ const reads291 = (points, expected) =>
+ points?.length === expected.length &&
+ points.every((p, i) => near(p[0], expected[i][0]) && near(p[1], expected[i][1]));
+ const zoom291 = (await screenAt(1, 0)).x - (await screenAt(0, 0)).x;
+ // The squares stand in the middle of the canvas: the knob over a frame at its top edge would
+ // lie outside the tab.
+ const one291 = await screenAt(40, 120);
+ const two291 = await screenAt(120, 120);
+
+ // One shape picked: the frame stands off its bounds, and its far corner pulled 60 pixels out
+ // doubles the square from the corner opposite.
+ const noFrame = await frame291();
+ await click(one291.x, one291.y);
+ await settle();
+ const framed = await frame291();
+ const cornerOne = await screenAt(70, 150);
+ const handle = framed.corners[2];
+ await shot('47-frame');
+ await drag(handle.x, handle.y, handle.x + 60 * zoom291, handle.y + 60 * zoom291, { steps: 8 });
+ await settle();
+ const scaledOne = await pointsOf291('va-one');
+ const redGrown = await tabPixel(110, 190);
+ await undo();
+ await settle();
+ const unscaledOne = await pointsOf291('va-one');
+ check(
+ 'a corner of the frame scales the selected shape from the opposite corner, in one undo step',
+ noFrame === null &&
+ near(handle.x - cornerOne.x, 15) &&
+ near(handle.y - cornerOne.y, 15) &&
+ framed.edges.length === 4 &&
+ reads291(scaledOne, [
+ [10, 90],
+ [130, 90],
+ [130, 210],
+ [10, 210],
+ ]) &&
+ colourDistance(redGrown, RED) < 14 &&
+ reads291(unscaledOne, [
+ [10, 90],
+ [70, 90],
+ [70, 150],
+ [10, 150],
+ ]),
+ JSON.stringify({ noFrame, handle, cornerOne, scaledOne, redGrown, unscaledOne }),
+ );
+
+ // Two shapes picked without a group (#217): a drag on one moves both by the same way.
+ await click(two291.x, two291.y, 'left', { shift: true });
+ await settle();
+ const pair = await json('__gm.vectorArt.selectedIds()');
+ await drag(one291.x, one291.y, one291.x, one291.y + 80 * zoom291, { steps: 8 });
+ await settle();
+ const pairOne = await pointsOf291('va-one');
+ const pairTwo = await pointsOf291('va-two');
+ const pairThree = await pointsOf291('va-three');
+ const blueMoved = await tabPixel(120, 200);
+ await undo();
+ await settle();
+ check(
+ 'two selected shapes that are not grouped are dragged together, and one undo puts both back',
+ pair.join() === 'va-one,va-two' &&
+ near(pairOne?.[0][1], 170) &&
+ near(pairTwo?.[0][1], 170) &&
+ near(pairTwo?.[0][0], 90) &&
+ near(pairThree?.[0][1], 90) &&
+ colourDistance(blueMoved, BLUE) < 14 &&
+ near((await pointsOf291('va-one'))?.[0][1], 90) &&
+ near((await pointsOf291('va-two'))?.[0][1], 90),
+ JSON.stringify({ pair, pairOne, pairTwo, pairThree, blueMoved }),
+ );
+
+ // The knob with Shift held: from above the middle to the right of it, a quarter turn of both
+ // shapes round the middle of their common frame, which is at 80, 120.
+ const both = await frame291();
+ const middle291 = await screenAt(80, 120);
+ const reach = middle291.y - both.knob.y;
+ const emptyBefore = await tabPixel(80, 160);
+ await drag(both.knob.x, both.knob.y, middle291.x + reach, middle291.y + 6, {
+ steps: 10,
+ modifiers: { shift: true },
+ });
+ await settle();
+ const turnedOne = await pointsOf291('va-one');
+ const turnedTwo = await pointsOf291('va-two');
+ const blueTurned = await tabPixel(80, 160);
+ const upright = await frame291();
+ await shot('47-frame-turned');
+ await undo();
+ await settle();
+ check(
+ 'the knob turns the selection round the middle of its frame, snapping to a quarter with Shift',
+ reads291(turnedOne, [
+ [110, 50],
+ [110, 110],
+ [50, 110],
+ [50, 50],
+ ]) &&
+ reads291(turnedTwo, [
+ [110, 130],
+ [110, 190],
+ [50, 190],
+ [50, 130],
+ ]) &&
+ colourDistance(emptyBefore, BLUE) > 60 &&
+ colourDistance(blueTurned, BLUE) < 14 &&
+ // Baked in: the frame is upright again, its knob over the middle of the turned pair.
+ near(upright.knob.x, middle291.x) &&
+ upright.knob.y < middle291.y &&
+ near((await pointsOf291('va-one'))?.[0][0], 10),
+ JSON.stringify({
+ turnedOne,
+ turnedTwo,
+ emptyBefore,
+ blueTurned,
+ upright: upright.knob,
+ middle: middle291,
+ }),
+ );
+
+ // The rows of the inspector: a typed width stretches both shapes from the left edge, and a
+ // typed turn is baked in, after which its row reads 0 again.
+ const widthField = await json("__gm.inspectorField('Width', true)");
+ await click(widthField.x, widthField.y);
+ await press('a', { ctrl: true });
+ await typeText('280');
+ await press('Enter');
+ await settle();
+ const wideOne = await pointsOf291('va-one');
+ const wideTwo = await pointsOf291('va-two');
+ await undo();
+ await settle();
+ const turnField = await json("__gm.inspectorField('Rotate by', true)");
+ await click(turnField.x, turnField.y);
+ await press('a', { ctrl: true });
+ await typeText('180');
+ await press('Enter');
+ await settle();
+ const halfOne = await pointsOf291('va-one');
+ const turnText = await json("__gm.inspectorLink('Rotate by')?.field?.value ?? null");
+ await undo();
+ await settle();
+ check(
+ 'a typed width stretches the selection and a typed turn is baked in, each one undo step',
+ reads291(wideOne, [
+ [10, 90],
+ [130, 90],
+ [130, 150],
+ [10, 150],
+ ]) &&
+ near(wideTwo?.[0][0], 170) &&
+ near(wideTwo?.[1][0], 290) &&
+ reads291(halfOne, [
+ [150, 150],
+ [90, 150],
+ [90, 90],
+ [150, 90],
+ ]) &&
+ Number(turnText) === 0 &&
+ near((await pointsOf291('va-one'))?.[0][0], 10),
+ JSON.stringify({ wideOne, wideTwo, halfOne, turnText }),
+ );
}
/** A square ring of `size` pixels with its corner at `x`, `y`. */