/** * 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; } /** 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[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 | 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; }