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

+13 -2
View File
@@ -32,7 +32,7 @@ import { currentNodePalette } from './graph/NodePalette';
import { currentSoundPicker } from './sound/SoundPicker';
import { currentImagePicker } from './texture/ImagePicker';
import { currentTilemapWidgets } from './panels/TilemapPanel';
import { currentShapeList } from './inspector/shapeSection';
import { currentGradientBar, currentShapeList } from './inspector/shapeSection';
import type { SnapToggle } from './graph/SnapToggle';
import type { EditorState, TilemapTool } from './state';
import { rectOf, textProbe } from './textProbe';
@@ -504,7 +504,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
name: shape.name,
fill: shape.fill,
opacity: shape.opacity,
stroke: shape.stroke ? { ...shape.stroke } : null,
stroke: shape.stroke ? (JSON.parse(JSON.stringify(shape.stroke)) as unknown) : null,
paint: shape.paint ? (JSON.parse(JSON.stringify(shape.paint)) as unknown) : null,
rings: shape.rings.map((ring) => ({
closed: ring.closed,
points: ring.points.map((point) => ({
@@ -525,6 +526,16 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
*/
shapeRows: () => currentShapeList()?.shownIds ?? null,
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null,
/** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */
paintButton: () => centreOf(vectorArtEditor.paintButton),
paintTarget: () => ({
...centreOf(vectorArtEditor.paintTargetButton),
text: vectorArtEditor.paintTargetButton.text,
target: vectorArtEditor.canvas.paintTarget,
}),
/** A colour stop's marker, or the bar at an offset, in the inspector's fill or outline bar. */
stopAt: (part: 'fill' | 'stroke', index: number) => currentGradientBar(part)?.stopGlobal(index) ?? null,
barAt: (part: 'fill' | 'stroke', offset: number) => currentGradientBar(part)?.barGlobal(offset) ?? null,
},
animation: {
current: () => editor.animationEditor.currentAssetId,
+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 } };
}
+177
View File
@@ -0,0 +1,177 @@
/**
* The colour stops of a gradient as a bar (#186, the user's answer: "als Leiste bearbeiten"). The bar
* shows the gradient over a checkerboard, so a stop's opacity can be seen; a marker under it stands
* for each stop. Pressing a marker picks it and dragging moves it along, pressing the bar anywhere
* else adds a stop there in the colour the gradient already has at that place.
*
* The bar reads the gradient out of the model on every redraw and writes only through its handlers:
* a drag previews into the shape and is committed once on release, the same rule every row follows.
*/
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
import type { VectorGradient } from '../../core/model/Asset';
import { cloneGradient, withStopAdded, withStopChanged } from '../../core/model/vectorPaint';
import { type Size, Theme, Widget } from '../../ui';
import { PaintCache } from '../../viewport/vectorArtPicture';
import type { EditorState } from '../state';
import { drawChecker } from '../texture/checker';
const BAR_HEIGHT = 18;
const MARKER_HEIGHT = 14;
/** Half the width of a marker, and how far from its middle a press still picks it. */
const MARKER_HALF = 6;
const DRAG_THRESHOLD = 2;
export interface GradientBarHandlers {
gradient(): VectorGradient | undefined;
selected(): number;
select(index: number): void;
/** Writes a gradient into the shape while a stop is dragged; no undo step yet. */
preview(gradient: VectorGradient): void;
/** One undo step from `before` to `after`; the model may still read like the preview. */
commit(before: VectorGradient, after: VectorGradient, label: string): void;
}
interface StopDrag {
index: number;
before: VectorGradient;
after: VectorGradient;
startX: number;
moved: boolean;
}
export class GradientBar extends Widget {
private readonly art = new Graphics();
private readonly paints = new PaintCache();
private drag: StopDrag | null = null;
private readonly stop: () => void;
constructor(
state: EditorState,
private readonly handlers: GradientBarHandlers,
) {
super();
this.cursor = Theme.hoverCursor;
this.addChild(this.art);
this.stop = state.on('project', () => this.invalidate());
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onMove(e));
this.on('pointerup', () => this.onRelease());
this.on('pointerupoutside', () => this.onRelease());
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stop();
this.paints.destroy();
super.destroy(options);
}
override measure(): Size {
return { w: this.minWidth, h: BAR_HEIGHT + MARKER_HEIGHT };
}
/** Where a stop's marker sits on screen, and where the bar is – for the check scripts. */
stopGlobal(index: number): { x: number; y: number } | null {
const stop = this.handlers.gradient()?.stops[index];
if (!stop) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + this.xOf(stop.offset), y: origin.y + BAR_HEIGHT + MARKER_HEIGHT / 2 };
}
barGlobal(offset: number): { x: number; y: number } {
const origin = this.getGlobalPosition();
return { x: origin.x + this.xOf(offset), y: origin.y + BAR_HEIGHT / 2 };
}
/** The bar runs between the two outermost marker centres, so a stop at 0 or 1 is fully visible. */
private xOf(offset: number): number {
return MARKER_HALF + offset * this.span();
}
private offsetAt(x: number): number {
return Math.min(Math.max((x - MARKER_HALF) / this.span(), 0), 1);
}
private span(): number {
return Math.max(1, this.w - MARKER_HALF * 2);
}
/** The marker under `x`: the picked one first, so a stop lying on another can still be dragged off. */
private markerAt(gradient: VectorGradient, x: number): number {
const picked = this.handlers.selected();
const near = (i: number) => {
const stop = gradient.stops[i];
return stop !== undefined && Math.abs(this.xOf(stop.offset) - x) <= MARKER_HALF;
};
if (near(picked)) return picked;
for (let i = gradient.stops.length - 1; i >= 0; i--) if (near(i)) return i;
return -1;
}
private onPress(e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const gradient = this.handlers.gradient();
if (!gradient) return;
e.stopPropagation();
const local = this.toLocal(e.global);
const marker = local.y >= BAR_HEIGHT ? this.markerAt(gradient, local.x) : -1;
if (marker >= 0) {
this.handlers.select(marker);
const before = cloneGradient(gradient);
this.drag = { index: marker, before, after: before, startX: local.x, moved: false };
this.invalidate();
return;
}
const after = withStopAdded(gradient, this.offsetAt(local.x));
this.handlers.commit(cloneGradient(gradient), after, 'Add colour stop');
this.handlers.select(after.stops.length - 1);
}
private onMove(e: FederatedPointerEvent): void {
const drag = this.drag;
if (!drag) return;
const x = this.toLocal(e.global).x;
if (!drag.moved && Math.abs(x - drag.startX) < DRAG_THRESHOLD) return;
drag.moved = true;
drag.after = withStopChanged(drag.before, drag.index, { offset: this.offsetAt(x) }) ?? drag.before;
this.handlers.preview(drag.after);
}
private onRelease(): void {
const drag = this.drag;
this.drag = null;
if (drag?.moved) this.handlers.commit(drag.before, drag.after, 'Move colour stop');
}
override redraw(): void {
const g = this.art.clear();
const gradient = this.handlers.gradient();
const x0 = MARKER_HALF;
const x1 = MARKER_HALF + this.span();
drawChecker(g, x0, 0, x1 - x0, BAR_HEIGHT, this.w, this.h);
if (gradient && gradient.stops.length > 0) {
const fill = this.paints.gradient({
type: 'linear',
from: { x: x0, y: 0 },
to: { x: x1, y: 0 },
stops: [...gradient.stops].sort((a, b) => a.offset - b.offset),
});
g.rect(x0, 0, x1 - x0, BAR_HEIGHT).fill({ fill });
}
g.rect(x0, 0, x1 - x0, BAR_HEIGHT).stroke({ width: 1, color: Theme.colors.border });
const picked = this.handlers.selected();
for (const [index, stop] of (gradient?.stops ?? []).entries()) {
const x = this.xOf(stop.offset);
const top = BAR_HEIGHT + 1;
const edge = index === picked ? Theme.colors.accent : Theme.colors.border;
g.poly([x, top, x - 4, top + 4, x + 4, top + 4]).fill(edge);
g.rect(x - MARKER_HALF + 1, top + 4, MARKER_HALF * 2 - 2, MARKER_HEIGHT - 6)
.fill(stop.color)
.stroke({ width: index === picked ? 2 : 1, color: edge });
}
this.paints.sweep();
}
override layout(): void {
this.redraw();
}
}
+11 -3
View File
@@ -17,7 +17,8 @@ import type { VectorArtAsset, VectorShape } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { vectorCanvasSize } from '../../core/model/vectorart';
import { shapeDrawing } from '../../core/model/vectorPath';
import { paintDrawing } from '../../viewport/vectorArtPicture';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
import { type Size, Theme, textStyle, truncate, Widget } from '../../ui';
import type { EditorState } from '../state';
import { shapeDropGap, shapeDropMove, shapeRowOrder } from './shapeRows';
@@ -52,7 +53,9 @@ export class ShapeList extends Widget {
private readonly shown: readonly string[];
private hoveredRow = -1;
private drag: RowDrag | null = null;
private readonly paints = new PaintCache();
private readonly stop: () => void;
private readonly stopPictures: () => void;
/**
* `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with
@@ -75,6 +78,8 @@ export class ShapeList extends Widget {
this.addChild(this.marker);
// A point dragged in the canvas changes no row, only what a row shows.
this.stop = state.on('project', () => this.invalidate());
// A shape filled with a picture (#186) shows its colour until the picture has landed.
this.stopPictures = textureLibrary.onChange(() => this.invalidate());
this.on('pointermove', (e: FederatedPointerEvent) => {
const index = this.rowAt(this.toLocal(e.global).y);
@@ -95,6 +100,8 @@ export class ShapeList extends Widget {
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stop();
this.stopPictures();
this.paints.destroy();
super.destroy(options);
}
@@ -197,6 +204,7 @@ export class ShapeList extends Widget {
row.label.position.set(PAD * 2 + SWATCH, (SHAPE_ROW_HEIGHT - row.label.height) / 2);
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
}
this.paints.sweep();
if (this.drag?.active) {
const y = this.dropGap(this.drag.y) * SHAPE_ROW_HEIGHT;
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
@@ -220,8 +228,8 @@ export class ShapeList extends Widget {
row.art.clear();
row.art.position.set(x, y);
row.art.scale.set(scale);
const drawing = shapeDrawing(shape);
if (drawing) paintDrawing(row.art, drawing);
const drawing = shapeDrawing(shape, this.state.project);
if (drawing) paintDrawing(row.art, drawing, this.paints);
}
override layout(): void {
+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;
}
+47 -3
View File
@@ -1,11 +1,11 @@
import type { Asset, VectorArtAsset } from '../../core/model/Asset';
import { vectorCanvasSize } from '../../core/model/vectorart';
import { Button, Dropdown, Menu } from '../../ui';
import { Button, Dropdown, Menu, type Stack } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import { SnapToggle } from '../graph/SnapToggle';
import { POINT_KINDS, type PointKind } from '../polygonEdit';
import type { EditorState } from '../state';
import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
import { type PaintTarget, VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
/** Room kept for the "256 × 256 px · 3 shapes" readout. */
const INFO_WIDTH = 190;
@@ -23,6 +23,18 @@ function pointLabel(kind: PointKind | null): string {
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
}
/**
* The Paint tool's menu takes the point type's place in the header (#186) – a point is not picked
* with the Paint tool, and the header has no room for both next to the size readout.
*/
const PAINT_TARGET_WIDTH = POINT_WIDTH;
const PAINT_TARGET_LABELS: Record<PaintTarget, string> = { fill: 'Fill', stroke: 'Outline' };
function paintTargetLabel(target: PaintTarget): string {
return `Handles: ${PAINT_TARGET_LABELS[target]}`;
}
/**
* The Vector art tab (#129, stage 3): the canvas shows the drawing and edits it, the header picks
* the tool. Fill, outline and the canvas size are rows of the inspector, as with every other asset –
@@ -32,6 +44,8 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
/** Public for the check scripts: the two tools, the magnet and the picked point's type. */
readonly selectButton: Button;
readonly penButton: Button;
readonly paintButton: Button;
readonly paintTargetButton = new Dropdown(paintTargetLabel('fill'), { width: PAINT_TARGET_WIDTH });
readonly snap = new SnapToggle(() => this.canvas);
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
@@ -39,10 +53,19 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
super(state, { infoWidth: INFO_WIDTH });
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') });
this.paintButton = new Button('Paint', { toggle: true, onClick: () => this.setTool('paint') });
this.pointButton.onClick = () => this.openPointMenu();
this.paintTargetButton.onClick = () => this.openPaintTargetMenu();
const canvas = new VectorArtCanvas(state);
canvas.onPointPicked = () => this.syncPoint();
this.mount(canvas, [this.selectButton, this.penButton, this.pointButton, ...this.snap.widgets]);
this.mount(canvas, [
this.selectButton,
this.penButton,
this.paintButton,
this.pointButton,
this.paintTargetButton,
...this.snap.widgets,
]);
this.setTool('select');
}
@@ -56,6 +79,21 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
new Menu(items).openBelow(this.pointButton);
}
/** Whose handles the paint tool shows (#186): the fill's or the outline's. */
private openPaintTargetMenu(): void {
const items = (['fill', 'stroke'] as const).map((target) => ({
label: PAINT_TARGET_LABELS[target],
checked: () => this.canvas.paintTarget === target,
onSelect: () => this.setPaintTarget(target),
}));
new Menu(items).openBelow(this.paintTargetButton);
}
setPaintTarget(target: PaintTarget): void {
this.canvas.setPaintTarget(target);
this.paintTargetButton.setText(paintTargetLabel(target));
}
private syncPoint(): void {
const kind = this.canvas.pickedKind;
this.pointButton.setText(pointLabel(kind));
@@ -66,6 +104,10 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
this.canvas.setTool(tool);
this.selectButton.setToggled(tool === 'select');
this.penButton.setToggled(tool === 'pen');
this.paintButton.setToggled(tool === 'paint');
this.pointButton.visible = tool !== 'paint';
this.paintTargetButton.visible = tool === 'paint';
(this.paintTargetButton.parent as Stack | null)?.relayout();
}
/** Where a canvas pixel of the shown drawing sits on screen – for the check scripts. */
@@ -97,6 +139,8 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
this.fitButton.setEnabled(asset !== null);
this.selectButton.setEnabled(asset !== null);
this.penButton.setEnabled(asset !== null);
this.paintButton.setEnabled(asset !== null);
this.paintTargetButton.setEnabled(asset !== null);
this.snap.setEnabled(asset !== null);
this.syncPoint();
}