diff --git a/README.md b/README.md index 3e55b857..dde49933 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, 1188 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1195 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/model/Asset.ts b/src/core/model/Asset.ts index 34dfd175..d6261520 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -239,15 +239,22 @@ export interface TilemapAsset extends AssetBase { * 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). + * side (#129). + * + * `mode` says how the two handles move together when one is dragged (#195): a corner leaves the + * other alone, a smooth point keeps it pointing the opposite way at its own length, a symmetric one + * mirrors it exactly. Absent is a corner. It changes no pixel – only what the next drag does. */ export interface VectorPoint { x: number; y: number; in?: Vec2; out?: Vec2; + mode?: VectorPointMode; } +export type VectorPointMode = 'corner' | 'smooth' | 'symmetric'; + /** * 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 diff --git a/src/core/model/vectorart.ts b/src/core/model/vectorart.ts index 50dbd9c0..b09df93d 100644 --- a/src/core/model/vectorart.ts +++ b/src/core/model/vectorart.ts @@ -127,14 +127,16 @@ export function vectorArtPicture(asset: Asset): GeneratedPicture | null { /** 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(cloneVectorPoint) }; +} + +export function cloneVectorPoint(point: VectorPoint): VectorPoint { 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 } } : {}), - })), + x: point.x, + y: point.y, + ...(point.in ? { in: { ...point.in } } : {}), + ...(point.out ? { out: { ...point.out } } : {}), + ...(point.mode && point.mode !== 'corner' ? { mode: point.mode } : {}), }; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 0b2454ea..bdb7dfb5 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -590,6 +590,7 @@ function normalizeShapes(raw: unknown): VectorShape[] { const handle = normalizeHandle(point[side]); if (handle) kept[side] = handle; } + if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode; points.push(kept); } // Closed is what a shape usually is, and what every ring written by the editor says. diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 68b4a0c7..692217a6 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -471,6 +471,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E snapButton: () => snapButton(vectorArtEditor.snap), /** Whether the pen has a line open that the next click would add a point to. */ drawing: () => vectorArtEditor.canvas.drawing, + /** The header's point type (#195): where to click it, what it says, and the type it reads. */ + pointButton: () => ({ + ...centreOf(vectorArtEditor.pointButton), + text: vectorArtEditor.pointButton.text, + enabled: vectorArtEditor.pointButton.enabled, + kind: vectorArtEditor.canvas.pickedKind, + }), /** 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); @@ -488,6 +495,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E y: point.y, in: point.in ? { ...point.in } : null, out: point.out ? { ...point.out } : null, + mode: point.mode ?? null, })), })), })); diff --git a/src/editor/polygonEdit.ts b/src/editor/polygonEdit.ts index ede3f9e1..4c53751e 100644 --- a/src/editor/polygonEdit.ts +++ b/src/editor/polygonEdit.ts @@ -8,11 +8,11 @@ * 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 { VectorPoint, VectorPointMode, 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'; +import { cloneVectorPoint, 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 }; @@ -196,29 +196,113 @@ export function withPointMoved(ring: VectorRing, index: number, to: Vec2): Vecto } /** - * 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. + * What a point is to the user (#195): straight has no handle at all, the other three are the mode + * its handles move in. A point with handles and no mode is a corner. + */ +export type PointKind = 'straight' | VectorPointMode; + +export const POINT_KINDS: readonly PointKind[] = ['straight', 'corner', 'smooth', 'symmetric']; + +export function pointKind(point: VectorPoint): PointKind { + return point.in || point.out ? (point.mode ?? 'corner') : 'straight'; +} + +/** + * A handle pulled to `to`, in canvas pixels; the other handle follows the point's mode. A corner + * leaves it where it is, a smooth point turns it to the opposite direction at its own length, a + * symmetric point mirrors it – and grows it, if it was missing, which is how the pen draws a curve. + * A handle dropped back onto its point is taken away; on a symmetric point its mirror goes with it. + * `mode` overrides the point's own and is written onto it. */ export function withHandleMoved( ring: VectorRing, index: number, side: 'in' | 'out', to: Vec2, - mirror = true, + mode?: VectorPointMode, ): VectorRing { return mapPoints(ring, (point, i) => { if (i !== index) return point; const offset = { x: to.x - point.x, y: to.y - point.y }; + const length = Math.hypot(offset.x, offset.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 }; + const how = mode ?? point.mode ?? 'corner'; + if (mode) next.mode = mode; + if (length < 1e-6) { + delete next[side]; + if (how === 'symmetric') delete next[other]; + return next; + } + next[side] = offset; + const across = next[other]; + if (how === 'symmetric') next[other] = { x: -offset.x, y: -offset.y }; + else if (how === 'smooth' && across) { + const keep = Math.hypot(across.x, across.y); + next[other] = { x: (-offset.x / length) * keep, y: (-offset.y / length) * keep }; + } return next; }); } +/** + * The point turned into `kind` (#195). Straight takes both handles away. A point without handles + * that becomes one of the other three grows them first, along the line between its neighbours at a + * third of the way to each – the curve one expects, without pulling two handles out by hand. Smooth + * and symmetric then line the handles up on the bisector of the two, so the curve keeps its general + * direction: smooth keeps each length, symmetric gives both their mean. A missing second handle is + * grown opposite the first at the same length. + */ +export function withPointKind(ring: VectorRing, index: number, kind: PointKind): VectorRing { + const point = ring.points[index]; + if (!point) return cloneVectorRing(ring); + if (kind === 'straight') return mapPoints(ring, (p, i) => (i === index ? { x: p.x, y: p.y } : p)); + const had = Boolean(point.in || point.out); + // Grown handles are in line and of one length already; lining them up again would only add rounding. + let handles = had ? { in: point.in, out: point.out } : grownHandles(ring, index); + if (!handles) return cloneVectorRing(ring); + if (had && kind !== 'corner') handles = linedUp(handles, kind); + const next: VectorPoint = { x: point.x, y: point.y, mode: kind }; + if (handles.in) next.in = handles.in; + if (handles.out) next.out = handles.out; + return mapPoints(ring, (p, i) => (i === index ? next : p)); +} + +function grownHandles(ring: VectorRing, index: number): { in: Vec2; out: Vec2 } | null { + const point = ring.points[index]!; + const count = ring.points.length; + // An open line's ends have one neighbour: the missing one stands on the point itself. + const before = ring.closed || index > 0 ? ring.points[(index - 1 + count) % count]! : point; + const after = ring.closed || index < count - 1 ? 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 null; + return { in: { x: -dx, y: -dy }, out: { x: dx, y: dy } }; +} + +function linedUp(handles: { in?: Vec2; out?: Vec2 }, kind: 'smooth' | 'symmetric'): { in: Vec2; out: Vec2 } { + const outLength = handles.out ? Math.hypot(handles.out.x, handles.out.y) : 0; + const inLength = handles.in ? Math.hypot(handles.in.x, handles.in.y) : 0; + const ahead = handles.out ? unit(handles.out) : { x: 0, y: 0 }; + const behind = handles.in ? unit(handles.in) : { x: 0, y: 0 }; + // The way out, averaged with the way in turned round; handles folded onto each other keep `out`'s. + let direction = unit({ x: ahead.x - behind.x, y: ahead.y - behind.y }); + if (direction.x === 0 && direction.y === 0) + direction = handles.out ? ahead : { x: -behind.x, y: -behind.y }; + let lengthIn = inLength || outLength; + let lengthOut = outLength || inLength; + if (kind === 'symmetric') lengthIn = lengthOut = (lengthIn + lengthOut) / 2; + return { + in: { x: -direction.x * lengthIn, y: -direction.y * lengthIn }, + out: { x: direction.x * lengthOut, y: direction.y * lengthOut }, + }; +} + +function unit(v: Vec2): Vec2 { + const length = Math.hypot(v.x, v.y); + return length < 1e-9 ? { x: 0, y: 0 } : { x: v.x / length, y: v.y / length }; +} + /** * 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 @@ -239,11 +323,16 @@ export function withPointOnSegment(ring: VectorRing, index: number, t: number): const c = lerp(c2, to, t); const d = lerp(a, b, t); const e = lerp(b, c, t); + const next = points[(index + 1) % ring.points.length]!; setHandle(points[index]!, 'out', a); - setHandle(points[(index + 1) % ring.points.length]!, 'in', c); - inserted = { x: at.x, y: at.y }; + setHandle(next, 'in', c); + // The split keeps the directions but shortens one handle of each end: no longer symmetric, + // still smooth. The new point sits on a curve, so its handles are in line – smooth as well. + for (const end of [points[index]!, next]) if (end.mode === 'symmetric') end.mode = 'smooth'; + inserted = { x: at.x, y: at.y, mode: 'smooth' }; setHandle(inserted, 'in', d); setHandle(inserted, 'out', e); + if (!inserted.in && !inserted.out) delete inserted.mode; } points.splice(index + 1, 0, inserted); return { closed: ring.closed, points }; @@ -255,24 +344,11 @@ export function withoutPoint(ring: VectorRing, index: number): VectorRing | 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. - */ +/** Alt-click: a straight point made round (symmetric), and one with handles made straight again. */ 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, - ); + return withPointKind(ring, index, pointKind(point) === 'straight' ? 'symmetric' : 'straight'); } /** The whole ring shifted – what dragging a shape does to each of its rings. */ @@ -284,13 +360,11 @@ function mapPoints(ring: VectorRing, fn: (point: VectorPoint, index: number) => return { closed: ring.closed, points: ring.points.map((point, i) => clonePoint(fn(clonePoint(point), i))) }; } +/** A mode without a handle to steer is dropped, so a straight point reads the same however it got there. */ function clonePoint(point: VectorPoint): VectorPoint { - return { - x: point.x, - y: point.y, - ...(point.in ? { in: { ...point.in } } : {}), - ...(point.out ? { out: { ...point.out } } : {}), - }; + const copy = cloneVectorPoint(point); + if (!copy.in && !copy.out) delete copy.mode; + return copy; } function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void { diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 383ce766..f59208c2 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -16,7 +16,7 @@ import { RemoveVectorShapeCommand, SetVectorShapeRingsCommand, } from '../../core/commands/vectorArtCommands'; -import type { VectorArtAsset, VectorRing, VectorShape } from '../../core/model/Asset'; +import type { VectorArtAsset, VectorPoint, 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'; @@ -35,12 +35,15 @@ import { } from '../../ui'; import { insideRing, + type PointKind, + pointKind, type RingGrip, ringGripAt, ringMoved, ringOutline, withHandleMoved, withPointMoved, + withPointKind, withPointOnSegment, withoutPoint, withSmoothToggled, @@ -99,6 +102,10 @@ export class VectorArtCanvas extends Widget { private drag: ShapeDrag | null = null; /** The ring the pen is drawing into; empty while no line is open. */ private pen = { shapeId: '', ring: -1 }; + /** The point last pressed or put down – the one the header's point type is about (#195). */ + private picked: { shapeId: string; ring: number; index: number } | null = null; + /** Told whenever `picked` changes, so the header can show that point's type. */ + onPointPicked: () => void = () => {}; /** This canvas's slot in the frame queue: one pending preview at a time, see `preview()`. */ private readonly frameKey = `vectorart:${(instances += 1)}`; @@ -118,10 +125,14 @@ export class VectorArtCanvas extends Widget { this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); state.on('project', () => this.redraw()); - state.on('selection', () => this.redraw()); + state.on('selection', () => { + if (this.picked && this.picked.shapeId !== this.selectedShapeId()) this.pick(null); + this.redraw(); + }); state.on('load', () => { this.needsFit = true; this.endPen(); + this.pick(null); this.redraw(); }); } @@ -135,6 +146,7 @@ export class VectorArtCanvas extends Widget { this.assetId = assetId; this.needsFit = true; this.endPen(); + this.pick(null); this.redraw(); } @@ -184,6 +196,35 @@ export class VectorArtCanvas extends Widget { return this.pen.ring >= 0; } + /** The type of the picked point; null while no point is picked or it has gone since (an undo). */ + get pickedKind(): PointKind | null { + const point = this.pickedPoint(); + return point ? pointKind(point) : null; + } + + /** Turns the picked point into `kind` – one undo step, as every other edit of a ring. */ + setPickedKind(kind: PointKind): void { + const picked = this.picked; + const shape = picked && this.shape(picked.shapeId); + const ring = shape?.rings[picked?.ring ?? -1]; + if (!picked || !shape || !ring?.points[picked.index]) return; + const rings = this.ringsWith(shape, picked.ring, withPointKind(ring, picked.index, kind)); + if (SetVectorShapeRingsCommand.changes(shape, rings)) + this.state.execute(new SetVectorShapeRingsCommand(this.assetId, shape.id, rings, 'Point type')); + } + + private pickedPoint(): VectorPoint | undefined { + const picked = this.picked; + return picked ? this.shape(picked.shapeId)?.rings[picked.ring]?.points[picked.index] : undefined; + } + + private pick(picked: { shapeId: string; ring: number; index: number } | null): void { + const was = this.picked; + this.picked = picked; + if (was?.shapeId !== picked?.shapeId || was?.ring !== picked?.ring || was?.index !== picked?.index) + this.onPointPicked(); + } + /** 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); @@ -280,6 +321,7 @@ export class VectorArtCanvas extends Widget { const shape = this.shape(shapeId); if (!shape) return; const rings = ring ? this.ringsWith(shape, ringIndex, ring) : shape.rings.map(cloneVectorRing); + this.pick({ shapeId, ring: ringIndex, index }); this.drag = { kind: 'pen', shapeId, @@ -322,6 +364,7 @@ export class VectorArtCanvas extends Widget { for (let i = asset.shapes.length - 1; i >= 0; i--) { const shape = asset.shapes[i]!; if (!shape.rings.some((ring) => insideRing(ring, at))) continue; + this.pick(null); if (shape.id !== selected?.id) this.state.select({ kind: 'shape', assetId: asset.id, shapeId: shape.id }); this.drag = { @@ -339,6 +382,7 @@ export class VectorArtCanvas extends Widget { }; return; } + this.pick(null); this.state.select({ kind: 'asset', assetId: this.assetId }); this.redraw(); } @@ -353,12 +397,15 @@ export class VectorArtCanvas extends Widget { local: Vec2, e: FederatedPointerEvent, ): void { + const index = grip.kind === 'segment' ? grip.index + 1 : grip.index; + this.pick({ shapeId: shape.id, ring: ringIndex, index }); 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.pick(null); this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Remove point')); return; } @@ -370,7 +417,6 @@ export class VectorArtCanvas extends Widget { } 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, @@ -416,8 +462,13 @@ export class VectorArtCanvas extends Widget { } 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)); + // The pen pulls both handles at once: the one it drags towards, the other mirrored behind it – + // a symmetric point, which is what a drag with a pen draws everywhere else too (#195). + drag.rings = this.replace( + drag.rings, + drag.ring, + withHandleMoved(ring, drag.index, 'out', to, 'symmetric'), + ); } else if (ring) { drag.rings = this.replace(drag.rings, drag.ring, withPointMoved(ring, drag.index, this.snapped(to))); } @@ -562,12 +613,14 @@ export class VectorArtCanvas extends Widget { private drawShape(shape: VectorShape | undefined): void { if (!shape) return; const half = POINT_SIZE / 2; - for (const ring of shape.rings) { + const picked = this.picked?.shapeId === shape.id ? this.picked : null; + for (const [ringIndex, ring] of shape.rings.entries()) { 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) { + for (const [index, point] of ring.points.entries()) { const at = imageToLocal(this.view, point.x, point.y); + const isPicked = picked?.ring === ringIndex && picked.index === index; for (const side of ['in', 'out'] as const) { const handle = handleAt(point, side); if (!handle) continue; @@ -583,8 +636,8 @@ export class VectorArtCanvas extends Widget { } this.overlay .rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE) - .fill(Theme.colors.accentText) - .stroke({ width: 1, color: Theme.colors.accent }); + .fill(isPicked ? Theme.colors.accent : Theme.colors.accentText) + .stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent }); } } } diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts index e6e9c7f1..1a4e577f 100644 --- a/src/editor/vectorart/VectorArtEditor.ts +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -1,13 +1,27 @@ import type { Asset, VectorArtAsset } from '../../core/model/Asset'; import { vectorCanvasSize } from '../../core/model/vectorart'; -import { Button } from '../../ui'; +import { Button, Dropdown, Menu } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; import { SnapToggle } from '../graph/SnapToggle'; +import { POINT_KINDS, type PointKind } from '../polygonEdit'; 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; +/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */ +const POINT_WIDTH = 150; + +export const POINT_KIND_LABELS: Record = { + straight: 'Straight', + corner: 'Corner', + smooth: 'Smooth', + symmetric: 'Symmetric', +}; + +function pointLabel(kind: PointKind | null): string { + return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`; +} /** * The Vector art tab (#129, stage 3): the canvas shows the drawing and edits it, the header picks @@ -15,19 +29,39 @@ const INFO_WIDTH = 190; * a panel of its own is #191. */ export class VectorArtEditor extends AssetTabEditor { - /** Public for the check scripts: the two tools and the magnet. */ + /** Public for the check scripts: the two tools, the magnet and the picked point's type. */ readonly selectButton: Button; readonly penButton: Button; readonly snap = new SnapToggle(() => this.canvas); + readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH }); 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.mount(new VectorArtCanvas(state), [this.selectButton, this.penButton, ...this.snap.widgets]); + this.pointButton.onClick = () => this.openPointMenu(); + const canvas = new VectorArtCanvas(state); + canvas.onPointPicked = () => this.syncPoint(); + this.mount(canvas, [this.selectButton, this.penButton, this.pointButton, ...this.snap.widgets]); this.setTool('select'); } + /** The four types a point can be (#195); the one it is now is ticked. */ + private openPointMenu(): void { + const items = POINT_KINDS.map((kind) => ({ + label: POINT_KIND_LABELS[kind], + checked: () => this.canvas.pickedKind === kind, + onSelect: () => this.canvas.setPickedKind(kind), + })); + new Menu(items).openBelow(this.pointButton); + } + + private syncPoint(): void { + const kind = this.canvas.pickedKind; + this.pointButton.setText(pointLabel(kind)); + this.pointButton.setEnabled(kind !== null); + } + setTool(tool: VectorTool): void { this.canvas.setTool(tool); this.selectButton.setToggled(tool === 'select'); @@ -64,5 +98,6 @@ export class VectorArtEditor extends AssetTabEditor { f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body')); expect(vectorArtRevision(f.art())).toBe(first); + // A point's type only says what the next drag does to its handles (#195). + const typed = square(); + typed.points[0] = { ...typed.points[0]!, mode: 'smooth' }; + f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [typed])); + 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); @@ -316,8 +322,8 @@ describe('what a project file may say', () => { closed: true, points: [ { x: 10, y: 10, out: { x: 4, y: 0 } }, - { x: 90, y: 10 }, - { x: 50, y: 80 }, + { x: 90, y: 10, in: { x: -3, y: 0 }, out: { x: 6, y: 0 }, mode: 'smooth' }, + { x: 50, y: 80, in: { x: -2, y: 1 }, out: { x: 2, y: -1 }, mode: 'symmetric' }, ], }, { @@ -350,7 +356,7 @@ describe('what a project file may say', () => { { closed: true, points: [ - { x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 } }, + { x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 }, mode: 'wobbly' }, { x: 'far', y: 10 }, { x: 30, y: 30 }, { x: 50 }, @@ -370,7 +376,8 @@ describe('what a project file may say', () => { 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. + // The broken handle falls away and leaves a corner, and so does a point type nobody knows (#195); + // 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 }, diff --git a/tests/editor/ring-edit.test.ts b/tests/editor/ring-edit.test.ts index 9f24d4d1..5f2847b9 100644 --- a/tests/editor/ring-edit.test.ts +++ b/tests/editor/ring-edit.test.ts @@ -4,10 +4,11 @@ * line does to the line. */ import { describe, expect, it } from 'vitest'; -import type { VectorRing } from '../../src/core/model/Asset'; +import type { VectorPointMode, VectorRing } from '../../src/core/model/Asset'; import { insideRing, nearestOnSegment, + pointKind, pointOnSegment, ringGripAt, ringMoved, @@ -15,6 +16,7 @@ import { ringSegments, withHandleMoved, withoutPoint, + withPointKind, withPointMoved, withPointOnSegment, withSmoothToggled, @@ -44,6 +46,11 @@ function arc(): VectorRing { }; } +/** One point at (50, 50) with a handle 10 to either side, in the given mode. */ +function handled(mode: VectorPointMode): VectorRing { + return { closed: false, points: [{ x: 50, y: 50, in: { x: -10, y: 0 }, out: { x: 10, y: 0 }, mode }] }; +} + describe('what the pointer grabs', () => { it('takes the nearest point', () => { expect(ringGripAt(square(), { x: 98, y: 3 }, 8)).toEqual({ kind: 'point', index: 1 }); @@ -92,21 +99,44 @@ describe('dragging', () => { 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 }); + it('writes a handle as an offset and mirrors the other one on a symmetric point', () => { + const bent = withHandleMoved(handled('symmetric'), 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('leaves the other handle alone on a corner (#195)', () => { + const bent = withHandleMoved(handled('corner'), 0, 'out', { x: 50, y: 20 }); + expect(bent.points[0]!.out).toEqual({ x: 0, y: -30 }); + expect(bent.points[0]!.in).toEqual({ x: -10, y: 0 }); + }); + + it('turns the other handle round at its own length on a smooth point (#195)', () => { + // `in` is 10 long; `out` is pulled 30 up, so `in` has to point 10 down. + const bent = withHandleMoved(handled('smooth'), 0, 'out', { x: 50, y: 20 }); + expect(bent.points[0]!.out).toEqual({ x: 0, y: -30 }); + expect(bent.points[0]!.in!.x).toBeCloseTo(0, 9); + expect(bent.points[0]!.in!.y).toBeCloseTo(10, 9); + }); + + it('grows the mirrored handle the pen asks for, so a pen curve has two (#195)', () => { + const fresh: VectorRing = { closed: false, points: [{ x: 50, y: 50 }] }; + const pulled = withHandleMoved(fresh, 0, 'out', { x: 70, y: 50 }, 'symmetric'); + const point = pulled.points[0]!; + expect(point.mode).toBe('symmetric'); + expect([point.out!.x, point.out!.y, point.in!.x, point.in!.y].map((v) => v + 0)).toEqual([20, 0, -20, 0]); + }); + 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('takes the mirror along on a symmetric point, which leaves it straight', () => { + const straightened = withHandleMoved(handled('symmetric'), 0, 'out', { x: 50, y: 50 }); + expect(straightened.points[0]).toEqual({ x: 50, y: 50 }); + }); + it('moves a whole ring', () => { const moved = ringMoved(square(), 10, -5); expect(moved.points.map((p) => [p.x, p.y])).toEqual([ @@ -154,6 +184,94 @@ describe('adding and taking away', () => { }); }); +describe('the four point types (#195)', () => { + it('reads a point without handles as straight and one without a mode as a corner', () => { + expect(pointKind({ x: 0, y: 0 })).toBe('straight'); + expect(pointKind({ x: 0, y: 0, mode: 'smooth' })).toBe('straight'); + expect(pointKind(arc().points[0]!)).toBe('corner'); + expect(pointKind(handled('symmetric').points[0]!)).toBe('symmetric'); + }); + + it('makes a point straight by taking both handles and the mode away', () => { + expect(withPointKind(handled('smooth'), 0, 'straight').points[0]).toEqual({ x: 50, y: 50 }); + }); + + it('grows handles on a straight point that becomes smooth', () => { + const smooth = withPointKind(square(), 1, 'smooth').points[1]!; + expect(smooth.mode).toBe('smooth'); + expect(smooth.out).toEqual({ x: 100 / 3, y: 100 / 3 }); + expect(smooth.in).toEqual({ x: -100 / 3, y: -100 / 3 }); + }); + + it('keeps a corner’s handles where they are', () => { + const bent: VectorRing = { + closed: false, + points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 0, y: -20 } }], + }; + expect(withPointKind(bent, 0, 'corner').points[0]).toEqual(bent.points[0]); + }); + + it('lines the handles up for smooth and keeps each length', () => { + // `in` points left (10 long), `out` up (20 long): the bisector runs up and to the right. + const bent: VectorRing = { + closed: false, + points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 0, y: -20 } }], + }; + const point = withPointKind(bent, 0, 'smooth').points[0]!; + const s = Math.SQRT1_2; + expect(point.out!.x).toBeCloseTo(20 * s, 9); + expect(point.out!.y).toBeCloseTo(-20 * s, 9); + expect(point.in!.x).toBeCloseTo(-10 * s, 9); + expect(point.in!.y).toBeCloseTo(10 * s, 9); + }); + + it('gives both handles their mean length for symmetric', () => { + const bent: VectorRing = { + closed: false, + points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 30, y: 0 } }], + }; + const point = withPointKind(bent, 0, 'symmetric').points[0]!; + expect(point.mode).toBe('symmetric'); + expect([point.in!.x, point.in!.y, point.out!.x, point.out!.y].map((v) => v + 0)).toEqual([-20, 0, 20, 0]); + }); + + it('grows the missing second handle opposite the first', () => { + const point = withPointKind(arc(), 0, 'smooth').points[0]!; + expect(point.out).toEqual({ x: 0, y: -40 }); + expect(point.in!.x).toBeCloseTo(0, 9); + expect(point.in!.y).toBeCloseTo(40, 9); + }); + + it('does not reach round an open line for the neighbour of its end', () => { + // The first point of an open L has one neighbour; wrapping to the last would tilt its handles. + const line: VectorRing = { + closed: false, + points: [ + { x: 0, y: 0 }, + { x: 90, y: 0 }, + { x: 90, y: 90 }, + ], + }; + expect(withPointKind(line, 0, 'smooth').points[0]!.out).toEqual({ x: 30, y: 0 }); + }); + + it('splits a symmetric curve into smooth points – the halves are no longer the same length', () => { + const curve: VectorRing = { + closed: false, + points: [ + { x: 0, y: 0, in: { x: 0, y: 30 }, out: { x: 0, y: -30 }, mode: 'symmetric' }, + { x: 100, y: 0, in: { x: 0, y: -30 }, out: { x: 0, y: 30 }, mode: 'symmetric' }, + ], + }; + const split = withPointOnSegment(curve, 0, 0.25); + expect(split.points.map((p) => p.mode)).toEqual(['smooth', 'smooth', 'smooth']); + }); + + it('keeps a straight point on a straight segment free of a mode', () => { + expect(withPointOnSegment(square(), 0, 0.5).points[1]).toEqual({ x: 50, 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); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 1dc7ac49..8f435a3a 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -13,7 +13,8 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; * 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. The last block, 47f, * walks the way a user walks instead: the asset comes out of the + menu, its row opens the tab, - * and a sprite is given the drawing through its Texture row. + * and a sprite is given the drawing through its Texture row. 47g draws on that asset to measure the + * four point types of #195. */ const CANVAS = 256; @@ -473,4 +474,120 @@ export async function vectorArt(t) { }), ); await shot('47-picture-picker'); + + // 47g (#195). The pen draws a curve with two handles, and the header's point type steers what + // a dragged handle does to the other one: a corner leaves it, a smooth point turns it round + // at its own length, straight takes both away. Drawn on the asset 47f made, which is empty. + await evaluate(`__gm.openDocument('asset:${madeId}')`); + await settle(); + const pointsOf = async () => + (await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`)).at(-1)?.rings[0]?.points ?? []; + const screenAt = (x, y) => json(`__gm.vectorArt.pointAt(${x}, ${y})`); + const pointType = () => json('__gm.vectorArt.pointButton()'); + const before195 = await pointType(); + check( + 'with no point picked the point type reads as none and cannot be opened', + before195.text === 'Point: –' && !before195.enabled, + JSON.stringify(before195), + ); + + await clickText('Pen'); + const A = await screenAt(60, 160); + const B = await screenAt(128, 80); + const pulledTo = await screenAt(168, 80); + const C = await screenAt(196, 160); + await click(A.x, A.y); + await drag(B.x, B.y, pulledTo.x, pulledTo.y); + await click(C.x, C.y); + await click(A.x, A.y); + const penPoints = await pointsOf(); + const curved = penPoints[1]; + check( + 'a drag with the pen grows both handles of the point, mirrored – a symmetric point', + penPoints.length === 3 && + curved?.mode === 'symmetric' && + near(curved.out?.x, 40) && + near(curved.out?.y, 0) && + near(curved.in?.x, -40) && + near(curved.in?.y, 0), + JSON.stringify(penPoints), + ); + + await clickText('Select'); + await click(B.x, B.y); + const picked = await pointType(); + check( + 'a click on that point picks it, and the header names its type', + picked.text === 'Point: Symmetric' && picked.enabled && picked.kind === 'symmetric', + JSON.stringify(picked), + ); + + // The menu ticks its entries in the text itself, so a row reads "☐ Corner" or "☑ Corner". + const openPointMenu = async () => { + const button = await pointType(); + await click(button.x, button.y); + return (await json('__gm.menuRows()')) ?? []; + }; + const choose = async (label) => { + const rows = await openPointMenu(); + const text = rows.find((row) => row.endsWith(` ${label}`)); + const row = text && (await inOverlay(text)); + if (!row) throw new Error(`no "${label}" in the point type menu: ${JSON.stringify(rows)}`); + await click(row.x, row.y); + await settle(); + }; + + const offered = await openPointMenu(); + check( + 'the point type menu offers the four types and ticks the one the point is', + offered.join('|') === '☐ Straight|☐ Corner|☐ Smooth|☑ Symmetric', + JSON.stringify(offered), + ); + await press('Escape'); + await settle(); + const dragOut = async (x, y) => { + const point = (await pointsOf())[1]; + const from = await screenAt(point.x + point.out.x, point.y + point.out.y); + const to = await screenAt(x, y); + await drag(from.x, from.y, to.x, to.y); + return (await pointsOf())[1]; + }; + + await choose('Corner'); + const cusp = await dragOut(128, 40); + check( + 'on a corner a dragged handle leaves the other one where it was', + cusp.mode === null && + near(cusp.out.x, 0) && + near(cusp.out.y, -40) && + near(cusp.in.x, -40) && + near(cusp.in.y, 0), + JSON.stringify(cusp), + ); + + await choose('Smooth'); + const lined = (await pointsOf())[1]; + const smoothDragged = await dragOut(128 + 60, 80); + const inLength = Math.hypot(smoothDragged.in.x, smoothDragged.in.y); + check( + 'Smooth lines the handles up, and dragging one turns the other round at its own length', + lined.mode === 'smooth' && + smoothDragged.mode === 'smooth' && + near(smoothDragged.out.x, 60) && + near(smoothDragged.out.y, 0) && + smoothDragged.in.x < 0 && + near(smoothDragged.in.y, 0) && + near(inLength, Math.hypot(lined.in.x, lined.in.y)) && + !near(inLength, 60), + JSON.stringify({ lined, smoothDragged }), + ); + await shot('47-point-smooth'); + + await choose('Straight'); + const straight = (await pointsOf())[1]; + check( + 'Straight takes both handles away, and the header says so', + straight.in === null && straight.out === null && (await pointType()).text === 'Point: Straight', + JSON.stringify({ straight, header: await pointType() }), + ); }