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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user