diff --git a/README.md b/README.md index 6c4cd7fe..c1cafa1e 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1222 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1232 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index cb3409da..83b4d880 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -9,10 +9,11 @@ * `model/vectorart.ts`), so a command that edits a shape cannot forget to say that it did. */ import { newId } from '../ids'; -import type { VectorRing, VectorShape, VectorStroke } from '../model/Asset'; +import type { VectorPaint, VectorRing, VectorShape, VectorStroke } from '../model/Asset'; import type { Project } from '../model/Project'; import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart'; import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean'; +import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint'; import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { nextNumberedName } from './naming'; @@ -200,20 +201,29 @@ export class MoveVectorShapeCommand implements Command { } } +/** Where the fill and the outline of a shape are painted from – what moves when the shape is moved. */ +export interface ShapePaints { + paint?: VectorPaint; + strokePaint?: VectorStroke['paint']; +} + /** * Writes the outlines of one shape – a point dragged, a point inserted on an edge, a handle pulled, * a ring closed. The rings are copied in and out, so the editor may hand over the list it was - * dragging on without the undo step pointing at live data. + * dragging on without the undo step pointing at live data. A whole shape dragged hands over its + * paints too (#186), so a gradient travels with the shape instead of staying where it was. */ export class SetVectorShapeRingsCommand implements Command { readonly label: string; private previous: VectorRing[] = []; + private previousPaints: ShapePaints = {}; constructor( private readonly assetId: string, private readonly shapeId: string, private readonly rings: readonly VectorRing[], label = 'Edit shape', + private readonly paints: ShapePaints | null = null, ) { this.label = label; } @@ -229,17 +239,42 @@ export class SetVectorShapeRingsCommand implements Command { const shape = requireVectorShape(project, this.assetId, this.shapeId); this.previous = shape.rings; shape.rings = cloneRings(this.rings); + if (this.paints) { + this.previousPaints = paintsOf(shape); + applyPaints(shape, this.paints); + } } undo(project: Project): void { - requireVectorShape(project, this.assetId, this.shapeId).rings = this.previous; + const shape = requireVectorShape(project, this.assetId, this.shapeId); + shape.rings = this.previous; + if (this.paints) applyPaints(shape, this.previousPaints); } } -/** What `SetVectorShapeStyleCommand` writes; a field left out stays as it is, `stroke: null` removes it. */ +export function paintsOf(shape: VectorShape): ShapePaints { + return { + ...(shape.paint ? { paint: clonePaint(shape.paint) } : {}), + ...(shape.stroke?.paint ? { strokePaint: cloneGradient(shape.stroke.paint) } : {}), + }; +} + +function applyPaints(shape: VectorShape, paints: ShapePaints): void { + if (paints.paint) shape.paint = clonePaint(paints.paint); + else delete shape.paint; + if (!shape.stroke) return; + if (paints.strokePaint) shape.stroke.paint = cloneGradient(paints.strokePaint); + else delete shape.stroke.paint; +} + +/** + * What `SetVectorShapeStyleCommand` writes; a field left out stays as it is, `null` removes it. A + * stroke brings its own paint along, as a whole. + */ export interface ShapeStyle { fill?: number; opacity?: number; + paint?: VectorPaint | null; stroke?: VectorStroke | null; } @@ -263,7 +298,15 @@ export class SetVectorShapeStyleCommand implements Command { throw new CommandError('An opacity is a number'); if (this.style.stroke && !(this.style.stroke.width > 0)) throw new CommandError('An outline is wider than nothing'); - this.previous = { fill: shape.fill, opacity: shape.opacity, stroke: shape.stroke ?? null }; + for (const paint of [this.style.paint, this.style.stroke?.paint]) + if (paint && paint.type !== 'texture' && paint.stops.length < MIN_STOPS) + throw new CommandError('A gradient needs two colours'); + this.previous = { + fill: shape.fill, + opacity: shape.opacity, + paint: shape.paint ? clonePaint(shape.paint) : null, + stroke: shape.stroke ? cloneStroke(shape.stroke) : null, + }; applyStyle(shape, this.style); } @@ -275,12 +318,24 @@ export class SetVectorShapeStyleCommand implements Command { function applyStyle(shape: VectorShape, style: ShapeStyle): 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) { + if (style.paint) shape.paint = clonePaint(style.paint); + else delete shape.paint; + } if (style.stroke !== undefined) { - if (style.stroke) shape.stroke = { ...style.stroke }; + if (style.stroke) shape.stroke = cloneStroke(style.stroke); else delete shape.stroke; } } +function cloneStroke(stroke: VectorStroke): VectorStroke { + return { + color: stroke.color, + width: stroke.width, + ...(stroke.paint ? { paint: cloneGradient(stroke.paint) } : {}), + }; +} + function cloneRings(rings: readonly VectorRing[]): VectorRing[] { return rings.map(cloneVectorRing); } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index d6261520..706931b7 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -269,12 +269,45 @@ export interface VectorRing { export interface VectorStroke { color: number; width: number; + /** A gradient along the outline (#186); absent is the solid `color`. */ + paint?: VectorGradient; +} + +/** One colour of a gradient; `offset` runs from 0 at `from` to 1 at `to`. */ +export interface GradientStop { + offset: number; + color: number; + alpha: number; } /** - * One filled figure. `fill` is a solid colour and `opacity` scales it – gradients and a texture - * fill are #186, boolean operations between shapes are #187. The list order is the drawing order: - * the last shape of the asset lies on top, as the last layer of a tilemap does. + * A gradient in canvas pixels (#186). Linear runs from `from` to `to`; radial has its centre on + * `from` and reaches its last colour at the distance of `to`. + */ +export interface VectorGradient { + type: 'linear' | 'radial'; + from: Vec2; + to: Vec2; + stops: GradientStop[]; +} + +/** + * A picture as fill, repeated across the shape (#186). `texture` names a texture or one of its + * frames, as a sprite's Texture row does; one tile spans the rectangle from `from` to `to`. + */ +export interface VectorTextureFill { + type: 'texture'; + from: Vec2; + to: Vec2; + texture: string; +} + +export type VectorPaint = VectorGradient | VectorTextureFill; + +/** + * One filled figure. `fill` is a solid colour and `opacity` scales it; a `paint` (#186) fills with a + * gradient or a picture instead, and `fill` stays what the shape goes back to. The list order is the + * drawing order: the last shape of the asset lies on top, as the last layer of a tilemap does. */ export interface VectorShape { id: string; @@ -282,6 +315,7 @@ export interface VectorShape { rings: VectorRing[]; fill: number; opacity: number; + paint?: VectorPaint; stroke?: VectorStroke; } diff --git a/src/core/model/generated.ts b/src/core/model/generated.ts index ac30c196..2dfe1dc9 100644 --- a/src/core/model/generated.ts +++ b/src/core/model/generated.ts @@ -12,6 +12,7 @@ * work; a fingerprint cannot be forgotten in a command, which is why the token is a string. */ import type { Asset } from './Asset'; +import type { Project } from './Project'; const PREFIX = 'gen:'; @@ -27,8 +28,11 @@ export interface GeneratedPicture { revision: string | number; } -/** Reads the canvas of one asset type; null when this asset draws nothing (yet). */ -export type PictureSource = (asset: Asset) => GeneratedPicture | null; +/** + * Reads the canvas of one asset type; null when this asset draws nothing (yet). The project is there + * for a drawing that shows another asset – a vector shape filled with a texture (#186). + */ +export type PictureSource = (asset: Asset, project?: Project) => GeneratedPicture | null; /** * Keyed by asset type as a plain string, not by `AssetType`: a plugin may bring an asset type of @@ -49,8 +53,8 @@ export function generatesPictures(type: string): boolean { } /** The canvas this asset draws, or null when its type draws none. */ -export function generatedPictureOf(asset: Asset): GeneratedPicture | null { - const picture = sources.get(asset.type)?.(asset) ?? null; +export function generatedPictureOf(asset: Asset, project?: Project): GeneratedPicture | null { + const picture = sources.get(asset.type)?.(asset, project) ?? null; if (!picture) return null; return picture.width > 0 && picture.height > 0 ? picture : null; } diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 26efc0e6..cae159d6 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -90,7 +90,7 @@ export function resolveTexture(project: Project, props: PropertyBag): TextureRef export function resolvePicture(project: Project, id: string): TextureRef | null { if (!id) return null; const asset = findAsset(project, id); - if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset); + if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset, project); const found = findFrame(project, id); return found ? refFor(found.asset, found.frame) : null; } @@ -100,8 +100,8 @@ export function resolvePicture(project: Project, id: string): TextureRef | null * stands where the file name stands, and the size is what a new sprite is measured by. No frames * and no slicing – those are cut out of an uploaded sheet. */ -function generatedRef(asset: Asset): TextureRef | null { - const picture = generatedPictureOf(asset); +function generatedRef(asset: Asset, project: Project): TextureRef | null { + const picture = generatedPictureOf(asset, project); if (!picture) return null; return { file: generatedKey(asset.id, picture.revision), diff --git a/src/core/model/vectorPaint.ts b/src/core/model/vectorPaint.ts new file mode 100644 index 00000000..14ae07a0 --- /dev/null +++ b/src/core/model/vectorPaint.ts @@ -0,0 +1,231 @@ +/** + * Gradients and picture fills of vector art shapes (#186). Everything a paint needs to be edited – + * where it starts when switched on, how a stop is added between two others, what moves along with + * a shape – is decided here, in canvas pixels, so it can be measured without a GPU. + * + * The stops keep the order they were added in, not the order of their offsets: a stop dragged past + * its neighbour stays the stop that is being dragged. Whoever draws sorts them (`sortedStops`). + */ +import type { GradientStop, VectorGradient, VectorPaint, VectorShape, VectorTextureFill } from './Asset'; +import { isGeneratedFile } from './generated'; +import type { Project } from './Project'; +import type { Vec2 } from './Properties'; +import { resolvePicture, type TextureRef } from './texture'; + +/** What the fill of a shape is made of; `solid` is the shape's own `fill` colour. */ +export type FillKind = 'solid' | 'linear' | 'radial' | 'texture'; +/** An outline has no picture fill. */ +export type StrokeKind = 'solid' | 'linear' | 'radial'; + +export const FILL_KINDS: readonly FillKind[] = ['solid', 'linear', 'radial', 'texture']; +export const STROKE_KINDS: readonly StrokeKind[] = ['solid', 'linear', 'radial']; + +/** A gradient needs two colours to go from one to the other. */ +export const MIN_STOPS = 2; + +/** An area in canvas pixels: the shape's reach, or the canvas when the shape has no point yet. */ +export interface Extent { + x0: number; + y0: number; + x1: number; + y1: number; +} + +export function fillKind(shape: VectorShape): FillKind { + return shape.paint?.type ?? 'solid'; +} + +export function strokeKind(shape: VectorShape): StrokeKind { + return shape.stroke?.paint?.type ?? 'solid'; +} + +/** + * The paint a fill gets when its kind is switched, or null for solid. A gradient switched to the + * other kind of gradient keeps its colours; where it lies is laid out anew, because the two ends of + * a linear axis make no sense as the centre and edge of a circle. A new gradient runs from the + * colour the shape had to white. + */ +export function fillPaintFor(shape: VectorShape, kind: FillKind, extent: Extent): VectorPaint | null { + if (kind === 'solid') return null; + if (kind === 'texture') { + const texture = shape.paint?.type === 'texture' ? shape.paint.texture : ''; + return { type: 'texture', ...tileOf(extent), texture }; + } + const stops = shape.paint && shape.paint.type !== 'texture' ? shape.paint.stops : null; + return gradientFor(kind, extent, stops ?? defaultStops(shape.fill)); +} + +/** The same for the outline: its gradient starts from the line's own colour. */ +export function strokePaintFor(shape: VectorShape, kind: StrokeKind, extent: Extent): VectorGradient | null { + if (kind === 'solid' || !shape.stroke) return null; + return gradientFor(kind, extent, shape.stroke.paint?.stops ?? defaultStops(shape.stroke.color)); +} + +function gradientFor( + type: 'linear' | 'radial', + extent: Extent, + stops: readonly GradientStop[], +): VectorGradient { + const cy = (extent.y0 + extent.y1) / 2; + if (type === 'linear') + return { type, from: { x: extent.x0, y: cy }, to: { x: extent.x1, y: cy }, stops: cloneStops(stops) }; + const cx = (extent.x0 + extent.x1) / 2; + const radius = Math.max(extent.x1 - extent.x0, extent.y1 - extent.y0) / 2; + return { type, from: { x: cx, y: cy }, to: { x: cx + radius, y: cy }, stops: cloneStops(stops) }; +} + +/** One tile over the whole shape, so a picture fills it exactly once until the handles move. */ +function tileOf(extent: Extent): { from: Vec2; to: Vec2 } { + return { from: { x: extent.x0, y: extent.y0 }, to: { x: extent.x1, y: extent.y1 } }; +} + +function defaultStops(color: number): GradientStop[] { + return [ + { offset: 0, color, alpha: 1 }, + { offset: 1, color: 0xffffff, alpha: 1 }, + ]; +} + +/** An area never thinner than a pixel, so a gradient laid over a line still has a direction. */ +export function paintExtent(reach: Extent | null, canvas: { width: number; height: number }): Extent { + const e = reach ?? { x0: 0, y0: 0, x1: canvas.width, y1: canvas.height }; + return { x0: e.x0, y0: e.y0, x1: Math.max(e.x1, e.x0 + 1), y1: Math.max(e.y1, e.y0 + 1) }; +} + +/** The stops as they are drawn: offsets within 0..1, in order along the gradient. */ +export function sortedStops(stops: readonly GradientStop[]): GradientStop[] { + return stops + .map((stop) => ({ offset: clamp01(stop.offset), color: stop.color, alpha: clamp01(stop.alpha) })) + .sort((a, b) => a.offset - b.offset); +} + +/** + * The colour a gradient has at `offset`, blended channel by channel between the stops around it – + * what a stop added there gets, so adding one changes no pixel until it is edited. + */ +export function colorAt(stops: readonly GradientStop[], offset: number): { color: number; alpha: number } { + const sorted = sortedStops(stops); + const first = sorted[0]; + if (!first) return { color: 0xffffff, alpha: 1 }; + const at = clamp01(offset); + if (at <= first.offset) return { color: first.color, alpha: first.alpha }; + for (let i = 1; i < sorted.length; i++) { + const b = sorted[i]!; + if (at > b.offset) continue; + const a = sorted[i - 1]!; + const t = b.offset > a.offset ? (at - a.offset) / (b.offset - a.offset) : 1; + return { color: mixColor(a.color, b.color, t), alpha: a.alpha + (b.alpha - a.alpha) * t }; + } + const last = sorted[sorted.length - 1]!; + return { color: last.color, alpha: last.alpha }; +} + +function mixColor(a: number, b: number, t: number): number { + const channel = (shift: number): number => { + const from = (a >> shift) & 0xff; + const to = (b >> shift) & 0xff; + return Math.round(from + (to - from) * t) << shift; + }; + return channel(16) | channel(8) | channel(0); +} + +/** A stop added at `offset`, in the colour the gradient already has there; it goes last in the list. */ +export function withStopAdded(gradient: VectorGradient, offset: number): VectorGradient { + const at = clamp01(offset); + return { + ...cloneGradient(gradient), + stops: [...cloneStops(gradient.stops), { offset: at, ...colorAt(gradient.stops, at) }], + }; +} + +/** A stop changed in place; null when there is no such stop. */ +export function withStopChanged( + gradient: VectorGradient, + index: number, + change: Partial, +): VectorGradient | null { + const stop = gradient.stops[index]; + if (!stop) return null; + const next = cloneGradient(gradient); + next.stops[index] = { + offset: change.offset !== undefined ? clamp01(change.offset) : stop.offset, + color: change.color ?? stop.color, + alpha: change.alpha !== undefined ? clamp01(change.alpha) : stop.alpha, + }; + return next; +} + +/** A stop taken out; null when it is not there or the gradient would be left with one colour. */ +export function withoutStop(gradient: VectorGradient, index: number): VectorGradient | null { + if (!gradient.stops[index] || gradient.stops.length <= MIN_STOPS) return null; + return { ...cloneGradient(gradient), stops: cloneStops(gradient.stops.filter((_, i) => i !== index)) }; +} + +/** A paint moved with its shape, so dragging the shape does not slide it under its own gradient. */ +export function paintMoved

(paint: P, dx: number, dy: number): P { + return { + ...clonePaint(paint), + from: { x: paint.from.x + dx, y: paint.from.y + dy }, + to: { x: paint.to.x + dx, y: paint.to.y + dy }, + }; +} + +/** A paint with one of its two handles in a new place. */ +export function paintWithHandle

(paint: P, handle: 'from' | 'to', at: Vec2): P { + return { ...clonePaint(paint), [handle]: { x: at.x, y: at.y } }; +} + +export function clonePaint

(paint: P): P { + return paint.type === 'texture' ? ({ ...cloneTextureFill(paint) } as P) : (cloneGradient(paint) as P); +} + +export function cloneGradient(gradient: VectorGradient): VectorGradient { + return { + type: gradient.type, + from: { ...gradient.from }, + to: { ...gradient.to }, + stops: cloneStops(gradient.stops), + }; +} + +function cloneTextureFill(fill: VectorTextureFill): VectorTextureFill { + return { type: 'texture', from: { ...fill.from }, to: { ...fill.to }, texture: fill.texture }; +} + +function cloneStops(stops: readonly GradientStop[]): GradientStop[] { + return stops.map((stop) => ({ offset: stop.offset, color: stop.color, alpha: stop.alpha })); +} + +/** + * The picture a texture fill shows: an uploaded texture or one of its frames. A drawn picture is + * refused – a drawing filled with another drawing, or with itself, would be rasterised inside its + * own rasterising. + */ +export function textureFillRef(project: Project | undefined, id: string): TextureRef | null { + const ref = project ? resolvePicture(project, id) : null; + return ref && !isGeneratedFile(ref.file) ? ref : null; +} + +/** What a texture fill resolves to, as text – the part of the fingerprint another asset decides. */ +export function textureFillText(project: Project | undefined, id: string): string { + const ref = textureFillRef(project, id); + if (!ref) return ''; + const rect = ref.rect ? `${ref.rect.x},${ref.rect.y},${ref.rect.w},${ref.rect.h}` : ''; + return `${ref.file}:${rect}:${ref.smooth ? 's' : 'n'}`; +} + +/** + * A paint written out for the drawing's fingerprint, in a fixed order. `picture` is what the texture + * resolves to right now: a picture uploaded anew under the same asset is a different drawing. + */ +export function paintText(paint: VectorPaint | undefined, picture = ''): string { + if (!paint) return '-'; + const ends = `${paint.from.x},${paint.from.y},${paint.to.x},${paint.to.y}`; + if (paint.type === 'texture') return `t${ends},${paint.texture},${picture}`; + const stops = paint.stops.map((s) => `${s.offset}:${s.color}:${s.alpha}`).join('/'); + return `${paint.type === 'linear' ? 'l' : 'r'}${ends},${stops}`; +} + +function clamp01(value: number): number { + return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0; +} diff --git a/src/core/model/vectorPath.ts b/src/core/model/vectorPath.ts index e51840e7..bd117e65 100644 --- a/src/core/model/vectorPath.ts +++ b/src/core/model/vectorPath.ts @@ -7,9 +7,21 @@ * Nothing is clipped here. The picture is rendered into a texture the size of the canvas, and what * lies outside it is simply not in that texture (answer 3). */ -import type { Asset, VectorArtAsset, VectorPoint, VectorRing, VectorShape } from './Asset'; -import type { PropertyBag } from './Properties'; +import type { + Asset, + GradientStop, + VectorArtAsset, + VectorGradient, + VectorPaint, + VectorPoint, + VectorRing, + VectorShape, +} from './Asset'; +import type { Project } from './Project'; +import type { PropertyBag, Vec2 } from './Properties'; +import type { TextureRef } from './texture'; import { isDrawableRing, vectorCanvasSize } from './vectorart'; +import { sortedStops, textureFillRef } from './vectorPaint'; /** One step along a path. `close` goes back to where the subpath began, in a straight line. */ export type PathOp = @@ -18,6 +30,14 @@ export type PathOp = | { op: 'curve'; c1x: number; c1y: number; c2x: number; c2y: number; x: number; y: number } | { op: 'close' }; +/** + * A fill that is not one colour (#186), with everything resolved that the model only names: the + * stops in order, the texture as the picture it stands for. `from` and `to` are in canvas pixels. + */ +export type DrawPaint = + | { type: 'linear' | 'radial'; from: Vec2; to: Vec2; stops: GradientStop[] } + | { type: 'texture'; from: Vec2; to: Vec2; ref: TextureRef }; + /** * One shape ready to be drawn. The two paths are apart because they are not the same set of rings: * an open ring is a line and has nothing to enclose, so it is stroked and never filled, while a @@ -31,23 +51,26 @@ export interface ShapeDrawing { stroke: PathOp[]; color: number; alpha: number; + /** Drawn instead of `color`; null is the solid colour. */ + paint: DrawPaint | null; strokeColor: number; /** In canvas pixels; 0 means no outline. */ strokeWidth: number; + strokePaint: DrawPaint | null; } /** What the asset draws, back to front – the last shape of the list lies on top. */ -export function vectorArtDrawings(asset: VectorArtAsset): ShapeDrawing[] { +export function vectorArtDrawings(asset: VectorArtAsset, project?: Project): ShapeDrawing[] { const drawings: ShapeDrawing[] = []; for (const shape of asset.shapes) { - const drawing = shapeDrawing(shape); + const drawing = shapeDrawing(shape, project); if (drawing) drawings.push(drawing); } return drawings; } /** Null when the shape draws nothing: no drawable ring, or fully transparent and without outline. */ -export function shapeDrawing(shape: VectorShape): ShapeDrawing | null { +export function shapeDrawing(shape: VectorShape, project?: Project): ShapeDrawing | null { const rings = shape.rings.filter(isDrawableRing); if (rings.length === 0) return null; const alpha = clamp01(shape.opacity); @@ -62,11 +85,33 @@ export function shapeDrawing(shape: VectorShape): ShapeDrawing | null { stroke, color: shape.fill, alpha, + paint: shape.paint ? drawPaint(shape.paint, project) : null, strokeColor: shape.stroke?.color ?? shape.fill, strokeWidth, + strokePaint: shape.stroke?.paint ? drawPaint(shape.stroke.paint, project) : null, }; } +/** + * What a paint draws, or null when it has to fall back to the solid colour: a gradient with less + * than two stops or no length, a picture that is not there or a tile of no size. A shape that went + * blank because its texture was deleted would be lost on the canvas; its colour keeps it findable. + */ +export function drawPaint(paint: VectorPaint, project?: Project): DrawPaint | null { + const from = { x: paint.from.x, y: paint.from.y }; + const to = { x: paint.to.x, y: paint.to.y }; + if (paint.type === 'texture') { + const ref = textureFillRef(project, paint.texture); + return ref && to.x !== from.x && to.y !== from.y ? { type: 'texture', from, to, ref } : null; + } + return isDrawableGradient(paint) ? { type: paint.type, from, to, stops: sortedStops(paint.stops) } : null; +} + +function isDrawableGradient(gradient: VectorGradient): boolean { + const length = Math.hypot(gradient.to.x - gradient.from.x, gradient.to.y - gradient.from.y); + return gradient.stops.length >= 2 && length > 0 && Number.isFinite(length); +} + /** * One ring as its own subpath. A segment is a straight line while neither of its two ends carries a * handle; one handle alone is enough for a curve, the missing control point then sitting on its own @@ -102,8 +147,9 @@ function clamp01(value: number): number { export function vectorArtCanvas( asset: Asset, props: PropertyBag = asset.props, + project?: Project, ): { width: number; height: number; drawings: ShapeDrawing[] } | null { if (asset.type !== 'vectorart') return null; const { width, height } = vectorCanvasSize(props); - return { width, height, drawings: vectorArtDrawings(asset) }; + return { width, height, drawings: vectorArtDrawings(asset, project) }; } diff --git a/src/core/model/vectorart.ts b/src/core/model/vectorart.ts index b09df93d..3843895e 100644 --- a/src/core/model/vectorart.ts +++ b/src/core/model/vectorart.ts @@ -10,7 +10,9 @@ */ import type { Asset, VectorArtAsset, VectorPoint, VectorRing, VectorShape } from './Asset'; import type { GeneratedPicture } from './generated'; +import type { Project } from './Project'; import type { PropertyBag, Vec2 } from './Properties'; +import { paintText, textureFillText } from './vectorPaint'; export const VECTOR_WIDTH_KEY = 'canvasWidth'; export const VECTOR_HEIGHT_KEY = 'canvasHeight'; @@ -77,9 +79,14 @@ export function handleAt(point: VectorPoint, side: 'in' | 'out'): { x: number; y * touches a shape, and the one command that forgets it leaves a stale picture on screen. The name of * a shape is deliberately not in it – renaming changes no pixel. */ -export function vectorArtRevision(asset: VectorArtAsset, props: PropertyBag = asset.props): string { +export function vectorArtRevision( + asset: VectorArtAsset, + props: PropertyBag = asset.props, + project?: Project, +): string { const { width, height } = vectorCanvasSize(props); - return fingerprint(`${width}x${height}|${asset.shapes.map(drawingText).join('|')}`); + const shapes = asset.shapes.map((shape) => drawingText(shape, project)); + return fingerprint(`${width}x${height}|${shapes.join('|')}`); } /** @@ -89,15 +96,18 @@ export function vectorArtRevision(asset: VectorArtAsset, props: PropertyBag = as * `{closed, points}` – a different text, a different fingerprint, and a reload would have * rasterised the same picture again under a new key. */ -function drawingText(shape: VectorShape): string { - const stroke = shape.stroke ? `${shape.stroke.color},${shape.stroke.width}` : '-'; +function drawingText(shape: VectorShape, project: Project | undefined): string { + const stroke = shape.stroke + ? `${shape.stroke.color},${shape.stroke.width},${paintText(shape.stroke.paint)}` + : '-'; + const texture = shape.paint?.type === 'texture' ? textureFillText(project, shape.paint.texture) : ''; const rings = shape.rings.map( (ring) => `${ring.closed ? 'c' : 'o'}${ring.points .map((p) => `${p.x},${p.y},${handleText(p.in)},${handleText(p.out)}`) .join(' ')}`, ); - return `${shape.fill},${shape.opacity},${stroke}[${rings.join(';')}]`; + return `${shape.fill},${shape.opacity},${paintText(shape.paint, texture)},${stroke}[${rings.join(';')}]`; } function handleText(handle: Vec2 | undefined): string { @@ -119,10 +129,10 @@ function fingerprint(text: string): string { * still has a canvas: an empty picture is what an empty drawing looks like, and a sprite wearing it * keeps its size while the first shape is being drawn. */ -export function vectorArtPicture(asset: Asset): GeneratedPicture | null { +export function vectorArtPicture(asset: Asset, project?: Project): GeneratedPicture | null { if (asset.type !== 'vectorart') return null; const { width, height } = vectorCanvasSize(asset.props); - return { width, height, revision: vectorArtRevision(asset) }; + return { width, height, revision: vectorArtRevision(asset, asset.props, project) }; } /** A deep copy of a ring: its points and the handles on them are all plain data. */ diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index dd21a911..5ebc83c2 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -68,7 +68,8 @@ import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './so * What a kind's definition carries beyond key, label and default. `decimals` belongs only on * properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging * produces fractions, and the field rounds the value away rather than hiding it. Without - * `assetTypes` an asset picker offers every placeable asset. + * `assetTypes` an asset picker offers every placeable asset; `uploadedOnly` keeps drawn pictures out + * of a texture picker (#186). */ type Extras = K extends 'number' ? { min?: number; max?: number; step?: number; decimals?: number } @@ -76,7 +77,9 @@ type Extras = K extends 'number' ? { options: { value: string; label: string }[] } : K extends 'asset' ? { assetTypes?: readonly AssetType[] } - : unknown; + : K extends 'texture' + ? { uploadedOnly?: boolean } + : unknown; /** * The definition of a flat kind: its default is a value of that kind, as the table says. diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index bdb7dfb5..75340991 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -25,7 +25,10 @@ import type { Asset, StateMachineAsset, TextureFrame, + GradientStop, TilemapLayer, + VectorGradient, + VectorPaint, VectorPoint, VectorRing, VectorShape, @@ -603,9 +606,15 @@ function normalizeShapes(raw: unknown): VectorShape[] { fill: normalizeColor(s.fill, SHAPE_FILL_DEFAULT), opacity: normalizeOpacity(s.opacity), }; + const paint = normalizePaint(s.paint); + if (paint) shape.paint = paint; const stroke = asRecord(s.stroke); const width = asFinite(stroke.width); - if (width !== null && width > 0) shape.stroke = { color: normalizeColor(stroke.color, 0), width }; + if (width !== null && width > 0) { + shape.stroke = { color: normalizeColor(stroke.color, 0), width }; + const line = normalizePaint(stroke.paint); + if (line && line.type !== 'texture') shape.stroke.paint = line; + } shapes.push(shape); } return shapes; @@ -619,6 +628,35 @@ function asFinite(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } +/** + * A gradient or picture fill (#186). One that cannot be drawn as written – no ends, an unknown type, + * fewer than two stops – is dropped and the shape reads as the solid colour it kept all along. A + * texture id is kept even when nothing answers to it: the drawing falls back to its colour and + * shows it, as a sprite shows a texture that has gone. + */ +function normalizePaint(raw: unknown): VectorPaint | null { + const p = asRecord(raw); + const from = normalizeHandle(p.from); + const to = normalizeHandle(p.to); + if (!from || !to) return null; + if (p.type === 'texture') + return typeof p.texture === 'string' ? { type: 'texture', from, to, texture: p.texture } : null; + if (p.type !== 'linear' && p.type !== 'radial') return null; + const stops: GradientStop[] = []; + for (const item of Array.isArray(p.stops) ? p.stops : []) { + const stop = asRecord(item); + const offset = asFinite(stop.offset); + if (offset === null) continue; + stops.push({ + offset: Math.max(0, Math.min(1, offset)), + color: normalizeColor(stop.color, 0), + alpha: normalizeOpacity(stop.alpha), + }); + } + const gradient: VectorGradient = { type: p.type, from, to, stops }; + return stops.length >= 2 ? gradient : null; +} + function normalizeHandle(raw: unknown): Vec2 | undefined { const handle = asRecord(raw); const x = asFinite(handle.x); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index dd2c5c97..a4b76190 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 5a38ada4..cb9201d2 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -238,7 +238,14 @@ const REFERENCES: Record = { 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, diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index 746020bb..ad298d57 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -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 = { + 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(); + +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(); + +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(); + +/** 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 } }; +} diff --git a/src/editor/vectorart/GradientBar.ts b/src/editor/vectorart/GradientBar.ts new file mode 100644 index 00000000..525cea97 --- /dev/null +++ b/src/editor/vectorart/GradientBar.ts @@ -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[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(); + } +} diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index 54a92cc3..446f5c99 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -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[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 { diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 38a2cecb..a2465469 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -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; +} + +/** 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[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 | 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; +} diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts index 1a4e577f..11a4e9d3 100644 --- a/src/editor/vectorart/VectorArtEditor.ts +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -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 = { 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 this.canvas); readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH }); @@ -39,10 +53,19 @@ export class VectorArtEditor extends AssetTabEditor 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 ({ + 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 { * Who draws an asset type, keyed by type as a plain string for the same reason the readers in * `core/model/generated.ts` are: a plugin may bring a type of its own. */ -export type PictureGenerator = (asset: Asset) => Texture | null; +export type PictureGenerator = (asset: Asset, project: Project) => Texture | null; const generators = new Map(); @@ -99,6 +99,6 @@ export function projectPictureFactory(projectOf: () => Project | null): PictureF return (assetId) => { const project = projectOf(); const asset = project ? findAsset(project, assetId) : null; - return asset ? (pictureGeneratorFor(asset.type)?.(asset) ?? null) : null; + return project && asset ? (pictureGeneratorFor(asset.type)?.(asset, project) ?? null) : null; }; } diff --git a/src/viewport/vectorArtPicture.ts b/src/viewport/vectorArtPicture.ts index 76971394..bc6a9f52 100644 --- a/src/viewport/vectorArtPicture.ts +++ b/src/viewport/vectorArtPicture.ts @@ -13,12 +13,23 @@ * The render runs inside the ask for the picture, which is safe because every caller asks while it * redraws its own widgets and the frame itself is rendered afterwards, on the ticker (`ui/render`). */ -import { Graphics, GraphicsPath, RenderTexture, type Renderer, type Texture } from 'pixi.js'; +import { + FillGradient, + type FillStyle, + Graphics, + GraphicsPath, + Matrix, + RenderTexture, + type Renderer, + type Texture, +} from 'pixi.js'; import type { Asset } from '../core/model/Asset'; import { registerPictureSource } from '../core/model/generated'; +import type { Project } from '../core/model/Project'; import { vectorArtPicture } from '../core/model/vectorart'; -import { type PathOp, type ShapeDrawing, vectorArtCanvas } from '../core/model/vectorPath'; +import { type DrawPaint, type PathOp, type ShapeDrawing, vectorArtCanvas } from '../core/model/vectorPath'; import { registerPictureGenerator } from './generatedPictures'; +import { textureLibrary } from './TextureLibrary'; /** * How much finer than the canvas the picture is rasterised (the user's answer: 2x). A drawing on a @@ -30,8 +41,8 @@ export const SUPERSAMPLE = 2; /** Registers vector art in both halves of the picture path; the returned function takes it out. */ export function installVectorArtPictures(renderer: Renderer): () => void { const removeSource = registerPictureSource('vectorart', vectorArtPicture); - const removeGenerator = registerPictureGenerator('vectorart', (asset) => - rasteriseVectorArt(asset, renderer), + const removeGenerator = registerPictureGenerator('vectorart', (asset, project) => + rasteriseVectorArt(asset, renderer, project), ); return () => { removeSource(); @@ -43,40 +54,151 @@ export function installVectorArtPictures(renderer: Renderer): () => void { * The asset as a texture, or null when it is not vector art at all. An asset without a single * drawable shape still gets its canvas: an empty picture is what an empty drawing looks like, and a * sprite wearing it keeps its size while the first shape is being drawn. + * + * Null too while a picture it is filled with is still loading (#186). Nothing is remembered for a + * null, so the next ask – the redraw that follows the picture landing – rasterises it complete, + * instead of a drawing with a hole in it staying on screen under a key that never changes. */ -export function rasteriseVectorArt(asset: Asset, renderer: Renderer): Texture | null { - const canvas = vectorArtCanvas(asset); +export function rasteriseVectorArt(asset: Asset, renderer: Renderer, project?: Project): Texture | null { + const canvas = vectorArtCanvas(asset, asset.props, project); if (!canvas) return null; + const graphics = new Graphics(); + const paints = new PaintCache(); + let complete = true; + for (const drawing of canvas.drawings) complete = paintDrawing(graphics, drawing, paints) && complete; + if (!complete) { + release(graphics, paints); + return null; + } const texture = RenderTexture.create({ width: canvas.width, height: canvas.height, resolution: SUPERSAMPLE, antialias: true, }); - const graphics = new Graphics(); - for (const drawing of canvas.drawings) paintDrawing(graphics, drawing); renderer.render({ container: graphics, target: texture, clear: true }); // The picture is on the GPU from here on; the scene graph that drew it is of no more use. - graphics.destroy(true); + release(graphics, paints); return texture; } +/** + * Lets the drawing go, but not the pictures it filled with. `destroy(true)` would destroy every + * texture the context's fills name – a picture fill samples the library's own cut of a texture + * (#186), and a destroyed cut left the next redraw reading a texture without a source. + */ +function release(graphics: Graphics, paints: PaintCache): void { + graphics.destroy(); + paints.destroy(); +} + +/** + * The gradients one painter keeps (#186). Each `FillGradient` builds a texture of its own, so a + * canvas that repaints on every pointer move would pile them up; one asked for again with the same + * stops is reused, and one not asked for between two sweeps is let go. + * + * Let go, not destroyed: the frame drawn last still has the gradient's texture bound, and destroying + * it there makes Pixi warn "destroyed while still bound to a shader" once per gradient. Handed to the + * texture garbage collector instead, it is unloaded once it has not been drawn for a while. + */ +export class PaintCache { + private readonly held = new Map(); + private readonly used = new Set(); + + gradient(paint: Extract): FillGradient { + const key = JSON.stringify(paint); + this.used.add(key); + let gradient = this.held.get(key); + if (!gradient) { + const colorStops = paint.stops.map((stop) => ({ + offset: stop.offset, + color: [ + ((stop.color >> 16) & 0xff) / 255, + ((stop.color >> 8) & 0xff) / 255, + (stop.color & 0xff) / 255, + stop.alpha, + ], + })); + gradient = + paint.type === 'linear' + ? new FillGradient({ + type: 'linear', + start: paint.from, + end: paint.to, + colorStops, + textureSpace: 'global', + }) + : new FillGradient({ + type: 'radial', + center: paint.from, + innerRadius: 0, + outerCenter: paint.from, + outerRadius: Math.hypot(paint.to.x - paint.from.x, paint.to.y - paint.from.y), + colorStops, + textureSpace: 'global', + }); + this.held.set(key, gradient); + } + return gradient; + } + + /** Lets go of what was not asked for since the last sweep; call it after a full repaint. */ + sweep(): void { + for (const [key, gradient] of this.held) + if (!this.used.has(key)) { + // Built lazily on first use: a gradient never drawn has no texture to hand over. + const source = (gradient.texture as Texture | undefined)?.source; + if (source) source.autoGarbageCollect = true; + this.held.delete(key); + } + this.used.clear(); + } + + destroy(): void { + this.used.clear(); + this.sweep(); + } +} + /** * One shape: its fill first, its outline over it, so a stroke is never half swallowed by the fill. * Exported because the Vector art tab paints the very same drawing onto its own `Graphics` (#129, * stage 3) – what one edits there has to be what a sprite ends up wearing. */ -export function paintDrawing(graphics: Graphics, drawing: ShapeDrawing): void { +export function paintDrawing(graphics: Graphics, drawing: ShapeDrawing, paints: PaintCache): boolean { + let complete = true; if (drawing.fill.length > 0) { + const fill = fillOf(drawing.paint, drawing.color, paints); + complete = fill !== null; // `checkForHoles` is what makes a ring inside another cut it out instead of painting over it. - graphics.path(pathOf(drawing.fill, true)).fill({ color: drawing.color, alpha: drawing.alpha }); + graphics + .path(pathOf(drawing.fill, true)) + .fill({ ...(fill ?? { color: drawing.color }), alpha: drawing.alpha }); } - if (drawing.stroke.length > 0) - graphics.path(pathOf(drawing.stroke, false)).stroke({ - color: drawing.strokeColor, - width: drawing.strokeWidth, - alpha: drawing.alpha, - }); + if (drawing.stroke.length > 0) { + const line = fillOf(drawing.strokePaint, drawing.strokeColor, paints) ?? { color: drawing.strokeColor }; + graphics + .path(pathOf(drawing.stroke, false)) + .stroke({ ...line, width: drawing.strokeWidth, alpha: drawing.alpha }); + } + return complete; +} + +/** + * How a paint fills, or null while its picture is still loading. A picture fill samples its own cut + * of the texture with `repeat`: the library's sheets are `mirror-repeat` (see `TextureLibrary`), and + * a `FillPattern` would switch the address mode on the shared source under every sprite wearing it. + * The matrix takes texture pixels to canvas pixels, so one tile spans `from` to `to`. + */ +function fillOf(paint: DrawPaint | null, color: number, paints: PaintCache): FillStyle | null { + if (!paint) return { color }; + if (paint.type !== 'texture') return { fill: paints.gradient(paint) }; + const texture = textureLibrary.get(paint.ref, 'repeat'); + if (!texture) return null; + const matrix = new Matrix() + .scale((paint.to.x - paint.from.x) / texture.width, (paint.to.y - paint.from.y) / texture.height) + .translate(paint.from.x, paint.from.y); + return { texture, matrix, textureSpace: 'global' }; } function pathOf(ops: readonly PathOp[], checkForHoles: boolean): GraphicsPath { diff --git a/tests/core/vector-paint.test.ts b/tests/core/vector-paint.test.ts new file mode 100644 index 00000000..741f763f --- /dev/null +++ b/tests/core/vector-paint.test.ts @@ -0,0 +1,307 @@ +/** + * Gradients and picture fills of vector art (#186): the rules of `vectorPaint`, what the drawing + * commands make of a paint, how a project file brings one back, and the commands that write it. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; +import { CommandError } from '../../src/core/commands/Command'; +import { + AddVectorShapeCommand, + SetVectorShapeRingsCommand, + SetVectorShapeStyleCommand, +} from '../../src/core/commands/vectorArtCommands'; +import type { + TextureAsset, + VectorArtAsset, + VectorGradient, + VectorRing, + VectorShape, +} from '../../src/core/model/Asset'; +import { findAsset, type Project } from '../../src/core/model/Project'; +import { vectorArtRevision } from '../../src/core/model/vectorart'; +import { + colorAt, + fillPaintFor, + paintExtent, + paintMoved, + sortedStops, + strokePaintFor, + textureFillRef, + withoutStop, + withStopAdded, + withStopChanged, +} from '../../src/core/model/vectorPaint'; +import { shapeDrawing } from '../../src/core/model/vectorPath'; +import { makeProject, roundTrip } from '../helpers'; + +const FILE = `${'c'.repeat(64)}.png`; + +const SQUARE: VectorRing = { + closed: true, + points: [ + { x: 20, y: 40 }, + { x: 120, y: 40 }, + { x: 120, y: 90 }, + { x: 20, y: 90 }, + ], +}; + +function shape(over: Partial = {}): VectorShape { + return { id: 's', name: 'Shape 1', rings: [SQUARE], fill: 0x336699, opacity: 1, ...over }; +} + +function gradient(over: Partial = {}): VectorGradient { + return { + type: 'linear', + from: { x: 0, y: 0 }, + to: { x: 100, y: 0 }, + stops: [ + { offset: 0, color: 0x000000, alpha: 1 }, + { offset: 1, color: 0xffffff, alpha: 0 }, + ], + ...over, + }; +} + +/** A project with a drawing holding one square, and a 64x32 texture with one 16x16 frame. */ +function withTexture() { + const fixture = makeProject(); + const art = new CreateAssetCommand('vectorart', 'Logo'); + fixture.stack.execute(art); + const texture = new CreateTextureCommand('Bricks', { file: FILE, width: 64, height: 32 }); + fixture.stack.execute(texture); + const sheet = findAsset(fixture.project, texture.asset.id) as TextureAsset; + sheet.frames.push({ id: 'f1', name: 'Brick', x: 16, y: 0, w: 16, h: 16 }); + const add = new AddVectorShapeCommand(art.asset.id, [SQUARE]); + fixture.stack.execute(add); + const drawing = (project: Project = fixture.project) => findAsset(project, art.asset.id) as VectorArtAsset; + return { ...fixture, assetId: art.asset.id, shapeId: add.shapeId, textureId: texture.asset.id, drawing }; +} + +describe('switching a fill to a paint', () => { + const extent = paintExtent({ x0: 20, y0: 40, x1: 120, y1: 90 }, { width: 256, height: 256 }); + + it('lays a linear gradient across the middle of the shape, from its colour to white', () => { + expect(fillPaintFor(shape(), 'linear', extent)).toEqual({ + type: 'linear', + from: { x: 20, y: 65 }, + to: { x: 120, y: 65 }, + stops: [ + { offset: 0, color: 0x336699, alpha: 1 }, + { offset: 1, color: 0xffffff, alpha: 1 }, + ], + }); + }); + + it('centres a radial gradient and lets it reach the farther side', () => { + const paint = fillPaintFor(shape(), 'radial', extent); + expect(paint).toMatchObject({ type: 'radial', from: { x: 70, y: 65 }, to: { x: 120, y: 65 } }); + }); + + it('keeps the colours when a gradient only changes its kind, and lays it out anew', () => { + const linear = gradient({ stops: [...gradient().stops, { offset: 0.3, color: 0xff0000, alpha: 1 }] }); + const radial = fillPaintFor(shape({ paint: linear }), 'radial', extent) as VectorGradient; + expect(radial.stops).toEqual(linear.stops); + expect(radial.from).toEqual({ x: 70, y: 65 }); + }); + + it('spans a picture over the shape once, keeping the picture it had', () => { + const tiled = shape({ + paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 8, y: 8 }, texture: 't' }, + }); + expect(fillPaintFor(tiled, 'texture', extent)).toEqual({ + type: 'texture', + from: { x: 20, y: 40 }, + to: { x: 120, y: 90 }, + texture: 't', + }); + expect(fillPaintFor(shape(), 'solid', extent)).toBeNull(); + }); + + it('starts an outline gradient from the line colour, and a shape without one gets none', () => { + const outlined = shape({ stroke: { color: 0x00ff00, width: 2 } }); + expect((strokePaintFor(outlined, 'linear', extent) as VectorGradient).stops[0]?.color).toBe(0x00ff00); + expect(strokePaintFor(shape(), 'linear', extent)).toBeNull(); + }); + + it('never lays a paint over nothing: a line has a height, an empty shape the canvas', () => { + expect(paintExtent({ x0: 5, y0: 10, x1: 50, y1: 10 }, { width: 64, height: 64 }).y1).toBe(11); + expect(paintExtent(null, { width: 64, height: 32 })).toEqual({ x0: 0, y0: 0, x1: 64, y1: 32 }); + }); +}); + +describe('colour stops', () => { + it('adds a stop in the colour the gradient already has there, so nothing changes yet', () => { + const added = withStopAdded(gradient(), 0.25); + expect(added.stops).toHaveLength(3); + expect(added.stops[2]).toEqual({ offset: 0.25, color: 0x404040, alpha: 0.75 }); + }); + + it('reads the colour between stops in drawing order, whatever order the list is in', () => { + const shuffled = [ + { offset: 1, color: 0x0000ff, alpha: 1 }, + { offset: 0, color: 0xff0000, alpha: 1 }, + ]; + expect(colorAt(shuffled, 0.5).color).toBe(0x800080); + expect(colorAt(shuffled, -3).color).toBe(0xff0000); + expect(colorAt(shuffled, 7).color).toBe(0x0000ff); + }); + + it('keeps a dragged stop where it is in the list, and sorts only for drawing', () => { + const moved = withStopChanged(gradient(), 0, { offset: 0.9 })!; + expect(moved.stops[0]?.offset).toBe(0.9); + expect(sortedStops(moved.stops).map((s) => s.offset)).toEqual([0.9, 1]); + expect(withStopChanged(gradient(), 0, { offset: 4, alpha: -1 })!.stops[0]).toMatchObject({ + offset: 1, + alpha: 0, + }); + expect(withStopChanged(gradient(), 5, { color: 1 })).toBeNull(); + }); + + it('takes a stop out but never the second-last one', () => { + const three = withStopAdded(gradient(), 0.5); + expect(withoutStop(three, 0)?.stops.map((s) => s.offset)).toEqual([1, 0.5]); + expect(withoutStop(gradient(), 0)).toBeNull(); + }); + + it('moves both ends of a paint with its shape', () => { + expect(paintMoved(gradient(), 5, -2)).toMatchObject({ from: { x: 5, y: -2 }, to: { x: 105, y: -2 } }); + }); +}); + +describe('drawing a paint', () => { + it('hands the stops out sorted and keeps the solid colour next to it', () => { + const drawn = shapeDrawing(shape({ paint: gradient({ stops: [...gradient().stops].reverse() }) }))!; + expect(drawn.color).toBe(0x336699); + expect(drawn.paint).toMatchObject({ type: 'linear', stops: [{ offset: 0 }, { offset: 1 }] }); + expect(drawn.strokePaint).toBeNull(); + }); + + it('falls back to the colour for a gradient that has no length or one stop', () => { + expect(shapeDrawing(shape({ paint: gradient({ to: { x: 0, y: 0 } }) }))!.paint).toBeNull(); + const single = gradient({ stops: [{ offset: 0, color: 0, alpha: 1 }] }); + expect(shapeDrawing(shape({ paint: single }))!.paint).toBeNull(); + }); + + it('paints the outline with its own gradient', () => { + const drawn = shapeDrawing( + shape({ stroke: { color: 0, width: 3, paint: gradient({ type: 'radial' }) } }), + )!; + expect(drawn.strokePaint?.type).toBe('radial'); + }); + + it('resolves a picture fill to the texture or frame it names, never to a drawn picture', () => { + const f = withTexture(); + const whole = textureFillRef(f.project, f.textureId); + expect(whole).toMatchObject({ file: FILE, rect: null }); + expect(textureFillRef(f.project, 'f1')).toMatchObject({ + file: FILE, + rect: { x: 16, y: 0, w: 16, h: 16 }, + }); + expect(textureFillRef(f.project, f.assetId)).toBeNull(); + expect(textureFillRef(f.project, 'gone')).toBeNull(); + const paint = { type: 'texture' as const, from: { x: 0, y: 0 }, to: { x: 16, y: 16 }, texture: 'f1' }; + expect(shapeDrawing(shape({ paint }), f.project)!.paint).toMatchObject({ + type: 'texture', + ref: { file: FILE }, + }); + // A picture that is not there, and a tile of no width, draw the colour instead. + expect(shapeDrawing(shape({ paint: { ...paint, texture: 'gone' } }), f.project)!.paint).toBeNull(); + expect(shapeDrawing(shape({ paint: { ...paint, to: { x: 0, y: 16 } } }), f.project)!.paint).toBeNull(); + }); +}); + +describe('a paint in the picture key and the project file', () => { + it('is a new drawing when a stop, an end or the picture behind a texture changes', () => { + const f = withTexture(); + const shapeOf = () => f.drawing().shapes[0]!; + const plain = vectorArtRevision(f.drawing(), f.drawing().props, f.project); + shapeOf().paint = gradient(); + const linear = vectorArtRevision(f.drawing(), f.drawing().props, f.project); + expect(linear).not.toBe(plain); + shapeOf().paint = withStopChanged(gradient(), 1, { alpha: 0.5 })!; + expect(vectorArtRevision(f.drawing(), f.drawing().props, f.project)).not.toBe(linear); + + shapeOf().paint = { type: 'texture', from: { x: 0, y: 0 }, to: { x: 16, y: 16 }, texture: 'f1' }; + const brick = vectorArtRevision(f.drawing(), f.drawing().props, f.project); + // The frame is moved on its sheet: same id on the shape, another picture in it. + (findAsset(f.project, f.textureId) as TextureAsset).frames[0]!.x = 32; + expect(vectorArtRevision(f.drawing(), f.drawing().props, f.project)).not.toBe(brick); + }); + + it('comes back from a project file as it was written', () => { + const f = withTexture(); + const shapeNow = f.drawing().shapes[0]!; + shapeNow.paint = gradient({ type: 'radial' }); + shapeNow.stroke = { color: 0xff0000, width: 2, paint: gradient() }; + const back = f.drawing(roundTrip(f.project)).shapes[0]!; + expect(back.paint).toEqual(shapeNow.paint); + expect(back.stroke).toEqual(shapeNow.stroke); + expect(vectorArtRevision(f.drawing(roundTrip(f.project)))).toBe(vectorArtRevision(f.drawing())); + }); + + it('drops a paint it cannot draw and keeps the colour the shape had', () => { + const f = withTexture(); + const raw = JSON.parse(JSON.stringify(f.project)) as { assets: { shapes?: unknown[] }[] }; + const drawing = raw.assets.find((a) => Array.isArray(a.shapes))!; + const written = drawing.shapes![0] as Record; + written.paint = { + type: 'linear', + from: { x: 0, y: 0 }, + to: { x: 1, y: 0 }, + stops: [{ offset: 0, color: 1 }], + }; + written.stroke = { + color: 5, + width: 1, + paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 1, y: 1 }, texture: 'x' }, + }; + const back = f.drawing(roundTrip(raw as unknown as Project)).shapes[0]!; + expect(back.paint).toBeUndefined(); + expect(back.stroke).toEqual({ color: 5, width: 1 }); + }); +}); + +describe('writing a paint', () => { + it('sets and removes a fill paint as one undo step each', () => { + const f = withTexture(); + f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: gradient() })); + expect(f.drawing().shapes[0]!.paint).toEqual(gradient()); + f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: null })); + expect(f.drawing().shapes[0]!.paint).toBeUndefined(); + f.stack.undo(); + expect(f.drawing().shapes[0]!.paint).toEqual(gradient()); + f.stack.undo(); + expect(f.drawing().shapes[0]!.paint).toBeUndefined(); + }); + + it('refuses a gradient of one colour, on the fill and on the outline', () => { + const f = withTexture(); + const one = gradient({ stops: [{ offset: 0, color: 0, alpha: 1 }] }); + expect(() => + new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: one }).execute(f.project), + ).toThrow(CommandError); + const stroke = { color: 0, width: 1, paint: one }; + expect(() => new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { stroke }).execute(f.project)).toThrow( + CommandError, + ); + }); + + it('moves the paints along when a whole shape is dragged, and undo takes both back', () => { + const f = withTexture(); + const shapeNow = f.drawing().shapes[0]!; + shapeNow.paint = gradient(); + shapeNow.stroke = { color: 0, width: 1, paint: gradient({ type: 'radial' }) }; + const rings = [{ ...SQUARE, points: SQUARE.points.map((p) => ({ x: p.x + 10, y: p.y })) }]; + const paints = { + paint: paintMoved(gradient(), 10, 0), + strokePaint: paintMoved(gradient({ type: 'radial' }), 10, 0), + }; + f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, f.shapeId, rings, 'Move shape', paints)); + expect(f.drawing().shapes[0]!.paint?.from).toEqual({ x: 10, y: 0 }); + expect(f.drawing().shapes[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 }); + f.stack.undo(); + expect(f.drawing().shapes[0]!.paint?.from).toEqual({ x: 0, y: 0 }); + expect(f.drawing().shapes[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 }); + }); +}); diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts index 315bba40..1fcfd964 100644 --- a/tests/editor/shape-rows.test.ts +++ b/tests/editor/shape-rows.test.ts @@ -5,8 +5,12 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; import { CanvasTextMetrics } from 'pixi.js'; -import { AddVectorShapeCommand, RemoveVectorShapeCommand } from '../../src/core/commands/vectorArtCommands'; -import type { VectorArtAsset } from '../../src/core/model/Asset'; +import { + AddVectorShapeCommand, + RemoveVectorShapeCommand, + SetVectorShapeStyleCommand, +} from '../../src/core/commands/vectorArtCommands'; +import type { VectorArtAsset, VectorGradient } from '../../src/core/model/Asset'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { createEmptyProject, findAssetOf } from '../../src/core/model/Project'; import { @@ -25,10 +29,13 @@ import { buildShapeListSection, buildShapeSection, refreshShapeSection, + setFillTexture, + setPaintKind, shapeSignature, } from '../../src/editor/inspector/shapeSection'; import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows'; import { EditorState, shapeSelection } from '../../src/editor/state'; +import { PaintCache } from '../../src/viewport/vectorArtPicture'; const ROW = 30; @@ -39,6 +46,8 @@ const ROW = 30; */ beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; + // A gradient (#186) paints itself onto a browser canvas too; the rows under test do not need it. + PaintCache.prototype.gradient = (() => null) as never; }); function makeHost(): InspectorHost { @@ -126,7 +135,7 @@ describe('shape rows', () => { it('shows fill, opacity and an outline switch, and no line rows without an outline', () => { const { host, assetId, ids } = withShapes(1); buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); - expect([...host.fields.keys()]).toEqual(['name', 'fill', 'opacity', 'outline']); + expect([...host.fields.keys()]).toEqual(['name', 'fillType', 'fill', 'opacity', 'outline']); expect(texts(host)).toContain('Without an outline only the fill is drawn.'); }); @@ -169,9 +178,11 @@ describe('shape rows', () => { buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect([...host.fields.keys()]).toEqual([ 'name', + 'fillType', 'fill', 'opacity', 'outline', + 'lineType', 'strokeColor', 'strokeWidth', ]); @@ -281,3 +292,84 @@ describe('combining from the inspector (#187)', () => { ); }); }); + +describe('fill and line types (#186)', () => { + const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!; + const rebuild = (host: InspectorHost, assetId: string, shapeId: string) => { + host.body.removeChildren(); + host.fields.clear(); + buildShapeSection(host, assetId, shapeId, [shapeId]); + }; + + it('switches the fill to a gradient in one undo step, and the colour row makes way for the stops', () => { + const { host, assetId, ids } = withShapes(1); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); + setPaintKind(host.state, assetId, ids[0]!, 'fill', 'linear'); + expect(shapeOf(host, assetId).paint).toMatchObject({ + type: 'linear', + stops: [{ offset: 0 }, { offset: 1 }], + }); + const signature = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]); + expect(signature).toContain('linear/none'); + rebuild(host, assetId, ids[0]!); + expect([...host.fields.keys()]).toEqual([ + 'name', + 'fillType', + 'fillStopColor', + 'fillStopAlpha', + 'fillStopOffset', + 'opacity', + 'outline', + ]); + host.state.undo(); + expect(shapeOf(host, assetId).paint).toBeUndefined(); + }); + + it('edits the picked stop, and "Remove stop" only works while three are left', () => { + const { host, assetId, ids } = withShapes(1); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); + setPaintKind(host.state, assetId, ids[0]!, 'fill', 'radial'); + rebuild(host, assetId, ids[0]!); + // The stop's colour is the only colour row left; opacity of the stop is the first number row. + widgets(host, ColorField)[0]!.onChange!(0xff0000); + widgets(host, NumberField)[0]!.onChange!('0.5'); + const paint = shapeOf(host, assetId).paint as VectorGradient; + expect(paint.stops[0]).toEqual({ offset: 0, color: 0xff0000, alpha: 0.5 }); + expect(button(host, 'Remove stop').eventMode).toBe('none'); + + host.state.execute( + new SetVectorShapeStyleCommand(assetId, ids[0]!, { + paint: { ...paint, stops: [...paint.stops, { offset: 0.5, color: 0, alpha: 1 }] }, + }), + ); + rebuild(host, assetId, ids[0]!); + expect(button(host, 'Remove stop').eventMode).not.toBe('none'); + button(host, 'Remove stop').onClick!(); + expect((shapeOf(host, assetId).paint as VectorGradient).stops.map((s) => s.offset)).toEqual([1, 0.5]); + }); + + it('gives the outline a type of its own, with the line colour as its first stop', () => { + const { host, assetId, ids } = withShapes(1); + shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 }; + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); + setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'linear'); + expect(shapeOf(host, assetId).stroke).toMatchObject({ + color: 0x112233, + width: 3, + paint: { type: 'linear' }, + }); + expect((shapeOf(host, assetId).stroke!.paint as VectorGradient).stops[0]?.color).toBe(0x112233); + setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'solid'); + expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x112233, width: 3 }); + }); + + it('a texture fill shows the picker, and picking writes the id into the paint', () => { + const { host, assetId, ids } = withShapes(1); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); + setPaintKind(host.state, assetId, ids[0]!, 'fill', 'texture'); + rebuild(host, assetId, ids[0]!); + expect(host.fields.has('fillTexture')).toBe(true); + setFillTexture(host.state, assetId, ids[0]!, 't1'); + expect(shapeOf(host, assetId).paint).toMatchObject({ type: 'texture', texture: 't1' }); + }); +}); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 8f917dd5..061bd4a5 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -1,4 +1,5 @@ import { colourDistance } from '../lib/harness.mjs'; +import { FIND_HALVES, HALVES_LEFT, HALVES_RIGHT, PASTE_HALVES } from './lib/pictures.mjs'; import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; /** @@ -14,7 +15,8 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; * the model, which is the only place a point's coordinates exist exactly. The last block, 47f, * walks the way a user walks instead: the asset comes out of the + menu, its row opens the tab, * and a sprite is given the drawing through its Texture row. 47g draws on that asset to measure the - * four point types of #195, and 47h combines two shapes on it (#187). + * four point types of #195, 47h combines two shapes on it (#187), and 47i fills a shape with + * gradients and a repeated picture (#186). */ const CANVAS = 256; @@ -682,4 +684,246 @@ export async function vectorArt(t) { restored?.map((s) => s.id).join(',') === 'va-left,va-right' && restored[0].rings[0].points.length === 4, JSON.stringify(restored?.map((s) => ({ id: s.id, points: s.rings[0]?.points.length }))), ); + // 47i (#186). Gradients and a picture fill, set the way a user sets them: the Fill type row, the + // stop bar under it, the Paint tool's handles in the tab. A two-colour picture is pasted in + // first – it is what the texture fill is measured with, red on the left and green on the right. + await evaluate(PASTE_HALVES); + await sleep(2500); + const halves = await json(FIND_HALVES); + const SKY = { + id: 'va-sky', + name: 'Sky', + fill: 0x0000ff, + opacity: 1, + rings: [ + { + closed: true, + points: [ + { x: 28, y: 28 }, + { x: 228, y: 28 }, + { x: 228, y: 228 }, + { x: 28, y: 228 }, + ], + }, + ], + }; + await mutate(` + const artPaint = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)}); + artPaint.shapes = ${JSON.stringify([SKY])}; + __gm.state.select({ kind: 'shape', assetId: ${JSON.stringify(madeId)}, shapeId: 'va-sky', shapeIds: ['va-sky'] })`); + await evaluate(`__gm.openDocument('asset:${madeId}')`); + await settle(); + const sky = async () => (await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`))?.[0] ?? null; + const tabPixel = async (x, y) => { + const atPaint = await screenAt(x, y); + return json(`__gm.pixelAt(${Math.round(atPaint.x)}, ${Math.round(atPaint.y)})`); + }; + const pickFrom = async (label, text) => { + const fieldPaint = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`); + await click(fieldPaint.x, fieldPaint.y); + await settle(); + const rowPaint = await inOverlay(text); + if (!rowPaint) + throw new Error(`no "${text}" under "${label}": ${JSON.stringify(await json('__gm.menuRows()'))}`); + await click(rowPaint.x, rowPaint.y); + await settle(); + }; + const offset = (p) => p?.stops?.map((s) => Math.round(s.offset * 100) / 100); + + await pickFrom('Fill type', 'Linear gradient'); + const linear = (await sky())?.paint; + const nearLeft = await tabPixel(48, 128); + const nearRight = await tabPixel(208, 128); + check( + 'Linear gradient lays the axis across the shape, from its blue to white, as the tab shows', + linear?.type === 'linear' && + near(linear.from.x, 28) && + near(linear.to.x, 228) && + near(linear.from.y, 128) && + linear.stops.map((s) => s.color).join() === `${0x0000ff},${0xffffff}` && + colourDistance(nearLeft, '#1a1aff') < 20 && + colourDistance(nearRight, '#e6e6ff') < 20, + JSON.stringify({ linear, nearLeft, nearRight }), + ); + + const middle = await json("__gm.vectorArt.barAt('fill', 0.5)"); + await click(middle.x, middle.y); + await settle(); + const addedPaint = (await sky())?.paint; + check( + 'a click on the bar adds a stop there in the colour the gradient has at that place', + addedPaint?.stops.length === 3 && + Math.abs(addedPaint.stops[2].offset - 0.5) < 0.02 && + colourDistance(`#${addedPaint.stops[2].color.toString(16).padStart(6, '0')}`, '#8080ff') < 6, + JSON.stringify(addedPaint?.stops), + ); + + const stopField = await json("__gm.inspectorField('Stop colour', true)"); + await click(stopField.x, stopField.y); + await press('a', { ctrl: true }); + await typeText('ff0000'); + await press('Enter'); + await settle(); + const reddened = (await sky())?.paint; + const redMiddle = await tabPixel(128, 128); + check( + 'the Stop colour row recolours the picked stop, and the middle of the shape turns red', + reddened?.stops[2]?.color === 0xff0000 && colourDistance(redMiddle, '#ff0000') < 24, + JSON.stringify({ stops: reddened?.stops, redMiddle }), + ); + + const marker = await json("__gm.vectorArt.stopAt('fill', 2)"); + const quarter = await json("__gm.vectorArt.barAt('fill', 0.25)"); + await drag(marker.x, marker.y, quarter.x, marker.y, { steps: 8 }); + await settle(); + const draggedStops = offset((await sky())?.paint); + const redQuarter = await tabPixel(78, 128); + await undo(); + await settle(); + const undoneStops = offset((await sky())?.paint); + check( + 'dragging the marker moves the stop along the bar in one undo step', + Math.abs(draggedStops?.[2] - 0.25) <= 0.02 && + colourDistance(redQuarter, '#ff0000') < 24 && + Math.abs(undoneStops?.[2] - 0.5) <= 0.02, + JSON.stringify({ draggedStops, redQuarter, undoneStops }), + ); + + const paintTool = await json('__gm.vectorArt.paintButton()'); + await click(paintTool.x, paintTool.y); + await settle(); + const axisEnd = await screenAt(228, 128); + const pulledIn = await screenAt(128, 128); + await drag(axisEnd.x, axisEnd.y, pulledIn.x, pulledIn.y, { steps: 8 }); + await settle(); + const shortened = (await sky())?.paint; + const pastEnd = await tabPixel(178, 128); + check( + 'the Paint tool drags the end of the axis, and past it the last colour holds', + (await json('__gm.vectorArt.tool()')) === 'paint' && + near(shortened?.to.x, 128) && + near(shortened?.to.y, 128) && + colourDistance(pastEnd, '#ffffff') < 20, + JSON.stringify({ to: shortened?.to, pastEnd }), + ); + + await pickFrom('Fill type', 'Radial gradient'); + const radial = (await sky())?.paint; + const ringPaint = await tabPixel(128, 150); + const cornerPaint = await tabPixel(40, 40); + check( + 'Radial gradient keeps the three colours and runs from the centre out to the farther side', + radial?.type === 'radial' && + radial.stops.length === 3 && + near(radial.from.x, 128) && + near(radial.from.y, 128) && + near(radial.to.x, 228) && + colourDistance(ringPaint, '#70008f') < 30 && + colourDistance(cornerPaint, '#ffffff') < 20, + JSON.stringify({ radial, ringPaint, cornerPaint }), + ); + await shot('47-paint-radial'); + + await pickFrom('Fill type', 'Texture'); + const textureRowPaint = await json("__gm.inspectorField('Texture', true)"); + await click(textureRowPaint.x, textureRowPaint.y); + await settle(); + const pictureOffers = (await json('__gm.menuRows()')) ?? []; + const halvesName = await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(halves?.id ?? '')})?.name ?? null`, + ); + const halvesEntry = halvesName && (await inOverlay(halvesName)); + if (halvesEntry) await click(halvesEntry.x, halvesEntry.y); + else await press('Escape'); + await settle(); + await sleep(300); + const tiled = (await sky())?.paint; + const leftHalf = await tabPixel(60, 100); + const rightHalf = await tabPixel(196, 100); + check( + 'Texture offers the uploaded pictures but no drawing, and fills the shape with the one picked', + Boolean(halvesEntry) && + !pictureOffers.includes('Emblem') && + tiled?.type === 'texture' && + tiled.texture === halves?.id && + colourDistance(leftHalf, HALVES_LEFT) < 20 && + colourDistance(rightHalf, HALVES_RIGHT) < 20, + JSON.stringify({ pictureOffers, tiled, leftHalf, rightHalf }), + ); + + const tileCorner = await screenAt(228, 228); + const tileHalf = await screenAt(128, 128); + await drag(tileCorner.x, tileCorner.y, tileHalf.x, tileHalf.y, { steps: 8 }); + await settle(); + await sleep(200); + const smaller = (await sky())?.paint; + const secondRed = await tabPixel(153, 100); + const secondGreen = await tabPixel(203, 100); + check( + 'a smaller tile repeats the picture: the next tile starts red again', + near(smaller?.to.x, 128) && + near(smaller?.to.y, 128) && + colourDistance(secondRed, HALVES_LEFT) < 20 && + colourDistance(secondGreen, HALVES_RIGHT) < 20, + JSON.stringify({ to: smaller?.to, secondRed, secondGreen }), + ); + await shot('47-paint-texture'); + + // The same drawing on a sprite in the scene: the rasteriser draws the picture fill too – it waits + // for the picture instead of keeping a drawing without it. + await mutate(` + __gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture = ${JSON.stringify(madeId)}; + __gm.state.select(null)`); + await showTab('scene'); + await evaluate(`__gm.lookAt(0, ${Y2})`); + await settle(); + await sleep(400); + const skyBox = await json("__gm.objectRect('va-bare-object')"); + const skyWorn = (cx, cy) => + json( + `__gm.pixelAt(${Math.round(skyBox.x + (cx / CANVAS - 0.5) * skyBox.w)}, ${Math.round(skyBox.y + (cy / CANVAS - 0.5) * skyBox.h)})`, + ); + const wornRed = await skyWorn(153, 100); + const wornGreen = await skyWorn(203, 100); + check( + 'a sprite wearing the drawing shows the repeated picture in the scene', + colourDistance(wornRed, HALVES_LEFT) < 24 && colourDistance(wornGreen, HALVES_RIGHT) < 24, + JSON.stringify({ wornRed, wornGreen }), + ); + + // The outline gets a gradient of its own, and the header's Handles menu switches the tool to it. + await evaluate(`__gm.openDocument('asset:${madeId}')`); + await mutate( + `__gm.state.select({ kind: 'shape', assetId: ${JSON.stringify(madeId)}, shapeId: 'va-sky', shapeIds: ['va-sky'] })`, + ); + await settle(); + const outlineSwitch = await json("__gm.inspectorField('Outline', true)"); + await click(outlineSwitch.x, outlineSwitch.y); + await settle(); + await pickFrom('Line type', 'Linear gradient'); + const handles = await json('__gm.vectorArt.paintTarget()'); + await click(handles.x, handles.y); + await settle(); + const outlineRow = ((await json('__gm.menuRows()')) ?? []).find((rowPaint) => + rowPaint.endsWith(' Outline'), + ); + const outlineEntry = outlineRow && (await inOverlay(outlineRow)); + if (outlineEntry) await click(outlineEntry.x, outlineEntry.y); + await settle(); + const lineEnd = await screenAt(228, 128); + const lineUp = await screenAt(228, 28); + await drag(lineEnd.x, lineEnd.y, lineUp.x, lineUp.y, { steps: 8 }); + await settle(); + const linedPaint = await sky(); + check( + 'the outline takes a gradient, and with Handles on Outline the tool moves its end, not the fill', + (await json('__gm.vectorArt.paintTarget()')).text === 'Handles: Outline' && + linedPaint?.stroke?.paint?.type === 'linear' && + near(linedPaint.stroke.paint.to.x, 228) && + near(linedPaint.stroke.paint.to.y, 28) && + near(linedPaint.paint?.to.x, 128) && + near(linedPaint.paint?.to.y, 128), + JSON.stringify({ stroke: linedPaint?.stroke, fillTo: linedPaint?.paint?.to }), + ); + await shot('47-paint-outline'); }