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

+8 -1
View File
@@ -238,7 +238,14 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
label: assetChoiceLabel(asset),
})),
},
texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: pictureChoices },
texture: {
none: NO_TEXTURE_LABEL,
label: textureRefLabel,
choices: (project, def) =>
pictureChoices(project).filter(
(choice) => !(def.kind === 'texture' && def.uploadedOnly) || choice.asset.type === 'texture',
),
},
statemachine: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
+340 -22
View File
@@ -17,19 +17,42 @@ import {
RenameVectorShapeCommand,
SetVectorShapeStyleCommand,
} from '../../core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorShape, VectorStroke } from '../../core/model/Asset';
import type {
VectorArtAsset,
VectorGradient,
VectorPaint,
VectorShape,
VectorStroke,
} from '../../core/model/Asset';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project';
import {
findShape,
SHAPE_FILL_DEFAULT,
shapeExtent,
type ShapeOrderMove,
shapeOrderTarget,
vectorCanvasSize,
} from '../../core/model/vectorart';
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
import {
FILL_KINDS,
type FillKind,
fillKind,
fillPaintFor,
MIN_STOPS,
paintExtent,
STROKE_KINDS,
type StrokeKind,
strokeKind,
strokePaintFor,
withoutStop,
withStopChanged,
} from '../../core/model/vectorPaint';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, Theme } from '../../ui';
import { shapeSelection } from '../state';
import { type EditorState, shapeSelection } from '../state';
import { GradientBar } from '../vectorart/GradientBar';
import { ShapeList } from '../vectorart/ShapeList';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
@@ -38,13 +61,103 @@ import { previewingHandlers } from './previewHandlers';
/** The outline a shape gets when one is switched on: black, two canvas pixels wide. */
const DEFAULT_STROKE: VectorStroke = { color: 0x000000, width: 2 };
/** What a style row writes while it is being dragged; `stroke` is never taken away by a preview. */
/** What a style row writes while it is being dragged; nothing is ever taken away by a preview. */
interface PreviewStyle {
fill?: number;
opacity?: number;
paint?: VectorPaint;
stroke?: VectorStroke;
}
/** Whose gradient a bar and its stop rows edit: the fill's or the outline's (#186). */
export type PaintPart = 'fill' | 'stroke';
const FILL_KIND_LABELS: Record<FillKind, string> = {
solid: 'Solid colour',
linear: 'Linear gradient',
radial: 'Radial gradient',
texture: 'Texture',
};
const FILL_TYPE_DEF: PropertyDef = {
key: 'fillType',
label: 'Fill type',
kind: 'enum',
default: 'solid',
options: FILL_KINDS.map((value) => ({
value,
label: FILL_KIND_LABELS[value],
})),
};
const LINE_TYPE_DEF: PropertyDef = {
key: 'lineType',
label: 'Line type',
kind: 'enum',
default: 'solid',
options: STROKE_KINDS.map((value) => ({
value,
label: FILL_KIND_LABELS[value],
})),
};
const TEXTURE_DEF: PropertyDef = {
key: 'fillTexture',
label: 'Texture',
kind: 'texture',
default: '',
uploadedOnly: true,
};
/** The three rows of the picked stop; the outline's carry a prefix so both sets can stand at once. */
function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } {
const prefix = part === 'fill' ? 'fillStop' : 'lineStop';
return {
color: { key: `${prefix}Color`, label: 'Stop colour', kind: 'color', default: 0xffffff },
alpha: {
key: `${prefix}Alpha`,
label: 'Stop opacity',
kind: 'number',
default: 1,
min: 0,
max: 1,
step: 0.05,
decimals: 2,
},
offset: {
key: `${prefix}Offset`,
label: 'Stop position',
kind: 'number',
default: 0,
min: 0,
max: 1,
step: 0.01,
decimals: 2,
},
};
}
/**
* The stop picked in each bar, by asset, shape and part. It outlives a rebuild of the rows – undo
* rebuilds them – and falls back to the first stop when the one it names has gone.
*/
const pickedStops = new Map<string, number>();
function pickedStop(
assetId: string,
shapeId: string,
part: PaintPart,
gradient: VectorGradient | undefined,
): number {
const index = pickedStops.get(`${assetId}/${shapeId}/${part}`) ?? 0;
return gradient && index < gradient.stops.length ? index : 0;
}
/** The bars as they were last built, for the check scripts. */
const builtBars = new Map<PaintPart, GradientBar>();
export function currentGradientBar(part: PaintPart): GradientBar | null {
return builtBars.get(part) ?? null;
}
const ORDER_BUTTONS: { move: ShapeOrderMove; label: string }[] = [
{ move: 'front', label: 'To front' },
{ move: 'forward', label: 'Forward' },
@@ -115,7 +228,8 @@ export function shapeSignature(
const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return 'missing';
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
return `${shapeListSignature(asset)}|${shape.stroke ? 'stroke' : 'none'}|${shapeIds.join(',')}|${blocked}`;
const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`;
return `${shapeListSignature(asset)}|${paints}|${shapeIds.join(',')}|${blocked}`;
}
/** The selected shapes in drawing order, the lowest first – the order they are combined in. */
@@ -169,15 +283,7 @@ export function buildShapeSection(
addTitle(host, 'VECTOR SHAPE');
addShapeName(host, assetId, shape);
styleRow(
host,
assetId,
shapeId,
FILL_DEF,
(s) => s.fill,
(input) => ({ fill: Number(input) }),
'Set fill',
);
fillRows(host, assetId, shape);
styleRow(
host,
assetId,
@@ -189,15 +295,18 @@ export function buildShapeSection(
);
outlineRow(host, assetId, shapeId, shape);
if (shape.stroke) {
styleRow(
host,
assetId,
shapeId,
STROKE_COLOR_DEF,
(s) => s.stroke?.color ?? DEFAULT_STROKE.color,
(input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }),
'Set line colour',
);
kindRow(host, assetId, shape, 'stroke');
if (strokeKind(shape) === 'solid')
styleRow(
host,
assetId,
shapeId,
STROKE_COLOR_DEF,
(s) => s.stroke?.color ?? DEFAULT_STROKE.color,
(input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }),
'Set line colour',
);
else gradientRows(host, assetId, shapeId, 'stroke');
styleRow(
host,
assetId,
@@ -259,6 +368,11 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
if (!shape) return false;
host.fields.get('name')?.setValue(shape.name);
host.fields.get('fill')?.setValue(shape.fill);
host.fields.get(FILL_TYPE_DEF.key)?.setValue(fillKind(shape));
host.fields.get(LINE_TYPE_DEF.key)?.setValue(strokeKind(shape));
host.fields.get(TEXTURE_DEF.key)?.setValue(shape.paint?.type === 'texture' ? shape.paint.texture : '');
refreshStopRows(host, assetId, shape, 'fill');
refreshStopRows(host, assetId, shape, 'stroke');
host.fields.get('opacity')?.setValue(shape.opacity);
host.fields.get('outline')?.setValue(Boolean(shape.stroke));
host.fields.get('strokeColor')?.setValue(shape.stroke?.color ?? DEFAULT_STROKE.color);
@@ -382,6 +496,7 @@ function styleRow(
function applyPreview(shape: VectorShape, style: PreviewStyle): void {
if (style.fill !== undefined) shape.fill = style.fill;
if (style.opacity !== undefined) shape.opacity = Math.max(0, Math.min(1, style.opacity));
if (style.paint !== undefined) shape.paint = style.paint;
if (style.stroke !== undefined) shape.stroke = { ...style.stroke };
}
@@ -406,3 +521,206 @@ function shapeNow(host: InspectorHost, assetId: string, shapeId: string): Vector
}
);
}
/**
* The fill: its kind, and under it what that kind is made of – the colour, the gradient's bar and
* stops, or the picture. The colour row goes while a paint fills the shape; the colour itself stays
* on the shape and is what "Solid colour" brings back.
*/
function fillRows(host: InspectorHost, assetId: string, shape: VectorShape): void {
kindRow(host, assetId, shape, 'fill');
const kind = fillKind(shape);
if (kind === 'solid')
styleRow(
host,
assetId,
shape.id,
FILL_DEF,
(s) => s.fill,
(input) => ({ fill: Number(input) }),
'Set fill',
);
else if (kind === 'texture') textureRow(host, assetId, shape);
else gradientRows(host, assetId, shape.id, 'fill');
}
/** "Fill type" or "Line type"; what picking one does is `setPaintKind`. */
function kindRow(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void {
const def = part === 'fill' ? FILL_TYPE_DEF : LINE_TYPE_DEF;
const value = part === 'fill' ? fillKind(shape) : strokeKind(shape);
const row = createFieldRow(host, def, value, {
onInput: () => {},
onChange: (input) => setPaintKind(host.state, assetId, shape.id, part, textOf(input)),
});
host.fields.set(def.key, row);
host.body.add(row.row);
}
/**
* Switches the fill or the outline to another kind: the paint is laid out over the shape anew, a
* gradient keeps its colours when it only changes between linear and radial, and it is one undo
* step. Exported because the row's menu is the only other way in, and a test has no menus.
*/
export function setPaintKind(
state: EditorState,
assetId: string,
shapeId: string,
part: PaintPart,
kind: string,
): void {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return;
const extent = paintExtent(shapeExtent(shape), vectorCanvasSize(asset.props));
if (part === 'fill') {
if (kind === fillKind(shape) || !(FILL_KINDS as readonly string[]).includes(kind)) return;
const paint = fillPaintFor(shape, kind as FillKind, extent);
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill type'));
} else if (shape.stroke) {
if (kind === strokeKind(shape) || !(STROKE_KINDS as readonly string[]).includes(kind)) return;
const paint = strokePaintFor(shape, kind as StrokeKind, extent);
const { color, width } = shape.stroke;
const stroke: VectorStroke = paint ? { color, width, paint } : { color, width };
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { stroke }, 'Line type'));
}
}
/** The picture of a texture fill; only uploaded textures and their frames are offered. */
function textureRow(host: InspectorHost, assetId: string, shape: VectorShape): void {
const value = shape.paint?.type === 'texture' ? shape.paint.texture : '';
const row = createFieldRow(host, TEXTURE_DEF, value, {
onInput: () => {},
onChange: (input) => setFillTexture(host.state, assetId, shape.id, textOf(input)),
});
host.fields.set(TEXTURE_DEF.key, row);
host.body.add(row.row);
addNote(host, 'Paint tool: drag the corners of one tile.', 'small');
}
/** Puts a picture into a texture fill; exported for the same reason as `setPaintKind`. */
export function setFillTexture(state: EditorState, assetId: string, shapeId: string, texture: string): void {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (shape?.paint?.type !== 'texture') return;
const paint = { ...shape.paint, texture };
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill texture'));
}
/**
* The bar of a gradient and the three rows of its picked stop, then the button that takes the
* picked stop out – there to be seen with two stops too, switched off, since a gradient needs two.
*/
function gradientRows(host: InspectorHost, assetId: string, shapeId: string, part: PaintPart): void {
const key = `${assetId}/${shapeId}/${part}`;
const gradientOf = (): VectorGradient | undefined => {
const shape = currentShape(host, assetId, shapeId);
return shape ? gradientIn(shape, part) : undefined;
};
const bar = new GradientBar(host.state, {
gradient: gradientOf,
selected: () => pickedStop(assetId, shapeId, part, gradientOf()),
select: (index) => {
pickedStops.set(key, index);
const shape = currentShape(host, assetId, shapeId);
if (shape) refreshStopRows(host, assetId, shape, part);
bar.invalidate();
},
preview: (gradient) => {
const shape = currentShape(host, assetId, shapeId);
if (!shape) return;
applyPreview(shape, withGradient(shape, part, gradient));
host.state.preview();
},
commit: (before, after, label) => {
const shape = currentShape(host, assetId, shapeId);
if (!shape) return;
applyPreview(shape, withGradient(shape, part, before));
host.state.execute(
new SetVectorShapeStyleCommand(assetId, shapeId, withGradient(shape, part, after), label),
);
},
});
builtBars.set(part, bar);
host.body.add(bar);
const defs = stopDefs(part);
const stopRow = (
def: PropertyDef,
read: (stop: { color: number; alpha: number; offset: number }) => number,
field: 'color' | 'alpha' | 'offset',
label: string,
) =>
styleRow(
host,
assetId,
shapeId,
def,
(s) => {
const gradient = gradientIn(s, part);
const stop = gradient?.stops[pickedStop(assetId, shapeId, part, gradient)];
return stop ? read(stop) : Number(def.default);
},
(input, s) => {
const gradient = gradientIn(s, part);
if (!gradient) return {};
const index = pickedStop(assetId, shapeId, part, gradient);
const next = withStopChanged(gradient, index, { [field]: Number(input) });
return next ? withGradient(s, part, next) : {};
},
label,
);
stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop colour');
stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity');
stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move colour stop');
const remove = new Button('Remove stop', {
variant: 'ghost',
onClick: () => {
const shape = currentShape(host, assetId, shapeId);
const gradient = shape && gradientIn(shape, part);
if (!shape || !gradient) return;
const next = withoutStop(gradient, pickedStop(assetId, shapeId, part, gradient));
if (!next) return;
pickedStops.set(key, 0);
host.state.execute(
new SetVectorShapeStyleCommand(
assetId,
shapeId,
withGradient(shape, part, next),
'Remove colour stop',
),
);
},
});
remove.setEnabled((gradientOf()?.stops.length ?? 0) > MIN_STOPS);
removeButtons.set(part, remove);
host.body.add(remove);
addNote(host, 'Click the bar to add a stop. Paint tool: drag the ends.', 'small');
}
/** The "Remove stop" buttons as last built; a stop added or taken out switches them. */
const removeButtons = new Map<PaintPart, Button>();
/** The picked stop's values into its three rows, after a pick, an undo or a drag on the bar. */
function refreshStopRows(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void {
const gradient = gradientIn(shape, part);
if (!gradient) return;
const stop = gradient.stops[pickedStop(assetId, shape.id, part, gradient)];
const defs = stopDefs(part);
if (stop) {
host.fields.get(defs.color.key)?.setValue(stop.color);
host.fields.get(defs.alpha.key)?.setValue(stop.alpha);
host.fields.get(defs.offset.key)?.setValue(stop.offset);
}
removeButtons.get(part)?.setEnabled(gradient.stops.length > MIN_STOPS);
}
function gradientIn(shape: VectorShape, part: PaintPart): VectorGradient | undefined {
const paint = part === 'fill' ? shape.paint : shape.stroke?.paint;
return paint && paint.type !== 'texture' ? paint : undefined;
}
/** A style that puts `gradient` where `part` says: the fill's paint, or the outline's. */
function withGradient(shape: VectorShape, part: PaintPart, gradient: VectorGradient): PreviewStyle {
return part === 'fill' ? { paint: gradient } : { stroke: { ...strokeOf(shape), paint: gradient } };
}