Scale, turn and mirror the shapes of a vector drawing (#291)
A shape could only be moved and reshaped point by point. Whoever
wanted it larger or turned pulled every point alone, and for several
shapes or a group there was no way at all. The answers in the issue
settle the rest: the change is baked into the points, nothing of it
is remembered, and the line width stays.
- With Select the selection stands in a frame of eight squares and a
knob, drawn like the one of the scene view. It holds one shape,
several shapes or a group, the same selection that is dragged as
one.
- A corner square scales from the opposite corner, a square on an
edge in its own direction. Shift keeps the proportions, Alt scales
about the middle.
- The knob turns round the middle of the frame, with Shift in steps
of 15 degrees. After the pull the frame stands upright again.
- The inspector has a band TRANSFORM with X, Y, Width, Height and
Rotate by, and the buttons Flip horizontal and Flip vertical.
- Points, handles, gradients and texture tiles are taken through the
same matrix. The rule is a pure function in vectorTransform.ts,
transformShapesCommand makes one undo step of any number of
shapes.
- A press goes to a point or a handle first, then to the frame, then
to a line. shapeFrame.ts holds where the handles are and what a
pull of each means.
Decided here:
- The frame stands 15 pixels off the shapes. The points of a shape
lie on its bounds, so a square on the bounds would cover them.
- The knob only turns. In the scene view it scales as well.
- A side cannot be pulled through the opposite one, the frame keeps
at least 1 pixel.
- Coordinates are rounded to a thousandth of a pixel, so a quarter
turn is exact.
- A texture tile stays upright under a turn and grows to the bounds
of the turned tile. The bands of a gradient stay square to its
axis under an uneven scale.
- The switch Scale stroke of the proposal is not built.
Found on the way:
- The inspector of a shape is now taller than the window. Two checks
of module 47 no longer found their row or button on the screen and
use the new hook inspectorButton, which scrolls there first.
- Not solved: with a shape at the top edge of the view the knob lies
outside the tab. The wiki names it under the limits.
Taken over from #217: the e2e check for two selected shapes without
a group, and unit tests for the snap of the common offset and for
the gradients of the shapes dragged along.
Tests:
- Gate on melektaus-atx: 209 files, 3176 tests (3121 + 55), exit 0
- e2e 47,48 on melektaus-atx: 112 checks, all pass
- No full run and no smokes. The sum of 1664 (1660 + 4) is reckoned,
1660 is what CI counted for 6d79cf5.
- 55 unit tests: 25 in tests/core/vector-transform.test.ts and 30 in
tests/editor/shape-frame.test.ts
- Four e2e checks in block 47k of module 47: pull a corner, turn
with Shift, type width and rotation, drag two shapes without a
group
- 47 unit counter-probes and four browser counter-probes all fail
their tests. Four of the unit ones did not at first: two showed
dead code, which is gone, two got tests.
- Not checked in the browser: the edge squares, Alt, the cursors,
the flip buttons and the rows X, Y and Height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d79cf59a3
commit
345289982b
14 files changed
+2026
-28
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Scaling, turning and mirroring vector art shapes (#291). A transformation is baked into the points:
|
||||
* the model keeps no matrix, so everything that reads points – combining, the picture a sprite wears,
|
||||
* a press in the tab – goes on reading them as before. This module says what a shape reads after an
|
||||
* affine map, how far a selection reaches, and which map a pull on the frame's handles means.
|
||||
*
|
||||
* The outline's width is left alone: a line of two pixels is two pixels at every size of its shape.
|
||||
*/
|
||||
import type { VectorGradient, VectorPaint, VectorPoint, VectorRing, VectorShape } from './Asset';
|
||||
import { apply, applyVector, type Mat } from './matrix';
|
||||
import type { Vec2 } from './Properties';
|
||||
import { cloneVectorPoint, handleAt } from './vectorart';
|
||||
import { cloneGradient, clonePaint, type Extent } from './vectorPaint';
|
||||
|
||||
/** A frame is never pulled thinner than this many canvas pixels, so it can be pulled open again. */
|
||||
export const MIN_FRAME_SIZE = 1;
|
||||
|
||||
/** A handle shorter than this is none: the point is a plain corner on that side. */
|
||||
const NO_HANDLE = 1e-6;
|
||||
|
||||
/** What of a shape a transformation moves: its outlines and where its paints lie. */
|
||||
export interface ShapeGeometry {
|
||||
rings: VectorRing[];
|
||||
paint?: VectorPaint;
|
||||
strokePaint?: VectorGradient;
|
||||
}
|
||||
|
||||
/** The side of a frame a handle sits on: -1, 0 or 1 on each axis, a corner has both. */
|
||||
export interface FrameSide {
|
||||
sx: -1 | 0 | 1;
|
||||
sy: -1 | 0 | 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas pixels are kept to a thousandth. A map multiplies every coordinate by sines and ratios, and
|
||||
* without this a square turned by 30 degrees and back would read 9.999999999999998 in the file.
|
||||
*/
|
||||
function tidy(value: number): number {
|
||||
const rounded = Math.round(value * 1000) / 1000;
|
||||
return rounded === 0 ? 0 : rounded;
|
||||
}
|
||||
|
||||
function tidyPoint(p: Vec2): Vec2 {
|
||||
return { x: tidy(p.x), y: tidy(p.y) };
|
||||
}
|
||||
|
||||
/** Scales by `fx` and `fy` with `anchor` staying where it is; a negative factor mirrors. */
|
||||
export function scaleAbout(anchor: Vec2, fx: number, fy: number): Mat {
|
||||
return { a: fx, b: 0, c: 0, d: fy, tx: anchor.x * (1 - fx), ty: anchor.y * (1 - fy) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns by `degrees` round `centre`, clockwise on screen. The cosine of a quarter turn comes out as
|
||||
* 6e-17 rather than 0; `tidy` takes that off every coordinate, so a square turned four times is
|
||||
* the square it was.
|
||||
*/
|
||||
export function rotationAbout(centre: Vec2, degrees: number): Mat {
|
||||
const r = (degrees * Math.PI) / 180;
|
||||
const cos = Math.cos(r);
|
||||
const sin = Math.sin(r);
|
||||
return {
|
||||
a: cos,
|
||||
b: sin,
|
||||
c: -sin,
|
||||
d: cos,
|
||||
tx: centre.x - cos * centre.x + sin * centre.y,
|
||||
ty: centre.y - sin * centre.x - cos * centre.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function extentCentre(extent: Extent): Vec2 {
|
||||
return { x: (extent.x0 + extent.x1) / 2, y: (extent.y0 + extent.y1) / 2 };
|
||||
}
|
||||
|
||||
/** The four corners of an extent taken through `m`: top left, top right, bottom right, bottom left. */
|
||||
export function extentCorners(extent: Extent, m: Mat): Vec2[] {
|
||||
return [
|
||||
apply(m, { x: extent.x0, y: extent.y0 }),
|
||||
apply(m, { x: extent.x1, y: extent.y0 }),
|
||||
apply(m, { x: extent.x1, y: extent.y1 }),
|
||||
apply(m, { x: extent.x0, y: extent.y1 }),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring under `m`. A handle is an offset from its point, so it takes the linear part only. A smooth
|
||||
* or symmetric point stays one: a linear map keeps two opposite offsets opposite.
|
||||
*/
|
||||
export function ringTransformed(ring: VectorRing, m: Mat): VectorRing {
|
||||
return {
|
||||
closed: ring.closed,
|
||||
points: ring.points.map((point) => {
|
||||
const next: VectorPoint = { ...cloneVectorPoint(point), ...tidyPoint(apply(m, point)) };
|
||||
for (const side of ['in', 'out'] as const) {
|
||||
const handle = point[side];
|
||||
if (!handle) continue;
|
||||
const moved = tidyPoint(applyVector(m, handle));
|
||||
if (Math.hypot(moved.x, moved.y) < NO_HANDLE) delete next[side];
|
||||
else next[side] = moved;
|
||||
}
|
||||
if (!next.in && !next.out) delete next.mode;
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A paint under `m`. The two ends of a gradient go along, which is exact for a move, a turn and an
|
||||
* even scale; a gradient squeezed in one direction keeps its bands square to its axis. A picture
|
||||
* fill is a tile with upright sides: scaled or mirrored it follows exactly, turned it keeps upright
|
||||
* and takes the room the turned tile reaches over – the picture itself does not turn.
|
||||
*/
|
||||
export function paintTransformed<P extends VectorPaint>(paint: P, m: Mat): P {
|
||||
const next = clonePaint(paint);
|
||||
if (paint.type !== 'texture' || (m.b === 0 && m.c === 0)) {
|
||||
next.from = tidyPoint(apply(m, paint.from));
|
||||
next.to = tidyPoint(apply(m, paint.to));
|
||||
return next;
|
||||
}
|
||||
const centre = apply(m, { x: (paint.from.x + paint.to.x) / 2, y: (paint.from.y + paint.to.y) / 2 });
|
||||
const hw = (paint.to.x - paint.from.x) / 2;
|
||||
const hh = (paint.to.y - paint.from.y) / 2;
|
||||
const halfWidth = (Math.abs(m.a * hw) + Math.abs(m.c * hh)) * (hw < 0 ? -1 : 1);
|
||||
const halfHeight = (Math.abs(m.b * hw) + Math.abs(m.d * hh)) * (hh < 0 ? -1 : 1);
|
||||
next.from = tidyPoint({ x: centre.x - halfWidth, y: centre.y - halfHeight });
|
||||
next.to = tidyPoint({ x: centre.x + halfWidth, y: centre.y + halfHeight });
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The rings are handed out as they are, not copied: nobody edits a ring in place. */
|
||||
export function geometryOf(shape: VectorShape): ShapeGeometry {
|
||||
return {
|
||||
rings: shape.rings,
|
||||
...(shape.paint ? { paint: clonePaint(shape.paint) } : {}),
|
||||
...(shape.stroke?.paint ? { strokePaint: cloneGradient(shape.stroke.paint) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function geometryTransformed(geometry: ShapeGeometry, m: Mat): ShapeGeometry {
|
||||
return {
|
||||
rings: geometry.rings.map((ring) => ringTransformed(ring, m)),
|
||||
...(geometry.paint ? { paint: paintTransformed(geometry.paint, m) } : {}),
|
||||
...(geometry.strokePaint ? { strokePaint: paintTransformed(geometry.strokePaint, m) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes a geometry straight into a shape – a gesture's preview, and the way back before its command. */
|
||||
export function writeGeometry(shape: VectorShape, geometry: ShapeGeometry): void {
|
||||
shape.rings = geometry.rings;
|
||||
if (geometry.paint) shape.paint = geometry.paint;
|
||||
if (geometry.strokePaint && shape.stroke) shape.stroke.paint = geometry.strokePaint;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the outlines of `shapes` reach, in canvas pixels; null when there is nothing with a size.
|
||||
* A curve counts as far as it is drawn, not as far as its handles stand out – the frame round a
|
||||
* circle touches the circle. A single point has no size and gets no frame.
|
||||
*/
|
||||
export function outlineExtent(shapes: readonly VectorShape[]): Extent | null {
|
||||
const e: Extent = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
|
||||
const take = (p: Vec2): void => {
|
||||
e.x0 = Math.min(e.x0, p.x);
|
||||
e.y0 = Math.min(e.y0, p.y);
|
||||
e.x1 = Math.max(e.x1, p.x);
|
||||
e.y1 = Math.max(e.y1, p.y);
|
||||
};
|
||||
for (const shape of shapes)
|
||||
for (const ring of shape.rings) {
|
||||
const count = ring.points.length;
|
||||
for (const [index, from] of ring.points.entries()) {
|
||||
take(from);
|
||||
if (index === count - 1 && !ring.closed) continue;
|
||||
const to = ring.points[(index + 1) % count]!;
|
||||
if (!from.out && !to.in) continue;
|
||||
const c1 = handleAt(from, 'out') ?? from;
|
||||
const c2 = handleAt(to, 'in') ?? to;
|
||||
for (const t of [
|
||||
...turningPoints(from.x, c1.x, c2.x, to.x),
|
||||
...turningPoints(from.y, c1.y, c2.y, to.y),
|
||||
])
|
||||
take({ x: cubic(from.x, c1.x, c2.x, to.x, t), y: cubic(from.y, c1.y, c2.y, to.y, t) });
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(e.x0)) return null;
|
||||
return e.x1 - e.x0 > 0 || e.y1 - e.y0 > 0 ? e : null;
|
||||
}
|
||||
|
||||
function cubic(p0: number, p1: number, p2: number, p3: number, t: number): number {
|
||||
const u = 1 - t;
|
||||
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
|
||||
}
|
||||
|
||||
/** Where one coordinate of a cubic stops growing or shrinking, strictly between its ends. */
|
||||
function turningPoints(p0: number, p1: number, p2: number, p3: number): number[] {
|
||||
const a = -p0 + 3 * p1 - 3 * p2 + p3;
|
||||
const b = 2 * (p0 - 2 * p1 + p2);
|
||||
const c = p1 - p0;
|
||||
const roots: number[] = [];
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
if (Math.abs(b) > 1e-12) roots.push(-c / b);
|
||||
} else {
|
||||
const disc = b * b - 4 * a * c;
|
||||
if (disc >= 0) {
|
||||
const root = Math.sqrt(disc);
|
||||
roots.push((-b + root) / (2 * a), (-b - root) / (2 * a));
|
||||
}
|
||||
}
|
||||
return roots.filter((t) => t > 0 && t < 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The map a pull on a frame handle means: the handle on `side` of `extent` is taken to `to`, the
|
||||
* opposite side stays put. `proportional` keeps the shape of the frame – a corner follows the
|
||||
* farther of its two axes, an edge takes the other axis along about its middle. `centred` scales
|
||||
* about the middle instead of the opposite side. A side without an extent has nothing to scale and
|
||||
* keeps its factor at one; no side is ever pulled through its opposite.
|
||||
*/
|
||||
export function frameScale(
|
||||
extent: Extent,
|
||||
side: FrameSide,
|
||||
to: Vec2,
|
||||
options: { proportional?: boolean; centred?: boolean } = {},
|
||||
): Mat {
|
||||
const centre = extentCentre(extent);
|
||||
const axis = (s: -1 | 0 | 1, low: number, high: number, mid: number, reach: number) => {
|
||||
const anchor = s === 0 || options.centred ? mid : s > 0 ? low : high;
|
||||
if (s === 0) return { anchor, factor: 1 };
|
||||
const size = high - low;
|
||||
const span = (s > 0 ? high : low) - anchor;
|
||||
if (Math.abs(span) < 1e-9) return { anchor, factor: 1 };
|
||||
return { anchor, factor: Math.max((reach - anchor) / span, Math.min(1, MIN_FRAME_SIZE / size)) };
|
||||
};
|
||||
const x = axis(side.sx, extent.x0, extent.x1, centre.x, to.x);
|
||||
const y = axis(side.sy, extent.y0, extent.y1, centre.y, to.y);
|
||||
if (options.proportional) {
|
||||
const factor = side.sx === 0 ? y.factor : side.sy === 0 ? x.factor : Math.max(x.factor, y.factor);
|
||||
x.factor = factor;
|
||||
y.factor = factor;
|
||||
}
|
||||
return scaleAbout({ x: x.anchor, y: y.anchor }, x.factor, y.factor);
|
||||
}
|
||||
|
||||
/** The map that puts the frame's left or top edge at `value`. */
|
||||
export function frameMovedTo(extent: Extent, axis: 'x' | 'y', value: number): Mat {
|
||||
const dx = axis === 'x' ? value - extent.x0 : 0;
|
||||
const dy = axis === 'y' ? value - extent.y0 : 0;
|
||||
return { a: 1, b: 0, c: 0, d: 1, tx: dx, ty: dy };
|
||||
}
|
||||
|
||||
/**
|
||||
* The map that makes the frame `value` wide or high, its left top corner staying put; null where
|
||||
* there is nothing to stretch (a frame without that extent) or the size is not one.
|
||||
*/
|
||||
export function frameSizedTo(extent: Extent, axis: 'x' | 'y', value: number): Mat | null {
|
||||
const size = axis === 'x' ? extent.x1 - extent.x0 : extent.y1 - extent.y0;
|
||||
if (!(size > 0) || !Number.isFinite(value)) return null;
|
||||
const factor = Math.max(value, MIN_FRAME_SIZE) / size;
|
||||
return scaleAbout({ x: extent.x0, y: extent.y0 }, axis === 'x' ? factor : 1, axis === 'y' ? factor : 1);
|
||||
}
|
||||
|
||||
/** Mirrors about the middle of the frame: `x` swaps left and right, `y` top and bottom. */
|
||||
export function frameFlipped(extent: Extent, axis: 'x' | 'y'): Mat {
|
||||
return scaleAbout(extentCentre(extent), axis === 'x' ? -1 : 1, axis === 'y' ? -1 : 1);
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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> = {}): 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));
|
||||
}
|
||||
@@ -41,8 +41,8 @@ export abstract class SheetCanvas<A> 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<A> 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));
|
||||
|
||||
@@ -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<FederatedPointerEvent, 'shiftKey' | 'altKey'>;
|
||||
|
||||
/** 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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
// 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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = <T extends Widget>(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 } });
|
||||
});
|
||||
});
|
||||
@@ -98,13 +98,25 @@ function widgets<T extends Widget>(host: InspectorHost, type: new (...args: neve
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The TRANSFORM rows (#291) stand over the shape's own, five number fields ahead of every other. */
|
||||
const FRAME_KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn'];
|
||||
const numberFields = (host: InspectorHost): NumberField[] =>
|
||||
widgets(host, NumberField).slice(FRAME_KEYS.length);
|
||||
|
||||
const texts = (host: InspectorHost): string[] => widgets(host, Label).map((label) => label.text);
|
||||
|
||||
describe('shape rows', () => {
|
||||
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
|
||||
const { host, assetId, ids } = withShapes(1);
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
expect([...host.fields.keys()]).toEqual(['name', 'fillType', 'fill', 'opacity', 'outline']);
|
||||
expect([...host.fields.keys()]).toEqual([
|
||||
...FRAME_KEYS,
|
||||
'name',
|
||||
'fillType',
|
||||
'fill',
|
||||
'opacity',
|
||||
'outline',
|
||||
]);
|
||||
expect(texts(host)).toContain('Without an outline only the fill is drawn.');
|
||||
});
|
||||
|
||||
@@ -125,7 +137,7 @@ describe('shape rows', () => {
|
||||
const { host, assetId, ids } = withShapes(1);
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
|
||||
const opacity = widgets(host, NumberField)[0]!;
|
||||
const opacity = numberFields(host)[0]!;
|
||||
opacity.onChange!('0.4');
|
||||
expect(shapeOf(host, assetId).opacity).toBeCloseTo(0.4);
|
||||
opacity.onChange!('5');
|
||||
@@ -146,6 +158,7 @@ describe('shape rows', () => {
|
||||
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
expect([...host.fields.keys()]).toEqual([
|
||||
...FRAME_KEYS,
|
||||
'name',
|
||||
'fillType',
|
||||
'fill',
|
||||
@@ -155,7 +168,7 @@ describe('shape rows', () => {
|
||||
'strokeColor',
|
||||
'strokeWidth',
|
||||
]);
|
||||
const width = widgets(host, NumberField)[1]!;
|
||||
const width = numberFields(host)[1]!;
|
||||
// The command refuses a line of no width, so the row never sends one.
|
||||
width.onChange!('0');
|
||||
expect(shapeOf(host, assetId).stroke?.width).toBe(0.1);
|
||||
@@ -189,7 +202,7 @@ describe('shape rows', () => {
|
||||
shape.name = 'Cloud';
|
||||
refreshShapeSection(host, assetId, ids[0]!);
|
||||
// The name field first, then the hex text inside the fill row – a name is a field, not a label.
|
||||
const fields = widgets(host, TextField);
|
||||
const fields = widgets(host, TextField).slice(FRAME_KEYS.length);
|
||||
expect(fields[0]!.value).toBe('Cloud');
|
||||
expect(fields[1]!.value).toBe(toHex(0x123456));
|
||||
});
|
||||
@@ -283,6 +296,7 @@ describe('fill and line types (#186)', () => {
|
||||
expect(signature).toContain('linear/none');
|
||||
rebuild(host, assetId, ids[0]!);
|
||||
expect([...host.fields.keys()]).toEqual([
|
||||
...FRAME_KEYS,
|
||||
'name',
|
||||
'fillType',
|
||||
'fillStopColor',
|
||||
@@ -302,7 +316,7 @@ describe('fill and line types (#186)', () => {
|
||||
rebuild(host, assetId, ids[0]!);
|
||||
// The stop's colour is the only colour row left; opacity of the stop is the first number row.
|
||||
widgets(host, ColorField)[0]!.onChange!(0xff0000);
|
||||
widgets(host, NumberField)[0]!.onChange!('0.5');
|
||||
numberFields(host)[0]!.onChange!('0.5');
|
||||
const paint = shapeOf(host, assetId).paint as VectorGradient;
|
||||
expect(paint.stops[0]).toEqual({ offset: 0, color: 0xff0000, alpha: 0.5 });
|
||||
expect(button(host, 'Remove stop').eventMode).toBe('none');
|
||||
|
||||
+196
-8
@@ -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`. */
|
||||
|
||||
Reference in new issue
Block a user