/** * 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; }