The asset type "vector art" exists as a model, which is stage 1 of #129 and shows nothing in the browser yet on purpose: a vectorart asset is not placeable, sits in the + menu and in the asset panel with its own icon (a curve between two points) and has no tab, so it behaves like a font - selectable, its rows in the inspector, a double click leading nowhere - because the tab is stage 3 and half an asset would only be in the way; the data model in Asset.ts is a shape built from rings (even-odd, so a shape may have holes, answer 6 in the issue), a ring being points with optional bezier handles in/out stored as an offset from the point and a closed flag (an open path is stroke only, answer 4), plus fill colour, opacity and an optional stroke (answer 5) - the two handles are kept apart instead of mirrored, because what "smooth" means is the editor's business and not the model's; canvas width, height and the filter are properties, so they get an inspector row and undo like any other, while the shapes are intrinsic data like the frames of a texture; the revision that #185 hands to the picture path is a fingerprint of what is drawn, not a counter, which is exactly why that key took a string token - no command can forget to bump it - and the name of a shape is deliberately left out of it, renaming changing no pixel; six undo commands in the new vectorArtCommands.ts (add a shape numbered in order, remove one which comes back to its old place - unlike a tilemap layer the last one may go too -, rename, move, write the rings as one step per gesture with the list copied, and set the style where stroke: null takes it away) and the pure function shapeOrderTarget for the four buttons of the planned inspector list (one forward, to front, one back, to back), which also serves dragging in that list; loading from a project file is strict - a point without numbers falls away, a broken handle leaves a corner behind, duplicate shape ids fall away like duplicate layer ids, a stroke of width 0 is no stroke - while a ring with a single point, which is what someone is drawing right now, stays; three findings on the way: JSON.stringify as the fingerprint was wrong because the same drawing came back from a project file as {points, closed} and was built as {closed, points}, so a reload would have rasterized the same picture under a new key (the fingerprint writes its fields in a fixed order now and a test measures that case), toNumber in serialize.ts turns any nonsense into 0 so the Number.isFinite checks ran into nothing and {x: 'far'} became the left edge instead of vanishing (shapes read numbers strictly via asFinite, 0 being a place on the canvas here), and "Vector art" would have been both a menu entry and the panel heading, which an existing test catches because a click by text would hit the heading - the group is called "Vector drawings" now while the menu entry stays "Vector art"; 20 new unit tests in tests/core/vectorart.test.ts and three counter-checks hold: the fingerprint back on JSON.stringify fails 2, reading the numbers loosely again fails 4, and not copying the ring list fails 1; no e2e check and the check sum stays 1157, only module 04 counting 19 asset types instead of 18 (#129)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 20:29:35 +02:00
1 parent cb1804dcf2
commit bdb3f066cf
13 files changed
+975 -3

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
/**
* 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;
}