diff --git a/README.md b/README.md index 1911d9bb..e2a349d9 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1165 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1172 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index e990331f..612c38fb 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -11,7 +11,7 @@ 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 { cloneVectorRing, 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'; @@ -199,15 +199,6 @@ function applyStyle(shape: VectorShape, style: ShapeStyle): void { } } -/** 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 } } : {}), - })), - })); + return rings.map(cloneVectorRing); } diff --git a/src/core/model/vectorart.ts b/src/core/model/vectorart.ts index 23267f6a..50dbd9c0 100644 --- a/src/core/model/vectorart.ts +++ b/src/core/model/vectorart.ts @@ -125,6 +125,19 @@ export function vectorArtPicture(asset: Asset): GeneratedPicture | null { return { width, height, revision: vectorArtRevision(asset) }; } +/** A deep copy of a ring: its points and the handles on them are all plain data. */ +export function cloneVectorRing(ring: VectorRing): VectorRing { + return { + closed: ring.closed, + points: ring.points.map((point) => ({ + x: point.x, + y: point.y, + ...(point.in ? { in: { ...point.in } } : {}), + ...(point.out ? { out: { ...point.out } } : {}), + })), + }; +} + /** 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'; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 4ffafdff..51d82f9d 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -54,6 +54,7 @@ import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; import type { PlayFrom } from './settings'; import type { EditorState } from './state'; +import { VectorArtEditor } from './vectorart/VectorArtEditor'; import { TextureEditor } from './texture/TextureEditor'; import { installFileImport } from './fileImport'; import type { PluginManager } from './plugins/loader'; @@ -67,6 +68,7 @@ export type CentreTab = | 'particles' | 'game' | 'texture' + | 'vectorart' | 'animation' | 'tile' | 'container' @@ -105,6 +107,7 @@ const PANE_ORDER = [ 'shader', 'particles', 'texture', + 'vectorart', 'animation', 'tile', 'container', @@ -121,6 +124,7 @@ const TAB_LABELS: Record = { shader: 'Shader', particles: 'Particles', texture: 'Texture', + vectorart: 'Vector art', animation: 'Animation', tile: 'Tile', container: 'Container', @@ -164,6 +168,7 @@ export class Editor implements PlayHost { readonly shaderEditor: ShaderEditor; readonly particlesEditor: ParticlesEditor; readonly textureEditor: TextureEditor; + readonly vectorArtEditor: VectorArtEditor; readonly animationEditor: AnimationEditor; readonly tileEditor: TileEditor; readonly containerEditor: ContainerEditor; @@ -234,6 +239,7 @@ export class Editor implements PlayHost { this.shaderEditor = new ShaderEditor(state); this.particlesEditor = new ParticlesEditor(state); this.textureEditor = new TextureEditor(state); + this.vectorArtEditor = new VectorArtEditor(state); this.animationEditor = new AnimationEditor(state); this.tileEditor = new TileEditor(state); this.containerEditor = new ContainerEditor(state); @@ -324,6 +330,7 @@ export class Editor implements PlayHost { shader: this.shaderEditor, particles: this.particlesEditor, texture: this.textureEditor, + vectorart: this.vectorArtEditor, animation: this.animationEditor, tile: this.tileEditor, container: this.containerEditor, diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts index f3f755b1..a7292056 100644 --- a/src/editor/assetTabs.ts +++ b/src/editor/assetTabs.ts @@ -9,6 +9,7 @@ export const TAB_OF_ASSET: Partial> = { shader: 'shader', particles: 'particles', texture: 'texture', + vectorart: 'vectorart', animation: 'animation', tile: 'tile', container: 'container', diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 948fa4ae..8f6bd0fe 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -40,8 +40,17 @@ import { rectOf, textProbe } from './textProbe'; * visible text and to read editor state. */ export function installDebugHook(app: Application, state: EditorState, editor: Editor): void { - const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy, assets, inspector } = - editor; + const { + viewport, + gameView, + graphEditor, + textureEditor, + vectorArtEditor, + containerEditor, + hierarchy, + assets, + inspector, + } = editor; const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => { const p = widget.getGlobalPosition(); return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; @@ -438,6 +447,38 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** Whether a press edits the selected frame's collision outline (#141). */ collisionMode: () => textureEditor.canvas.collisionMode, }, + /** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */ + vectorArt: { + current: () => vectorArtEditor.currentAssetId, + info: () => vectorArtEditor.shownInfo, + pointAt: (x: number, y: number) => vectorArtEditor.canvasToScreen(x, y), + rect: () => vectorArtEditor.canvasRect(), + tool: () => vectorArtEditor.canvas.currentTool, + snapping: () => vectorArtEditor.canvas.snapping, + /** Whether the pen has a line open that the next click would add a point to. */ + drawing: () => vectorArtEditor.canvas.drawing, + /** The shapes as the model holds them, so a check can read points and handles exactly. */ + shapes: (assetId: string) => { + const asset = findAsset(state.project, assetId); + if (!asset || asset.type !== 'vectorart') return null; + return asset.shapes.map((shape) => ({ + id: shape.id, + name: shape.name, + fill: shape.fill, + opacity: shape.opacity, + rings: shape.rings.map((ring) => ({ + closed: ring.closed, + points: ring.points.map((point) => ({ + x: point.x, + y: point.y, + in: point.in ? { ...point.in } : null, + out: point.out ? { ...point.out } : null, + })), + })), + })); + }, + selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''), + }, animation: { current: () => editor.animationEditor.currentAssetId, info: () => editor.animationEditor.shownInfo, diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 72f86c0b..c8c6798c 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -97,6 +97,8 @@ export class Inspector extends Panel { else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId); else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId); else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); + // A shape shows its asset's rows until it has rows of its own (#129, stage 4). + else if (s.kind === 'shape') buildAssetSection(this.host, s.assetId); else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId); else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys); else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds); @@ -110,6 +112,7 @@ export class Inspector extends Panel { if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId); if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId); if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId); + if (s.kind === 'shape') return assetSignature(this.state.project, s.assetId); if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId); if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys); return objectSignature(this.state, s.holderId, s.objectIds, s.objectId); @@ -137,11 +140,13 @@ export class Inspector extends Panel { ? refreshNodeSection(this.host, s.assetId, s.nodeId) : s.kind === 'frame' ? refreshFrameSection(this.host, s.assetId, s.frameId) - : s.kind === 'annotation' - ? refreshAnnotationSection(this.host, s.assetId, s.annotationId) - : s.kind === 'track' - ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); + : s.kind === 'shape' + ? refreshAssetSection(this.host, s.assetId) + : s.kind === 'annotation' + ? refreshAnnotationSection(this.host, s.assetId, s.annotationId) + : s.kind === 'track' + ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/polygonEdit.ts b/src/editor/polygonEdit.ts index 330c4613..ede3f9e1 100644 --- a/src/editor/polygonEdit.ts +++ b/src/editor/polygonEdit.ts @@ -2,9 +2,17 @@ * Editing an outline by its corners (#141, #151), the same in the Scene view (a polygon collider) * and in the Texture tab (a frame's collision outline): drag a corner, press on an edge to put a * corner there, double-click a corner to take it away. Pure, so the rules are testable without a view. + * + * The lower half of the file says the same about a ring of a vector art shape (#129, stage 3). It is + * the same set of gestures on a richer point: one that may carry Bezier handles. They are kept here + * rather than in a module of their own so the two cannot drift apart – a corner is grabbed by the + * same rule in the Texture tab and in the Vector art tab. */ +import type { VectorPoint, VectorRing } from '../core/model/Asset'; import type { Vec2 } from '../core/model/Properties'; import { closestOnSegment } from '../core/util/vec'; +// Where a handle sits in canvas pixels; it is stored as an offset, and the model says so once. +import { cloneVectorRing, handleAt } from '../core/model/vectorart'; /** What lies under the pointer: a corner, or the edge from corner `index` to the next one. */ export type PolygonGrip = { kind: 'corner'; index: number } | { kind: 'edge'; index: number; at: Vec2 }; @@ -62,3 +70,235 @@ export function withoutCorner(points: readonly Vec2[], index: number, min = MIN_ if (points.length - 1 < min) return null; return points.filter((_, i) => i !== index).map((p) => ({ ...p })); } + +/** How finely a curved segment is walked when the pointer asks what it is near. */ +const CURVE_STEPS = 16; + +/** + * What lies under the pointer on a ring: a point, one of its two handles, or the segment that + * starts at point `index` – `at` is the place on it and `t` how far along it lies, which is where a + * new point goes. For a closed ring the last segment is the way back to the first point. + */ +export type RingGrip = + | { kind: 'point'; index: number } + | { kind: 'handle'; index: number; side: 'in' | 'out' } + | { kind: 'segment'; index: number; at: Vec2; t: number }; + +/** + * The grip within `tolerance` of `p`, the nearest one. A handle wins over a point and a point over a + * segment: a handle pulled onto its own point must stay grabbable, and every point lies on two + * segments. Only the handles of `shown` are offered, because only those are drawn – a handle one + * cannot see is one nobody means to grab. + */ +export function ringGripAt( + ring: VectorRing, + p: Vec2, + tolerance: number, + shown: readonly number[] = ring.points.map((_, i) => i), +): RingGrip | null { + let best: RingGrip | null = null; + let nearest = tolerance; + for (const index of shown) { + const point = ring.points[index]; + if (!point) continue; + for (const side of ['in', 'out'] as const) { + const at = handleAt(point, side); + const distance = at ? Math.hypot(at.x - p.x, at.y - p.y) : Infinity; + if (distance > nearest) continue; + best = { kind: 'handle', index, side }; + nearest = distance; + } + } + if (best) return best; + for (const [index, point] of ring.points.entries()) { + const distance = Math.hypot(point.x - p.x, point.y - p.y); + if (distance > nearest) continue; + best = { kind: 'point', index }; + nearest = distance; + } + if (best) return best; + for (const [index, from] of ringSegments(ring)) { + const to = ring.points[(index + 1) % ring.points.length]!; + const hit = nearestOnSegment(from, to, p); + if (hit.distance > nearest) continue; + best = { kind: 'segment', index, at: hit.at, t: hit.t }; + nearest = hit.distance; + } + return best; +} + +/** The segments of a ring as `[index, from]`; a closed ring has the way back from its last point. */ +export function ringSegments(ring: VectorRing): [number, VectorPoint][] { + const segments: [number, VectorPoint][] = []; + const last = ring.points.length - (ring.closed ? 0 : 1); + for (let i = 0; i < last; i++) segments.push([i, ring.points[i]!]); + return segments; +} + +/** The point on the segment nearest to `p`, straight or curved, and how far along it lies. */ +export function nearestOnSegment( + from: VectorPoint, + to: VectorPoint, + p: Vec2, +): { at: Vec2; t: number; distance: number } { + if (!from.out && !to.in) { + const at = closestOnSegment(p, from, to); + const span = Math.hypot(to.x - from.x, to.y - from.y); + const t = span > 0 ? Math.hypot(at.x - from.x, at.y - from.y) / span : 0; + return { at, t, distance: Math.hypot(at.x - p.x, at.y - p.y) }; + } + // A curve is walked rather than solved: a handful of samples is as near as a pointer can aim. + let best = { at: { x: from.x, y: from.y }, t: 0, distance: Infinity }; + for (let step = 0; step <= CURVE_STEPS; step++) { + const t = step / CURVE_STEPS; + const at = pointOnSegment(from, to, t); + const distance = Math.hypot(at.x - p.x, at.y - p.y); + if (distance < best.distance) best = { at, t, distance }; + } + return best; +} + +/** Where the segment is at `t` (0 at `from`, 1 at `to`), curve or line. */ +export function pointOnSegment(from: VectorPoint, to: VectorPoint, t: number): Vec2 { + const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y }; + const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y }; + const u = 1 - t; + return { + x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x, + y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y, + }; +} + +/** A ring walked as a polyline – for hit-testing its inside and for drawing it in the editor. */ +export function ringOutline(ring: VectorRing): Vec2[] { + const points: Vec2[] = ring.points.map((p) => ({ x: p.x, y: p.y })); + if (ring.points.length < 2) return points; + const walked: Vec2[] = []; + for (const [index, from] of ringSegments(ring)) { + const to = ring.points[(index + 1) % ring.points.length]!; + walked.push({ x: from.x, y: from.y }); + if (!from.out && !to.in) continue; + for (let step = 1; step < CURVE_STEPS; step++) walked.push(pointOnSegment(from, to, step / CURVE_STEPS)); + } + if (!ring.closed) + walked.push({ x: ring.points[ring.points.length - 1]!.x, y: ring.points[ring.points.length - 1]!.y }); + return walked; +} + +/** Whether `p` lies inside the ring; an open ring encloses nothing one can click into. */ +export function insideRing(ring: VectorRing, p: Vec2): boolean { + return ring.closed && insideOutline(ringOutline(ring), p); +} + +/** The point moved; its handles ride along, being offsets from it. */ +export function withPointMoved(ring: VectorRing, index: number, to: Vec2): VectorRing { + return mapPoints(ring, (point, i) => (i === index ? { ...point, x: to.x, y: to.y } : point)); +} + +/** + * A handle pulled to `to`, in canvas pixels. The other handle of the point follows mirrored while it + * exists – that is what makes a point smooth – and a handle dropped back onto its point is taken + * away, which turns the point back into a corner. + */ +export function withHandleMoved( + ring: VectorRing, + index: number, + side: 'in' | 'out', + to: Vec2, + mirror = true, +): VectorRing { + return mapPoints(ring, (point, i) => { + if (i !== index) return point; + const offset = { x: to.x - point.x, y: to.y - point.y }; + const next: VectorPoint = { ...point }; + const other = side === 'in' ? 'out' : 'in'; + if (Math.hypot(offset.x, offset.y) < 1e-6) delete next[side]; + else next[side] = offset; + if (mirror && next[other]) next[other] = { x: -offset.x, y: -offset.y }; + return next; + }); +} + +/** + * A point put on the segment after `index`, at `t` along it. A curved segment is split rather than + * cut: the two halves together draw exactly the curve that was there (de Casteljau), so a point put + * on a round side does not flatten it. A straight one gives a plain corner. + */ +export function withPointOnSegment(ring: VectorRing, index: number, t: number): VectorRing { + const from = ring.points[index]; + const to = ring.points[(index + 1) % ring.points.length]; + if (!from || !to) return cloneVectorRing(ring); + const points = ring.points.map(clonePoint); + const at = pointOnSegment(from, to, t); + let inserted: VectorPoint = { x: at.x, y: at.y }; + if (from.out || to.in) { + const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y }; + const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y }; + const a = lerp(from, c1, t); + const b = lerp(c1, c2, t); + const c = lerp(c2, to, t); + const d = lerp(a, b, t); + const e = lerp(b, c, t); + setHandle(points[index]!, 'out', a); + setHandle(points[(index + 1) % ring.points.length]!, 'in', c); + inserted = { x: at.x, y: at.y }; + setHandle(inserted, 'in', d); + setHandle(inserted, 'out', e); + } + points.splice(index + 1, 0, inserted); + return { closed: ring.closed, points }; +} + +/** The ring without point `index`; null when nothing would be left of it. */ +export function withoutPoint(ring: VectorRing, index: number): VectorRing | null { + if (ring.points.length <= 1) return null; + return { closed: ring.closed, points: ring.points.filter((_, i) => i !== index).map(clonePoint) }; +} + +/** + * A corner made round and a round point made a corner again (Alt-click). The handles a corner gets + * point along the line between its neighbours, at a third of the way to each – the shape one expects + * from a smooth point, without asking the user to pull two handles first. + */ +export function withSmoothToggled(ring: VectorRing, index: number): VectorRing { + const point = ring.points[index]; + if (!point) return cloneVectorRing(ring); + if (point.in || point.out) return mapPoints(ring, (p, i) => (i === index ? { x: p.x, y: p.y } : p)); + const count = ring.points.length; + const before = ring.points[(index - 1 + count) % count] ?? point; + const after = ring.points[(index + 1) % count] ?? point; + const dx = (after.x - before.x) / 3; + const dy = (after.y - before.y) / 3; + if (Math.hypot(dx, dy) < 1e-6) return cloneVectorRing(ring); + return mapPoints(ring, (p, i) => + i === index ? { ...p, in: { x: -dx, y: -dy }, out: { x: dx, y: dy } } : p, + ); +} + +/** The whole ring shifted – what dragging a shape does to each of its rings. */ +export function ringMoved(ring: VectorRing, dx: number, dy: number): VectorRing { + return mapPoints(ring, (point) => ({ ...point, x: point.x + dx, y: point.y + dy })); +} + +function mapPoints(ring: VectorRing, fn: (point: VectorPoint, index: number) => VectorPoint): VectorRing { + return { closed: ring.closed, points: ring.points.map((point, i) => clonePoint(fn(clonePoint(point), i))) }; +} + +function clonePoint(point: VectorPoint): VectorPoint { + return { + x: point.x, + y: point.y, + ...(point.in ? { in: { ...point.in } } : {}), + ...(point.out ? { out: { ...point.out } } : {}), + }; +} + +function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void { + const offset = { x: at.x - point.x, y: at.y - point.y }; + if (Math.hypot(offset.x, offset.y) < 1e-6) delete point[side]; + else point[side] = offset; +} + +function lerp(a: Vec2, b: Vec2, t: number): Vec2 { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} diff --git a/src/editor/state.ts b/src/editor/state.ts index c3da48f8..8b3bd887 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -37,6 +37,8 @@ export type Selection = | { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] } /** A frame rectangle inside a texture asset (the Texture tab). */ | { kind: 'frame'; assetId: string; frameId: string } + /** One shape of a vector art asset (the Vector art tab, #129). */ + | { kind: 'shape'; assetId: string; shapeId: string } /** One or more groups or comments of a graph, `annotationId` being the primary one. */ | { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] } /** @@ -551,6 +553,13 @@ export class EditorState { return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null; } + /** The vector drawing the selection is about – the asset itself or one shape of it (#129). */ + get selectedVectorArtId(): string | null { + const s = this.selection; + if (s?.kind !== 'asset' && s?.kind !== 'shape') return null; + return findAssetOf(this.project, s.assetId, 'vectorart')?.id ?? null; + } + /** The tile the selection refers to; only the asset itself, it has nothing inside to pick. */ get selectedTileId(): string | null { const s = this.selection; @@ -666,6 +675,7 @@ export function sameSelection(a: Selection, b: Selection): boolean { ); } if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId; + if (a.kind === 'shape' && b.kind === 'shape') return a.assetId === b.assetId && a.shapeId === b.shapeId; if (a.kind === 'track' && b.kind === 'track') return ( a.assetId === b.assetId && diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts new file mode 100644 index 00000000..f5f27c27 --- /dev/null +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -0,0 +1,577 @@ +/** + * The Vector art tab's canvas (#129, stage 3). It shows the drawing the rasteriser would make – the + * same commands from `core/model/vectorPath`, drawn straight onto a `Graphics` instead of into a + * texture – and lets one edit the shapes on top of it: the pen puts down points, the arrow drags + * points, handles and whole shapes. + * + * Two rules run through the whole file. The drawing lives in a container scaled by the zoom, so a + * stroke of two canvas pixels is two canvas pixels at every zoom, while points and handles are drawn + * in widget pixels, so they stay grabbable when the drawing is tiny. And a gesture writes the model + * directly as a preview and puts it back on release, so one drag is one undo step – the same way + * the Texture tab edits a collision outline. + */ +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { + AddVectorShapeCommand, + RemoveVectorShapeCommand, + SetVectorShapeRingsCommand, +} from '../../core/commands/vectorArtCommands'; +import type { VectorArtAsset, VectorRing, VectorShape } from '../../core/model/Asset'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAsset } from '../../core/model/Project'; +import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart'; +import { vectorArtDrawings } from '../../core/model/vectorPath'; +import { paintDrawing } from '../../viewport/vectorArtPicture'; +import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { + insideRing, + type RingGrip, + ringGripAt, + ringMoved, + ringOutline, + withHandleMoved, + withPointMoved, + withPointOnSegment, + withoutPoint, + withSmoothToggled, +} from '../polygonEdit'; +import type { EditorState } from '../state'; +import { drawChecker } from '../texture/checker'; +import { fitView, imageToLocal, localToImage, type SheetView, zoomAbout } from '../texture/sheetView'; + +/** Which gesture a press begins: the arrow edits what is there, the pen adds to it. */ +export type VectorTool = 'select' | 'pen'; + +/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */ +const POINT_SIZE = 8; +/** Radius of a Bezier handle's knob, in screen pixels. */ +const HANDLE_RADIUS = 4; +/** Pixels the pointer must travel before a press becomes a drag rather than a click. */ +const DRAG_THRESHOLD = 3; +/** The grid points snap to while snapping is on, in canvas pixels. */ +export const SNAP_STEP = 8; + +/** A gesture in progress. `before` is what the shape read when it began – the undo step's other side. */ +interface ShapeDrag { + kind: 'point' | 'handle' | 'shape' | 'pen'; + shapeId: string; + before: VectorRing[]; + rings: VectorRing[]; + /** Which ring and which of its points the gesture is about; -1 for a whole shape. */ + ring: number; + index: number; + side: 'in' | 'out'; + /** Where the press was, in widget pixels, and where in canvas pixels. */ + pointer: Vec2; + at: Vec2; + moved: boolean; + label: string; +} + +export class VectorArtCanvas extends Widget { + private readonly bg = new Graphics(); + /** Everything the view moves: the drawing, scaled by the zoom, and the overlay above it. */ + private readonly content = new Container(); + private readonly checker = new Graphics(); + private readonly art = new Graphics(); + private readonly overlay = new Graphics(); + private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly clip = new Graphics(); + private view: SheetView = { panX: 0, panY: 0, zoom: 1 }; + private assetId = ''; + private needsFit = true; + private tool: VectorTool = 'select'; + private snap = false; + private panning: { pointer: Vec2; panX: number; panY: number } | null = null; + private drag: ShapeDrag | null = null; + /** The ring the pen is drawing into; empty while no line is open. */ + private pen = { shapeId: '', ring: -1 }; + + constructor(private readonly state: EditorState) { + super(); + this.content.addChild(this.checker, this.art, this.overlay); + this.addChild(this.bg, this.content, this.hint, this.clip); + this.content.mask = this.clip; + this.content.eventMode = 'none'; + this.hint.eventMode = 'none'; + this.focusable = true; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', () => this.onPointerUp()); + this.on('pointerupoutside', () => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + + state.on('project', () => this.redraw()); + state.on('selection', () => this.redraw()); + state.on('load', () => { + this.needsFit = true; + this.endPen(); + this.redraw(); + }); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; + } + + show(assetId: string): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.needsFit = true; + this.endPen(); + this.redraw(); + } + + get currentAssetId(): string { + return this.assetId; + } + + saveView(): { view: SheetView; needsFit: boolean } { + return { view: { ...this.view }, needsFit: this.needsFit }; + } + + restoreView(saved: unknown): void { + const { view, needsFit } = saved as { view: SheetView; needsFit: boolean }; + if (needsFit) return; + this.view = { ...view }; + this.needsFit = false; + this.redraw(); + } + + fit(): void { + this.needsFit = true; + this.redraw(); + } + + setTool(tool: VectorTool): void { + if (this.tool === tool) return; + this.tool = tool; + // A line the pen left open belongs to the pen; the arrow must not add to it later. + this.endPen(); + this.redraw(); + } + + get currentTool(): VectorTool { + return this.tool; + } + + setSnap(on: boolean): void { + this.snap = on; + } + + get snapping(): boolean { + return this.snap; + } + + /** Whether the pen has a line open – the check scripts and the hint read it. */ + get drawing(): boolean { + return this.pen.ring >= 0; + } + + /** Where a canvas pixel sits on screen; the check scripts aim their drags with it. */ + canvasToScreen(x: number, y: number): Vec2 { + const local = imageToLocal(this.view, x, y); + const origin = this.getGlobalPosition(); + return { x: origin.x + local.x, y: origin.y + local.y }; + } + + private asset(): VectorArtAsset | null { + const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; + return asset && asset.type === 'vectorart' ? asset : null; + } + + private selectedShapeId(): string { + const s = this.state.selection; + return s?.kind === 'shape' && s.assetId === this.assetId ? s.shapeId : ''; + } + + private shape(shapeId: string): VectorShape | undefined { + const asset = this.asset(); + return asset ? findShape(asset, shapeId) : undefined; + } + + /** Delete takes the selected shape away – the canvas edits shapes, so that is what it removes. */ + override onKeyDown(event: KeyboardEvent): boolean { + if (event.key === 'Escape' && this.drawing) { + this.endPen(); + this.redraw(); + return true; + } + if (event.key !== 'Delete' && event.key !== 'Backspace') return super.onKeyDown(event); + const shapeId = this.selectedShapeId(); + if (!shapeId) return super.onKeyDown(event); + this.endPen(); + this.state.execute(new RemoveVectorShapeCommand(this.assetId, shapeId)); + this.state.select({ kind: 'asset', assetId: this.assetId }); + return true; + } + + private onPointerDown(e: FederatedPointerEvent): void { + const asset = this.asset(); + if (!asset) return; + const local = this.toLocal(e.global); + if (e.button === 1 || (e.button === 0 && e.shiftKey)) { + this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; + e.stopPropagation(); + return; + } + if (e.button !== 0) return; + ui().focus.focus(this); + const at = localToImage(this.view, local.x, local.y); + const tolerance = POINT_SIZE / this.view.zoom; + if (this.tool === 'pen') this.pressPen(asset, at, local, tolerance); + else this.pressSelect(asset, at, local, tolerance, e); + e.stopPropagation(); + } + + /** + * A press with the pen: the first one makes a shape with one point in it, every later one adds a + * point to the line that is open, and a press on that line's first point closes it. Dragging away + * from the point that was just put down pulls its handles out, which is how a curve is drawn. + */ + private pressPen(asset: VectorArtAsset, at: Vec2, local: Vec2, tolerance: number): void { + const point = this.snapped(at); + const open = this.openRing(); + if (!open) { + const command = new AddVectorShapeCommand(asset.id, [{ points: [point], closed: false }]); + if (!this.state.execute(command)) return; + this.state.select({ kind: 'shape', assetId: asset.id, shapeId: command.shapeId }); + this.pen = { shapeId: command.shapeId, ring: 0 }; + this.startPenDrag(command.shapeId, 0, 0, local, point); + return; + } + const { shape, ring } = open; + const first = ring.points[0]; + if (first && ring.points.length >= 2 && Math.hypot(first.x - at.x, first.y - at.y) <= tolerance) { + const rings = this.ringsWith(shape, this.pen.ring, { ...ring, closed: true }); + this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Close shape')); + this.endPen(); + this.redraw(); + return; + } + const next = { ...ring, points: [...ring.points, point] }; + this.startPenDrag(shape.id, this.pen.ring, next.points.length - 1, local, point, next); + } + + private startPenDrag( + shapeId: string, + ringIndex: number, + index: number, + local: Vec2, + at: Vec2, + ring?: VectorRing, + ): void { + const shape = this.shape(shapeId); + if (!shape) return; + const rings = ring ? this.ringsWith(shape, ringIndex, ring) : shape.rings.map(cloneVectorRing); + this.drag = { + kind: 'pen', + shapeId, + before: shape.rings, + rings, + ring: ringIndex, + index, + side: 'out', + pointer: { x: local.x, y: local.y }, + at, + moved: false, + label: 'Draw shape', + }; + this.preview(); + } + + /** + * A press with the arrow. On the selected shape first, so its points stay reachable under a shape + * drawn later: a double-click on a point takes it away, Alt makes it round or square again, a + * press on a point or a handle drags it, one on a line puts a point there. Inside a shape's fill + * the whole shape is dragged; on nothing the selection is cleared. + */ + private pressSelect( + asset: VectorArtAsset, + at: Vec2, + local: Vec2, + tolerance: number, + e: FederatedPointerEvent, + ): void { + const selected = this.shape(this.selectedShapeId()); + if (selected) { + for (const [ringIndex, ring] of selected.rings.entries()) { + const grip = ringGripAt(ring, at, tolerance); + if (!grip) continue; + this.pressGrip(asset, selected, ringIndex, ring, grip, at, local, e); + return; + } + } + // Last first: the later shape is drawn on top, so that is what a click on an overlap means. + for (let i = asset.shapes.length - 1; i >= 0; i--) { + const shape = asset.shapes[i]!; + if (!shape.rings.some((ring) => insideRing(ring, at))) continue; + if (shape.id !== selected?.id) + this.state.select({ kind: 'shape', assetId: asset.id, shapeId: shape.id }); + this.drag = { + kind: 'shape', + shapeId: shape.id, + before: shape.rings, + rings: shape.rings.map(cloneVectorRing), + ring: -1, + index: -1, + side: 'out', + pointer: { x: local.x, y: local.y }, + at, + moved: false, + label: 'Move shape', + }; + return; + } + this.state.select({ kind: 'asset', assetId: this.assetId }); + this.redraw(); + } + + private pressGrip( + asset: VectorArtAsset, + shape: VectorShape, + ringIndex: number, + ring: VectorRing, + grip: RingGrip, + at: Vec2, + local: Vec2, + e: FederatedPointerEvent, + ): void { + if (grip.kind === 'point') { + if (pressCount(e, `point ${shape.id} ${ringIndex} ${grip.index}`) === 2) { + const next = withoutPoint(ring, grip.index); + const rings = next + ? this.ringsWith(shape, ringIndex, next) + : shape.rings.filter((_, i) => i !== ringIndex).map(cloneVectorRing); + this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Remove point')); + return; + } + if (e.altKey) { + const rings = this.ringsWith(shape, ringIndex, withSmoothToggled(ring, grip.index)); + this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Smooth point')); + return; + } + } + const ringNow = + grip.kind === 'segment' ? withPointOnSegment(ring, grip.index, grip.t) : cloneVectorRing(ring); + const index = grip.kind === 'segment' ? grip.index + 1 : grip.index; + this.drag = { + kind: grip.kind === 'handle' ? 'handle' : 'point', + shapeId: shape.id, + before: shape.rings, + rings: this.ringsWith(shape, ringIndex, ringNow), + ring: ringIndex, + index, + side: grip.kind === 'handle' ? grip.side : 'out', + pointer: { x: local.x, y: local.y }, + at, + moved: grip.kind === 'segment', + label: grip.kind === 'segment' ? 'Add point' : grip.kind === 'handle' ? 'Bend shape' : 'Move point', + }; + if (grip.kind === 'segment') this.preview(); + else this.redraw(); + } + + private onPointerMove(e: FederatedPointerEvent): void { + const local = this.toLocal(e.global); + if (this.panning) { + this.view = { + ...this.view, + panX: this.panning.panX + (local.x - this.panning.pointer.x), + panY: this.panning.panY + (local.y - this.panning.pointer.y), + }; + this.redraw(); + return; + } + const drag = this.drag; + if (!drag) { + this.updateCursor(local); + return; + } + if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD) + return; + drag.moved = true; + const to = localToImage(this.view, local.x, local.y); + const ring = drag.rings[drag.ring]; + if (drag.kind === 'shape') { + const delta = this.snapped({ x: to.x - drag.at.x, y: to.y - drag.at.y }); + const before = drag.before; + drag.rings = before.map((r) => ringMoved(r, delta.x, delta.y)); + } else if (ring && drag.kind === 'handle') { + drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to)); + } else if (ring && drag.kind === 'pen') { + // The pen pulls both handles at once: the one it drags towards, the other mirrored behind it. + drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, 'out', to)); + } else if (ring) { + drag.rings = this.replace(drag.rings, drag.ring, withPointMoved(ring, drag.index, this.snapped(to))); + } + this.preview(); + } + + /** Writes the gesture into the model as a preview; no undo step until the pointer is up. */ + private preview(): void { + const drag = this.drag; + const shape = drag && this.shape(drag.shapeId); + if (!drag || !shape) return; + shape.rings = drag.rings; + this.state.preview(); + } + + private onPointerUp(): void { + this.panning = null; + const drag = this.drag; + this.drag = null; + const shape = drag && this.shape(drag.shapeId); + if (!drag || !shape) return; + // The preview moved it already; put it back so the command records the state that was there. + shape.rings = drag.before; + if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring }; + if (SetVectorShapeRingsCommand.changes(shape, drag.rings)) + this.state.execute(new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label)); + else this.state.preview(); + } + + private onWheel(e: FederatedWheelEvent): void { + const local = this.toLocal(e.global); + this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y); + this.needsFit = false; + this.redraw(); + e.preventDefault(); + } + + private updateCursor(local: Vec2): void { + const asset = this.asset(); + if (!asset) { + this.cursor = 'default'; + return; + } + if (this.tool === 'pen') { + this.cursor = 'crosshair'; + return; + } + const at = localToImage(this.view, local.x, local.y); + const tolerance = POINT_SIZE / this.view.zoom; + const selected = this.shape(this.selectedShapeId()); + const onGrip = selected?.rings.some((ring) => ringGripAt(ring, at, tolerance) !== null) ?? false; + const inside = asset.shapes.some((shape) => shape.rings.some((ring) => insideRing(ring, at))); + this.cursor = onGrip ? 'pointer' : inside ? 'move' : 'default'; + } + + /** The ring the pen is adding to, with its shape; null when no line is open or it has closed. */ + private openRing(): { shape: VectorShape; ring: VectorRing } | null { + const shape = this.pen.ring < 0 ? undefined : this.shape(this.pen.shapeId); + const ring = shape?.rings[this.pen.ring]; + if (!shape || !ring || ring.closed) return null; + return { shape, ring }; + } + + private endPen(): void { + this.pen = { shapeId: '', ring: -1 }; + } + + private ringsWith(shape: VectorShape, index: number, ring: VectorRing): VectorRing[] { + return this.replace(shape.rings, index, ring); + } + + private replace(rings: readonly VectorRing[], index: number, ring: VectorRing): VectorRing[] { + const next = rings.map(cloneVectorRing); + if (index >= 0 && index < next.length) next[index] = ring; + else next.push(ring); + return next; + } + + /** On the grid while snapping is on, and on nothing in particular while it is off. */ + private snapped(at: Vec2): Vec2 { + if (!this.snap) return { x: at.x, y: at.y }; + return { x: Math.round(at.x / SNAP_STEP) * SNAP_STEP, y: Math.round(at.y / SNAP_STEP) * SNAP_STEP }; + } + + override redraw(): void { + this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport); + this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + const asset = this.asset(); + const size = asset ? vectorCanvasSize(asset.props) : null; + + if (asset && size && this.needsFit && this.w > 0 && this.h > 0) { + this.view = fitView(size.width, size.height, this.w, this.h); + this.needsFit = false; + } + + this.checker.clear(); + this.art.clear(); + this.overlay.clear(); + if (asset && size) { + const origin = imageToLocal(this.view, 0, 0); + drawChecker( + this.checker, + origin.x, + origin.y, + size.width * this.view.zoom, + size.height * this.view.zoom, + this.w, + this.h, + ); + // The drawing itself is in canvas pixels; the container carries the view instead. + this.art.position.set(origin.x, origin.y); + this.art.scale.set(this.view.zoom); + for (const drawing of vectorArtDrawings(asset)) paintDrawing(this.art, drawing); + this.drawFrame(origin, size); + this.drawShape(this.shape(this.selectedShapeId())); + } + this.syncHint(asset); + } + + /** The canvas edge: what lies outside it is drawn here but is not in the picture. */ + private drawFrame(origin: Vec2, size: { width: number; height: number }): void { + this.overlay + .rect(origin.x, origin.y, size.width * this.view.zoom, size.height * this.view.zoom) + .stroke({ width: 1, color: Theme.colors.marquee }); + } + + /** The selected shape's points and handles, in widget pixels so they keep their size. */ + private drawShape(shape: VectorShape | undefined): void { + if (!shape) return; + const half = POINT_SIZE / 2; + for (const ring of shape.rings) { + const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y)); + if (screen.length >= 2) + this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent }); + for (const point of ring.points) { + const at = imageToLocal(this.view, point.x, point.y); + for (const side of ['in', 'out'] as const) { + const handle = handleAt(point, side); + if (!handle) continue; + const tip = imageToLocal(this.view, handle.x, handle.y); + this.overlay + .moveTo(at.x, at.y) + .lineTo(tip.x, tip.y) + .stroke({ width: 1, color: Theme.colors.marquee }); + this.overlay + .circle(tip.x, tip.y, HANDLE_RADIUS) + .fill(Theme.colors.viewport) + .stroke({ width: 1, color: Theme.colors.marquee }); + } + this.overlay + .rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE) + .fill(Theme.colors.accentText) + .stroke({ width: 1, color: Theme.colors.accent }); + } + } + } + + private syncHint(asset: VectorArtAsset | null): void { + const text = !asset + ? 'No vector drawing selected – pick one in the Assets panel.' + : this.tool === 'pen' + ? this.drawing + ? 'Pen: click adds a point, drag bends it, click the first point to close, Esc ends the line.' + : 'Pen: click to start a shape.' + : asset.shapes.length === 0 + ? 'Nothing drawn yet – take the Pen and click on the canvas.' + : ''; + this.hint.text = text; + this.hint.visible = text !== ''; + this.hint.position.set(Theme.padding * 2, Theme.padding * 2); + } +} diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts new file mode 100644 index 00000000..ce5b6da0 --- /dev/null +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -0,0 +1,76 @@ +import type { Asset, VectorArtAsset } from '../../core/model/Asset'; +import { vectorCanvasSize } from '../../core/model/vectorart'; +import { Button } from '../../ui'; +import { AssetTabEditor } from '../AssetTabEditor'; +import type { EditorState } from '../state'; +import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas'; + +/** Room kept for the "256 × 256 px · 3 shapes" readout. */ +const INFO_WIDTH = 190; + +/** + * The Vector art tab (#129, stage 3): the canvas shows the drawing and edits it, the header picks + * the tool. Fill, outline and the canvas size are rows of the inspector, as with every other asset – + * a panel of its own is #191. + */ +export class VectorArtEditor extends AssetTabEditor { + /** Public for the check scripts: the two tools and the grid switch. */ + readonly selectButton: Button; + readonly penButton: Button; + readonly snapButton: Button; + + constructor(state: EditorState) { + super(state, { infoWidth: INFO_WIDTH }); + this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') }); + this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') }); + this.snapButton = new Button('Grid', { + toggle: true, + onClick: () => this.setSnap(!this.canvas.snapping), + }); + this.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, this.snapButton]); + this.setTool('select'); + } + + setTool(tool: VectorTool): void { + this.canvas.setTool(tool); + this.selectButton.setToggled(tool === 'select'); + this.penButton.setToggled(tool === 'pen'); + } + + setSnap(on: boolean): void { + this.canvas.setSnap(on); + this.snapButton.setToggled(on); + } + + /** Where a canvas pixel of the shown drawing sits on screen – for the check scripts. */ + canvasToScreen(x: number, y: number): { x: number; y: number } { + return this.canvas.canvasToScreen(x, y); + } + + /** The drawing area without the header, so a check can ask what lies outside it. */ + canvasRect(): { x: number; y: number; w: number; h: number } { + const p = this.canvas.getGlobalPosition(); + return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h }; + } + + override selectedId(): string { + return this.state.selectedVectorArtId ?? ''; + } + + override isMine(asset: Asset): asset is VectorArtAsset { + return asset.type === 'vectorart'; + } + + protected override infoText(asset: VectorArtAsset): string { + const { width, height } = vectorCanvasSize(asset.props); + const count = asset.shapes.length; + return `${width} × ${height} px · ${count} ${count === 1 ? 'shape' : 'shapes'}`; + } + + protected override syncExtras(asset: VectorArtAsset | null): void { + this.fitButton.setEnabled(asset !== null); + this.selectButton.setEnabled(asset !== null); + this.penButton.setEnabled(asset !== null); + this.snapButton.setEnabled(asset !== null); + } +} diff --git a/src/viewport/vectorArtPicture.ts b/src/viewport/vectorArtPicture.ts index efe31fa3..76971394 100644 --- a/src/viewport/vectorArtPicture.ts +++ b/src/viewport/vectorArtPicture.ts @@ -54,15 +54,19 @@ export function rasteriseVectorArt(asset: Asset, renderer: Renderer): Texture | antialias: true, }); const graphics = new Graphics(); - for (const drawing of canvas.drawings) paint(graphics, drawing); + for (const drawing of canvas.drawings) paintDrawing(graphics, drawing); renderer.render({ container: graphics, target: texture, clear: true }); // The picture is on the GPU from here on; the scene graph that drew it is of no more use. graphics.destroy(true); return texture; } -/** One shape: its fill first, its outline over it, so a stroke is never half swallowed by the fill. */ -function paint(graphics: Graphics, drawing: ShapeDrawing): void { +/** + * One shape: its fill first, its outline over it, so a stroke is never half swallowed by the fill. + * Exported because the Vector art tab paints the very same drawing onto its own `Graphics` (#129, + * stage 3) – what one edits there has to be what a sprite ends up wearing. + */ +export function paintDrawing(graphics: Graphics, drawing: ShapeDrawing): void { if (drawing.fill.length > 0) { // `checkForHoles` is what makes a ring inside another cut it out instead of painting over it. graphics.path(pathOf(drawing.fill, true)).fill({ color: drawing.color, alpha: drawing.alpha }); diff --git a/tests/editor/ring-edit.test.ts b/tests/editor/ring-edit.test.ts new file mode 100644 index 00000000..9f24d4d1 --- /dev/null +++ b/tests/editor/ring-edit.test.ts @@ -0,0 +1,174 @@ +/** + * The editing rules of a vector art ring (#129, stage 3). They are the gestures of the Vector art + * tab without a view: what the pointer grabs, what a drag writes, and what putting a point on a + * line does to the line. + */ +import { describe, expect, it } from 'vitest'; +import type { VectorRing } from '../../src/core/model/Asset'; +import { + insideRing, + nearestOnSegment, + pointOnSegment, + ringGripAt, + ringMoved, + ringOutline, + ringSegments, + withHandleMoved, + withoutPoint, + withPointMoved, + withPointOnSegment, + withSmoothToggled, +} from '../../src/editor/polygonEdit'; + +/** A square of 100, corners only. */ +function square(): VectorRing { + return { + closed: true, + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ], + }; +} + +/** Two points with a handle each, bulging upwards between them. */ +function arc(): VectorRing { + return { + closed: false, + points: [ + { x: 0, y: 0, out: { x: 0, y: -40 } }, + { x: 100, y: 0, in: { x: 0, y: -40 } }, + ], + }; +} + +describe('what the pointer grabs', () => { + it('takes the nearest point', () => { + expect(ringGripAt(square(), { x: 98, y: 3 }, 8)).toEqual({ kind: 'point', index: 1 }); + }); + + it('offers nothing further away than the tolerance', () => { + expect(ringGripAt(square(), { x: 50, y: 50 }, 8)).toBeNull(); + }); + + it('gives a handle before the point it belongs to', () => { + const ring = arc(); + // The handle of point 0 sits at (0, −40); its own point is 40 away, so both are in reach at 50. + expect(ringGripAt(ring, { x: 0, y: -38 }, 50)).toEqual({ kind: 'handle', index: 0, side: 'out' }); + }); + + it('only offers the handles that are drawn', () => { + const ring = arc(); + expect(ringGripAt(ring, { x: 0, y: -38 }, 50, [1])).toEqual({ kind: 'point', index: 0 }); + }); + + it('gives the segment under the pointer, with how far along it lies', () => { + const grip = ringGripAt(square(), { x: 50, y: 2 }, 8); + expect(grip?.kind).toBe('segment'); + if (grip?.kind !== 'segment') throw new Error('no segment'); + expect(grip.index).toBe(0); + expect(grip.at).toEqual({ x: 50, y: 0 }); + expect(grip.t).toBeCloseTo(0.5, 6); + }); + + it('finds the curve of a curved segment, not its chord', () => { + // The chord runs along y = 0; the curve reaches up to about y = −30 in its middle. + const hit = nearestOnSegment(arc().points[0]!, arc().points[1]!, { x: 50, y: -30 }); + expect(hit.distance).toBeLessThan(1); + expect(hit.t).toBeCloseTo(0.5, 1); + }); + + it('walks the way back to the first point only on a closed ring', () => { + expect(ringSegments(square()).length).toBe(4); + expect(ringSegments({ ...square(), closed: false }).length).toBe(3); + }); +}); + +describe('dragging', () => { + it('moves a point and takes its handles along', () => { + const moved = withPointMoved(arc(), 0, { x: 10, y: 10 }); + expect(moved.points[0]).toEqual({ x: 10, y: 10, out: { x: 0, y: -40 } }); + }); + + it('writes a handle as an offset and mirrors the other one', () => { + const ring: VectorRing = { + closed: false, + points: [{ x: 50, y: 50, in: { x: -10, y: 0 }, out: { x: 10, y: 0 } }], + }; + const bent = withHandleMoved(ring, 0, 'out', { x: 70, y: 60 }); + expect(bent.points[0]!.out).toEqual({ x: 20, y: 10 }); + expect(bent.points[0]!.in).toEqual({ x: -20, y: -10 }); + }); + + it('takes a handle away when it is dropped on its own point', () => { + const straightened = withHandleMoved(arc(), 0, 'out', { x: 0, y: 0 }); + expect(straightened.points[0]!.out).toBeUndefined(); + }); + + it('moves a whole ring', () => { + const moved = ringMoved(square(), 10, -5); + expect(moved.points.map((p) => [p.x, p.y])).toEqual([ + [10, -5], + [110, -5], + [110, 95], + [10, 95], + ]); + }); +}); + +describe('adding and taking away', () => { + it('puts a corner on a straight segment', () => { + const next = withPointOnSegment(square(), 0, 0.5); + expect(next.points.length).toBe(5); + expect(next.points[1]).toEqual({ x: 50, y: 0 }); + }); + + it('splits a curved segment without changing the curve', () => { + const before = arc(); + const after = withPointOnSegment(before, 0, 0.5); + expect(after.points.length).toBe(3); + // The two halves together have to draw what the one segment drew: measured at five places. + for (const t of [0, 0.25, 0.5, 0.75, 1]) { + const was = pointOnSegment(before.points[0]!, before.points[1]!, t); + const half = t <= 0.5 ? 0 : 1; + const now = pointOnSegment(after.points[half]!, after.points[half + 1]!, (t - half * 0.5) * 2); + expect(now.x).toBeCloseTo(was.x, 6); + expect(now.y).toBeCloseTo(was.y, 6); + } + }); + + it('takes a point away, down to the last one', () => { + const three = withoutPoint(square(), 3); + expect(three?.points.length).toBe(3); + expect(withoutPoint({ closed: false, points: [{ x: 0, y: 0 }] }, 0)).toBeNull(); + }); + + it('makes a corner round and a round point square again', () => { + // Corner 1 lies between (0, 0) and (100, 100), so its handles point along that line. + const round = withSmoothToggled(square(), 1); + expect(round.points[1]!.out).toEqual({ x: 100 / 3, y: 100 / 3 }); + expect(round.points[1]!.in).toEqual({ x: -100 / 3, y: -100 / 3 }); + expect(withSmoothToggled(round, 1).points[1]).toEqual({ x: 100, y: 0 }); + }); +}); + +describe('the inside of a ring', () => { + it('holds what lies in it and not what lies beside it', () => { + expect(insideRing(square(), { x: 50, y: 50 })).toBe(true); + expect(insideRing(square(), { x: 150, y: 50 })).toBe(false); + }); + + it('is empty while the ring is open – a line encloses nothing', () => { + expect(insideRing({ ...square(), closed: false }, { x: 50, y: 50 })).toBe(false); + }); + + it('follows a curve rather than the chord it spans', () => { + // A closed arc bulging up to y = −30: a point above the chord is inside it, one below is not. + const bulge: VectorRing = { closed: true, points: arc().points }; + expect(insideRing(bulge, { x: 50, y: -10 })).toBe(true); + expect(insideRing(bulge, { x: 50, y: 10 })).toBe(false); + expect(ringOutline(bulge).length).toBeGreaterThan(2); + }); +}); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 86f57629..f31c4117 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -2,14 +2,16 @@ import { colourDistance } from '../lib/harness.mjs'; import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; /** - * Vector art (#129), stage 2: the drawing is rasterised and travels the picture path of #185. What + * Vector art (#129), stages 2 and 3: the drawing is rasterised and travels the picture path of #185. What * only a running editor can show – the shapes are painted by the GPU, so what a check reads here is * the picture itself: the fill of a shape, the hole a ring inside it cuts, which of two shapes lies * on top, the outline along a ring, and that a changed drawing becomes a new picture while the old * one is let go. * - * The shapes are put into the model rather than drawn with the mouse: the tab that draws them is - * stage 3, and these checks are about the rasteriser, not about the tool. + * The shapes of the first blocks are put into the model rather than drawn with the mouse: those + * checks are about the rasteriser, not about the tool. From 47c on it is the tool that is measured – + * the pen puts points down, the arrow drags one and takes one away, and what comes out is read from + * the model, which is the only place a point's coordinates exist exactly. */ const CANVAS = 256; @@ -67,7 +69,8 @@ const SHAPES = [ ]; export async function vectorArt(t) { - const { check, json, evaluate, mutate, settle, sleep, shot, showTab } = t; + const { check, click, doubleClick, drag, clickText, json, evaluate, mutate, settle, sleep, shot, showTab } = + t; const Y = LANES.vectorArt; // 47a. A drawing on a sprite: what the rasteriser put on the GPU, read off the screen. @@ -151,4 +154,85 @@ export async function vectorArt(t) { ); const repainted = await at(48, 200); check('the sprite shows the changed fill', colourDistance(repainted, REDRAWN) < 14, repainted); + + // 47c. The tab: the pen draws a triangle and closes it. + await evaluate("__gm.openDocument('asset:va-art')"); + await settle(); + const info = await json('__gm.vectorArt.info()'); + check( + 'the Vector art tab shows the drawing and its canvas', + (await json('__gm.vectorArt.current()')) === 'va-art' && info.startsWith(`${CANVAS} \u00d7 ${CANVAS} px`), + JSON.stringify({ current: await json('__gm.vectorArt.current()'), info }), + ); + + await clickText('Pen'); + check('the pen is the tool a press uses', (await json('__gm.vectorArt.tool()')) === 'pen'); + + const TRIANGLE = [ + { x: 40, y: 40 }, + { x: 110, y: 40 }, + { x: 110, y: 110 }, + ]; + const screen = []; + for (const point of TRIANGLE) { + const at = await json(`__gm.vectorArt.pointAt(${point.x}, ${point.y})`); + screen.push(at); + await click(at.x, at.y); + } + // The first point again: that is what closes a line, and the pen has nothing open afterwards. + await click(screen[0].x, screen[0].y); + await settle(); + + const drawn = (await json("__gm.vectorArt.shapes('va-art')")).at(-1); + const near = (a, b) => Math.abs(a - b) <= 1.5; + check( + 'the pen drew a closed shape of three points where it was clicked', + Boolean(drawn) && + drawn.rings.length === 1 && + drawn.rings[0].closed && + drawn.rings[0].points.length === 3 && + drawn.rings[0].points.every((p, i) => near(p.x, TRIANGLE[i].x) && near(p.y, TRIANGLE[i].y)) && + (await json('__gm.vectorArt.drawing()')) === false, + JSON.stringify(drawn?.rings?.[0]), + ); + + // 47d. The arrow: a point is dragged, one is made round, one is taken away. + await clickText('Select'); + const corner = await json(`__gm.vectorArt.pointAt(${TRIANGLE[1].x}, ${TRIANGLE[1].y})`); + const target = await json(`__gm.vectorArt.pointAt(180, 40)`); + await drag(corner.x, corner.y, target.x, target.y); + const moved = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1]; + check( + 'dragging a point writes it where the pointer left it', + near(moved.x, 180) && near(moved.y, 40), + JSON.stringify(moved), + ); + + const pulled = await json(`__gm.vectorArt.pointAt(180, 40)`); + await click(pulled.x, pulled.y, 'left', { alt: true }); + const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1]; + check( + 'Alt on a point makes it round – it grows the two handles of a curve', + Boolean(smooth.in) && Boolean(smooth.out), + JSON.stringify(smooth), + ); + + await shot('47-vector-tab'); + + const firstPoint = await json(`__gm.vectorArt.pointAt(${TRIANGLE[0].x}, ${TRIANGLE[0].y})`); + await doubleClick(firstPoint.x, firstPoint.y); + const left = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points; + check( + 'a double-click takes a point away', + left.length === 2 && near(left[0].x, 180), + JSON.stringify(left.map((p) => [Math.round(p.x), Math.round(p.y)])), + ); + + // The drawing is a picture like any other: what the tool changed reaches the sprite by itself. + const undrawn = await json("__gm.drawnPicture('va-art')"); + check( + 'what was drawn in the tab is the picture the sprite wears', + Boolean(undrawn) && undrawn.file !== after?.file && undrawn.kept, + JSON.stringify({ was: after?.file, now: undrawn?.file }), + ); } diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index fb73a35d..521b4f1c 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -114,6 +114,7 @@ export const TAB_LABELS = { statemachine: 'State machine', shader: 'Shader', texture: 'Texture', + vectorart: 'Vector art', animation: 'Animation', tile: 'Tile', container: 'Container', @@ -161,6 +162,7 @@ const DOCUMENT_KINDS = [ 'shader', 'particles', 'texture', + 'vectorart', 'animation', 'tile', 'container',