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
+2090
-98
No files matched your search
@@ -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,7 +77,9 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
? { options: { value: string; label: string }[] }
|
||||
: K extends 'asset'
|
||||
? { assetTypes?: readonly AssetType[] }
|
||||
: unknown;
|
||||
: K extends 'texture'
|
||||
? { uploadedOnly?: boolean }
|
||||
: unknown;
|
||||
|
||||
/**
|
||||
* The definition of a flat kind: its default is a value of that kind, as the table says.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user