A vector drawing was one flat list of shapes. With many shapes the list got hard to read, and several shapes could not be kept or moved together. Now the shapes stand on layers, as in pixel art, and can be put into groups that nest. - A drawing has layers, each with a name and an eye. A hidden layer is neither in the tab nor in the picture a sprite wears, and its shapes cannot be pressed. One layer is the one worked on: the pen and Add shape draw onto it, and SHAPES lists what is on it. - The inspector has a list LAYERS over the shapes. Its plus adds a layer, the cross on a row removes it with all on it, the last one stays. A double-click renames, a drag sorts. - Group (Ctrl+G) puts the selected shapes into a group, Ungroup (Ctrl+Shift+G) takes it away. A group stands over its rows in the list, the rows one step in, and a row is dragged into a group or out of it. - In the tab a press on a shape takes its outermost group, and a drag moves the group as one undo step. - The model has layers with a tree of shapes and groups instead of a flat shapes array. The rules are pure functions in core/model/vectorTree.ts. A drawing saved before opens with all its shapes on one layer. - SortableList takes rows with a depth and drops into a tree. The flat lists of tilemap and pixel art run as before. Decided here: - A group has no transformation of its own and cannot be hidden or locked. Rotating and scaling is the follow-up #291. - A group is never empty: the one that loses its last shape goes with it. - Several selected shapes are dragged together, grouped or not, and a click on a shape of the selection keeps the selection. This is what #217 asked for. - Shift+click selects like Ctrl+click. With Select, Shift pans only beside the shapes, as in the scene view. The middle button pans anywhere, and with the pen and Paint Shift pans as before. - A row of the list picks one shape of a group alone. - Shapes change their layer through a row Layer in the inspector, which is there from two layers on. Rows are not dragged between layers. - A group cannot span two layers, the button is off then. - The pen does not draw onto a hidden layer, the hint says so. - Groups are always shown unfolded. - The four order buttons work inside the own group or layer. Tests: - Gate: 204 files, 3067 tests (3008 + 59), exit 0 - e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change: 209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned and CI measures it. - 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in tests/editor/vector-layers.test.ts - Six e2e checks with a real mouse in block 47j - 47 unit counter-probes for layers and groups and three for the Shift click all fail tests, run on stein2 - Three browser counter-probes fail their checks - Two screenshots looked at (group in the list, hidden layer) - Only unit tested: the row Layer, sorting and removing layers, copying a group, the Shift pan beside the shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
942 lines
35 KiB
TypeScript
942 lines
35 KiB
TypeScript
/**
|
||
* The Vector art tab's canvas (#129, stage 3). It shows the drawing the rasteriser would make – the
|
||
* same commands from `core/model/vectorPath`, drawn straight onto a `Graphics` instead of into a
|
||
* texture – and lets one edit the shapes on top of it: the pen puts down points, the arrow drags
|
||
* points, handles and whole shapes.
|
||
*
|
||
* Two rules run through the whole file. The drawing lives in a container scaled by the zoom, so a
|
||
* stroke of two canvas pixels is two canvas pixels at every zoom, while points and handles are drawn
|
||
* in widget pixels, so they stay grabbable when the drawing is tiny. And a gesture writes the model
|
||
* directly as a preview and puts it back on release, so one drag is one undo step – the same way
|
||
* the Texture tab edits a collision outline.
|
||
*/
|
||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import {
|
||
AddVectorShapeCommand,
|
||
paintsOf,
|
||
SetVectorShapeRingsCommand,
|
||
SetVectorShapeStyleCommand,
|
||
} from '../../core/commands/vectorArtCommands';
|
||
import type {
|
||
VectorArtAsset,
|
||
VectorPaint,
|
||
VectorPoint,
|
||
VectorRing,
|
||
VectorShape,
|
||
} from '../../core/model/Asset';
|
||
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 { vectorArtDrawings } from '../../core/model/vectorPath';
|
||
import { allShapes, drawnShapes, outermostGroup, shapesIn } from '../../core/model/vectorTree';
|
||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||
import { cancelFrameWork, onFrame, pressCount, Theme, type Widget } from '../../ui';
|
||
import {
|
||
insideRing,
|
||
type PointKind,
|
||
pointKind,
|
||
type RingGrip,
|
||
ringGripAt,
|
||
ringMoved,
|
||
ringOutline,
|
||
withHandleMoved,
|
||
withPointMoved,
|
||
withPointKind,
|
||
withPointOnSegment,
|
||
withoutPoint,
|
||
withSmoothToggled,
|
||
} from '../polygonEdit';
|
||
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 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';
|
||
|
||
/**
|
||
* Which gesture a press begins: the arrow edits what is there, the pen adds to it, and the paint
|
||
* tool moves the two handles of the selected shape's gradient or picture fill (#186).
|
||
*/
|
||
export type VectorTool = 'select' | 'pen' | 'paint';
|
||
|
||
/** Whose paint the paint tool shows: the fill's or the outline's. */
|
||
export type PaintTarget = 'fill' | 'stroke';
|
||
|
||
/** The grid points snap to while snapping is on, in canvas pixels. */
|
||
const SNAP_STEP = 8;
|
||
|
||
/** One shape as a drag moves it. `before` is what it read when the drag began – the undo step's other side. */
|
||
interface MovedShape {
|
||
shapeId: string;
|
||
before: VectorRing[];
|
||
rings: VectorRing[];
|
||
/** Its paints as they were – they travel with a whole shape. */
|
||
paints: ReturnType<typeof paintsOf>;
|
||
}
|
||
|
||
/** A gesture in progress, on the shape it began on. */
|
||
interface ShapeDrag extends MovedShape {
|
||
kind: 'point' | 'handle' | 'shape' | 'pen';
|
||
/** The other selected shapes, which a whole shape dragged takes along (#190). */
|
||
others: MovedShape[];
|
||
/** Which ring and which of its points the gesture is about; -1 for a whole shape. */
|
||
ring: number;
|
||
index: number;
|
||
side: 'in' | 'out';
|
||
/** Where the press was, in widget pixels, and where in canvas pixels. */
|
||
pointer: Vec2;
|
||
at: Vec2;
|
||
moved: boolean;
|
||
label: string;
|
||
/** How far a whole shape has been dragged. */
|
||
delta: Vec2;
|
||
}
|
||
|
||
/** A paint handle being dragged; `before` is the paint as it was, the undo step's other side. */
|
||
interface PaintDrag {
|
||
shapeId: string;
|
||
target: PaintTarget;
|
||
handle: 'from' | 'to';
|
||
before: VectorPaint;
|
||
paint: VectorPaint;
|
||
pointer: Vec2;
|
||
moved: boolean;
|
||
}
|
||
|
||
/** Counts canvases so each gets its own key in the frame queue. */
|
||
let instances = 0;
|
||
|
||
export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||
private readonly art = new Graphics();
|
||
private readonly overlay = new Graphics();
|
||
private tool: VectorTool = 'select';
|
||
private snap = false;
|
||
private drag: ShapeDrag | null = null;
|
||
private paintDrag: PaintDrag | null = null;
|
||
private target: PaintTarget = 'fill';
|
||
/** Gradients live on the GPU; one repaint reuses what the one before made. */
|
||
private readonly paints = new PaintCache();
|
||
private readonly stopPictures: () => void;
|
||
/** The ring the pen is drawing into; empty while no line is open. */
|
||
private pen = { shapeId: '', ring: -1 };
|
||
/** The point last pressed or put down – the one the header's point type is about (#195). */
|
||
private picked: { shapeId: string; ring: number; index: number } | null = null;
|
||
/** Told whenever `picked` changes, so the header can show that point's type. */
|
||
onPointPicked: () => void = () => {};
|
||
/** This canvas's slot in the frame queue: one pending preview at a time, see `preview()`. */
|
||
private readonly frameKey = `vectorart:${(instances += 1)}`;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.content.addChild(this.art, this.overlay);
|
||
|
||
state.on('project', () => this.redraw());
|
||
state.on('selection', () => {
|
||
if (this.picked && this.picked.shapeId !== this.selectedShapeId()) this.pick(null);
|
||
this.redraw();
|
||
});
|
||
state.on('load', () => {
|
||
this.needsFit = true;
|
||
this.endPen();
|
||
this.pick(null);
|
||
this.redraw();
|
||
});
|
||
// A picture fill (#186) shows the shape's colour until its picture has landed.
|
||
this.stopPictures = textureLibrary.onChange(() => this.redraw());
|
||
}
|
||
|
||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||
this.stopPictures();
|
||
this.paints.destroy();
|
||
super.destroy(options);
|
||
}
|
||
|
||
show(assetId: string): void {
|
||
if (this.assetId === assetId) return;
|
||
this.assetId = assetId;
|
||
this.needsFit = true;
|
||
this.endPen();
|
||
this.pick(null);
|
||
this.redraw();
|
||
}
|
||
|
||
setTool(tool: VectorTool): void {
|
||
if (this.tool === tool) return;
|
||
this.tool = tool;
|
||
// A line the pen left open belongs to the pen; the arrow must not add to it later.
|
||
this.endPen();
|
||
this.redraw();
|
||
}
|
||
|
||
get currentTool(): VectorTool {
|
||
return this.tool;
|
||
}
|
||
|
||
setSnapping(on: boolean): void {
|
||
this.snap = on;
|
||
}
|
||
|
||
get snapping(): boolean {
|
||
return this.snap;
|
||
}
|
||
|
||
setPaintTarget(target: PaintTarget): void {
|
||
if (this.target === target) return;
|
||
this.target = target;
|
||
this.redraw();
|
||
}
|
||
|
||
get paintTarget(): PaintTarget {
|
||
return this.target;
|
||
}
|
||
|
||
/** The paint the paint tool is about: the selected shape's fill or outline paint, if it has one. */
|
||
shownPaint(): VectorPaint | null {
|
||
const shape = this.shape(this.selectedShapeId());
|
||
return (shape && paintOf(shape, this.target)) ?? null;
|
||
}
|
||
|
||
/** Whether the pen has a line open – the check scripts and the hint read it. */
|
||
get drawing(): boolean {
|
||
return this.pen.ring >= 0;
|
||
}
|
||
|
||
/** Which point is picked, null for none – the check scripts read it. */
|
||
get pickedAt(): { ring: number; index: number } | null {
|
||
const picked = this.picked;
|
||
return picked && this.pickedPoint() ? { ring: picked.ring, index: picked.index } : null;
|
||
}
|
||
|
||
/** The type of the picked point; null while no point is picked or it has gone since (an undo). */
|
||
get pickedKind(): PointKind | null {
|
||
const point = this.pickedPoint();
|
||
return point ? pointKind(point) : null;
|
||
}
|
||
|
||
/** Turns the picked point into `kind` – one undo step, as every other edit of a ring. */
|
||
setPickedKind(kind: PointKind): void {
|
||
const picked = this.picked;
|
||
const shape = picked && this.shape(picked.shapeId);
|
||
const ring = shape?.rings[picked?.ring ?? -1];
|
||
if (!picked || !shape || !ring?.points[picked.index]) return;
|
||
const rings = this.ringsWith(shape, picked.ring, withPointKind(ring, picked.index, kind));
|
||
if (SetVectorShapeRingsCommand.changes(shape, rings))
|
||
this.state.execute(new SetVectorShapeRingsCommand(this.assetId, shape.id, rings, 'Point type'));
|
||
}
|
||
|
||
private pickedPoint(): VectorPoint | undefined {
|
||
const picked = this.picked;
|
||
return picked ? this.shape(picked.shapeId)?.rings[picked.ring]?.points[picked.index] : undefined;
|
||
}
|
||
|
||
private pick(picked: { shapeId: string; ring: number; index: number } | null): void {
|
||
const was = this.picked;
|
||
this.picked = picked;
|
||
if (was?.shapeId !== picked?.shapeId || was?.ring !== picked?.ring || was?.index !== picked?.index)
|
||
this.onPointPicked();
|
||
}
|
||
|
||
private asset(): VectorArtAsset | null {
|
||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||
return asset && asset.type === 'vectorart' ? asset : null;
|
||
}
|
||
|
||
private selectedShapeId(): string {
|
||
const s = this.state.selection;
|
||
return s?.kind === 'shape' && s.assetId === this.assetId ? s.shapeId : '';
|
||
}
|
||
|
||
private selectedShapeIds(): string[] {
|
||
const s = this.state.selection;
|
||
return s?.kind === 'shape' && s.assetId === this.assetId ? s.shapeIds : [];
|
||
}
|
||
|
||
private shape(shapeId: string): VectorShape | undefined {
|
||
const asset = this.asset();
|
||
return asset ? findShape(asset, shapeId) : undefined;
|
||
}
|
||
|
||
/** Delete takes the picked point away (#288), and the selected shapes while none is picked. */
|
||
override onKeyDown(event: KeyboardEvent): boolean {
|
||
if (event.key === 'Escape' && this.drawing) {
|
||
this.endPen();
|
||
this.redraw();
|
||
return true;
|
||
}
|
||
if (event.key !== 'Delete' && event.key !== 'Backspace') return super.onKeyDown(event);
|
||
if (this.removePickedPoint()) return true;
|
||
const shapeIds = this.selectedShapeIds();
|
||
if (shapeIds.length === 0) return super.onKeyDown(event);
|
||
this.endPen();
|
||
removeShapes(this.state, this.assetId, shapeIds);
|
||
return true;
|
||
}
|
||
|
||
/** Whether a point is picked and drawn as such – the paint tool shows no points. */
|
||
private pointShown(): boolean {
|
||
return this.tool !== 'paint' && this.pickedPoint() !== undefined;
|
||
}
|
||
|
||
/** False while no point is picked, so Delete goes on to the selected shapes. */
|
||
removePickedPoint(): boolean {
|
||
if (deleteTakes(this.pointShown()) !== 'point') return false;
|
||
this.removePicked();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Takes the picked point away. A ring left without a point goes with it, and a shape left without
|
||
* a ring too: nothing of it could be seen or picked again.
|
||
*/
|
||
private removePicked(): void {
|
||
const picked = this.picked;
|
||
const shape = picked && this.shape(picked.shapeId);
|
||
const ring = shape?.rings[picked?.ring ?? -1];
|
||
if (!picked || !shape || !ring?.points[picked.index]) return;
|
||
const next = withoutPoint(ring, picked.index);
|
||
const rings = next
|
||
? this.ringsWith(shape, picked.ring, next)
|
||
: shape.rings.filter((_, i) => i !== picked.ring).map(cloneVectorRing);
|
||
this.pick(null);
|
||
if (!next && this.pen.shapeId === shape.id) this.endPen();
|
||
if (rings.length === 0) removeShapes(this.state, this.assetId, [shape.id]);
|
||
else this.state.execute(new SetVectorShapeRingsCommand(this.assetId, shape.id, rings, 'Remove point'));
|
||
}
|
||
|
||
protected pressTarget(): VectorArtAsset | null {
|
||
return this.asset();
|
||
}
|
||
|
||
/** A right-click on a point of the selected shape picks it and opens its menu (#288). */
|
||
protected override context(_asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void {
|
||
const selected = this.tool === 'paint' ? undefined : this.shape(this.selectedShapeId());
|
||
if (!selected) return;
|
||
const at = localToImage(this.view, local.x, local.y);
|
||
for (const [ringIndex, ring] of selected.rings.entries()) {
|
||
const grip = ringGripAt(ring, at, POINT_SIZE / this.view.zoom);
|
||
if (grip?.kind !== 'point') continue;
|
||
e.stopPropagation();
|
||
this.pick({ shapeId: selected.id, ring: ringIndex, index: grip.index });
|
||
this.redraw();
|
||
openPointMenu(
|
||
{
|
||
removable: true,
|
||
remove: () => this.removePicked(),
|
||
kind: this.pickedKind,
|
||
setKind: (kind) => this.setPickedKind(kind),
|
||
},
|
||
e.global.x,
|
||
e.global.y,
|
||
this,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
/** Shift on a shape adds it to the selection; on the ground beside the shapes it still pans. */
|
||
protected override shiftPresses(asset: VectorArtAsset, local: Vec2): boolean {
|
||
if (this.tool !== 'select') return false;
|
||
return this.shapeAt(asset, localToImage(this.view, local.x, local.y)) !== undefined;
|
||
}
|
||
|
||
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;
|
||
if (this.tool === 'pen') this.pressPen(asset, at, local, tolerance);
|
||
else if (this.tool === 'paint') this.pressPaint(asset, at, local, tolerance);
|
||
else this.pressSelect(asset, at, local, tolerance, e);
|
||
e.stopPropagation();
|
||
}
|
||
|
||
/**
|
||
* A press with the pen: the first one makes a shape with one point in it, every later one adds a
|
||
* point to the line that is open, and a press on that line's first point closes it. Dragging away
|
||
* from the point that was just put down pulls its handles out, which is how a curve is drawn.
|
||
*/
|
||
private pressPen(asset: VectorArtAsset, at: Vec2, local: Vec2, tolerance: number): void {
|
||
const point = this.snapped(at);
|
||
const open = this.openRing();
|
||
if (!open) {
|
||
const layer = activeLayerOf(this.state, asset);
|
||
// A line drawn onto a hidden layer could not be seen; the hint says to show the layer first.
|
||
if (!layer || layer.hidden) return;
|
||
const command = new AddVectorShapeCommand(
|
||
asset.id,
|
||
[{ points: [point], closed: false }],
|
||
undefined,
|
||
layer.id,
|
||
);
|
||
if (!this.state.execute(command)) return;
|
||
this.state.select(shapeSelection(asset.id, [command.shapeId]));
|
||
this.pen = { shapeId: command.shapeId, ring: 0 };
|
||
this.startPenDrag(command.shapeId, 0, 0, local, point);
|
||
return;
|
||
}
|
||
const { shape, ring } = open;
|
||
const first = ring.points[0];
|
||
if (first && ring.points.length >= 2 && Math.hypot(first.x - at.x, first.y - at.y) <= tolerance) {
|
||
const rings = this.ringsWith(shape, this.pen.ring, { ...ring, closed: true });
|
||
this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Close shape'));
|
||
this.endPen();
|
||
this.redraw();
|
||
return;
|
||
}
|
||
const next = { ...ring, points: [...ring.points, point] };
|
||
this.startPenDrag(shape.id, this.pen.ring, next.points.length - 1, local, point, next);
|
||
}
|
||
|
||
private startPenDrag(
|
||
shapeId: string,
|
||
ringIndex: number,
|
||
index: number,
|
||
local: Vec2,
|
||
at: Vec2,
|
||
ring?: VectorRing,
|
||
): void {
|
||
const shape = this.shape(shapeId);
|
||
if (!shape) return;
|
||
const rings = ring ? this.ringsWith(shape, ringIndex, ring) : shape.rings.map(cloneVectorRing);
|
||
this.pick({ shapeId, ring: ringIndex, index });
|
||
this.drag = {
|
||
kind: 'pen',
|
||
shapeId,
|
||
before: shape.rings,
|
||
rings,
|
||
ring: ringIndex,
|
||
index,
|
||
side: 'out',
|
||
pointer: { x: local.x, y: local.y },
|
||
at,
|
||
moved: false,
|
||
label: 'Draw shape',
|
||
delta: { x: 0, y: 0 },
|
||
paints: {},
|
||
others: [],
|
||
};
|
||
this.preview();
|
||
}
|
||
|
||
/**
|
||
* A press with the arrow. On the selected shape first, so its points stay reachable under a shape
|
||
* drawn later: a press on a point picks it and drags it, Alt makes it round or square again, a
|
||
* press on a handle drags that, and a double-click on a line puts a point there (#288). Inside a
|
||
* shape's fill, and on its line, the whole shape is dragged; on nothing the selection is cleared.
|
||
* Ctrl or Shift adds a shape to the selection or takes it out again, as on a row of the list,
|
||
* which is how shapes are picked for combining (#187). A shape that stands in a group brings the
|
||
* whole group along, and a shape of the selection carries all of it (#190) – so a group is moved
|
||
* by dragging any of its shapes.
|
||
*/
|
||
private pressSelect(
|
||
asset: VectorArtAsset,
|
||
at: Vec2,
|
||
local: Vec2,
|
||
tolerance: number,
|
||
e: FederatedPointerEvent,
|
||
): void {
|
||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||
const hit = this.shapeAt(asset, at);
|
||
if (hit) {
|
||
this.pick(null);
|
||
this.state.toggleShape(asset.id, hit.id);
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
const shape = this.shapeAt(asset, at);
|
||
if (shape) {
|
||
this.pick(null);
|
||
const held = this.selectedShapeIds();
|
||
const ids = held.includes(shape.id) ? held : this.withGroup(asset, shape.id);
|
||
this.state.select(shapeSelection(asset.id, ids, shape.id));
|
||
this.dragShape(shape, at, local);
|
||
return;
|
||
}
|
||
this.pick(null);
|
||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||
this.redraw();
|
||
}
|
||
|
||
/** 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);
|
||
return group ? shapesIn(group.items).map((shape) => shape.id) : [shapeId];
|
||
}
|
||
|
||
/** Begins dragging `shape` and, with it, every other selected shape. */
|
||
private dragShape(shape: VectorShape, at: Vec2, local: Vec2): void {
|
||
const moved = (one: VectorShape): MovedShape => ({
|
||
shapeId: one.id,
|
||
before: one.rings,
|
||
rings: one.rings.map(cloneVectorRing),
|
||
paints: paintsOf(one),
|
||
});
|
||
const others = this.selectedShapeIds()
|
||
.filter((id) => id !== shape.id)
|
||
.flatMap((id) => this.shape(id) ?? [])
|
||
.map(moved);
|
||
this.drag = {
|
||
kind: 'shape',
|
||
...moved(shape),
|
||
others,
|
||
ring: -1,
|
||
index: -1,
|
||
side: 'out',
|
||
pointer: { x: local.x, y: local.y },
|
||
at,
|
||
moved: false,
|
||
label: others.length > 0 ? 'Move shapes' : 'Move shape',
|
||
delta: { x: 0, y: 0 },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A press with the paint tool: on a handle of the shown paint it drags that handle – `to` first,
|
||
* so a gradient squeezed to nothing can be pulled out again – anywhere else it picks the shape
|
||
* under the pointer, whose handles are then the ones shown.
|
||
*/
|
||
private pressPaint(asset: VectorArtAsset, at: Vec2, local: Vec2, tolerance: number): void {
|
||
const shape = this.shape(this.selectedShapeId());
|
||
const paint = shape && paintOf(shape, this.target);
|
||
if (shape && paint) {
|
||
const handle = (['to', 'from'] as const).find(
|
||
(end) => Math.hypot(paint[end].x - at.x, paint[end].y - at.y) <= tolerance,
|
||
);
|
||
if (handle) {
|
||
this.paintDrag = {
|
||
shapeId: shape.id,
|
||
target: this.target,
|
||
handle,
|
||
before: paint,
|
||
paint,
|
||
pointer: { x: local.x, y: local.y },
|
||
moved: false,
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
const hit = this.shapeAt(asset, at);
|
||
this.pick(null);
|
||
this.state.select(hit ? shapeSelection(asset.id, [hit.id]) : { kind: 'asset', assetId: asset.id });
|
||
this.redraw();
|
||
}
|
||
|
||
/** The shape under a canvas pixel. Last first: a later shape is drawn on top of an earlier one. */
|
||
private shapeAt(asset: VectorArtAsset, at: Vec2): VectorShape | undefined {
|
||
// Only what is drawn: a shape on a hidden layer is not there to be pressed.
|
||
const shapes = drawnShapes(asset);
|
||
for (let i = shapes.length - 1; i >= 0; i--) {
|
||
const shape = shapes[i]!;
|
||
if (shape.rings.some((ring) => insideRing(ring, at))) return shape;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
private pressGrip(
|
||
asset: VectorArtAsset,
|
||
shape: VectorShape,
|
||
ringIndex: number,
|
||
ring: VectorRing,
|
||
grip: RingGrip,
|
||
at: Vec2,
|
||
local: Vec2,
|
||
e: FederatedPointerEvent,
|
||
): void {
|
||
if (grip.kind === 'segment') {
|
||
// The new point is picked and stays put; until the double-click the line carries its shape.
|
||
if (pointPress('line', pressCount(e, `vector add ${shape.id}`)) !== 'add') {
|
||
this.pick(null);
|
||
this.dragShape(shape, at, local);
|
||
this.redraw();
|
||
return;
|
||
}
|
||
const rings = this.ringsWith(shape, ringIndex, withPointOnSegment(ring, grip.index, grip.t));
|
||
this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Add point'));
|
||
this.pick({ shapeId: shape.id, ring: ringIndex, index: grip.index + 1 });
|
||
this.redraw();
|
||
return;
|
||
}
|
||
this.pick({ shapeId: shape.id, ring: ringIndex, index: grip.index });
|
||
if (grip.kind === 'point' && e.altKey) {
|
||
const rings = this.ringsWith(shape, ringIndex, withSmoothToggled(ring, grip.index));
|
||
this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Smooth point'));
|
||
return;
|
||
}
|
||
this.drag = {
|
||
kind: grip.kind,
|
||
shapeId: shape.id,
|
||
before: shape.rings,
|
||
rings: shape.rings.map(cloneVectorRing),
|
||
ring: ringIndex,
|
||
index: grip.index,
|
||
side: grip.kind === 'handle' ? grip.side : 'out',
|
||
pointer: { x: local.x, y: local.y },
|
||
at,
|
||
moved: false,
|
||
label: grip.kind === 'handle' ? 'Bend shape' : 'Move point',
|
||
delta: { x: 0, y: 0 },
|
||
paints: {},
|
||
others: [],
|
||
};
|
||
this.redraw();
|
||
}
|
||
|
||
protected move(local: Vec2): void {
|
||
if (this.paintDrag) {
|
||
this.movePaint(this.paintDrag, local);
|
||
return;
|
||
}
|
||
const drag = this.drag;
|
||
if (!drag) {
|
||
this.updateCursor(local);
|
||
return;
|
||
}
|
||
if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD)
|
||
return;
|
||
drag.moved = true;
|
||
const to = localToImage(this.view, local.x, local.y);
|
||
const ring = drag.rings[drag.ring];
|
||
if (drag.kind === 'shape') {
|
||
const delta = this.snapped({ x: to.x - drag.at.x, y: to.y - drag.at.y });
|
||
drag.delta = delta;
|
||
for (const part of [drag, ...drag.others])
|
||
part.rings = part.before.map((r) => ringMoved(r, delta.x, delta.y));
|
||
} else if (ring && drag.kind === 'handle') {
|
||
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
|
||
} else if (ring && drag.kind === 'pen') {
|
||
// The pen pulls both handles at once: the one it drags towards, the other mirrored behind it –
|
||
// a symmetric point, which is what a drag with a pen draws everywhere else too (#195).
|
||
drag.rings = this.replace(
|
||
drag.rings,
|
||
drag.ring,
|
||
withHandleMoved(ring, drag.index, 'out', to, 'symmetric'),
|
||
);
|
||
} else if (ring) {
|
||
drag.rings = this.replace(drag.rings, drag.ring, withPointMoved(ring, drag.index, this.snapped(to)));
|
||
}
|
||
this.preview();
|
||
}
|
||
|
||
private movePaint(drag: PaintDrag, local: Vec2): void {
|
||
if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD)
|
||
return;
|
||
drag.moved = true;
|
||
drag.paint = paintWithHandle(
|
||
drag.before,
|
||
drag.handle,
|
||
this.snapped(localToImage(this.view, local.x, local.y)),
|
||
);
|
||
this.preview();
|
||
}
|
||
|
||
/**
|
||
* Writes the gesture into the model as a preview; no undo step until the pointer is up.
|
||
*
|
||
* Once per frame, not once per pointer move: a model event rebuilds the panels, the run in the
|
||
* editor and the picture the drawing is rasterised into, which costs more than a frame on a busy
|
||
* scene, so a mouse reporting faster than the screen refreshes would leave the drawing behind
|
||
* until the hand stopped. `drag.rings` is the truth in the meantime and the gesture reads it,
|
||
* not the model.
|
||
*/
|
||
private preview(): void {
|
||
onFrame(this.frameKey, () => this.writePreview());
|
||
}
|
||
|
||
private writePreview(): void {
|
||
if (this.destroyed) return;
|
||
const paintDrag = this.paintDrag;
|
||
const painted = paintDrag && this.shape(paintDrag.shapeId);
|
||
if (paintDrag && painted) {
|
||
setPaintOf(painted, paintDrag.target, paintDrag.paint);
|
||
this.state.preview();
|
||
return;
|
||
}
|
||
const drag = this.drag;
|
||
if (!drag || !this.shape(drag.shapeId)) return;
|
||
for (const part of [drag, ...drag.others]) {
|
||
const shape = this.shape(part.shapeId);
|
||
if (!shape) continue;
|
||
shape.rings = part.rings;
|
||
// A whole shape takes its gradients along (#186), handles and all.
|
||
if (drag.kind !== 'shape') continue;
|
||
const delta = drag.delta;
|
||
if (part.paints.paint) shape.paint = paintMoved(part.paints.paint, delta.x, delta.y);
|
||
if (part.paints.strokePaint && shape.stroke)
|
||
shape.stroke.paint = paintMoved(part.paints.strokePaint, delta.x, delta.y);
|
||
}
|
||
this.state.preview();
|
||
}
|
||
|
||
protected release(): void {
|
||
if (this.paintDrag) {
|
||
this.releasePaint(this.paintDrag);
|
||
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.
|
||
cancelFrameWork(this.frameKey);
|
||
if (!drag || !this.shape(drag.shapeId)) return;
|
||
if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring };
|
||
const commands: SetVectorShapeRingsCommand[] = [];
|
||
for (const part of [drag, ...drag.others]) {
|
||
const shape = this.shape(part.shapeId);
|
||
if (!shape) continue;
|
||
// The preview moved it already; put it back so the command records the state that was there.
|
||
shape.rings = part.before;
|
||
const moved = drag.kind === 'shape' ? this.movedPaints(part, drag.delta) : null;
|
||
if (moved) {
|
||
if (part.paints.paint) shape.paint = part.paints.paint;
|
||
if (part.paints.strokePaint && shape.stroke) shape.stroke.paint = part.paints.strokePaint;
|
||
}
|
||
if (SetVectorShapeRingsCommand.changes(shape, part.rings))
|
||
commands.push(
|
||
new SetVectorShapeRingsCommand(this.assetId, part.shapeId, part.rings, drag.label, moved),
|
||
);
|
||
}
|
||
// Several shapes dragged together are one undo step.
|
||
if (commands.length > 0) this.state.execute(oneStep(drag.label, commands));
|
||
else this.state.preview();
|
||
}
|
||
|
||
/** The paints of a dragged shape where the drag leaves them; null when it has none to move. */
|
||
private movedPaints(part: MovedShape, delta: Vec2): ReturnType<typeof paintsOf> | null {
|
||
const { paint, strokePaint } = part.paints;
|
||
if (!paint && !strokePaint) return null;
|
||
return {
|
||
...(paint ? { paint: paintMoved(paint, delta.x, delta.y) } : {}),
|
||
...(strokePaint ? { strokePaint: paintMoved(strokePaint, delta.x, delta.y) } : {}),
|
||
};
|
||
}
|
||
|
||
/** One handle dragged is one undo step, written through the same command as the inspector's rows. */
|
||
private releasePaint(drag: PaintDrag): void {
|
||
this.paintDrag = null;
|
||
cancelFrameWork(this.frameKey);
|
||
const shape = this.shape(drag.shapeId);
|
||
if (!shape) return;
|
||
setPaintOf(shape, drag.target, drag.before);
|
||
if (!drag.moved) {
|
||
this.state.preview();
|
||
return;
|
||
}
|
||
const style =
|
||
drag.target === 'fill'
|
||
? { paint: drag.paint }
|
||
: shape.stroke && drag.paint.type !== 'texture'
|
||
? { stroke: { ...shape.stroke, paint: drag.paint } }
|
||
: null;
|
||
if (style)
|
||
this.state.execute(new SetVectorShapeStyleCommand(this.assetId, shape.id, style, 'Move paint handle'));
|
||
}
|
||
|
||
/** Points and handles keep the plain arrow, as every other clickable does (`Theme.hoverCursor`). */
|
||
private updateCursor(local: Vec2): void {
|
||
const asset = this.asset();
|
||
if (!asset || this.tool === 'paint') {
|
||
this.cursor = 'default';
|
||
return;
|
||
}
|
||
if (this.tool === 'pen') {
|
||
this.cursor = 'crosshair';
|
||
return;
|
||
}
|
||
const at = localToImage(this.view, local.x, local.y);
|
||
const tolerance = POINT_SIZE / this.view.zoom;
|
||
const selected = this.shape(this.selectedShapeId());
|
||
const grips = selected?.rings.map((ring) => ringGripAt(ring, at, tolerance)?.kind) ?? [];
|
||
// 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 inside = this.shapeAt(asset, at) !== undefined;
|
||
this.cursor = (inside || onLine) && !onGrip ? 'move' : Theme.hoverCursor;
|
||
}
|
||
|
||
/** The ring the pen is adding to, with its shape; null when no line is open or it has closed. */
|
||
private openRing(): { shape: VectorShape; ring: VectorRing } | null {
|
||
const shape = this.pen.ring < 0 ? undefined : this.shape(this.pen.shapeId);
|
||
const ring = shape?.rings[this.pen.ring];
|
||
if (!shape || !ring || ring.closed) return null;
|
||
return { shape, ring };
|
||
}
|
||
|
||
private endPen(): void {
|
||
this.pen = { shapeId: '', ring: -1 };
|
||
}
|
||
|
||
private ringsWith(shape: VectorShape, index: number, ring: VectorRing): VectorRing[] {
|
||
return this.replace(shape.rings, index, ring);
|
||
}
|
||
|
||
private replace(rings: readonly VectorRing[], index: number, ring: VectorRing): VectorRing[] {
|
||
const next = rings.map(cloneVectorRing);
|
||
if (index >= 0 && index < next.length) next[index] = ring;
|
||
else next.push(ring);
|
||
return next;
|
||
}
|
||
|
||
/** On the grid while snapping is on, and on nothing in particular while it is off. */
|
||
private snapped(at: Vec2): Vec2 {
|
||
if (!this.snap) return { x: at.x, y: at.y };
|
||
return { x: Math.round(at.x / SNAP_STEP) * SNAP_STEP, y: Math.round(at.y / SNAP_STEP) * SNAP_STEP };
|
||
}
|
||
|
||
override redraw(): void {
|
||
this.drawGround();
|
||
const asset = this.asset();
|
||
const size = asset ? vectorCanvasSize(asset.props) : null;
|
||
|
||
if (asset && size && this.needsFit && this.w > 0 && this.h > 0) {
|
||
this.view = fitView(size.width, size.height, this.w, this.h);
|
||
this.needsFit = false;
|
||
}
|
||
|
||
this.checker.clear();
|
||
this.art.clear();
|
||
this.overlay.clear();
|
||
if (asset && size) {
|
||
const origin = imageToLocal(this.view, 0, 0);
|
||
drawChecker(
|
||
this.checker,
|
||
origin.x,
|
||
origin.y,
|
||
size.width * this.view.zoom,
|
||
size.height * this.view.zoom,
|
||
this.w,
|
||
this.h,
|
||
);
|
||
// The drawing itself is in canvas pixels; the container carries the view instead.
|
||
this.art.position.set(origin.x, origin.y);
|
||
this.art.scale.set(this.view.zoom);
|
||
for (const drawing of vectorArtDrawings(asset, this.state.project))
|
||
paintDrawing(this.art, drawing, this.paints);
|
||
this.paints.sweep();
|
||
this.drawFrame(origin, size);
|
||
for (const shapeId of this.selectedShapeIds())
|
||
if (shapeId !== this.selectedShapeId()) this.drawOutline(this.shape(shapeId));
|
||
if (this.tool === 'paint') {
|
||
this.drawOutline(this.shape(this.selectedShapeId()));
|
||
this.drawPaintHandles(this.shownPaint());
|
||
} else this.drawShape(this.shape(this.selectedShapeId()));
|
||
}
|
||
this.syncHint(asset);
|
||
}
|
||
|
||
/** The canvas edge: what lies outside it is drawn here but is not in the picture. */
|
||
private drawFrame(origin: Vec2, size: { width: number; height: number }): void {
|
||
this.overlay
|
||
.rect(origin.x, origin.y, size.width * this.view.zoom, size.height * this.view.zoom)
|
||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||
}
|
||
|
||
/** A shape selected alongside the primary one: its outline only, its points are not editable. */
|
||
private drawOutline(shape: VectorShape | undefined): void {
|
||
for (const ring of shape?.rings ?? []) {
|
||
const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y));
|
||
if (screen.length >= 2)
|
||
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The two handles of a paint, joined by a line. A picture fill also shows the tile its handles
|
||
* span – that rectangle is the one copy of the picture every other is repeated from.
|
||
*/
|
||
private drawPaintHandles(paint: VectorPaint | null): void {
|
||
if (!paint) return;
|
||
const from = imageToLocal(this.view, paint.from.x, paint.from.y);
|
||
const to = imageToLocal(this.view, paint.to.x, paint.to.y);
|
||
const color = Theme.colors.marquee;
|
||
if (paint.type === 'texture')
|
||
this.overlay
|
||
.rect(
|
||
Math.min(from.x, to.x),
|
||
Math.min(from.y, to.y),
|
||
Math.abs(to.x - from.x),
|
||
Math.abs(to.y - from.y),
|
||
)
|
||
.stroke({ width: 1, color });
|
||
else if (paint.type === 'radial')
|
||
this.overlay
|
||
.circle(from.x, from.y, Math.hypot(to.x - from.x, to.y - from.y))
|
||
.stroke({ width: 1, color, alpha: 0.6 });
|
||
this.overlay.moveTo(from.x, from.y).lineTo(to.x, to.y).stroke({ width: 1, color });
|
||
const half = POINT_SIZE / 2;
|
||
this.overlay
|
||
.rect(from.x - half, from.y - half, POINT_SIZE, POINT_SIZE)
|
||
.fill(Theme.colors.accent)
|
||
.stroke({ width: 1, color: Theme.colors.accentText });
|
||
this.overlay
|
||
.circle(to.x, to.y, HANDLE_RADIUS + 1)
|
||
.fill(Theme.colors.accentText)
|
||
.stroke({ width: 1, color: Theme.colors.accent });
|
||
}
|
||
|
||
/** The selected shape's points and handles, in widget pixels so they keep their size. */
|
||
private drawShape(shape: VectorShape | undefined): void {
|
||
if (!shape) return;
|
||
const picked = this.picked?.shapeId === shape.id ? this.picked : null;
|
||
const toLocal = (x: number, y: number) => imageToLocal(this.view, x, y);
|
||
for (const [ringIndex, ring] of shape.rings.entries()) {
|
||
const screen = ringOutline(ring).map((p) => toLocal(p.x, p.y));
|
||
if (screen.length >= 2)
|
||
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
|
||
for (const [index, point] of ring.points.entries())
|
||
drawPointGrip(this.overlay, point, picked?.ring === ringIndex && picked.index === index, toLocal);
|
||
}
|
||
}
|
||
|
||
private paintHint(): string {
|
||
const shape = this.shape(this.selectedShapeId());
|
||
const part = this.target === 'fill' ? 'fill' : 'outline';
|
||
if (!shape) return 'Paint: click a shape to show the handles of its gradient or texture.';
|
||
if (this.target === 'stroke' && !shape.stroke) return 'Paint: this shape has no outline.';
|
||
return this.shownPaint()
|
||
? `Paint: drag the two handles of the ${part}.`
|
||
: `Paint: the ${part} is one color – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`;
|
||
}
|
||
|
||
private syncHint(asset: VectorArtAsset | null): void {
|
||
const text = !asset
|
||
? 'No vector drawing selected – pick one in the Assets panel.'
|
||
: this.tool === 'pen'
|
||
? this.drawing
|
||
? 'Pen: click adds a point, drag bends it, click the first point to close, Esc ends the line.'
|
||
: activeLayerOf(this.state, asset)?.hidden
|
||
? 'Pen: this layer is hidden – show it in the inspector to draw on it.'
|
||
: 'Pen: click to start a shape.'
|
||
: this.tool === 'paint'
|
||
? this.paintHint()
|
||
: allShapes(asset).length === 0
|
||
? 'Nothing drawn yet – take the Pen and click on the canvas.'
|
||
: '';
|
||
this.hint.text = text;
|
||
|
||
this.hint.visible = text !== '';
|
||
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
|
||
}
|
||
}
|
||
|
||
function paintOf(shape: VectorShape, target: PaintTarget): VectorPaint | undefined {
|
||
return target === 'fill' ? shape.paint : shape.stroke?.paint;
|
||
}
|
||
|
||
/** Writes a previewed paint straight into the shape; an outline only ever takes a gradient. */
|
||
function setPaintOf(shape: VectorShape, target: PaintTarget, paint: VectorPaint): void {
|
||
if (target === 'fill') shape.paint = paint;
|
||
else if (shape.stroke && paint.type !== 'texture') shape.stroke.paint = paint;
|
||
}
|