Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
849 lines
31 KiB
TypeScript
849 lines
31 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 {
|
||
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, Theme, type Widget } 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 { 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';
|
||
|
||
/** 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. */
|
||
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();
|
||
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;
|
||
}
|
||
|
||
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'));
|
||
}
|
||
|
||
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.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 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;
|
||
}
|