diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 6457f010..f13e7b5b 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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': diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 334491ab..71537141 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -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`); diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts new file mode 100644 index 00000000..e990331f --- /dev/null +++ b/src/core/commands/vectorArtCommands.ts @@ -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 | 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 } } : {}), + })), + })); +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index dd942ca4..34dfd175 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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 diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 08d0ebd0..48f41412 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { 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 = { */ 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 = new Set([ 'scene', 'texture', + 'vectorart', 'animation', 'tile', 'statemachine', diff --git a/src/core/model/vectorart.ts b/src/core/model/vectorart.ts new file mode 100644 index 00000000..23267f6a --- /dev/null +++ b/src/core/model/vectorart.ts @@ -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; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index e9ead75b..0d605f53 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { // 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 }, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index fea11fa3..0b2454ea 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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 = {}; diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index a27e1466..c35922fa 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -16,6 +16,9 @@ export const ASSET_GROUP_LABELS: Record = { 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', diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index a787cedc..489db5c7 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -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 = { animation, @@ -44,6 +45,7 @@ export const ASSET_ICON_SVG: Record = { tile, tilemap, variables, + vectorart, }; /** The List tab's: it edits a list value, which is no asset. */ diff --git a/src/editor/panels/icons/vectorart.svg b/src/editor/panels/icons/vectorart.svg new file mode 100644 index 00000000..abf25eca --- /dev/null +++ b/src/editor/panels/icons/vectorart.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/core/vectorart.test.ts b/tests/core/vectorart.test.ts new file mode 100644 index 00000000..0b4a6e68 --- /dev/null +++ b/tests/core/vectorart.test.ts @@ -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; + 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; + 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: [] }]); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 296c33fe..ba2b42e4 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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 }),