153 lines
6.5 KiB
TypeScript
153 lines
6.5 KiB
TypeScript
/**
|
||
* Vector art (#129): shapes made of rings of points, rasterised onto a canvas and handed to the
|
||
* picture path as a generated picture (#185). This module is the model side of it – what a canvas
|
||
* is, what a shape has to look like to be drawable, and what makes the picture a new one.
|
||
*
|
||
* The canvas size is a property, so the inspector edits it with undo like any other row and a
|
||
* placement could in principle override it; the shapes are intrinsic data on the asset, as a
|
||
* texture's frames are. Everything is drawn in canvas pixels from the top-left corner, and what
|
||
* lies outside the canvas is cut off (answer 3) – otherwise a sprite's size would be a lie.
|
||
*/
|
||
import type { Asset, VectorArtAsset, VectorPoint, VectorRing, VectorShape } from './Asset';
|
||
import type { GeneratedPicture } from './generated';
|
||
import type { PropertyBag, Vec2 } from './Properties';
|
||
|
||
export const VECTOR_WIDTH_KEY = 'canvasWidth';
|
||
export const VECTOR_HEIGHT_KEY = 'canvasHeight';
|
||
export const VECTOR_CANVAS_DEFAULT = 256;
|
||
/** As wide as a texture may sensibly be rasterised; the supersampling of #129 doubles it again. */
|
||
export const VECTOR_CANVAS_MAX = 4096;
|
||
/** The fill of a shape nobody has coloured yet: a mid grey, visible on a light and a dark canvas. */
|
||
export const SHAPE_FILL_DEFAULT = 0x8899aa;
|
||
|
||
/** A canvas in pixels, read from the bag so a placement's override is seen too. */
|
||
export function vectorCanvasSize(props: PropertyBag): { width: number; height: number } {
|
||
return { width: canvasSide(props[VECTOR_WIDTH_KEY]), height: canvasSide(props[VECTOR_HEIGHT_KEY]) };
|
||
}
|
||
|
||
function canvasSide(value: unknown): number {
|
||
const side = Math.floor(Number(value));
|
||
return Number.isFinite(side) && side >= 1 ? Math.min(side, VECTOR_CANVAS_MAX) : VECTOR_CANVAS_DEFAULT;
|
||
}
|
||
|
||
export function findShape(asset: VectorArtAsset, shapeId: string): VectorShape | undefined {
|
||
return asset.shapes.find((shape) => shape.id === shapeId);
|
||
}
|
||
|
||
/**
|
||
* Whether the ring is worth drawing: a closed one needs three points to enclose anything, an open
|
||
* one two to be a line. A ring below that is kept – it is a shape someone is still drawing – but
|
||
* the rasteriser passes over it.
|
||
*/
|
||
export function isDrawableRing(ring: VectorRing): boolean {
|
||
return ring.points.length >= (ring.closed ? 3 : 2);
|
||
}
|
||
|
||
export function isDrawableShape(shape: VectorShape): boolean {
|
||
return shape.rings.some(isDrawableRing);
|
||
}
|
||
|
||
/** How far a shape reaches, in canvas pixels, handles included; null when it has no point at all. */
|
||
export function shapeExtent(shape: VectorShape): { x0: number; y0: number; x1: number; y1: number } | null {
|
||
let x0 = Infinity;
|
||
let y0 = Infinity;
|
||
let x1 = -Infinity;
|
||
let y1 = -Infinity;
|
||
for (const ring of shape.rings)
|
||
for (const point of ring.points)
|
||
for (const at of [point, handleAt(point, 'in'), handleAt(point, 'out')]) {
|
||
if (!at) continue;
|
||
x0 = Math.min(x0, at.x);
|
||
y0 = Math.min(y0, at.y);
|
||
x1 = Math.max(x1, at.x);
|
||
y1 = Math.max(y1, at.y);
|
||
}
|
||
return Number.isFinite(x0) ? { x0, y0, x1, y1 } : null;
|
||
}
|
||
|
||
/** A handle in canvas coordinates: it is stored as an offset from its point. */
|
||
export function handleAt(point: VectorPoint, side: 'in' | 'out'): { x: number; y: number } | null {
|
||
const handle = point[side];
|
||
return handle ? { x: point.x + handle.x, y: point.y + handle.y } : null;
|
||
}
|
||
|
||
/**
|
||
* The token that says which drawing this is (#185). It is a fingerprint of what is drawn and of the
|
||
* canvas it is drawn on, not a counter: a counter would have to be raised by every command that
|
||
* 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 {
|
||
const { width, height } = vectorCanvasSize(props);
|
||
return fingerprint(`${width}x${height}|${asset.shapes.map(drawingText).join('|')}`);
|
||
}
|
||
|
||
/**
|
||
* What one shape draws, written out field by field in a fixed order. Deliberately not
|
||
* `JSON.stringify` of the shape: that writes the keys in the order they were set, so the same
|
||
* drawing came back from a project file as `{points, closed}` where it was built 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}` : '-';
|
||
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(';')}]`;
|
||
}
|
||
|
||
function handleText(handle: Vec2 | undefined): string {
|
||
return handle ? `${handle.x},${handle.y}` : '-';
|
||
}
|
||
|
||
/** FNV-1a over the text, in base 36: short, stable across sessions, and free of `@` (see #185). */
|
||
function fingerprint(text: string): string {
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < text.length; i++) {
|
||
hash ^= text.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||
}
|
||
return hash.toString(36);
|
||
}
|
||
|
||
/**
|
||
* What the picture path is told about a vector art asset (#185). An asset without a drawable shape
|
||
* 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 {
|
||
if (asset.type !== 'vectorart') return null;
|
||
const { width, height } = vectorCanvasSize(asset.props);
|
||
return { width, height, revision: vectorArtRevision(asset) };
|
||
}
|
||
|
||
/** Where a shape lands when it is moved in the drawing order (the user's four buttons of #129). */
|
||
export type ShapeOrderMove = 'forward' | 'front' | 'backward' | 'back';
|
||
|
||
/**
|
||
* The index `shapeId` moves to, or -1 when it is already there or not in the list. The list order is
|
||
* the drawing order and the last shape lies on top, so "forward" is one step towards the end.
|
||
*/
|
||
export function shapeOrderTarget(
|
||
shapes: readonly VectorShape[],
|
||
shapeId: string,
|
||
move: ShapeOrderMove,
|
||
): number {
|
||
const from = shapes.findIndex((shape) => shape.id === shapeId);
|
||
if (from < 0) return -1;
|
||
const last = shapes.length - 1;
|
||
const to =
|
||
move === 'forward'
|
||
? Math.min(from + 1, last)
|
||
: move === 'front'
|
||
? last
|
||
: move === 'backward'
|
||
? Math.max(from - 1, 0)
|
||
: 0;
|
||
return to === from ? -1 : to;
|
||
}
|