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

A shape could only be moved and reshaped point by point. Whoever
wanted it larger or turned pulled every point alone, and for several
shapes or a group there was no way at all. The answers in the issue
settle the rest: the change is baked into the points, nothing of it
is remembered, and the line width stays.

- With Select the selection stands in a frame of eight squares and a
  knob, drawn like the one of the scene view. It holds one shape,
  several shapes or a group, the same selection that is dragged as
  one.
- A corner square scales from the opposite corner, a square on an
  edge in its own direction. Shift keeps the proportions, Alt scales
  about the middle.
- The knob turns round the middle of the frame, with Shift in steps
  of 15 degrees. After the pull the frame stands upright again.
- The inspector has a band TRANSFORM with X, Y, Width, Height and
  Rotate by, and the buttons Flip horizontal and Flip vertical.
- Points, handles, gradients and texture tiles are taken through the
  same matrix. The rule is a pure function in vectorTransform.ts,
  transformShapesCommand makes one undo step of any number of
  shapes.
- A press goes to a point or a handle first, then to the frame, then
  to a line. shapeFrame.ts holds where the handles are and what a
  pull of each means.

Decided here:

- The frame stands 15 pixels off the shapes. The points of a shape
  lie on its bounds, so a square on the bounds would cover them.
- The knob only turns. In the scene view it scales as well.
- A side cannot be pulled through the opposite one, the frame keeps
  at least 1 pixel.
- Coordinates are rounded to a thousandth of a pixel, so a quarter
  turn is exact.
- A texture tile stays upright under a turn and grows to the bounds
  of the turned tile. The bands of a gradient stay square to its
  axis under an uneven scale.
- The switch Scale stroke of the proposal is not built.

Found on the way:

- The inspector of a shape is now taller than the window. Two checks
  of module 47 no longer found their row or button on the screen and
  use the new hook inspectorButton, which scrolls there first.
- Not solved: with a shape at the top edge of the view the knob lies
  outside the tab. The wiki names it under the limits.

Taken over from #217: the e2e check for two selected shapes without
a group, and unit tests for the snap of the common offset and for
the gradients of the shapes dragged along.

Tests:
- Gate on melektaus-atx: 209 files, 3176 tests (3121 + 55), exit 0
- e2e 47,48 on melektaus-atx: 112 checks, all pass
- No full run and no smokes. The sum of 1664 (1660 + 4) is reckoned,
  1660 is what CI counted for 6d79cf5.
- 55 unit tests: 25 in tests/core/vector-transform.test.ts and 30 in
  tests/editor/shape-frame.test.ts
- Four e2e checks in block 47k of module 47: pull a corner, turn
  with Shift, type width and rotation, drag two shapes without a
  group
- 47 unit counter-probes and four browser counter-probes all fail
  their tests. Four of the unit ones did not at first: two showed
  dead code, which is gone, two got tests.
- Not checked in the browser: the edge squares, Alt, the cursors,
  the flip buttons and the rows X, Y and Height

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 11:20:55 +02:00
1 parent 6d79cf59a3
commit 345289982b
14 files changed
+2026 -28

No files matched your search

+17
View File
@@ -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`.
+5
View File
@@ -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,
/**
+6 -1
View File
@@ -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));
+201
View File
@@ -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));
}
+3 -3
View File
@@ -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));
+195 -10
View File
@@ -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';
+120
View File
@@ -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);
}