Vector shapes can fill with gradients and textures (#186)
game-maker / gate (push) Successful in 2m14s
game-maker / suite (push) Failing after 7m14s

A vector shape could only be filled and outlined with one flat colour.
Shading, glows and patterned surfaces had to be faked with stacks of
shapes. Every shape now has a paint type for its fill and its outline.

- Fill type: solid colour, linear gradient, radial gradient or
  texture. Line type offers the same without texture.
- Colour stops are edited on a bar in the inspector, as agreed in the
  issue. A click on the bar adds a stop in the colour the gradient
  already has there, so adding never changes the picture. At least two
  stops remain.
- The new Paint tool in the tab shows two handles for the selected
  shape: axis start and end, centre and radius, or the corners of one
  texture tile. "Handles: Fill / Outline" picks which paint is edited.
- Moving a whole shape moves its gradients and tile with it. Every
  gesture is one undo step.
- A texture fill always tiles, because Pixi turns a non-repeating
  pattern into a repeating one anyway. When switched on, one tile
  covers the whole shape.
- Only uploaded textures and frames can be a fill, never a drawing.
  A drawing filled with itself would rasterise itself mid-raster.
- The fingerprint of a drawing includes the texture and frame a fill
  points to, so the picture redraws when the image is swapped.

Findings:

- graphics.destroy(true) also destroyed the textures of the fills.
  With a texture fill it deleted the tile slice and the next redraw
  crashed on reading 'scaleMode' of null. It is now destroy().
- The gradient cache destroyed old gradients itself, which gave 20
  Pixi warnings "destroyed while still bound". They are now handed to
  Pixi's texture GC with autoGarbageCollect switched on. The console
  of module 47 is empty again, the last CI log had 0 of them.

Tests:

- ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986)
- npm run build: exit 0
- GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44),
  0 console warnings
- 25 new unit tests, 10 new e2e checks in block 47i
- Four counter-checks bite, originals restored and verified with cmp
- Check sum 1232 in README and wiki, calculated (1222 + 10)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 13:05:02 +02:00
1 parent 1116818011
commit 592ea942d9
22 files changed
+2090 -98

No files matched your search

+252 -11
View File
@@ -15,14 +15,24 @@ import {
AddVectorShapeCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
paintsOf,
SetVectorShapeRingsCommand,
SetVectorShapeStyleCommand,
} from '../../core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorPoint, VectorRing, VectorShape } from '../../core/model/Asset';
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 { paintDrawing } from '../../viewport/vectorArtPicture';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
import {
cancelFrameWork,
onFrame,
@@ -53,8 +63,14 @@ 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. */
export type VectorTool = 'select' | 'pen';
/**
* 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;
@@ -80,6 +96,20 @@ interface ShapeDrag {
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. */
@@ -101,6 +131,11 @@ export class VectorArtCanvas extends Widget {
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). */
@@ -136,6 +171,14 @@ export class VectorArtCanvas extends Widget {
this.pick(null);
this.redraw();
});
// A picture fill (#186) shows the shape's colour until its picture has landed.
this.stopPictures = textureLibrary.onChange(() => this.redraw());
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stopPictures();
this.paints.destroy();
super.destroy(options);
}
override measure(): Size {
@@ -192,6 +235,22 @@ export class VectorArtCanvas extends Widget {
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;
@@ -287,6 +346,7 @@ export class VectorArtCanvas extends Widget {
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();
}
@@ -344,6 +404,8 @@ export class VectorArtCanvas extends Widget {
at,
moved: false,
label: 'Draw shape',
delta: { x: 0, y: 0 },
paints: {},
};
this.preview();
}
@@ -395,6 +457,8 @@ export class VectorArtCanvas extends Widget {
at,
moved: false,
label: 'Move shape',
delta: { x: 0, y: 0 },
paints: paintsOf(shape),
};
return;
}
@@ -403,6 +467,37 @@ export class VectorArtCanvas extends Widget {
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--) {
@@ -454,6 +549,8 @@ export class VectorArtCanvas extends Widget {
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();
@@ -470,6 +567,10 @@ export class VectorArtCanvas extends Widget {
this.redraw();
return;
}
if (this.paintDrag) {
this.movePaint(this.paintDrag, local);
return;
}
const drag = this.drag;
if (!drag) {
this.updateCursor(local);
@@ -483,6 +584,7 @@ export class VectorArtCanvas extends Widget {
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));
@@ -500,6 +602,18 @@ export class VectorArtCanvas extends Widget {
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.
*
@@ -514,15 +628,34 @@ export class VectorArtCanvas extends Widget {
}
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 (this.destroyed || !drag || !shape) return;
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.
@@ -532,11 +665,49 @@ export class VectorArtCanvas extends Widget {
// 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));
this.state.execute(
new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label, moved),
);
else this.state.preview();
}
/** The paints of a dragged shape where the drag leaves them; null when it has none to move. */
private movedPaints(drag: ShapeDrag): ReturnType<typeof paintsOf> | null {
const { paint, strokePaint } = drag.paints;
if (!paint && !strokePaint) return null;
return {
...(paint ? { paint: paintMoved(paint, drag.delta.x, drag.delta.y) } : {}),
...(strokePaint ? { strokePaint: paintMoved(strokePaint, drag.delta.x, drag.delta.y) } : {}),
};
}
/** One handle dragged is one undo step, written through the same command as the inspector's rows. */
private releasePaint(drag: PaintDrag): void {
this.paintDrag = null;
cancelFrameWork(this.frameKey);
const shape = this.shape(drag.shapeId);
if (!shape) return;
setPaintOf(shape, drag.target, drag.before);
if (!drag.moved) {
this.state.preview();
return;
}
const style =
drag.target === 'fill'
? { paint: drag.paint }
: shape.stroke && drag.paint.type !== 'texture'
? { stroke: { ...shape.stroke, paint: drag.paint } }
: null;
if (style)
this.state.execute(new SetVectorShapeStyleCommand(this.assetId, shape.id, style, 'Move paint handle'));
}
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
@@ -557,6 +728,14 @@ export class VectorArtCanvas extends Widget {
}
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)));
@@ -620,11 +799,16 @@ export class VectorArtCanvas extends Widget {
// 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)) paintDrawing(this.art, drawing);
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));
this.drawShape(this.shape(this.selectedShapeId()));
if (this.tool === 'paint') {
this.drawOutline(this.shape(this.selectedShapeId()));
this.drawPaintHandles(this.shownPaint());
} else this.drawShape(this.shape(this.selectedShapeId()));
}
this.syncHint(asset);
}
@@ -645,6 +829,40 @@ export class VectorArtCanvas extends Widget {
}
}
/**
* 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;
@@ -678,6 +896,16 @@ export class VectorArtCanvas extends Widget {
}
}
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.'
@@ -685,11 +913,24 @@ export class VectorArtCanvas extends Widget {
? 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.'
: asset.shapes.length === 0
? 'Nothing drawn yet – take the Pen and click on the canvas.'
: '';
: 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;
}