Vector shapes can fill with gradients and textures (#186)
A vector shape could only be filled and outlined with one flat colour. Shading, glows and patterned surfaces had to be faked with stacks of shapes. Every shape now has a paint type for its fill and its outline. - Fill type: solid colour, linear gradient, radial gradient or texture. Line type offers the same without texture. - Colour stops are edited on a bar in the inspector, as agreed in the issue. A click on the bar adds a stop in the colour the gradient already has there, so adding never changes the picture. At least two stops remain. - The new Paint tool in the tab shows two handles for the selected shape: axis start and end, centre and radius, or the corners of one texture tile. "Handles: Fill / Outline" picks which paint is edited. - Moving a whole shape moves its gradients and tile with it. Every gesture is one undo step. - A texture fill always tiles, because Pixi turns a non-repeating pattern into a repeating one anyway. When switched on, one tile covers the whole shape. - Only uploaded textures and frames can be a fill, never a drawing. A drawing filled with itself would rasterise itself mid-raster. - The fingerprint of a drawing includes the texture and frame a fill points to, so the picture redraws when the image is swapped. Findings: - graphics.destroy(true) also destroyed the textures of the fills. With a texture fill it deleted the tile slice and the next redraw crashed on reading 'scaleMode' of null. It is now destroy(). - The gradient cache destroyed old gradients itself, which gave 20 Pixi warnings "destroyed while still bound". They are now handed to Pixi's texture GC with autoGarbageCollect switched on. The console of module 47 is empty again, the last CI log had 0 of them. Tests: - ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44), 0 console warnings - 25 new unit tests, 10 new e2e checks in block 47i - Four counter-checks bite, originals restored and verified with cmp - Check sum 1232 in README and wiki, calculated (1222 + 10) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1116818011
commit
592ea942d9
22 files changed
+2077
-85
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+37
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<GradientStop>,
|
||||
): 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<P extends VectorPaint>(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<P extends VectorPaint>(paint: P, handle: 'from' | 'to', at: Vec2): P {
|
||||
return { ...clonePaint(paint), [handle]: { x: at.x, y: at.y } };
|
||||
}
|
||||
|
||||
export function clonePaint<P extends VectorPaint>(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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 FlatKind> = K extends 'number'
|
||||
? { min?: number; max?: number; step?: number; decimals?: number }
|
||||
@@ -76,6 +77,8 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
? { options: { value: string; label: string }[] }
|
||||
: K extends 'asset'
|
||||
? { assetTypes?: readonly AssetType[] }
|
||||
: K extends 'texture'
|
||||
? { uploadedOnly?: boolean }
|
||||
: unknown;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
+13
-2
@@ -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,
|
||||
|
||||
@@ -238,7 +238,14 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
label: assetChoiceLabel(asset),
|
||||
})),
|
||||
},
|
||||
texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: pictureChoices },
|
||||
texture: {
|
||||
none: NO_TEXTURE_LABEL,
|
||||
label: textureRefLabel,
|
||||
choices: (project, def) =>
|
||||
pictureChoices(project).filter(
|
||||
(choice) => !(def.kind === 'texture' && def.uploadedOnly) || choice.asset.type === 'texture',
|
||||
),
|
||||
},
|
||||
statemachine: {
|
||||
none: NO_ASSET_LABEL,
|
||||
label: assetRefLabel,
|
||||
|
||||
@@ -17,19 +17,42 @@ import {
|
||||
RenameVectorShapeCommand,
|
||||
SetVectorShapeStyleCommand,
|
||||
} from '../../core/commands/vectorArtCommands';
|
||||
import type { VectorArtAsset, VectorShape, VectorStroke } from '../../core/model/Asset';
|
||||
import type {
|
||||
VectorArtAsset,
|
||||
VectorGradient,
|
||||
VectorPaint,
|
||||
VectorShape,
|
||||
VectorStroke,
|
||||
} from '../../core/model/Asset';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
findShape,
|
||||
SHAPE_FILL_DEFAULT,
|
||||
shapeExtent,
|
||||
type ShapeOrderMove,
|
||||
shapeOrderTarget,
|
||||
vectorCanvasSize,
|
||||
} from '../../core/model/vectorart';
|
||||
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
|
||||
import {
|
||||
FILL_KINDS,
|
||||
type FillKind,
|
||||
fillKind,
|
||||
fillPaintFor,
|
||||
MIN_STOPS,
|
||||
paintExtent,
|
||||
STROKE_KINDS,
|
||||
type StrokeKind,
|
||||
strokeKind,
|
||||
strokePaintFor,
|
||||
withoutStop,
|
||||
withStopChanged,
|
||||
} from '../../core/model/vectorPaint';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, HStack, Theme } from '../../ui';
|
||||
import { shapeSelection } from '../state';
|
||||
import { type EditorState, shapeSelection } from '../state';
|
||||
import { GradientBar } from '../vectorart/GradientBar';
|
||||
import { ShapeList } from '../vectorart/ShapeList';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
@@ -38,13 +61,103 @@ import { previewingHandlers } from './previewHandlers';
|
||||
/** The outline a shape gets when one is switched on: black, two canvas pixels wide. */
|
||||
const DEFAULT_STROKE: VectorStroke = { color: 0x000000, width: 2 };
|
||||
|
||||
/** What a style row writes while it is being dragged; `stroke` is never taken away by a preview. */
|
||||
/** What a style row writes while it is being dragged; nothing is ever taken away by a preview. */
|
||||
interface PreviewStyle {
|
||||
fill?: number;
|
||||
opacity?: number;
|
||||
paint?: VectorPaint;
|
||||
stroke?: VectorStroke;
|
||||
}
|
||||
|
||||
/** Whose gradient a bar and its stop rows edit: the fill's or the outline's (#186). */
|
||||
export type PaintPart = 'fill' | 'stroke';
|
||||
|
||||
const FILL_KIND_LABELS: Record<FillKind, string> = {
|
||||
solid: 'Solid colour',
|
||||
linear: 'Linear gradient',
|
||||
radial: 'Radial gradient',
|
||||
texture: 'Texture',
|
||||
};
|
||||
|
||||
const FILL_TYPE_DEF: PropertyDef = {
|
||||
key: 'fillType',
|
||||
label: 'Fill type',
|
||||
kind: 'enum',
|
||||
default: 'solid',
|
||||
options: FILL_KINDS.map((value) => ({
|
||||
value,
|
||||
label: FILL_KIND_LABELS[value],
|
||||
})),
|
||||
};
|
||||
const LINE_TYPE_DEF: PropertyDef = {
|
||||
key: 'lineType',
|
||||
label: 'Line type',
|
||||
kind: 'enum',
|
||||
default: 'solid',
|
||||
options: STROKE_KINDS.map((value) => ({
|
||||
value,
|
||||
label: FILL_KIND_LABELS[value],
|
||||
})),
|
||||
};
|
||||
const TEXTURE_DEF: PropertyDef = {
|
||||
key: 'fillTexture',
|
||||
label: 'Texture',
|
||||
kind: 'texture',
|
||||
default: '',
|
||||
uploadedOnly: true,
|
||||
};
|
||||
|
||||
/** The three rows of the picked stop; the outline's carry a prefix so both sets can stand at once. */
|
||||
function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } {
|
||||
const prefix = part === 'fill' ? 'fillStop' : 'lineStop';
|
||||
return {
|
||||
color: { key: `${prefix}Color`, label: 'Stop colour', kind: 'color', default: 0xffffff },
|
||||
alpha: {
|
||||
key: `${prefix}Alpha`,
|
||||
label: 'Stop opacity',
|
||||
kind: 'number',
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
decimals: 2,
|
||||
},
|
||||
offset: {
|
||||
key: `${prefix}Offset`,
|
||||
label: 'Stop position',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
decimals: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The stop picked in each bar, by asset, shape and part. It outlives a rebuild of the rows – undo
|
||||
* rebuilds them – and falls back to the first stop when the one it names has gone.
|
||||
*/
|
||||
const pickedStops = new Map<string, number>();
|
||||
|
||||
function pickedStop(
|
||||
assetId: string,
|
||||
shapeId: string,
|
||||
part: PaintPart,
|
||||
gradient: VectorGradient | undefined,
|
||||
): number {
|
||||
const index = pickedStops.get(`${assetId}/${shapeId}/${part}`) ?? 0;
|
||||
return gradient && index < gradient.stops.length ? index : 0;
|
||||
}
|
||||
|
||||
/** The bars as they were last built, for the check scripts. */
|
||||
const builtBars = new Map<PaintPart, GradientBar>();
|
||||
|
||||
export function currentGradientBar(part: PaintPart): GradientBar | null {
|
||||
return builtBars.get(part) ?? null;
|
||||
}
|
||||
|
||||
const ORDER_BUTTONS: { move: ShapeOrderMove; label: string }[] = [
|
||||
{ move: 'front', label: 'To front' },
|
||||
{ move: 'forward', label: 'Forward' },
|
||||
@@ -115,7 +228,8 @@ export function shapeSignature(
|
||||
const shape = asset && findShape(asset, shapeId);
|
||||
if (!asset || !shape) return 'missing';
|
||||
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
|
||||
return `${shapeListSignature(asset)}|${shape.stroke ? 'stroke' : 'none'}|${shapeIds.join(',')}|${blocked}`;
|
||||
const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`;
|
||||
return `${shapeListSignature(asset)}|${paints}|${shapeIds.join(',')}|${blocked}`;
|
||||
}
|
||||
|
||||
/** The selected shapes in drawing order, the lowest first – the order they are combined in. */
|
||||
@@ -169,15 +283,7 @@ export function buildShapeSection(
|
||||
|
||||
addTitle(host, 'VECTOR SHAPE');
|
||||
addShapeName(host, assetId, shape);
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
shapeId,
|
||||
FILL_DEF,
|
||||
(s) => s.fill,
|
||||
(input) => ({ fill: Number(input) }),
|
||||
'Set fill',
|
||||
);
|
||||
fillRows(host, assetId, shape);
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
@@ -189,6 +295,8 @@ export function buildShapeSection(
|
||||
);
|
||||
outlineRow(host, assetId, shapeId, shape);
|
||||
if (shape.stroke) {
|
||||
kindRow(host, assetId, shape, 'stroke');
|
||||
if (strokeKind(shape) === 'solid')
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
@@ -198,6 +306,7 @@ export function buildShapeSection(
|
||||
(input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }),
|
||||
'Set line colour',
|
||||
);
|
||||
else gradientRows(host, assetId, shapeId, 'stroke');
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
@@ -259,6 +368,11 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
|
||||
if (!shape) return false;
|
||||
host.fields.get('name')?.setValue(shape.name);
|
||||
host.fields.get('fill')?.setValue(shape.fill);
|
||||
host.fields.get(FILL_TYPE_DEF.key)?.setValue(fillKind(shape));
|
||||
host.fields.get(LINE_TYPE_DEF.key)?.setValue(strokeKind(shape));
|
||||
host.fields.get(TEXTURE_DEF.key)?.setValue(shape.paint?.type === 'texture' ? shape.paint.texture : '');
|
||||
refreshStopRows(host, assetId, shape, 'fill');
|
||||
refreshStopRows(host, assetId, shape, 'stroke');
|
||||
host.fields.get('opacity')?.setValue(shape.opacity);
|
||||
host.fields.get('outline')?.setValue(Boolean(shape.stroke));
|
||||
host.fields.get('strokeColor')?.setValue(shape.stroke?.color ?? DEFAULT_STROKE.color);
|
||||
@@ -382,6 +496,7 @@ function styleRow(
|
||||
function applyPreview(shape: VectorShape, style: PreviewStyle): void {
|
||||
if (style.fill !== undefined) shape.fill = style.fill;
|
||||
if (style.opacity !== undefined) shape.opacity = Math.max(0, Math.min(1, style.opacity));
|
||||
if (style.paint !== undefined) shape.paint = style.paint;
|
||||
if (style.stroke !== undefined) shape.stroke = { ...style.stroke };
|
||||
}
|
||||
|
||||
@@ -406,3 +521,206 @@ function shapeNow(host: InspectorHost, assetId: string, shapeId: string): Vector
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fill: its kind, and under it what that kind is made of – the colour, the gradient's bar and
|
||||
* stops, or the picture. The colour row goes while a paint fills the shape; the colour itself stays
|
||||
* on the shape and is what "Solid colour" brings back.
|
||||
*/
|
||||
function fillRows(host: InspectorHost, assetId: string, shape: VectorShape): void {
|
||||
kindRow(host, assetId, shape, 'fill');
|
||||
const kind = fillKind(shape);
|
||||
if (kind === 'solid')
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
shape.id,
|
||||
FILL_DEF,
|
||||
(s) => s.fill,
|
||||
(input) => ({ fill: Number(input) }),
|
||||
'Set fill',
|
||||
);
|
||||
else if (kind === 'texture') textureRow(host, assetId, shape);
|
||||
else gradientRows(host, assetId, shape.id, 'fill');
|
||||
}
|
||||
|
||||
/** "Fill type" or "Line type"; what picking one does is `setPaintKind`. */
|
||||
function kindRow(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void {
|
||||
const def = part === 'fill' ? FILL_TYPE_DEF : LINE_TYPE_DEF;
|
||||
const value = part === 'fill' ? fillKind(shape) : strokeKind(shape);
|
||||
const row = createFieldRow(host, def, value, {
|
||||
onInput: () => {},
|
||||
onChange: (input) => setPaintKind(host.state, assetId, shape.id, part, textOf(input)),
|
||||
});
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the fill or the outline to another kind: the paint is laid out over the shape anew, a
|
||||
* gradient keeps its colours when it only changes between linear and radial, and it is one undo
|
||||
* step. Exported because the row's menu is the only other way in, and a test has no menus.
|
||||
*/
|
||||
export function setPaintKind(
|
||||
state: EditorState,
|
||||
assetId: string,
|
||||
shapeId: string,
|
||||
part: PaintPart,
|
||||
kind: string,
|
||||
): void {
|
||||
const asset = findAssetOf(state.project, assetId, 'vectorart');
|
||||
const shape = asset && findShape(asset, shapeId);
|
||||
if (!asset || !shape) return;
|
||||
const extent = paintExtent(shapeExtent(shape), vectorCanvasSize(asset.props));
|
||||
if (part === 'fill') {
|
||||
if (kind === fillKind(shape) || !(FILL_KINDS as readonly string[]).includes(kind)) return;
|
||||
const paint = fillPaintFor(shape, kind as FillKind, extent);
|
||||
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill type'));
|
||||
} else if (shape.stroke) {
|
||||
if (kind === strokeKind(shape) || !(STROKE_KINDS as readonly string[]).includes(kind)) return;
|
||||
const paint = strokePaintFor(shape, kind as StrokeKind, extent);
|
||||
const { color, width } = shape.stroke;
|
||||
const stroke: VectorStroke = paint ? { color, width, paint } : { color, width };
|
||||
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { stroke }, 'Line type'));
|
||||
}
|
||||
}
|
||||
|
||||
/** The picture of a texture fill; only uploaded textures and their frames are offered. */
|
||||
function textureRow(host: InspectorHost, assetId: string, shape: VectorShape): void {
|
||||
const value = shape.paint?.type === 'texture' ? shape.paint.texture : '';
|
||||
const row = createFieldRow(host, TEXTURE_DEF, value, {
|
||||
onInput: () => {},
|
||||
onChange: (input) => setFillTexture(host.state, assetId, shape.id, textOf(input)),
|
||||
});
|
||||
host.fields.set(TEXTURE_DEF.key, row);
|
||||
host.body.add(row.row);
|
||||
addNote(host, 'Paint tool: drag the corners of one tile.', 'small');
|
||||
}
|
||||
|
||||
/** Puts a picture into a texture fill; exported for the same reason as `setPaintKind`. */
|
||||
export function setFillTexture(state: EditorState, assetId: string, shapeId: string, texture: string): void {
|
||||
const asset = findAssetOf(state.project, assetId, 'vectorart');
|
||||
const shape = asset && findShape(asset, shapeId);
|
||||
if (shape?.paint?.type !== 'texture') return;
|
||||
const paint = { ...shape.paint, texture };
|
||||
state.execute(new SetVectorShapeStyleCommand(assetId, shapeId, { paint }, 'Fill texture'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar of a gradient and the three rows of its picked stop, then the button that takes the
|
||||
* picked stop out – there to be seen with two stops too, switched off, since a gradient needs two.
|
||||
*/
|
||||
function gradientRows(host: InspectorHost, assetId: string, shapeId: string, part: PaintPart): void {
|
||||
const key = `${assetId}/${shapeId}/${part}`;
|
||||
const gradientOf = (): VectorGradient | undefined => {
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
return shape ? gradientIn(shape, part) : undefined;
|
||||
};
|
||||
const bar = new GradientBar(host.state, {
|
||||
gradient: gradientOf,
|
||||
selected: () => pickedStop(assetId, shapeId, part, gradientOf()),
|
||||
select: (index) => {
|
||||
pickedStops.set(key, index);
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
if (shape) refreshStopRows(host, assetId, shape, part);
|
||||
bar.invalidate();
|
||||
},
|
||||
preview: (gradient) => {
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
if (!shape) return;
|
||||
applyPreview(shape, withGradient(shape, part, gradient));
|
||||
host.state.preview();
|
||||
},
|
||||
commit: (before, after, label) => {
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
if (!shape) return;
|
||||
applyPreview(shape, withGradient(shape, part, before));
|
||||
host.state.execute(
|
||||
new SetVectorShapeStyleCommand(assetId, shapeId, withGradient(shape, part, after), label),
|
||||
);
|
||||
},
|
||||
});
|
||||
builtBars.set(part, bar);
|
||||
host.body.add(bar);
|
||||
|
||||
const defs = stopDefs(part);
|
||||
const stopRow = (
|
||||
def: PropertyDef,
|
||||
read: (stop: { color: number; alpha: number; offset: number }) => number,
|
||||
field: 'color' | 'alpha' | 'offset',
|
||||
label: string,
|
||||
) =>
|
||||
styleRow(
|
||||
host,
|
||||
assetId,
|
||||
shapeId,
|
||||
def,
|
||||
(s) => {
|
||||
const gradient = gradientIn(s, part);
|
||||
const stop = gradient?.stops[pickedStop(assetId, shapeId, part, gradient)];
|
||||
return stop ? read(stop) : Number(def.default);
|
||||
},
|
||||
(input, s) => {
|
||||
const gradient = gradientIn(s, part);
|
||||
if (!gradient) return {};
|
||||
const index = pickedStop(assetId, shapeId, part, gradient);
|
||||
const next = withStopChanged(gradient, index, { [field]: Number(input) });
|
||||
return next ? withGradient(s, part, next) : {};
|
||||
},
|
||||
label,
|
||||
);
|
||||
stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop colour');
|
||||
stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity');
|
||||
stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move colour stop');
|
||||
|
||||
const remove = new Button('Remove stop', {
|
||||
variant: 'ghost',
|
||||
onClick: () => {
|
||||
const shape = currentShape(host, assetId, shapeId);
|
||||
const gradient = shape && gradientIn(shape, part);
|
||||
if (!shape || !gradient) return;
|
||||
const next = withoutStop(gradient, pickedStop(assetId, shapeId, part, gradient));
|
||||
if (!next) return;
|
||||
pickedStops.set(key, 0);
|
||||
host.state.execute(
|
||||
new SetVectorShapeStyleCommand(
|
||||
assetId,
|
||||
shapeId,
|
||||
withGradient(shape, part, next),
|
||||
'Remove colour stop',
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
remove.setEnabled((gradientOf()?.stops.length ?? 0) > MIN_STOPS);
|
||||
removeButtons.set(part, remove);
|
||||
host.body.add(remove);
|
||||
addNote(host, 'Click the bar to add a stop. Paint tool: drag the ends.', 'small');
|
||||
}
|
||||
|
||||
/** The "Remove stop" buttons as last built; a stop added or taken out switches them. */
|
||||
const removeButtons = new Map<PaintPart, Button>();
|
||||
|
||||
/** The picked stop's values into its three rows, after a pick, an undo or a drag on the bar. */
|
||||
function refreshStopRows(host: InspectorHost, assetId: string, shape: VectorShape, part: PaintPart): void {
|
||||
const gradient = gradientIn(shape, part);
|
||||
if (!gradient) return;
|
||||
const stop = gradient.stops[pickedStop(assetId, shape.id, part, gradient)];
|
||||
const defs = stopDefs(part);
|
||||
if (stop) {
|
||||
host.fields.get(defs.color.key)?.setValue(stop.color);
|
||||
host.fields.get(defs.alpha.key)?.setValue(stop.alpha);
|
||||
host.fields.get(defs.offset.key)?.setValue(stop.offset);
|
||||
}
|
||||
removeButtons.get(part)?.setEnabled(gradient.stops.length > MIN_STOPS);
|
||||
}
|
||||
|
||||
function gradientIn(shape: VectorShape, part: PaintPart): VectorGradient | undefined {
|
||||
const paint = part === 'fill' ? shape.paint : shape.stroke?.paint;
|
||||
return paint && paint.type !== 'texture' ? paint : undefined;
|
||||
}
|
||||
|
||||
/** A style that puts `gradient` where `part` says: the fill's paint, or the outline's. */
|
||||
function withGradient(shape: VectorShape, part: PaintPart, gradient: VectorGradient): PreviewStyle {
|
||||
return part === 'fill' ? { paint: gradient } : { stroke: { ...strokeOf(shape), paint: gradient } };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* The colour stops of a gradient as a bar (#186, the user's answer: "als Leiste bearbeiten"). The bar
|
||||
* shows the gradient over a checkerboard, so a stop's opacity can be seen; a marker under it stands
|
||||
* for each stop. Pressing a marker picks it and dragging moves it along, pressing the bar anywhere
|
||||
* else adds a stop there in the colour the gradient already has at that place.
|
||||
*
|
||||
* The bar reads the gradient out of the model on every redraw and writes only through its handlers:
|
||||
* a drag previews into the shape and is committed once on release, the same rule every row follows.
|
||||
*/
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import type { VectorGradient } from '../../core/model/Asset';
|
||||
import { cloneGradient, withStopAdded, withStopChanged } from '../../core/model/vectorPaint';
|
||||
import { type Size, Theme, Widget } from '../../ui';
|
||||
import { PaintCache } from '../../viewport/vectorArtPicture';
|
||||
import type { EditorState } from '../state';
|
||||
import { drawChecker } from '../texture/checker';
|
||||
|
||||
const BAR_HEIGHT = 18;
|
||||
const MARKER_HEIGHT = 14;
|
||||
/** Half the width of a marker, and how far from its middle a press still picks it. */
|
||||
const MARKER_HALF = 6;
|
||||
const DRAG_THRESHOLD = 2;
|
||||
|
||||
export interface GradientBarHandlers {
|
||||
gradient(): VectorGradient | undefined;
|
||||
selected(): number;
|
||||
select(index: number): void;
|
||||
/** Writes a gradient into the shape while a stop is dragged; no undo step yet. */
|
||||
preview(gradient: VectorGradient): void;
|
||||
/** One undo step from `before` to `after`; the model may still read like the preview. */
|
||||
commit(before: VectorGradient, after: VectorGradient, label: string): void;
|
||||
}
|
||||
|
||||
interface StopDrag {
|
||||
index: number;
|
||||
before: VectorGradient;
|
||||
after: VectorGradient;
|
||||
startX: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
export class GradientBar extends Widget {
|
||||
private readonly art = new Graphics();
|
||||
private readonly paints = new PaintCache();
|
||||
private drag: StopDrag | null = null;
|
||||
private readonly stop: () => void;
|
||||
|
||||
constructor(
|
||||
state: EditorState,
|
||||
private readonly handlers: GradientBarHandlers,
|
||||
) {
|
||||
super();
|
||||
this.cursor = Theme.hoverCursor;
|
||||
this.addChild(this.art);
|
||||
this.stop = state.on('project', () => this.invalidate());
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onMove(e));
|
||||
this.on('pointerup', () => this.onRelease());
|
||||
this.on('pointerupoutside', () => this.onRelease());
|
||||
}
|
||||
|
||||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||
this.stop();
|
||||
this.paints.destroy();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: this.minWidth, h: BAR_HEIGHT + MARKER_HEIGHT };
|
||||
}
|
||||
|
||||
/** Where a stop's marker sits on screen, and where the bar is – for the check scripts. */
|
||||
stopGlobal(index: number): { x: number; y: number } | null {
|
||||
const stop = this.handlers.gradient()?.stops[index];
|
||||
if (!stop) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + this.xOf(stop.offset), y: origin.y + BAR_HEIGHT + MARKER_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
barGlobal(offset: number): { x: number; y: number } {
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + this.xOf(offset), y: origin.y + BAR_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
/** The bar runs between the two outermost marker centres, so a stop at 0 or 1 is fully visible. */
|
||||
private xOf(offset: number): number {
|
||||
return MARKER_HALF + offset * this.span();
|
||||
}
|
||||
|
||||
private offsetAt(x: number): number {
|
||||
return Math.min(Math.max((x - MARKER_HALF) / this.span(), 0), 1);
|
||||
}
|
||||
|
||||
private span(): number {
|
||||
return Math.max(1, this.w - MARKER_HALF * 2);
|
||||
}
|
||||
|
||||
/** The marker under `x`: the picked one first, so a stop lying on another can still be dragged off. */
|
||||
private markerAt(gradient: VectorGradient, x: number): number {
|
||||
const picked = this.handlers.selected();
|
||||
const near = (i: number) => {
|
||||
const stop = gradient.stops[i];
|
||||
return stop !== undefined && Math.abs(this.xOf(stop.offset) - x) <= MARKER_HALF;
|
||||
};
|
||||
if (near(picked)) return picked;
|
||||
for (let i = gradient.stops.length - 1; i >= 0; i--) if (near(i)) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private onPress(e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const gradient = this.handlers.gradient();
|
||||
if (!gradient) return;
|
||||
e.stopPropagation();
|
||||
const local = this.toLocal(e.global);
|
||||
const marker = local.y >= BAR_HEIGHT ? this.markerAt(gradient, local.x) : -1;
|
||||
if (marker >= 0) {
|
||||
this.handlers.select(marker);
|
||||
const before = cloneGradient(gradient);
|
||||
this.drag = { index: marker, before, after: before, startX: local.x, moved: false };
|
||||
this.invalidate();
|
||||
return;
|
||||
}
|
||||
const after = withStopAdded(gradient, this.offsetAt(local.x));
|
||||
this.handlers.commit(cloneGradient(gradient), after, 'Add colour stop');
|
||||
this.handlers.select(after.stops.length - 1);
|
||||
}
|
||||
|
||||
private onMove(e: FederatedPointerEvent): void {
|
||||
const drag = this.drag;
|
||||
if (!drag) return;
|
||||
const x = this.toLocal(e.global).x;
|
||||
if (!drag.moved && Math.abs(x - drag.startX) < DRAG_THRESHOLD) return;
|
||||
drag.moved = true;
|
||||
drag.after = withStopChanged(drag.before, drag.index, { offset: this.offsetAt(x) }) ?? drag.before;
|
||||
this.handlers.preview(drag.after);
|
||||
}
|
||||
|
||||
private onRelease(): void {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (drag?.moved) this.handlers.commit(drag.before, drag.after, 'Move colour stop');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.art.clear();
|
||||
const gradient = this.handlers.gradient();
|
||||
const x0 = MARKER_HALF;
|
||||
const x1 = MARKER_HALF + this.span();
|
||||
drawChecker(g, x0, 0, x1 - x0, BAR_HEIGHT, this.w, this.h);
|
||||
if (gradient && gradient.stops.length > 0) {
|
||||
const fill = this.paints.gradient({
|
||||
type: 'linear',
|
||||
from: { x: x0, y: 0 },
|
||||
to: { x: x1, y: 0 },
|
||||
stops: [...gradient.stops].sort((a, b) => a.offset - b.offset),
|
||||
});
|
||||
g.rect(x0, 0, x1 - x0, BAR_HEIGHT).fill({ fill });
|
||||
}
|
||||
g.rect(x0, 0, x1 - x0, BAR_HEIGHT).stroke({ width: 1, color: Theme.colors.border });
|
||||
const picked = this.handlers.selected();
|
||||
for (const [index, stop] of (gradient?.stops ?? []).entries()) {
|
||||
const x = this.xOf(stop.offset);
|
||||
const top = BAR_HEIGHT + 1;
|
||||
const edge = index === picked ? Theme.colors.accent : Theme.colors.border;
|
||||
g.poly([x, top, x - 4, top + 4, x + 4, top + 4]).fill(edge);
|
||||
g.rect(x - MARKER_HALF + 1, top + 4, MARKER_HALF * 2 - 2, MARKER_HEIGHT - 6)
|
||||
.fill(stop.color)
|
||||
.stroke({ width: index === picked ? 2 : 1, color: edge });
|
||||
}
|
||||
this.paints.sweep();
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import type { VectorArtAsset, VectorShape } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { shapeDrawing } from '../../core/model/vectorPath';
|
||||
import { paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { type Size, Theme, textStyle, truncate, Widget } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { shapeDropGap, shapeDropMove, shapeRowOrder } from './shapeRows';
|
||||
@@ -52,7 +53,9 @@ export class ShapeList extends Widget {
|
||||
private readonly shown: readonly string[];
|
||||
private hoveredRow = -1;
|
||||
private drag: RowDrag | null = null;
|
||||
private readonly paints = new PaintCache();
|
||||
private readonly stop: () => void;
|
||||
private readonly stopPictures: () => void;
|
||||
|
||||
/**
|
||||
* `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with
|
||||
@@ -75,6 +78,8 @@ export class ShapeList extends Widget {
|
||||
this.addChild(this.marker);
|
||||
// A point dragged in the canvas changes no row, only what a row shows.
|
||||
this.stop = state.on('project', () => this.invalidate());
|
||||
// A shape filled with a picture (#186) shows its colour until the picture has landed.
|
||||
this.stopPictures = textureLibrary.onChange(() => this.invalidate());
|
||||
|
||||
this.on('pointermove', (e: FederatedPointerEvent) => {
|
||||
const index = this.rowAt(this.toLocal(e.global).y);
|
||||
@@ -95,6 +100,8 @@ export class ShapeList extends Widget {
|
||||
|
||||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||
this.stop();
|
||||
this.stopPictures();
|
||||
this.paints.destroy();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
@@ -197,6 +204,7 @@ export class ShapeList extends Widget {
|
||||
row.label.position.set(PAD * 2 + SWATCH, (SHAPE_ROW_HEIGHT - row.label.height) / 2);
|
||||
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
|
||||
}
|
||||
this.paints.sweep();
|
||||
if (this.drag?.active) {
|
||||
const y = this.dropGap(this.drag.y) * SHAPE_ROW_HEIGHT;
|
||||
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
|
||||
@@ -220,8 +228,8 @@ export class ShapeList extends Widget {
|
||||
row.art.clear();
|
||||
row.art.position.set(x, y);
|
||||
row.art.scale.set(scale);
|
||||
const drawing = shapeDrawing(shape);
|
||||
if (drawing) paintDrawing(row.art, drawing);
|
||||
const drawing = shapeDrawing(shape, this.state.project);
|
||||
if (drawing) paintDrawing(row.art, drawing, this.paints);
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
|
||||
@@ -15,14 +15,24 @@ import {
|
||||
AddVectorShapeCommand,
|
||||
RemoveVectorShapeCommand,
|
||||
RemoveVectorShapesCommand,
|
||||
paintsOf,
|
||||
SetVectorShapeRingsCommand,
|
||||
SetVectorShapeStyleCommand,
|
||||
} from '../../core/commands/vectorArtCommands';
|
||||
import type { VectorArtAsset, VectorPoint, VectorRing, VectorShape } from '../../core/model/Asset';
|
||||
import type {
|
||||
VectorArtAsset,
|
||||
VectorPaint,
|
||||
VectorPoint,
|
||||
VectorRing,
|
||||
VectorShape,
|
||||
} from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
|
||||
import { vectorArtDrawings } from '../../core/model/vectorPath';
|
||||
import { paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import {
|
||||
cancelFrameWork,
|
||||
onFrame,
|
||||
@@ -53,8 +63,14 @@ import { type EditorState, shapeSelection } from '../state';
|
||||
import { drawChecker } from '../texture/checker';
|
||||
import { fitView, imageToLocal, localToImage, type SheetView, zoomAbout } from '../texture/sheetView';
|
||||
|
||||
/** Which gesture a press begins: the arrow edits what is there, the pen adds to it. */
|
||||
export type VectorTool = 'select' | 'pen';
|
||||
/**
|
||||
* Which gesture a press begins: the arrow edits what is there, the pen adds to it, and the paint
|
||||
* tool moves the two handles of the selected shape's gradient or picture fill (#186).
|
||||
*/
|
||||
export type VectorTool = 'select' | 'pen' | 'paint';
|
||||
|
||||
/** Whose paint the paint tool shows: the fill's or the outline's. */
|
||||
export type PaintTarget = 'fill' | 'stroke';
|
||||
|
||||
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
||||
const POINT_SIZE = 8;
|
||||
@@ -80,6 +96,20 @@ interface ShapeDrag {
|
||||
at: Vec2;
|
||||
moved: boolean;
|
||||
label: string;
|
||||
/** How far a whole shape has been dragged, and its paints as they were – they travel with it. */
|
||||
delta: Vec2;
|
||||
paints: ReturnType<typeof paintsOf>;
|
||||
}
|
||||
|
||||
/** A paint handle being dragged; `before` is the paint as it was, the undo step's other side. */
|
||||
interface PaintDrag {
|
||||
shapeId: string;
|
||||
target: PaintTarget;
|
||||
handle: 'from' | 'to';
|
||||
before: VectorPaint;
|
||||
paint: VectorPaint;
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** Counts canvases so each gets its own key in the frame queue. */
|
||||
@@ -101,6 +131,11 @@ export class VectorArtCanvas extends Widget {
|
||||
private snap = false;
|
||||
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
|
||||
private drag: ShapeDrag | null = null;
|
||||
private paintDrag: PaintDrag | null = null;
|
||||
private target: PaintTarget = 'fill';
|
||||
/** Gradients live on the GPU; one repaint reuses what the one before made. */
|
||||
private readonly paints = new PaintCache();
|
||||
private readonly stopPictures: () => void;
|
||||
/** The ring the pen is drawing into; empty while no line is open. */
|
||||
private pen = { shapeId: '', ring: -1 };
|
||||
/** The point last pressed or put down – the one the header's point type is about (#195). */
|
||||
@@ -136,6 +171,14 @@ export class VectorArtCanvas extends Widget {
|
||||
this.pick(null);
|
||||
this.redraw();
|
||||
});
|
||||
// A picture fill (#186) shows the shape's colour until its picture has landed.
|
||||
this.stopPictures = textureLibrary.onChange(() => this.redraw());
|
||||
}
|
||||
|
||||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||
this.stopPictures();
|
||||
this.paints.destroy();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
@@ -192,6 +235,22 @@ export class VectorArtCanvas extends Widget {
|
||||
return this.snap;
|
||||
}
|
||||
|
||||
setPaintTarget(target: PaintTarget): void {
|
||||
if (this.target === target) return;
|
||||
this.target = target;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get paintTarget(): PaintTarget {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
/** The paint the paint tool is about: the selected shape's fill or outline paint, if it has one. */
|
||||
shownPaint(): VectorPaint | null {
|
||||
const shape = this.shape(this.selectedShapeId());
|
||||
return (shape && paintOf(shape, this.target)) ?? null;
|
||||
}
|
||||
|
||||
/** Whether the pen has a line open – the check scripts and the hint read it. */
|
||||
get drawing(): boolean {
|
||||
return this.pen.ring >= 0;
|
||||
@@ -287,6 +346,7 @@ export class VectorArtCanvas extends Widget {
|
||||
const at = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = POINT_SIZE / this.view.zoom;
|
||||
if (this.tool === 'pen') this.pressPen(asset, at, local, tolerance);
|
||||
else if (this.tool === 'paint') this.pressPaint(asset, at, local, tolerance);
|
||||
else this.pressSelect(asset, at, local, tolerance, e);
|
||||
e.stopPropagation();
|
||||
}
|
||||
@@ -344,6 +404,8 @@ export class VectorArtCanvas extends Widget {
|
||||
at,
|
||||
moved: false,
|
||||
label: 'Draw shape',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: {},
|
||||
};
|
||||
this.preview();
|
||||
}
|
||||
@@ -395,6 +457,8 @@ export class VectorArtCanvas extends Widget {
|
||||
at,
|
||||
moved: false,
|
||||
label: 'Move shape',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: paintsOf(shape),
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -403,6 +467,37 @@ export class VectorArtCanvas extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* A press with the paint tool: on a handle of the shown paint it drags that handle – `to` first,
|
||||
* so a gradient squeezed to nothing can be pulled out again – anywhere else it picks the shape
|
||||
* under the pointer, whose handles are then the ones shown.
|
||||
*/
|
||||
private pressPaint(asset: VectorArtAsset, at: Vec2, local: Vec2, tolerance: number): void {
|
||||
const shape = this.shape(this.selectedShapeId());
|
||||
const paint = shape && paintOf(shape, this.target);
|
||||
if (shape && paint) {
|
||||
const handle = (['to', 'from'] as const).find(
|
||||
(end) => Math.hypot(paint[end].x - at.x, paint[end].y - at.y) <= tolerance,
|
||||
);
|
||||
if (handle) {
|
||||
this.paintDrag = {
|
||||
shapeId: shape.id,
|
||||
target: this.target,
|
||||
handle,
|
||||
before: paint,
|
||||
paint,
|
||||
pointer: { x: local.x, y: local.y },
|
||||
moved: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
const hit = this.shapeAt(asset, at);
|
||||
this.pick(null);
|
||||
this.state.select(hit ? shapeSelection(asset.id, [hit.id]) : { kind: 'asset', assetId: asset.id });
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The shape under a canvas pixel. Last first: a later shape is drawn on top of an earlier one. */
|
||||
private shapeAt(asset: VectorArtAsset, at: Vec2): VectorShape | undefined {
|
||||
for (let i = asset.shapes.length - 1; i >= 0; i--) {
|
||||
@@ -454,6 +549,8 @@ export class VectorArtCanvas extends Widget {
|
||||
at,
|
||||
moved: grip.kind === 'segment',
|
||||
label: grip.kind === 'segment' ? 'Add point' : grip.kind === 'handle' ? 'Bend shape' : 'Move point',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: {},
|
||||
};
|
||||
if (grip.kind === 'segment') this.preview();
|
||||
else this.redraw();
|
||||
@@ -470,6 +567,10 @@ export class VectorArtCanvas extends Widget {
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
if (this.paintDrag) {
|
||||
this.movePaint(this.paintDrag, local);
|
||||
return;
|
||||
}
|
||||
const drag = this.drag;
|
||||
if (!drag) {
|
||||
this.updateCursor(local);
|
||||
@@ -483,6 +584,7 @@ export class VectorArtCanvas extends Widget {
|
||||
if (drag.kind === 'shape') {
|
||||
const delta = this.snapped({ x: to.x - drag.at.x, y: to.y - drag.at.y });
|
||||
const before = drag.before;
|
||||
drag.delta = delta;
|
||||
drag.rings = before.map((r) => ringMoved(r, delta.x, delta.y));
|
||||
} else if (ring && drag.kind === 'handle') {
|
||||
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
|
||||
@@ -500,6 +602,18 @@ export class VectorArtCanvas extends Widget {
|
||||
this.preview();
|
||||
}
|
||||
|
||||
private movePaint(drag: PaintDrag, local: Vec2): void {
|
||||
if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD)
|
||||
return;
|
||||
drag.moved = true;
|
||||
drag.paint = paintWithHandle(
|
||||
drag.before,
|
||||
drag.handle,
|
||||
this.snapped(localToImage(this.view, local.x, local.y)),
|
||||
);
|
||||
this.preview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the gesture into the model as a preview; no undo step until the pointer is up.
|
||||
*
|
||||
@@ -514,15 +628,34 @@ export class VectorArtCanvas extends Widget {
|
||||
}
|
||||
|
||||
private writePreview(): void {
|
||||
if (this.destroyed) return;
|
||||
const paintDrag = this.paintDrag;
|
||||
const painted = paintDrag && this.shape(paintDrag.shapeId);
|
||||
if (paintDrag && painted) {
|
||||
setPaintOf(painted, paintDrag.target, paintDrag.paint);
|
||||
this.state.preview();
|
||||
return;
|
||||
}
|
||||
const drag = this.drag;
|
||||
const shape = drag && this.shape(drag.shapeId);
|
||||
if (this.destroyed || !drag || !shape) return;
|
||||
if (!drag || !shape) return;
|
||||
shape.rings = drag.rings;
|
||||
// A whole shape takes its gradients along (#186), handles and all.
|
||||
if (drag.kind === 'shape') {
|
||||
const delta = drag.delta;
|
||||
if (drag.paints.paint) shape.paint = paintMoved(drag.paints.paint, delta.x, delta.y);
|
||||
if (drag.paints.strokePaint && shape.stroke)
|
||||
shape.stroke.paint = paintMoved(drag.paints.strokePaint, delta.x, delta.y);
|
||||
}
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
this.panning = null;
|
||||
if (this.paintDrag) {
|
||||
this.releasePaint(this.paintDrag);
|
||||
return;
|
||||
}
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
// The gesture is over, so a preview still waiting for its frame has nothing left to write.
|
||||
@@ -532,11 +665,49 @@ export class VectorArtCanvas extends Widget {
|
||||
// The preview moved it already; put it back so the command records the state that was there.
|
||||
shape.rings = drag.before;
|
||||
if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring };
|
||||
const moved = drag.kind === 'shape' ? this.movedPaints(drag) : null;
|
||||
if (moved) {
|
||||
if (drag.paints.paint) shape.paint = drag.paints.paint;
|
||||
if (drag.paints.strokePaint && shape.stroke) shape.stroke.paint = drag.paints.strokePaint;
|
||||
}
|
||||
if (SetVectorShapeRingsCommand.changes(shape, drag.rings))
|
||||
this.state.execute(new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label));
|
||||
this.state.execute(
|
||||
new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label, moved),
|
||||
);
|
||||
else this.state.preview();
|
||||
}
|
||||
|
||||
/** The paints of a dragged shape where the drag leaves them; null when it has none to move. */
|
||||
private movedPaints(drag: ShapeDrag): ReturnType<typeof paintsOf> | null {
|
||||
const { paint, strokePaint } = drag.paints;
|
||||
if (!paint && !strokePaint) return null;
|
||||
return {
|
||||
...(paint ? { paint: paintMoved(paint, drag.delta.x, drag.delta.y) } : {}),
|
||||
...(strokePaint ? { strokePaint: paintMoved(strokePaint, drag.delta.x, drag.delta.y) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** One handle dragged is one undo step, written through the same command as the inspector's rows. */
|
||||
private releasePaint(drag: PaintDrag): void {
|
||||
this.paintDrag = null;
|
||||
cancelFrameWork(this.frameKey);
|
||||
const shape = this.shape(drag.shapeId);
|
||||
if (!shape) return;
|
||||
setPaintOf(shape, drag.target, drag.before);
|
||||
if (!drag.moved) {
|
||||
this.state.preview();
|
||||
return;
|
||||
}
|
||||
const style =
|
||||
drag.target === 'fill'
|
||||
? { paint: drag.paint }
|
||||
: shape.stroke && drag.paint.type !== 'texture'
|
||||
? { stroke: { ...shape.stroke, paint: drag.paint } }
|
||||
: null;
|
||||
if (style)
|
||||
this.state.execute(new SetVectorShapeStyleCommand(this.assetId, shape.id, style, 'Move paint handle'));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
|
||||
@@ -557,6 +728,14 @@ export class VectorArtCanvas extends Widget {
|
||||
}
|
||||
const at = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = POINT_SIZE / this.view.zoom;
|
||||
if (this.tool === 'paint') {
|
||||
const paint = this.shownPaint();
|
||||
const onHandle =
|
||||
paint !== null &&
|
||||
[paint.from, paint.to].some((end) => Math.hypot(end.x - at.x, end.y - at.y) <= tolerance);
|
||||
this.cursor = onHandle ? 'pointer' : 'default';
|
||||
return;
|
||||
}
|
||||
const selected = this.shape(this.selectedShapeId());
|
||||
const onGrip = selected?.rings.some((ring) => ringGripAt(ring, at, tolerance) !== null) ?? false;
|
||||
const inside = asset.shapes.some((shape) => shape.rings.some((ring) => insideRing(ring, at)));
|
||||
@@ -620,11 +799,16 @@ export class VectorArtCanvas extends Widget {
|
||||
// The drawing itself is in canvas pixels; the container carries the view instead.
|
||||
this.art.position.set(origin.x, origin.y);
|
||||
this.art.scale.set(this.view.zoom);
|
||||
for (const drawing of vectorArtDrawings(asset)) paintDrawing(this.art, drawing);
|
||||
for (const drawing of vectorArtDrawings(asset, this.state.project))
|
||||
paintDrawing(this.art, drawing, this.paints);
|
||||
this.paints.sweep();
|
||||
this.drawFrame(origin, size);
|
||||
for (const shapeId of this.selectedShapeIds())
|
||||
if (shapeId !== this.selectedShapeId()) this.drawOutline(this.shape(shapeId));
|
||||
this.drawShape(this.shape(this.selectedShapeId()));
|
||||
if (this.tool === 'paint') {
|
||||
this.drawOutline(this.shape(this.selectedShapeId()));
|
||||
this.drawPaintHandles(this.shownPaint());
|
||||
} else this.drawShape(this.shape(this.selectedShapeId()));
|
||||
}
|
||||
this.syncHint(asset);
|
||||
}
|
||||
@@ -645,6 +829,40 @@ export class VectorArtCanvas extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two handles of a paint, joined by a line. A picture fill also shows the tile its handles
|
||||
* span – that rectangle is the one copy of the picture every other is repeated from.
|
||||
*/
|
||||
private drawPaintHandles(paint: VectorPaint | null): void {
|
||||
if (!paint) return;
|
||||
const from = imageToLocal(this.view, paint.from.x, paint.from.y);
|
||||
const to = imageToLocal(this.view, paint.to.x, paint.to.y);
|
||||
const color = Theme.colors.marquee;
|
||||
if (paint.type === 'texture')
|
||||
this.overlay
|
||||
.rect(
|
||||
Math.min(from.x, to.x),
|
||||
Math.min(from.y, to.y),
|
||||
Math.abs(to.x - from.x),
|
||||
Math.abs(to.y - from.y),
|
||||
)
|
||||
.stroke({ width: 1, color });
|
||||
else if (paint.type === 'radial')
|
||||
this.overlay
|
||||
.circle(from.x, from.y, Math.hypot(to.x - from.x, to.y - from.y))
|
||||
.stroke({ width: 1, color, alpha: 0.6 });
|
||||
this.overlay.moveTo(from.x, from.y).lineTo(to.x, to.y).stroke({ width: 1, color });
|
||||
const half = POINT_SIZE / 2;
|
||||
this.overlay
|
||||
.rect(from.x - half, from.y - half, POINT_SIZE, POINT_SIZE)
|
||||
.fill(Theme.colors.accent)
|
||||
.stroke({ width: 1, color: Theme.colors.accentText });
|
||||
this.overlay
|
||||
.circle(to.x, to.y, HANDLE_RADIUS + 1)
|
||||
.fill(Theme.colors.accentText)
|
||||
.stroke({ width: 1, color: Theme.colors.accent });
|
||||
}
|
||||
|
||||
/** The selected shape's points and handles, in widget pixels so they keep their size. */
|
||||
private drawShape(shape: VectorShape | undefined): void {
|
||||
if (!shape) return;
|
||||
@@ -678,6 +896,16 @@ export class VectorArtCanvas extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
private paintHint(): string {
|
||||
const shape = this.shape(this.selectedShapeId());
|
||||
const part = this.target === 'fill' ? 'fill' : 'outline';
|
||||
if (!shape) return 'Paint: click a shape to show the handles of its gradient or texture.';
|
||||
if (this.target === 'stroke' && !shape.stroke) return 'Paint: this shape has no outline.';
|
||||
return this.shownPaint()
|
||||
? `Paint: drag the two handles of the ${part}.`
|
||||
: `Paint: the ${part} is one colour – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`;
|
||||
}
|
||||
|
||||
private syncHint(asset: VectorArtAsset | null): void {
|
||||
const text = !asset
|
||||
? 'No vector drawing selected – pick one in the Assets panel.'
|
||||
@@ -685,11 +913,24 @@ export class VectorArtCanvas extends Widget {
|
||||
? this.drawing
|
||||
? 'Pen: click adds a point, drag bends it, click the first point to close, Esc ends the line.'
|
||||
: 'Pen: click to start a shape.'
|
||||
: 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;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Asset, VectorArtAsset } from '../../core/model/Asset';
|
||||
import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { Button, Dropdown, Menu } from '../../ui';
|
||||
import { Button, Dropdown, Menu, type Stack } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import { POINT_KINDS, type PointKind } from '../polygonEdit';
|
||||
import type { EditorState } from '../state';
|
||||
import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
|
||||
import { type PaintTarget, VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
|
||||
|
||||
/** Room kept for the "256 × 256 px · 3 shapes" readout. */
|
||||
const INFO_WIDTH = 190;
|
||||
@@ -23,6 +23,18 @@ function pointLabel(kind: PointKind | null): string {
|
||||
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Paint tool's menu takes the point type's place in the header (#186) – a point is not picked
|
||||
* with the Paint tool, and the header has no room for both next to the size readout.
|
||||
*/
|
||||
const PAINT_TARGET_WIDTH = POINT_WIDTH;
|
||||
|
||||
const PAINT_TARGET_LABELS: Record<PaintTarget, string> = { fill: 'Fill', stroke: 'Outline' };
|
||||
|
||||
function paintTargetLabel(target: PaintTarget): string {
|
||||
return `Handles: ${PAINT_TARGET_LABELS[target]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Vector art tab (#129, stage 3): the canvas shows the drawing and edits it, the header picks
|
||||
* the tool. Fill, outline and the canvas size are rows of the inspector, as with every other asset –
|
||||
@@ -32,6 +44,8 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
/** Public for the check scripts: the two tools, the magnet and the picked point's type. */
|
||||
readonly selectButton: Button;
|
||||
readonly penButton: Button;
|
||||
readonly paintButton: Button;
|
||||
readonly paintTargetButton = new Dropdown(paintTargetLabel('fill'), { width: PAINT_TARGET_WIDTH });
|
||||
readonly snap = new SnapToggle(() => this.canvas);
|
||||
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
|
||||
|
||||
@@ -39,10 +53,19 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
|
||||
this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') });
|
||||
this.paintButton = new Button('Paint', { toggle: true, onClick: () => this.setTool('paint') });
|
||||
this.pointButton.onClick = () => this.openPointMenu();
|
||||
this.paintTargetButton.onClick = () => this.openPaintTargetMenu();
|
||||
const canvas = new VectorArtCanvas(state);
|
||||
canvas.onPointPicked = () => this.syncPoint();
|
||||
this.mount(canvas, [this.selectButton, this.penButton, this.pointButton, ...this.snap.widgets]);
|
||||
this.mount(canvas, [
|
||||
this.selectButton,
|
||||
this.penButton,
|
||||
this.paintButton,
|
||||
this.pointButton,
|
||||
this.paintTargetButton,
|
||||
...this.snap.widgets,
|
||||
]);
|
||||
this.setTool('select');
|
||||
}
|
||||
|
||||
@@ -56,6 +79,21 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
new Menu(items).openBelow(this.pointButton);
|
||||
}
|
||||
|
||||
/** Whose handles the paint tool shows (#186): the fill's or the outline's. */
|
||||
private openPaintTargetMenu(): void {
|
||||
const items = (['fill', 'stroke'] as const).map((target) => ({
|
||||
label: PAINT_TARGET_LABELS[target],
|
||||
checked: () => this.canvas.paintTarget === target,
|
||||
onSelect: () => this.setPaintTarget(target),
|
||||
}));
|
||||
new Menu(items).openBelow(this.paintTargetButton);
|
||||
}
|
||||
|
||||
setPaintTarget(target: PaintTarget): void {
|
||||
this.canvas.setPaintTarget(target);
|
||||
this.paintTargetButton.setText(paintTargetLabel(target));
|
||||
}
|
||||
|
||||
private syncPoint(): void {
|
||||
const kind = this.canvas.pickedKind;
|
||||
this.pointButton.setText(pointLabel(kind));
|
||||
@@ -66,6 +104,10 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
this.canvas.setTool(tool);
|
||||
this.selectButton.setToggled(tool === 'select');
|
||||
this.penButton.setToggled(tool === 'pen');
|
||||
this.paintButton.setToggled(tool === 'paint');
|
||||
this.pointButton.visible = tool !== 'paint';
|
||||
this.paintTargetButton.visible = tool === 'paint';
|
||||
(this.paintTargetButton.parent as Stack | null)?.relayout();
|
||||
}
|
||||
|
||||
/** Where a canvas pixel of the shown drawing sits on screen – for the check scripts. */
|
||||
@@ -97,6 +139,8 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
this.fitButton.setEnabled(asset !== null);
|
||||
this.selectButton.setEnabled(asset !== null);
|
||||
this.penButton.setEnabled(asset !== null);
|
||||
this.paintButton.setEnabled(asset !== null);
|
||||
this.paintTargetButton.setEnabled(asset !== null);
|
||||
this.snap.setEnabled(asset !== null);
|
||||
this.syncPoint();
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export class GeneratedPictures<T> {
|
||||
* 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<string, PictureGenerator>();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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<string, FillGradient>();
|
||||
private readonly used = new Set<string>();
|
||||
|
||||
gradient(paint: Extract<DrawPaint, { stops: unknown }>): 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 {
|
||||
|
||||
@@ -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> = {}): VectorShape {
|
||||
return { id: 's', name: 'Shape 1', rings: [SQUARE], fill: 0x336699, opacity: 1, ...over };
|
||||
}
|
||||
|
||||
function gradient(over: Partial<VectorGradient> = {}): 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<string, unknown>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
+245
-1
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user