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

+3
View File
@@ -39,6 +39,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'rigidbody' };
case 'container':
return { ...base, type: 'container', objects: [] };
// A drawing starts empty: the pen tool of #129 sets the first shape down.
case 'vectorart':
return { ...base, type: 'vectorart', shapes: [] };
case 'animation':
return { ...base, type: 'animation', root: '', tracks: [] };
case 'tile':
+15
View File
@@ -9,8 +9,11 @@ import type {
TilemapAsset,
TilemapLayer,
VariablesAsset,
VectorArtAsset,
VectorShape,
} from '../model/Asset';
import { findLayer } from '../model/tilemap';
import { findShape } from '../model/vectorart';
import { findObject, type ObjectTree } from '../model/hierarchy';
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
import {
@@ -106,6 +109,18 @@ export function requireTilemapLayer(project: Project, assetId: string, layerId:
return layer;
}
export function requireVectorArt(project: Project, assetId: string): VectorArtAsset {
const asset = findAssetOf(project, assetId, 'vectorart');
if (!asset) throw new CommandError(`Vector art ${assetId} not found`);
return asset;
}
export function requireVectorShape(project: Project, assetId: string, shapeId: string): VectorShape {
const shape = findShape(requireVectorArt(project, assetId), shapeId);
if (!shape) throw new CommandError(`Shape ${shapeId} not found`);
return shape;
}
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
const asset = findAssetOf(project, assetId, 'statemachine');
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
+213
View File
@@ -0,0 +1,213 @@
/**
* Editing the shapes of a vector art asset (#129). The canvas size and the filter are ordinary
* properties and go through `SetAssetPropertyCommand`; the shapes are intrinsic data, as a
* tilemap's layers are, so they have commands of their own.
*
* One gesture is one command: dragging a point writes the whole ring list once, because the pointer
* decides where a drag ends and an undo has to take the drag back, not its last pixel. Nothing here
* bumps a revision – the picture is fingerprinted from the shapes themselves (see
* `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 { Project } from '../model/Project';
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { nextNumberedName } from './naming';
import { requireVectorArt, requireVectorShape } from './require';
export class AddVectorShapeCommand implements Command {
readonly label = 'Add shape';
readonly shapeId = newId();
/** `rings` are what the pen tool drew; a shape added from the list starts without a ring. */
constructor(
private readonly assetId: string,
private readonly rings: readonly VectorRing[] = [],
private readonly fill = SHAPE_FILL_DEFAULT,
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const name = nextNumberedName(
asset.shapes.map((shape) => shape.name),
'Shape',
);
asset.shapes.push({
id: this.shapeId,
name,
rings: cloneRings(this.rings),
fill: this.fill,
opacity: 1,
});
}
undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
asset.shapes = removeById(asset.shapes, this.shapeId);
}
}
/** Takes a shape out; unlike a tilemap layer the last one may go – an empty drawing is a drawing. */
export class RemoveVectorShapeCommand implements Command {
readonly label = 'Remove shape';
private removed: Removed<VectorShape> | undefined;
constructor(
private readonly assetId: string,
private readonly shapeId: string,
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const removed = removeFirst(asset.shapes, (shape) => shape.id === this.shapeId);
if (!removed) throw new CommandError(`Shape ${this.shapeId} not found`);
this.removed = removed;
}
undo(project: Project): void {
if (this.removed) restoreAt(requireVectorArt(project, this.assetId).shapes, [this.removed]);
}
}
export class RenameVectorShapeCommand implements Command {
readonly label = 'Rename shape';
private previous = '';
constructor(
private readonly assetId: string,
private readonly shapeId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const shape = requireVectorShape(project, this.assetId, this.shapeId);
const name = this.name.trim();
if (!name) throw new CommandError('A shape needs a name');
this.previous = shape.name;
shape.name = name;
}
undo(project: Project): void {
requireVectorShape(project, this.assetId, this.shapeId).name = this.previous;
}
}
/** Moves a shape in the drawing order; the last shape in the list is drawn on top. */
export class MoveVectorShapeCommand implements Command {
readonly label = 'Reorder shapes';
constructor(
private readonly assetId: string,
private readonly from: number,
private readonly to: number,
) {}
execute(project: Project): void {
this.move(project, this.from, this.to);
}
undo(project: Project): void {
this.move(project, this.to, this.from);
}
private move(project: Project, from: number, to: number): void {
if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to))
throw new CommandError('No such shape');
}
}
/**
* 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.
*/
export class SetVectorShapeRingsCommand implements Command {
readonly label: string;
private previous: VectorRing[] = [];
constructor(
private readonly assetId: string,
private readonly shapeId: string,
private readonly rings: readonly VectorRing[],
label = 'Edit shape',
) {
this.label = label;
}
/** Nothing to do when the shape already reads exactly like this – no empty undo step. */
static changes(shape: VectorShape, rings: readonly VectorRing[]): boolean {
return JSON.stringify(shape.rings) !== JSON.stringify(rings);
}
execute(project: Project): void {
// Any outline may be written, a half-drawn one included: what is too short to draw is passed
// over by the rasteriser (see `isDrawableRing`) instead of being refused here.
const shape = requireVectorShape(project, this.assetId, this.shapeId);
this.previous = shape.rings;
shape.rings = cloneRings(this.rings);
}
undo(project: Project): void {
requireVectorShape(project, this.assetId, this.shapeId).rings = this.previous;
}
}
/** What `SetVectorShapeStyleCommand` writes; a field left out stays as it is, `stroke: null` removes it. */
export interface ShapeStyle {
fill?: number;
opacity?: number;
stroke?: VectorStroke | null;
}
/** Fill colour, opacity and outline of one shape – the rows the inspector shows for it (answer 8). */
export class SetVectorShapeStyleCommand implements Command {
readonly label: string;
private previous: ShapeStyle = {};
constructor(
private readonly assetId: string,
private readonly shapeId: string,
private readonly style: ShapeStyle,
label = 'Set shape style',
) {
this.label = label;
}
execute(project: Project): void {
const shape = requireVectorShape(project, this.assetId, this.shapeId);
if (this.style.opacity !== undefined && !Number.isFinite(this.style.opacity))
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 };
applyStyle(shape, this.style);
}
undo(project: Project): void {
applyStyle(requireVectorShape(project, this.assetId, this.shapeId), this.previous);
}
}
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.stroke !== undefined) {
if (style.stroke) shape.stroke = { ...style.stroke };
else delete shape.stroke;
}
}
/** A deep copy: rings, their points and the handles on them are all plain data. */
function cloneRings(rings: readonly VectorRing[]): VectorRing[] {
return rings.map((ring) => ({
closed: ring.closed,
points: ring.points.map((point) => ({
x: point.x,
y: point.y,
...(point.in ? { in: { ...point.in } } : {}),
...(point.out ? { out: { ...point.out } } : {}),
})),
}));
}
+55
View File
@@ -235,6 +235,60 @@ export interface TilemapAsset extends AssetBase {
layers: TilemapLayer[];
}
/**
* A point of a ring. `in` and `out` are the Bezier handles, as an offset from the point itself, so
* a point may be dragged without touching them; absent is a corner, and the edge to that side is
* straight. They are kept apart rather than mirrored, because a point may bend differently on each
* side – what "smooth" means is the editor's business, not the model's (#129).
*/
export interface VectorPoint {
x: number;
y: number;
in?: Vec2;
out?: Vec2;
}
/**
* One outline of a shape, in canvas pixels from the canvas's top-left corner. A shape holds several
* of them so it can have holes: the rings are filled even-odd, so a ring inside another cuts it out
* (#129, answer 6). `closed` false is an open path – it is stroked and not filled.
*/
export interface VectorRing {
points: VectorPoint[];
closed: boolean;
}
/** The outline drawn along a ring; `width` is in canvas pixels. Absent on a shape is no outline. */
export interface VectorStroke {
color: number;
width: 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.
*/
export interface VectorShape {
id: string;
name: string;
rings: VectorRing[];
fill: number;
opacity: number;
stroke?: VectorStroke;
}
/**
* A picture drawn from curves instead of uploaded (#129). It is rasterised onto a canvas whose size
* is a property, and handed to the picture path through the generated-picture key of #185 – so a
* sprite, a tile, a tilemap and a particle source wear it exactly as they wear a texture, and the
* shapes never leave this asset. The shapes are intrinsic data, as a texture's frames are.
*/
export interface VectorArtAsset extends AssetBase {
type: 'vectorart';
shapes: VectorShape[];
}
/**
* The graph is the motion of one particle: every output of its `Output` node is a function of the
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
@@ -256,6 +310,7 @@ export type Asset =
| RigidbodyAsset
| ContainerAsset
| TextureAsset
| VectorArtAsset
| AnimationAsset
| TileAsset
| TilemapAsset
+13 -1
View File
@@ -9,6 +9,7 @@ export const ASSET_TYPES = [
'rigidbody',
'container',
'texture',
'vectorart',
'animation',
'tile',
'tilemap',
@@ -32,6 +33,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
rigidbody: 'Rigidbody',
container: 'Container',
texture: 'Texture',
vectorart: 'Vector art',
animation: 'Animation',
tile: 'Tile',
tilemap: 'Tilemap',
@@ -52,12 +54,22 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
*/
export type PlaceableAssetType = Exclude<
AssetType,
'scene' | 'texture' | 'animation' | 'tile' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables'
| 'scene'
| 'texture'
| 'vectorart'
| 'animation'
| 'tile'
| 'statemachine'
| 'shader'
| 'font'
| 'sound'
| 'variables'
>;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'scene',
'texture',
'vectorart',
'animation',
'tile',
'statemachine',
+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;
}
+41
View File
@@ -34,6 +34,12 @@ import {
} from '../model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
import { SLICE_SCALE_KEY } from '../model/texture';
import {
VECTOR_CANVAS_DEFAULT,
VECTOR_CANVAS_MAX,
VECTOR_HEIGHT_KEY,
VECTOR_WIDTH_KEY,
} from '../model/vectorart';
import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems';
import { FONT_KEY } from './fonts';
@@ -254,6 +260,41 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96).
{ key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 },
],
// The shapes are intrinsic data on the asset (see VectorArtAsset); these three say how big the
// picture they are drawn onto is and how it is sampled – the same `filter` row a texture has,
// because a generated picture goes through the same path (#185).
vectorart: [
{
key: VECTOR_WIDTH_KEY,
label: 'Canvas width',
kind: 'number',
default: VECTOR_CANVAS_DEFAULT,
min: 1,
max: VECTOR_CANVAS_MAX,
step: 1,
decimals: 0,
},
{
key: VECTOR_HEIGHT_KEY,
label: 'Canvas height',
kind: 'number',
default: VECTOR_CANVAS_DEFAULT,
min: 1,
max: VECTOR_CANVAS_MAX,
step: 1,
decimals: 0,
},
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
],
// The root and the rows are intrinsic data on the asset (see AnimationAsset).
animation: [
{ key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 },
+77
View File
@@ -26,6 +26,9 @@ import type {
StateMachineAsset,
TextureFrame,
TilemapLayer,
VectorPoint,
VectorRing,
VectorShape,
} from '../model/Asset';
import { parseCellKey } from '../model/tilemap';
import { pluginsOf } from './projectPlugins';
@@ -55,6 +58,7 @@ import {
setFrameSlice,
} from '../model/texture';
import { pointsOf } from '../model/polygon';
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { hasParentCycle } from '../model/walk';
import {
DECLARED_PORT_KINDS,
@@ -191,6 +195,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push({ id, type, name, props, pictures });
} else if (type === 'tilemap') {
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
} else if (type === 'vectorart') {
assets.push({ id, type, name, props, shapes: normalizeShapes(a.shapes) });
} else if (type === 'animation') {
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
const root = typeof a.root === 'string' ? a.root : '';
@@ -558,6 +564,77 @@ function normalizeLayers(raw: unknown): TilemapLayer[] {
return layers;
}
/**
* The shapes of a vector art asset (#129). A point whose coordinates are no numbers falls away, as
* a frame's outline corners do, and so does a handle that is not a pair of numbers – the point then
* reads as a corner, which is what a shape drawn without handles says anyway. A shape left without
* any drawable ring is kept: it is an outline someone is still drawing, and the rasteriser passes
* over it. A doubled shape id is dropped as a doubled layer is.
*/
function normalizeShapes(raw: unknown): VectorShape[] {
const shapes: VectorShape[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const s = asRecord(item);
if (typeof s.id !== 'string' || !s.id || shapes.some((o) => o.id === s.id)) continue;
const rings: VectorRing[] = [];
for (const r of Array.isArray(s.rings) ? s.rings : []) {
const ring = asRecord(r);
const points: VectorPoint[] = [];
for (const p of Array.isArray(ring.points) ? ring.points : []) {
const point = asRecord(p);
const x = asFinite(point.x);
const y = asFinite(point.y);
if (x === null || y === null) continue;
const kept: VectorPoint = { x, y };
for (const side of ['in', 'out'] as const) {
const handle = normalizeHandle(point[side]);
if (handle) kept[side] = handle;
}
points.push(kept);
}
// Closed is what a shape usually is, and what every ring written by the editor says.
rings.push({ points, closed: ring.closed !== false });
}
const shape: VectorShape = {
id: s.id,
name: typeof s.name === 'string' ? s.name : 'Shape',
rings,
fill: normalizeColor(s.fill, SHAPE_FILL_DEFAULT),
opacity: normalizeOpacity(s.opacity),
};
const stroke = asRecord(s.stroke);
const width = asFinite(stroke.width);
if (width !== null && width > 0) shape.stroke = { color: normalizeColor(stroke.color, 0), width };
shapes.push(shape);
}
return shapes;
}
/**
* A number as written, or null – unlike `toNumber`, which reads anything unusable as 0. A coordinate
* of 0 is a place on the canvas, so "broken" and "the left edge" must not come out the same.
*/
function asFinite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function normalizeHandle(raw: unknown): Vec2 | undefined {
const handle = asRecord(raw);
const x = asFinite(handle.x);
const y = asFinite(handle.y);
return x !== null && y !== null ? { x, y } : undefined;
}
function normalizeColor(raw: unknown, fallback: number): number {
const value = asFinite(raw);
return value === null ? fallback : Math.max(0, Math.min(0xffffff, Math.floor(value)));
}
function normalizeOpacity(raw: unknown): number {
const value = asFinite(raw);
return value === null ? 1 : Math.max(0, Math.min(1, value));
}
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
const r = asRecord(raw);
const bag: PropertyBag = {};
+3
View File
@@ -16,6 +16,9 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
rigidbody: 'Rigidbodies',
container: 'Containers',
texture: 'Textures',
// Not "Vector art": that is the `+` menu entry, and a heading of the same words would take the
// click meant for the menu (see the test below the labels).
vectorart: 'Vector drawings',
animation: 'Animations',
tile: 'Tiles',
tilemap: 'Tilemaps',
+2
View File
@@ -24,6 +24,7 @@ import texture from './icons/texture.svg?raw';
import tile from './icons/tile.svg?raw';
import tilemap from './icons/tilemap.svg?raw';
import variables from './icons/variables.svg?raw';
import vectorart from './icons/vectorart.svg?raw';
export const ASSET_ICON_SVG: Record<AssetType, string> = {
animation,
@@ -44,6 +45,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
tile,
tilemap,
variables,
vectorart,
};
/** The List tab's: it edits a list value, which is no asset. */
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- A curve between two points, the way a pen tool shows one: the corners are the points. -->
<path d="M-4.5 4.5 C-4.5 -3 4.5 3 4.5 -4.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="-6.5" y="2.5" width="4" height="4" fill="currentColor" />
<rect x="2.5" y="-6.5" width="4" height="4" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 430 B

+393
View File
@@ -0,0 +1,393 @@
import { afterEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
AddVectorShapeCommand,
MoveVectorShapeCommand,
RemoveVectorShapeCommand,
RenameVectorShapeCommand,
SetVectorShapeRingsCommand,
SetVectorShapeStyleCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorRing } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { generatedPictureOf, registerPictureSource } from '../../src/core/model/generated';
import { findAsset, type Project } from '../../src/core/model/Project';
import { resolvePicture } from '../../src/core/model/texture';
import {
handleAt,
isDrawableRing,
isDrawableShape,
SHAPE_FILL_DEFAULT,
shapeExtent,
shapeOrderTarget,
VECTOR_CANVAS_DEFAULT,
VECTOR_CANVAS_MAX,
VECTOR_HEIGHT_KEY,
VECTOR_WIDTH_KEY,
vectorArtPicture,
vectorArtRevision,
vectorCanvasSize,
} from '../../src/core/model/vectorart';
import { makeProject, roundTrip } from '../helpers';
/** A square, and the same square with one point bent into a curve. */
function square(): VectorRing {
return {
closed: true,
points: [
{ x: 10, y: 10 },
{ x: 90, y: 10 },
{ x: 90, y: 90 },
{ x: 10, y: 90 },
],
};
}
function drawing() {
const fixture = makeProject();
const create = new CreateAssetCommand('vectorart', 'Logo');
fixture.stack.execute(create);
const assetId = create.asset.id;
const art = (project: Project = fixture.project): VectorArtAsset =>
findAsset(project, assetId) as VectorArtAsset;
return { ...fixture, assetId, art };
}
describe('the vector art asset', () => {
it('is a picture, not something placed in a scene', () => {
expect(isPlaceableType('vectorart')).toBe(false);
});
it('starts empty on a canvas of its own', () => {
const f = drawing();
expect(f.art().shapes).toEqual([]);
expect(vectorCanvasSize(f.art().props)).toEqual({
width: VECTOR_CANVAS_DEFAULT,
height: VECTOR_CANVAS_DEFAULT,
});
});
it('reads a canvas that is missing, broken or absurd as one it can draw on', () => {
expect(vectorCanvasSize({})).toEqual({ width: 256, height: 256 });
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 0, [VECTOR_HEIGHT_KEY]: -40 })).toEqual({
width: 256,
height: 256,
});
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 99999, [VECTOR_HEIGHT_KEY]: 64.7 })).toEqual({
width: VECTOR_CANVAS_MAX,
height: 64,
});
});
});
describe('what is worth drawing', () => {
it('needs three points closed and two open', () => {
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 2) })).toBe(false);
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true);
expect(isDrawableRing({ closed: false, points: square().points.slice(0, 2) })).toBe(true);
expect(isDrawableShape({ id: 's', name: 'S', rings: [], fill: 0, opacity: 1 })).toBe(false);
});
it('measures a shape including the handles pulled off its points', () => {
const shape = {
id: 's',
name: 'S',
rings: [
{
closed: true,
points: [
{ x: 10, y: 10, out: { x: -30, y: 5 } },
{ x: 90, y: 90 },
],
},
],
fill: 0,
opacity: 1,
};
expect(handleAt(shape.rings[0]!.points[0]!, 'out')).toEqual({ x: -20, y: 15 });
expect(handleAt(shape.rings[0]!.points[1]!, 'in')).toBeNull();
expect(shapeExtent(shape)).toEqual({ x0: -20, y0: 10, x1: 90, y1: 90 });
expect(shapeExtent({ ...shape, rings: [] })).toBeNull();
});
});
describe('the revision', () => {
it('changes with every pixel and with nothing else', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
const first = vectorArtRevision(f.art());
f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body'));
expect(vectorArtRevision(f.art())).toBe(first);
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0xff0000 }));
const coloured = vectorArtRevision(f.art());
expect(coloured).not.toBe(first);
const moved = square();
moved.points[0] = { x: 11, y: 10 };
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [moved]));
expect(vectorArtRevision(f.art())).not.toBe(coloured);
});
it('changes when the canvas is resized, because the picture is a different one', () => {
const f = drawing();
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
const before = vectorArtRevision(f.art());
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_WIDTH_KEY, 512));
expect(vectorArtRevision(f.art())).not.toBe(before);
});
it('is the same for the same drawing, however the object was built', () => {
const asset = (ring: VectorRing): VectorArtAsset => ({
id: 'a',
type: 'vectorart',
name: 'A',
props: {},
shapes: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }],
});
// The same ring with its two keys set the other way round – what a project file gives back.
const built = { closed: true, points: square().points };
const loaded = { points: square().points, closed: true } as VectorRing;
expect(vectorArtRevision(asset(loaded))).toBe(vectorArtRevision(asset(built)));
});
it('survives a save and load: the same drawing is the same picture', () => {
const f = drawing();
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
expect(vectorArtRevision(f.art(roundTrip(f.project)))).toBe(vectorArtRevision(f.art()));
});
});
describe('the way into the picture path', () => {
let unregister: (() => void) | null = null;
afterEach(() => {
unregister?.();
unregister = null;
});
it('hands over the canvas and the revision, and only for vector art', () => {
const f = drawing();
expect(vectorArtPicture(f.art())).toEqual({
width: VECTOR_CANVAS_DEFAULT,
height: VECTOR_CANVAS_DEFAULT,
revision: vectorArtRevision(f.art()),
});
const other = new CreateAssetCommand('sprite', 'Box');
f.stack.execute(other);
expect(vectorArtPicture(other.asset)).toBeNull();
});
it('resolves to a generated picture of the canvas size once it is registered (#185)', () => {
const f = drawing();
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_HEIGHT_KEY, 128));
expect(generatedPictureOf(f.art())).toBeNull();
unregister = registerPictureSource('vectorart', vectorArtPicture);
const ref = resolvePicture(f.project, f.assetId);
expect(ref?.file).toBe(`gen:${f.assetId}@${vectorArtRevision(f.art())}`);
expect(ref?.sourceWidth).toBe(VECTOR_CANVAS_DEFAULT);
expect(ref?.sourceHeight).toBe(128);
});
});
describe('editing the shapes', () => {
it('numbers new shapes and takes them back out again', () => {
const f = drawing();
const first = new AddVectorShapeCommand(f.assetId);
const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
f.stack.execute(first);
f.stack.execute(second);
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
expect(f.art().shapes[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
expect(f.art().shapes[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
f.stack.undo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1']);
f.stack.redo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
});
it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => {
const f = drawing();
const ids = [0, 1, 2].map(() => {
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
return add.shapeId;
});
f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!));
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[0], ids[2]]);
f.stack.undo();
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
// The last one may go: an empty drawing is a drawing.
for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id));
expect(f.art().shapes).toEqual([]);
});
it('keeps the drawing order apart from the list order in no way at all', () => {
const f = drawing();
const ids = ['a', 'b', 'c'].map(() => {
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
return add.shapeId;
});
// Last is on top, so "forward" is one step towards the end and "front" is the end.
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'forward')).toBe(1);
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'front')).toBe(2);
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'backward')).toBe(1);
expect(shapeOrderTarget(f.art().shapes, ids[1]!, 'back')).toBe(0);
// Already there, and not there at all.
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'front')).toBe(-1);
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'back')).toBe(-1);
expect(shapeOrderTarget(f.art().shapes, 'nobody', 'front')).toBe(-1);
f.stack.execute(new MoveVectorShapeCommand(f.assetId, 0, 2));
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
f.stack.undo();
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
});
it('copies the outline it is handed, so a later drag does not reach into the undo step', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
const rings = [square()];
rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } };
const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings);
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, rings)).toBe(true);
f.stack.execute(edit);
rings[0]!.points[1] = { x: -999, y: -999 };
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
expect(f.art().shapes[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
f.stack.undo();
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, [square()])).toBe(false);
});
it('writes fill, opacity and outline one field at a time and takes each back', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
fill: 0x00ff00,
opacity: 4,
stroke: { color: 0, width: 2 },
}),
);
// An opacity outside 0…1 is clamped, not refused: a slider may overshoot.
expect(f.art().shapes[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null }));
expect(f.art().shapes[0]!.stroke).toBeUndefined();
f.stack.undo();
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.undo();
expect(f.art().shapes[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(f.art().shapes[0]!.stroke).toBeUndefined();
});
it('refuses an outline of no width and a shape of no name', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
expect(() =>
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: { color: 0, width: 0 } }),
),
).toThrow(/wider than nothing/);
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, ' '))).toThrow(
/needs a name/,
);
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, 'nobody', 'X'))).toThrow(
/not found/,
);
});
});
describe('what a project file may say', () => {
it('keeps a whole drawing across save and load', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [
{
closed: true,
points: [
{ x: 10, y: 10, out: { x: 4, y: 0 } },
{ x: 90, y: 10 },
{ x: 50, y: 80 },
],
},
{
closed: false,
points: [
{ x: 20, y: 20 },
{ x: 30, y: 30 },
],
},
]);
f.stack.execute(add);
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
fill: 0x334455,
opacity: 0.5,
stroke: { color: 0xffffff, width: 3 },
}),
);
expect(f.art(roundTrip(f.project)).shapes).toEqual(f.art().shapes);
});
it('drops what cannot be drawn from and keeps what someone is still drawing', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
art.shapes = [
{
id: 'one',
name: 'One',
rings: [
{
closed: true,
points: [
{ x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 } },
{ x: 'far', y: 10 },
{ x: 30, y: 30 },
{ x: 50 },
],
},
{ points: [{ x: 0, y: 0 }] },
],
fill: 0x123456,
opacity: 7,
stroke: { color: 0xffffff, width: 0 },
},
// A doubled id would make one reference mean two shapes, as a doubled layer id does.
{ id: 'one', name: 'Twin', rings: [] },
{ name: 'Nameless', rings: [] },
'not a shape',
];
const loaded = f.art(roundTrip(f.project));
expect(loaded.shapes).toHaveLength(1);
const shape = loaded.shapes[0]!;
// The broken handle falls away and leaves a corner; the broken points fall away entirely.
expect(shape.rings[0]!.points).toEqual([
{ x: 10, y: 10, out: { x: 1, y: 2 } },
{ x: 30, y: 30 },
]);
// A ring of one point is kept – it is an outline someone is still drawing – and closed by default.
expect(shape.rings[1]).toEqual({ closed: true, points: [{ x: 0, y: 0 }] });
expect(shape).toMatchObject({ fill: 0x123456, opacity: 1 });
// An outline of no width is no outline.
expect(shape.stroke).toBeUndefined();
});
it('falls back to a fill and a name where the file says nothing usable', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }];
const shape = f.art(roundTrip(f.project)).shapes[0]!;
expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(shape.rings).toEqual([{ closed: false, points: [] }]);
});
});
+2 -2
View File
@@ -77,7 +77,7 @@ export async function assets(t) {
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
// what can be placed in a scene, a line, then what only lives in the library – and it is
// tall enough to show all eighteen types without scrolling.
// tall enough to show all nineteen types without scrolling.
await clickText('+');
const rows = await json('__gm.menuRows()');
const cut = rows.indexOf(null);
@@ -90,7 +90,7 @@ export async function assets(t) {
);
check(
'every asset type is offered once, each group sorted by label',
placeable.length + library.length === 18 &&
placeable.length + library.length === 19 &&
String(placeable) === String([...placeable].sort()) &&
String(library) === String([...library].sort()),
JSON.stringify({ placeable, library }),