Files
feuerwerk/src/editor/vectorart/VectorArtCanvas.ts
T
adminandClaude Opus 5.5 14972d057f
game-maker / gate (push) Successful in 3m53s
game-maker / suite (push) Successful in 11m55s
Refactoring round 8: hide data files, safe saves, Esc as Cancel (#85)
Recurring cleanup round over the code since round 7 (bc688f3). The
issue now also asks to fix bugs and small inconsistencies found on the
way, so unlike the rounds before, this one changes behaviour in a few
places. Duplicated code in api went from 23 clones with 228 lines to
none (jscpd, at least 50 tokens). In src it stays at 22 clones with
271 lines, the same before and after.

Bugs:
- nginx served project and snapshot files as they were, so anyone who
  knew a project id could read it without signing in. Every file the
  API writes now goes through AtomicFile (temp file, mode, rename).
  Projects, snapshots, releases, accounts and sessions are 0640, and
  nginx answers 403 for them.
- Any signed-in account could delete a shared image, font or sound,
  which projects of other accounts may wear. Deleting one is now for
  administrators only. The editor never deleted a file, so the
  client's delete is gone.
- Two saves of one project could run at once. The lock file was
  removed after each save, so a waiting save and the next one locked
  different files. The lock file now stays, and deleting a project
  takes the lock as well.
- Bytes that are no picture got a 500 instead of a 422, because the
  error handler also threw for warnings silenced with @.
- A session file was rewritten in place. A request reading it at that
  moment found it empty and was signed out. It is replaced atomically.
- Delete did nothing for a shape picked in the vector art list. Delete
  now goes through removeSelection, one case per kind of selection,
  and a kind without a case fails the type check.

Inconsistencies:
- Escape on a question does what its Cancel does. Cancel on "Delete
  project?" and "Restore snapshot?" leads back to the picker or the
  list like the other questions did, and so does deleting a project.
- Editor texts use American spelling throughout, like the port names:
  48 British spellings in 45 lines (colour, centre, initialised).
- objectName and groupName replace eleven copies of "own name, else
  the asset's" and three of "Group". One copy fell back to the object
  id instead of "?".

Duplicates, dead code, comments:
- routeFiles serves images, fonts and sounds instead of three copies.
  HashedFileStore holds what the three stores shared: keeping a file
  under its hash with its name, listing and deleting. The stores keep
  only which bytes they take and what a record says.
- AtomicFile replaces nine copies of temp file plus rename.
- removeShapes is the one way the canvas, the inspector button and
  Delete take shapes out.
- GraphInterpreter.fire was called by tests only. The 54 calls use a
  helper in tests/helpers.ts now.
- FileStore.delete, HttpFileStore.delete and the PluginApi type were
  unused. A doc comment in actions.ts sat above the wrong function.

Checked and left as is:
- solidCells is used by tests only. It is the per-cell view before the
  merge into rectangles, the one place the largest-scale rule shows.
- An undo checker ran all 124 commands of the unit tests and compared
  the project before and after undo. The only differences are empty
  ports, groups and notes lists where there were none, which the file
  format drops anyway.

Docs:
- README: the check sum.
- Wiki, own commit: API (admin-only deletes, 422, file modes, lock
  file), Home (the "Not yet" list named three things that exist),
  Concepts, Keyboard, Vector art, Architecture and Development, and
  the 38 characters Gitea flagged as ambiguous on eight pages.
- .gitignore: the lock files, and pasted-*.png in any folder, which
  keeps the screenshot archive out.

Tests:
- ./check.sh: 195 files, 2817 tests, exit 0 (+2 for the name helpers).
- PHP: accounts 52 in the web container and 53 in the CI image with
  the race test (+2), snapshots 15 (+1), releases 20.
- Full e2e run: ALL PASSED, 1586 checks (+6 in 12, 47, 49 and 50).
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks. The example
  project files were restored byte for byte afterwards.
- The file stores of HEAD and of this commit give byte-identical
  output for the live folders and for store, list and delete in a
  throwaway folder.
- Counter-checks, all fail as expected: a 500 for bad bytes fails 12,
  no admin check fails 49, removing the lock file again loses saves
  in 3 of 3 runs (151 to 170 of 321), Delete ignoring shapes fails 47,
  Escape that only closes fails 50, and dropping the shape case fails
  the type check (TS2366).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:12:55 +02:00

812 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
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 { 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 { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips';
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';
/**
* 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;
/** 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 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;
}
/** 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 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();
removeShapes(this.state, this.assetId, shapeIds);
return true;
}
protected pressTarget(): VectorArtAsset | null {
return this.asset();
}
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 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();
}
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 });
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();
}
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);
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'));
}
/** 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 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 = inside && !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.'
: '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;
}