A vector shape could only be filled and outlined with one flat colour. Shading, glows and patterned surfaces had to be faked with stacks of shapes. Every shape now has a paint type for its fill and its outline. - Fill type: solid colour, linear gradient, radial gradient or texture. Line type offers the same without texture. - Colour stops are edited on a bar in the inspector, as agreed in the issue. A click on the bar adds a stop in the colour the gradient already has there, so adding never changes the picture. At least two stops remain. - The new Paint tool in the tab shows two handles for the selected shape: axis start and end, centre and radius, or the corners of one texture tile. "Handles: Fill / Outline" picks which paint is edited. - Moving a whole shape moves its gradients and tile with it. Every gesture is one undo step. - A texture fill always tiles, because Pixi turns a non-repeating pattern into a repeating one anyway. When switched on, one tile covers the whole shape. - Only uploaded textures and frames can be a fill, never a drawing. A drawing filled with itself would rasterise itself mid-raster. - The fingerprint of a drawing includes the texture and frame a fill points to, so the picture redraws when the image is swapped. Findings: - graphics.destroy(true) also destroyed the textures of the fills. With a texture fill it deleted the tile slice and the next redraw crashed on reading 'scaleMode' of null. It is now destroy(). - The gradient cache destroyed old gradients itself, which gave 20 Pixi warnings "destroyed while still bound". They are now handed to Pixi's texture GC with autoGarbageCollect switched on. The console of module 47 is empty again, the last CI log had 0 of them. Tests: - ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44), 0 console warnings - 25 new unit tests, 10 new e2e checks in block 47i - Four counter-checks bite, originals restored and verified with cmp - Check sum 1232 in README and wiki, calculated (1222 + 10) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
937 lines
34 KiB
TypeScript
937 lines
34 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 { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||
import {
|
||
AddVectorShapeCommand,
|
||
RemoveVectorShapeCommand,
|
||
RemoveVectorShapesCommand,
|
||
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, handleAt, vectorCanvasSize } from '../../core/model/vectorart';
|
||
import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
|
||
import { vectorArtDrawings } from '../../core/model/vectorPath';
|
||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||
import {
|
||
cancelFrameWork,
|
||
onFrame,
|
||
pressCount,
|
||
type Size,
|
||
Theme,
|
||
textStyle,
|
||
ui,
|
||
Widget,
|
||
zoomFactor,
|
||
} from '../../ui';
|
||
import {
|
||
insideRing,
|
||
type PointKind,
|
||
pointKind,
|
||
type RingGrip,
|
||
ringGripAt,
|
||
ringMoved,
|
||
ringOutline,
|
||
withHandleMoved,
|
||
withPointMoved,
|
||
withPointKind,
|
||
withPointOnSegment,
|
||
withoutPoint,
|
||
withSmoothToggled,
|
||
} from '../polygonEdit';
|
||
import { type EditorState, shapeSelection } from '../state';
|
||
import { drawChecker } from '../texture/checker';
|
||
import { fitView, imageToLocal, localToImage, type SheetView, zoomAbout } from '../texture/sheetView';
|
||
|
||
/**
|
||
* 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';
|
||
|
||
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
||
const POINT_SIZE = 8;
|
||
/** Radius of a Bezier handle's knob, in screen pixels. */
|
||
const HANDLE_RADIUS = 4;
|
||
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||
const DRAG_THRESHOLD = 3;
|
||
/** The grid points snap to while snapping is on, in canvas pixels. */
|
||
export const SNAP_STEP = 8;
|
||
|
||
/** A gesture in progress. `before` is what the shape read when it began – the undo step's other side. */
|
||
interface ShapeDrag {
|
||
kind: 'point' | 'handle' | 'shape' | 'pen';
|
||
shapeId: string;
|
||
before: VectorRing[];
|
||
rings: VectorRing[];
|
||
/** 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, and its paints as they were – they travel with it. */
|
||
delta: Vec2;
|
||
paints: ReturnType<typeof paintsOf>;
|
||
}
|
||
|
||
/** 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 Widget {
|
||
private readonly bg = new Graphics();
|
||
/** Everything the view moves: the drawing, scaled by the zoom, and the overlay above it. */
|
||
private readonly content = new Container();
|
||
private readonly checker = new Graphics();
|
||
private readonly art = new Graphics();
|
||
private readonly overlay = new Graphics();
|
||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||
private readonly clip = new Graphics();
|
||
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
|
||
private assetId = '';
|
||
private needsFit = true;
|
||
private tool: VectorTool = 'select';
|
||
private snap = false;
|
||
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
|
||
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.checker, this.art, this.overlay);
|
||
this.addChild(this.bg, this.content, this.hint, this.clip);
|
||
this.content.mask = this.clip;
|
||
this.content.eventMode = 'none';
|
||
this.hint.eventMode = 'none';
|
||
this.focusable = true;
|
||
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||
this.on('pointerup', () => this.onPointerUp());
|
||
this.on('pointerupoutside', () => this.onPointerUp());
|
||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||
|
||
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);
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
show(assetId: string): void {
|
||
if (this.assetId === assetId) return;
|
||
this.assetId = assetId;
|
||
this.needsFit = true;
|
||
this.endPen();
|
||
this.pick(null);
|
||
this.redraw();
|
||
}
|
||
|
||
get currentAssetId(): string {
|
||
return this.assetId;
|
||
}
|
||
|
||
saveView(): { view: SheetView; needsFit: boolean } {
|
||
return { view: { ...this.view }, needsFit: this.needsFit };
|
||
}
|
||
|
||
restoreView(saved: unknown): void {
|
||
const { view, needsFit } = saved as { view: SheetView; needsFit: boolean };
|
||
if (needsFit) return;
|
||
this.view = { ...view };
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
}
|
||
|
||
fit(): void {
|
||
this.needsFit = true;
|
||
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;
|
||
}
|
||
|
||
/** 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();
|
||
}
|
||
|
||
/** Where a canvas pixel sits on screen; the check scripts aim their drags with it. */
|
||
canvasToScreen(x: number, y: number): Vec2 {
|
||
const local = imageToLocal(this.view, x, y);
|
||
const origin = this.getGlobalPosition();
|
||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||
}
|
||
|
||
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 selected shapes away – the canvas edits shapes, so that is what it removes. */
|
||
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);
|
||
const shapeIds = this.selectedShapeIds();
|
||
if (shapeIds.length === 0) return super.onKeyDown(event);
|
||
this.endPen();
|
||
this.state.execute(
|
||
shapeIds.length === 1
|
||
? new RemoveVectorShapeCommand(this.assetId, shapeIds[0]!)
|
||
: new RemoveVectorShapesCommand(this.assetId, shapeIds),
|
||
);
|
||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||
return true;
|
||
}
|
||
|
||
private onPointerDown(e: FederatedPointerEvent): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const local = this.toLocal(e.global);
|
||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
ui().focus.focus(this);
|
||
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 command = new AddVectorShapeCommand(asset.id, [{ points: [point], closed: false }]);
|
||
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: {},
|
||
};
|
||
this.preview();
|
||
}
|
||
|
||
/**
|
||
* A press with the arrow. On the selected shape first, so its points stay reachable under a shape
|
||
* drawn later: a double-click on a point takes it away, Alt makes it round or square again, a
|
||
* press on a point or a handle drags it, one on a line puts a point there. Inside a shape's fill
|
||
* the whole shape is dragged; on nothing the selection is cleared. Ctrl adds a shape to the
|
||
* selection or takes it out again, which is how shapes are picked for combining (#187).
|
||
*/
|
||
private pressSelect(
|
||
asset: VectorArtAsset,
|
||
at: Vec2,
|
||
local: Vec2,
|
||
tolerance: number,
|
||
e: FederatedPointerEvent,
|
||
): void {
|
||
if (e.ctrlKey || e.metaKey) {
|
||
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);
|
||
this.state.select(shapeSelection(asset.id, [shape.id]));
|
||
this.drag = {
|
||
kind: 'shape',
|
||
shapeId: shape.id,
|
||
before: shape.rings,
|
||
rings: shape.rings.map(cloneVectorRing),
|
||
ring: -1,
|
||
index: -1,
|
||
side: 'out',
|
||
pointer: { x: local.x, y: local.y },
|
||
at,
|
||
moved: false,
|
||
label: 'Move shape',
|
||
delta: { x: 0, y: 0 },
|
||
paints: paintsOf(shape),
|
||
};
|
||
return;
|
||
}
|
||
this.pick(null);
|
||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||
this.redraw();
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
for (let i = asset.shapes.length - 1; i >= 0; i--) {
|
||
const shape = asset.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 {
|
||
const index = grip.kind === 'segment' ? grip.index + 1 : grip.index;
|
||
this.pick({ shapeId: shape.id, ring: ringIndex, index });
|
||
if (grip.kind === 'point') {
|
||
if (pressCount(e, `point ${shape.id} ${ringIndex} ${grip.index}`) === 2) {
|
||
const next = withoutPoint(ring, grip.index);
|
||
const rings = next
|
||
? this.ringsWith(shape, ringIndex, next)
|
||
: shape.rings.filter((_, i) => i !== ringIndex).map(cloneVectorRing);
|
||
this.pick(null);
|
||
this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Remove point'));
|
||
return;
|
||
}
|
||
if (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;
|
||
}
|
||
}
|
||
const ringNow =
|
||
grip.kind === 'segment' ? withPointOnSegment(ring, grip.index, grip.t) : cloneVectorRing(ring);
|
||
this.drag = {
|
||
kind: grip.kind === 'handle' ? 'handle' : 'point',
|
||
shapeId: shape.id,
|
||
before: shape.rings,
|
||
rings: this.ringsWith(shape, ringIndex, ringNow),
|
||
ring: ringIndex,
|
||
index,
|
||
side: grip.kind === 'handle' ? grip.side : 'out',
|
||
pointer: { x: local.x, y: local.y },
|
||
at,
|
||
moved: grip.kind === 'segment',
|
||
label: grip.kind === 'segment' ? 'Add point' : grip.kind === 'handle' ? 'Bend shape' : 'Move point',
|
||
delta: { x: 0, y: 0 },
|
||
paints: {},
|
||
};
|
||
if (grip.kind === 'segment') this.preview();
|
||
else this.redraw();
|
||
}
|
||
|
||
private onPointerMove(e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
if (this.panning) {
|
||
this.view = {
|
||
...this.view,
|
||
panX: this.panning.panX + (local.x - this.panning.pointer.x),
|
||
panY: this.panning.panY + (local.y - this.panning.pointer.y),
|
||
};
|
||
this.redraw();
|
||
return;
|
||
}
|
||
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 });
|
||
const before = drag.before;
|
||
drag.delta = delta;
|
||
drag.rings = 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;
|
||
const shape = drag && this.shape(drag.shapeId);
|
||
if (!drag || !shape) return;
|
||
shape.rings = drag.rings;
|
||
// A whole shape takes its gradients along (#186), handles and all.
|
||
if (drag.kind === 'shape') {
|
||
const delta = drag.delta;
|
||
if (drag.paints.paint) shape.paint = paintMoved(drag.paints.paint, delta.x, delta.y);
|
||
if (drag.paints.strokePaint && shape.stroke)
|
||
shape.stroke.paint = paintMoved(drag.paints.strokePaint, delta.x, delta.y);
|
||
}
|
||
this.state.preview();
|
||
}
|
||
|
||
private onPointerUp(): void {
|
||
this.panning = null;
|
||
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);
|
||
const shape = drag && this.shape(drag.shapeId);
|
||
if (!drag || !shape) return;
|
||
// The preview moved it already; put it back so the command records the state that was there.
|
||
shape.rings = drag.before;
|
||
if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring };
|
||
const moved = drag.kind === 'shape' ? this.movedPaints(drag) : null;
|
||
if (moved) {
|
||
if (drag.paints.paint) shape.paint = drag.paints.paint;
|
||
if (drag.paints.strokePaint && shape.stroke) shape.stroke.paint = drag.paints.strokePaint;
|
||
}
|
||
if (SetVectorShapeRingsCommand.changes(shape, drag.rings))
|
||
this.state.execute(
|
||
new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label, moved),
|
||
);
|
||
else this.state.preview();
|
||
}
|
||
|
||
/** The paints of a dragged shape where the drag leaves them; null when it has none to move. */
|
||
private movedPaints(drag: ShapeDrag): ReturnType<typeof paintsOf> | null {
|
||
const { paint, strokePaint } = drag.paints;
|
||
if (!paint && !strokePaint) return null;
|
||
return {
|
||
...(paint ? { paint: paintMoved(paint, drag.delta.x, drag.delta.y) } : {}),
|
||
...(strokePaint ? { strokePaint: paintMoved(strokePaint, drag.delta.x, drag.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'));
|
||
}
|
||
|
||
private onWheel(e: FederatedWheelEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
e.preventDefault();
|
||
}
|
||
|
||
private updateCursor(local: Vec2): void {
|
||
const asset = this.asset();
|
||
if (!asset) {
|
||
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;
|
||
if (this.tool === 'paint') {
|
||
const paint = this.shownPaint();
|
||
const onHandle =
|
||
paint !== null &&
|
||
[paint.from, paint.to].some((end) => Math.hypot(end.x - at.x, end.y - at.y) <= tolerance);
|
||
this.cursor = onHandle ? 'pointer' : 'default';
|
||
return;
|
||
}
|
||
const selected = this.shape(this.selectedShapeId());
|
||
const onGrip = selected?.rings.some((ring) => ringGripAt(ring, at, tolerance) !== null) ?? false;
|
||
const inside = asset.shapes.some((shape) => shape.rings.some((ring) => insideRing(ring, at)));
|
||
this.cursor = onGrip ? 'pointer' : inside ? 'move' : 'default';
|
||
}
|
||
|
||
/** 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.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||
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 half = POINT_SIZE / 2;
|
||
const picked = this.picked?.shapeId === shape.id ? this.picked : null;
|
||
for (const [ringIndex, ring] of shape.rings.entries()) {
|
||
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 });
|
||
for (const [index, point] of ring.points.entries()) {
|
||
const at = imageToLocal(this.view, point.x, point.y);
|
||
const isPicked = picked?.ring === ringIndex && picked.index === index;
|
||
for (const side of ['in', 'out'] as const) {
|
||
const handle = handleAt(point, side);
|
||
if (!handle) continue;
|
||
const tip = imageToLocal(this.view, handle.x, handle.y);
|
||
this.overlay
|
||
.moveTo(at.x, at.y)
|
||
.lineTo(tip.x, tip.y)
|
||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||
this.overlay
|
||
.circle(tip.x, tip.y, HANDLE_RADIUS)
|
||
.fill(Theme.colors.viewport)
|
||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||
}
|
||
this.overlay
|
||
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
||
.fill(isPicked ? Theme.colors.accent : Theme.colors.accentText)
|
||
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent });
|
||
}
|
||
}
|
||
}
|
||
|
||
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 colour – 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.'
|
||
: 'Pen: click to start a shape.'
|
||
: this.tool === 'paint'
|
||
? this.paintHint()
|
||
: asset.shapes.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;
|
||
}
|