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`. */