Vector art has a tab to draw in, which is stage 3 of #129
game-maker / gate (push) Canceled after 37s
game-maker / suite (push) Canceled after 37s

Stages 1 and 2 gave the asset a model and a picture, but the only way
to put a shape into it was a test calling mutate.  The tab closes that
gap: what you draw there is what the sprite in the scene carries, with
no extra step, because the fingerprint changes and the picture registry
of #185 redraws.

- The tab has a header with Select, Pen, Grid and Fit, a chequerboard
  behind the canvas and the canvas itself as a frame.  It paints with
  the same commands as the rasterizer, paintDrawing being exported
  from viewport/vectorArtPicture.ts for it, so the tab cannot drift
  away from the picture.
- Pen sets points on a click and pulls the handles out on a drag, both
  of them mirrored; a click on the first point closes the ring and Esc
  ends an open line.
- Select drags a point, a handle or a whole shape, a click on an edge
  puts a point there, a double click takes one away, and Alt turns a
  corner round and back.  Delete removes the selected shape.
- The rules grew inside polygonEdit.ts instead of a second module, as
  the plan says, so a collider polygon and a frame outline get the
  same behaviour where it applies.
- A point on a curved edge splits the curve by de Casteljau instead of
  cutting it: both halves together draw exactly what was there before.
- A curved edge is walked instead of measured over its chord, or a
  click on the bow of a curve would grab nothing; the same walk carries
  the test for the inside of a ring.
- One gesture is one undo step.  The preview writes the model directly
  and the old state is put back on release before the command runs,
  which is the pattern the frame outlines of #141 already use.
- state.ts gained a selection kind "shape".  The inspector shows the
  rows of the asset for it, the shape list with colour, opacity and
  stroke being stage 4.

One counter-check was no counter-check: disabling the pen's closing
tolerance by multiplying it with 0 left the run green, and rightly so,
because the closing click lands on exactly the screen coordinate point
0 came from, so the distance is 0 and <= 0 is true.  Only an
unsatisfiable condition really switched it off, and then 2 checks fail.
A second trap on the way: a false && in front of the guard breaks the
TS narrowing, npm run build bails out, and two runs went against the
old bundle.

Tests: check.sh 127 files, 1878 tests, EXIT 0 (was 126 / 1860).
Full e2e suite ALL PASSED - 1172 checks, module 47 alone 17 (was 10).
18 new unit tests in tests/editor/ring-edit.test.ts and 7 new e2e
checks in blocks 47c and 47d.  Two counter-checks hold: no de
Casteljau fails 1 unit test, measuring a curve over its chord fails
another.  Originals checked back with cmp and rebuilt.
This commit is contained in:
admin committed 2026-09-20 21:34:14 +02:00
1 parent 6371576d37
commit 3d7ad65f34
15 files changed
+1246 -21

No files matched your search

+1 -1
View File
@@ -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
+2 -11
View File
@@ -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);
}
+13
View File
@@ -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';
+7
View File
@@ -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<CentreTab, string> = {
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,
+1
View File
@@ -9,6 +9,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
shader: 'shader',
particles: 'particles',
texture: 'texture',
vectorart: 'vectorart',
animation: 'animation',
tile: 'tile',
container: 'container',
+43 -2
View File
@@ -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,
+5
View File
@@ -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,6 +140,8 @@ export class Inspector extends Panel {
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.kind === 'shape'
? refreshAssetSection(this.host, s.assetId)
: s.kind === 'annotation'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.kind === 'track'
+240
View File
@@ -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 };
}
+10
View File
@@ -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 &&
+577
View File
@@ -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);
}
}
+76
View File
@@ -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<VectorArtAsset, VectorArtCanvas> {
/** 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);
}
}
+7 -3
View File
@@ -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 });
+174
View File
@@ -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);
});
});
+88 -4
View File
@@ -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 }),
);
}
+2
View File
@@ -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',