Vector art points have four types and the pen draws whole curves (#195)
The pen only pulled out the outgoing handle of a new point, because the opposite handle was mirrored only if it already existed. The first segment of every dragged point was therefore half a curve. Beyond that bug, a point had no way to say how its handles relate. - A vector point has an optional mode: straight, corner, smooth or symmetric. Missing means corner. The mode is not part of the fingerprint, because it changes no pixel, only the next drag. - Dragging a handle follows the mode. Corner leaves the other handle alone, smooth turns it along and keeps its length, symmetric mirrors it exactly. - The pen pulls out both handles mirrored and marks the point symmetric. A plain click still sets a point without handles. - The tab header has a "Point: ..." menu for the last clicked or set point. That point is drawn filled. Changing its type is one undo step. Text instead of four icons, because the UI kit has no tooltips. - Switching a point without handles to a curved type first grows handles along its neighbours. Smooth and symmetric line the handles up and keep the rough direction of the curve. - A point inserted on a curve is smooth. Symmetric neighbours become smooth, because the split shortens one of their handles. - Alt-click still toggles between straight and symmetric. Found on the way: the old Alt rounding took the point at the other end of an open line as a neighbour, so its handles came out skewed. Fixed and held by a unit test. Existing points with handles now count as corner. Before, every drag mirrored. No pixel changes, only the next drag behaves differently. Tests: - ./check.sh: 129 files, 1917 tests, exit 0 (before 1903) - GM_STEPS=47, freshly seeded: ALL PASSED, 39 checks (before 32) - Check sum 1195 in README and wiki is computed (1188 + 7), CI measures it - Counter-checks: pen without 'symmetric' fails 3 checks, no smooth branch fails 1 unit test and 1 e2e check Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90627ccf47
commit
3ff7e91dbf
11 files changed
+483
-61
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -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 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
|
* 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
|
* 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 {
|
export interface VectorPoint {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
in?: Vec2;
|
in?: Vec2;
|
||||||
out?: 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
|
* 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
|
* of them so it can have holes: the rings are filled even-odd, so a ring inside another cuts it out
|
||||||
|
|||||||
@@ -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. */
|
/** A deep copy of a ring: its points and the handles on them are all plain data. */
|
||||||
export function cloneVectorRing(ring: VectorRing): VectorRing {
|
export function cloneVectorRing(ring: VectorRing): VectorRing {
|
||||||
|
return { closed: ring.closed, points: ring.points.map(cloneVectorPoint) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneVectorPoint(point: VectorPoint): VectorPoint {
|
||||||
return {
|
return {
|
||||||
closed: ring.closed,
|
|
||||||
points: ring.points.map((point) => ({
|
|
||||||
x: point.x,
|
x: point.x,
|
||||||
y: point.y,
|
y: point.y,
|
||||||
...(point.in ? { in: { ...point.in } } : {}),
|
...(point.in ? { in: { ...point.in } } : {}),
|
||||||
...(point.out ? { out: { ...point.out } } : {}),
|
...(point.out ? { out: { ...point.out } } : {}),
|
||||||
})),
|
...(point.mode && point.mode !== 'corner' ? { mode: point.mode } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ function normalizeShapes(raw: unknown): VectorShape[] {
|
|||||||
const handle = normalizeHandle(point[side]);
|
const handle = normalizeHandle(point[side]);
|
||||||
if (handle) kept[side] = handle;
|
if (handle) kept[side] = handle;
|
||||||
}
|
}
|
||||||
|
if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode;
|
||||||
points.push(kept);
|
points.push(kept);
|
||||||
}
|
}
|
||||||
// Closed is what a shape usually is, and what every ring written by the editor says.
|
// Closed is what a shape usually is, and what every ring written by the editor says.
|
||||||
|
|||||||
@@ -471,6 +471,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
snapButton: () => snapButton(vectorArtEditor.snap),
|
snapButton: () => snapButton(vectorArtEditor.snap),
|
||||||
/** Whether the pen has a line open that the next click would add a point to. */
|
/** Whether the pen has a line open that the next click would add a point to. */
|
||||||
drawing: () => vectorArtEditor.canvas.drawing,
|
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. */
|
/** The shapes as the model holds them, so a check can read points and handles exactly. */
|
||||||
shapes: (assetId: string) => {
|
shapes: (assetId: string) => {
|
||||||
const asset = findAsset(state.project, assetId);
|
const asset = findAsset(state.project, assetId);
|
||||||
@@ -488,6 +495,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
y: point.y,
|
y: point.y,
|
||||||
in: point.in ? { ...point.in } : null,
|
in: point.in ? { ...point.in } : null,
|
||||||
out: point.out ? { ...point.out } : null,
|
out: point.out ? { ...point.out } : null,
|
||||||
|
mode: point.mode ?? null,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+106
-32
@@ -8,11 +8,11 @@
|
|||||||
* rather than in a module of their own so the two cannot drift apart – a corner is grabbed by the
|
* 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.
|
* 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 type { Vec2 } from '../core/model/Properties';
|
||||||
import { closestOnSegment } from '../core/util/vec';
|
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.
|
// 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. */
|
/** 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 };
|
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
|
* What a point is to the user (#195): straight has no handle at all, the other three are the mode
|
||||||
* exists – that is what makes a point smooth – and a handle dropped back onto its point is taken
|
* its handles move in. A point with handles and no mode is a corner.
|
||||||
* away, which turns the point back into 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(
|
export function withHandleMoved(
|
||||||
ring: VectorRing,
|
ring: VectorRing,
|
||||||
index: number,
|
index: number,
|
||||||
side: 'in' | 'out',
|
side: 'in' | 'out',
|
||||||
to: Vec2,
|
to: Vec2,
|
||||||
mirror = true,
|
mode?: VectorPointMode,
|
||||||
): VectorRing {
|
): VectorRing {
|
||||||
return mapPoints(ring, (point, i) => {
|
return mapPoints(ring, (point, i) => {
|
||||||
if (i !== index) return point;
|
if (i !== index) return point;
|
||||||
const offset = { x: to.x - point.x, y: to.y - point.y };
|
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 next: VectorPoint = { ...point };
|
||||||
const other = side === 'in' ? 'out' : 'in';
|
const other = side === 'in' ? 'out' : 'in';
|
||||||
if (Math.hypot(offset.x, offset.y) < 1e-6) delete next[side];
|
const how = mode ?? point.mode ?? 'corner';
|
||||||
else next[side] = offset;
|
if (mode) next.mode = mode;
|
||||||
if (mirror && next[other]) next[other] = { x: -offset.x, y: -offset.y };
|
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;
|
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
|
* 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
|
* 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 c = lerp(c2, to, t);
|
||||||
const d = lerp(a, b, t);
|
const d = lerp(a, b, t);
|
||||||
const e = lerp(b, c, t);
|
const e = lerp(b, c, t);
|
||||||
|
const next = points[(index + 1) % ring.points.length]!;
|
||||||
setHandle(points[index]!, 'out', a);
|
setHandle(points[index]!, 'out', a);
|
||||||
setHandle(points[(index + 1) % ring.points.length]!, 'in', c);
|
setHandle(next, 'in', c);
|
||||||
inserted = { x: at.x, y: at.y };
|
// 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, 'in', d);
|
||||||
setHandle(inserted, 'out', e);
|
setHandle(inserted, 'out', e);
|
||||||
|
if (!inserted.in && !inserted.out) delete inserted.mode;
|
||||||
}
|
}
|
||||||
points.splice(index + 1, 0, inserted);
|
points.splice(index + 1, 0, inserted);
|
||||||
return { closed: ring.closed, points };
|
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) };
|
return { closed: ring.closed, points: ring.points.filter((_, i) => i !== index).map(clonePoint) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Alt-click: a straight point made round (symmetric), and one with handles made straight again. */
|
||||||
* 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 {
|
export function withSmoothToggled(ring: VectorRing, index: number): VectorRing {
|
||||||
const point = ring.points[index];
|
const point = ring.points[index];
|
||||||
if (!point) return cloneVectorRing(ring);
|
if (!point) return cloneVectorRing(ring);
|
||||||
if (point.in || point.out) return mapPoints(ring, (p, i) => (i === index ? { x: p.x, y: p.y } : p));
|
return withPointKind(ring, index, pointKind(point) === 'straight' ? 'symmetric' : 'straight');
|
||||||
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. */
|
/** 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))) };
|
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 {
|
function clonePoint(point: VectorPoint): VectorPoint {
|
||||||
return {
|
const copy = cloneVectorPoint(point);
|
||||||
x: point.x,
|
if (!copy.in && !copy.out) delete copy.mode;
|
||||||
y: point.y,
|
return copy;
|
||||||
...(point.in ? { in: { ...point.in } } : {}),
|
|
||||||
...(point.out ? { out: { ...point.out } } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void {
|
function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
RemoveVectorShapeCommand,
|
RemoveVectorShapeCommand,
|
||||||
SetVectorShapeRingsCommand,
|
SetVectorShapeRingsCommand,
|
||||||
} from '../../core/commands/vectorArtCommands';
|
} 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 type { Vec2 } from '../../core/model/Properties';
|
||||||
import { findAsset } from '../../core/model/Project';
|
import { findAsset } from '../../core/model/Project';
|
||||||
import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart';
|
import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart';
|
||||||
@@ -35,12 +35,15 @@ import {
|
|||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import {
|
import {
|
||||||
insideRing,
|
insideRing,
|
||||||
|
type PointKind,
|
||||||
|
pointKind,
|
||||||
type RingGrip,
|
type RingGrip,
|
||||||
ringGripAt,
|
ringGripAt,
|
||||||
ringMoved,
|
ringMoved,
|
||||||
ringOutline,
|
ringOutline,
|
||||||
withHandleMoved,
|
withHandleMoved,
|
||||||
withPointMoved,
|
withPointMoved,
|
||||||
|
withPointKind,
|
||||||
withPointOnSegment,
|
withPointOnSegment,
|
||||||
withoutPoint,
|
withoutPoint,
|
||||||
withSmoothToggled,
|
withSmoothToggled,
|
||||||
@@ -99,6 +102,10 @@ export class VectorArtCanvas extends Widget {
|
|||||||
private drag: ShapeDrag | null = null;
|
private drag: ShapeDrag | null = null;
|
||||||
/** The ring the pen is drawing into; empty while no line is open. */
|
/** The ring the pen is drawing into; empty while no line is open. */
|
||||||
private pen = { shapeId: '', ring: -1 };
|
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()`. */
|
/** This canvas's slot in the frame queue: one pending preview at a time, see `preview()`. */
|
||||||
private readonly frameKey = `vectorart:${(instances += 1)}`;
|
private readonly frameKey = `vectorart:${(instances += 1)}`;
|
||||||
|
|
||||||
@@ -118,10 +125,14 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||||
|
|
||||||
state.on('project', () => this.redraw());
|
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', () => {
|
state.on('load', () => {
|
||||||
this.needsFit = true;
|
this.needsFit = true;
|
||||||
this.endPen();
|
this.endPen();
|
||||||
|
this.pick(null);
|
||||||
this.redraw();
|
this.redraw();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -135,6 +146,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.assetId = assetId;
|
this.assetId = assetId;
|
||||||
this.needsFit = true;
|
this.needsFit = true;
|
||||||
this.endPen();
|
this.endPen();
|
||||||
|
this.pick(null);
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +196,35 @@ export class VectorArtCanvas extends Widget {
|
|||||||
return this.pen.ring >= 0;
|
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. */
|
/** Where a canvas pixel sits on screen; the check scripts aim their drags with it. */
|
||||||
canvasToScreen(x: number, y: number): Vec2 {
|
canvasToScreen(x: number, y: number): Vec2 {
|
||||||
const local = imageToLocal(this.view, x, y);
|
const local = imageToLocal(this.view, x, y);
|
||||||
@@ -280,6 +321,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
const shape = this.shape(shapeId);
|
const shape = this.shape(shapeId);
|
||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
const rings = ring ? this.ringsWith(shape, ringIndex, ring) : shape.rings.map(cloneVectorRing);
|
const rings = ring ? this.ringsWith(shape, ringIndex, ring) : shape.rings.map(cloneVectorRing);
|
||||||
|
this.pick({ shapeId, ring: ringIndex, index });
|
||||||
this.drag = {
|
this.drag = {
|
||||||
kind: 'pen',
|
kind: 'pen',
|
||||||
shapeId,
|
shapeId,
|
||||||
@@ -322,6 +364,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
for (let i = asset.shapes.length - 1; i >= 0; i--) {
|
for (let i = asset.shapes.length - 1; i >= 0; i--) {
|
||||||
const shape = asset.shapes[i]!;
|
const shape = asset.shapes[i]!;
|
||||||
if (!shape.rings.some((ring) => insideRing(ring, at))) continue;
|
if (!shape.rings.some((ring) => insideRing(ring, at))) continue;
|
||||||
|
this.pick(null);
|
||||||
if (shape.id !== selected?.id)
|
if (shape.id !== selected?.id)
|
||||||
this.state.select({ kind: 'shape', assetId: asset.id, shapeId: shape.id });
|
this.state.select({ kind: 'shape', assetId: asset.id, shapeId: shape.id });
|
||||||
this.drag = {
|
this.drag = {
|
||||||
@@ -339,6 +382,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.pick(null);
|
||||||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
@@ -353,12 +397,15 @@ export class VectorArtCanvas extends Widget {
|
|||||||
local: Vec2,
|
local: Vec2,
|
||||||
e: FederatedPointerEvent,
|
e: FederatedPointerEvent,
|
||||||
): void {
|
): void {
|
||||||
|
const index = grip.kind === 'segment' ? grip.index + 1 : grip.index;
|
||||||
|
this.pick({ shapeId: shape.id, ring: ringIndex, index });
|
||||||
if (grip.kind === 'point') {
|
if (grip.kind === 'point') {
|
||||||
if (pressCount(e, `point ${shape.id} ${ringIndex} ${grip.index}`) === 2) {
|
if (pressCount(e, `point ${shape.id} ${ringIndex} ${grip.index}`) === 2) {
|
||||||
const next = withoutPoint(ring, grip.index);
|
const next = withoutPoint(ring, grip.index);
|
||||||
const rings = next
|
const rings = next
|
||||||
? this.ringsWith(shape, ringIndex, next)
|
? this.ringsWith(shape, ringIndex, next)
|
||||||
: shape.rings.filter((_, i) => i !== ringIndex).map(cloneVectorRing);
|
: shape.rings.filter((_, i) => i !== ringIndex).map(cloneVectorRing);
|
||||||
|
this.pick(null);
|
||||||
this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Remove point'));
|
this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Remove point'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -370,7 +417,6 @@ export class VectorArtCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
const ringNow =
|
const ringNow =
|
||||||
grip.kind === 'segment' ? withPointOnSegment(ring, grip.index, grip.t) : cloneVectorRing(ring);
|
grip.kind === 'segment' ? withPointOnSegment(ring, grip.index, grip.t) : cloneVectorRing(ring);
|
||||||
const index = grip.kind === 'segment' ? grip.index + 1 : grip.index;
|
|
||||||
this.drag = {
|
this.drag = {
|
||||||
kind: grip.kind === 'handle' ? 'handle' : 'point',
|
kind: grip.kind === 'handle' ? 'handle' : 'point',
|
||||||
shapeId: shape.id,
|
shapeId: shape.id,
|
||||||
@@ -416,8 +462,13 @@ export class VectorArtCanvas extends Widget {
|
|||||||
} else if (ring && drag.kind === 'handle') {
|
} else if (ring && drag.kind === 'handle') {
|
||||||
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
|
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
|
||||||
} else if (ring && drag.kind === 'pen') {
|
} else if (ring && drag.kind === 'pen') {
|
||||||
// The pen pulls both handles at once: the one it drags towards, the other mirrored behind it.
|
// 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));
|
// 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) {
|
} else if (ring) {
|
||||||
drag.rings = this.replace(drag.rings, drag.ring, withPointMoved(ring, drag.index, this.snapped(to)));
|
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 {
|
private drawShape(shape: VectorShape | undefined): void {
|
||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
const half = POINT_SIZE / 2;
|
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));
|
const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y));
|
||||||
if (screen.length >= 2)
|
if (screen.length >= 2)
|
||||||
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
|
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 at = imageToLocal(this.view, point.x, point.y);
|
||||||
|
const isPicked = picked?.ring === ringIndex && picked.index === index;
|
||||||
for (const side of ['in', 'out'] as const) {
|
for (const side of ['in', 'out'] as const) {
|
||||||
const handle = handleAt(point, side);
|
const handle = handleAt(point, side);
|
||||||
if (!handle) continue;
|
if (!handle) continue;
|
||||||
@@ -583,8 +636,8 @@ export class VectorArtCanvas extends Widget {
|
|||||||
}
|
}
|
||||||
this.overlay
|
this.overlay
|
||||||
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
||||||
.fill(Theme.colors.accentText)
|
.fill(isPicked ? Theme.colors.accent : Theme.colors.accentText)
|
||||||
.stroke({ width: 1, color: Theme.colors.accent });
|
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import type { Asset, VectorArtAsset } from '../../core/model/Asset';
|
import type { Asset, VectorArtAsset } from '../../core/model/Asset';
|
||||||
import { vectorCanvasSize } from '../../core/model/vectorart';
|
import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||||
import { Button } from '../../ui';
|
import { Button, Dropdown, Menu } from '../../ui';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import { SnapToggle } from '../graph/SnapToggle';
|
import { SnapToggle } from '../graph/SnapToggle';
|
||||||
|
import { POINT_KINDS, type PointKind } from '../polygonEdit';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
|
import { VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
|
||||||
|
|
||||||
/** Room kept for the "256 × 256 px · 3 shapes" readout. */
|
/** Room kept for the "256 × 256 px · 3 shapes" readout. */
|
||||||
const INFO_WIDTH = 190;
|
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<PointKind, string> = {
|
||||||
|
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
|
* 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.
|
* a panel of its own is #191.
|
||||||
*/
|
*/
|
||||||
export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCanvas> {
|
export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCanvas> {
|
||||||
/** 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 selectButton: Button;
|
||||||
readonly penButton: Button;
|
readonly penButton: Button;
|
||||||
readonly snap = new SnapToggle(() => this.canvas);
|
readonly snap = new SnapToggle(() => this.canvas);
|
||||||
|
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
|
||||||
|
|
||||||
constructor(state: EditorState) {
|
constructor(state: EditorState) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
|
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
|
||||||
this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') });
|
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');
|
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 {
|
setTool(tool: VectorTool): void {
|
||||||
this.canvas.setTool(tool);
|
this.canvas.setTool(tool);
|
||||||
this.selectButton.setToggled(tool === 'select');
|
this.selectButton.setToggled(tool === 'select');
|
||||||
@@ -64,5 +98,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
|||||||
this.selectButton.setEnabled(asset !== null);
|
this.selectButton.setEnabled(asset !== null);
|
||||||
this.penButton.setEnabled(asset !== null);
|
this.penButton.setEnabled(asset !== null);
|
||||||
this.snap.setEnabled(asset !== null);
|
this.snap.setEnabled(asset !== null);
|
||||||
|
this.syncPoint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +121,12 @@ describe('the revision', () => {
|
|||||||
f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body'));
|
f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body'));
|
||||||
expect(vectorArtRevision(f.art())).toBe(first);
|
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 }));
|
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0xff0000 }));
|
||||||
const coloured = vectorArtRevision(f.art());
|
const coloured = vectorArtRevision(f.art());
|
||||||
expect(coloured).not.toBe(first);
|
expect(coloured).not.toBe(first);
|
||||||
@@ -316,8 +322,8 @@ describe('what a project file may say', () => {
|
|||||||
closed: true,
|
closed: true,
|
||||||
points: [
|
points: [
|
||||||
{ x: 10, y: 10, out: { x: 4, y: 0 } },
|
{ x: 10, y: 10, out: { x: 4, y: 0 } },
|
||||||
{ x: 90, y: 10 },
|
{ x: 90, y: 10, in: { x: -3, y: 0 }, out: { x: 6, y: 0 }, mode: 'smooth' },
|
||||||
{ x: 50, y: 80 },
|
{ 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,
|
closed: true,
|
||||||
points: [
|
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: 'far', y: 10 },
|
||||||
{ x: 30, y: 30 },
|
{ x: 30, y: 30 },
|
||||||
{ x: 50 },
|
{ x: 50 },
|
||||||
@@ -370,7 +376,8 @@ describe('what a project file may say', () => {
|
|||||||
const loaded = f.art(roundTrip(f.project));
|
const loaded = f.art(roundTrip(f.project));
|
||||||
expect(loaded.shapes).toHaveLength(1);
|
expect(loaded.shapes).toHaveLength(1);
|
||||||
const shape = loaded.shapes[0]!;
|
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([
|
expect(shape.rings[0]!.points).toEqual([
|
||||||
{ x: 10, y: 10, out: { x: 1, y: 2 } },
|
{ x: 10, y: 10, out: { x: 1, y: 2 } },
|
||||||
{ x: 30, y: 30 },
|
{ x: 30, y: 30 },
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* line does to the line.
|
* line does to the line.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { VectorRing } from '../../src/core/model/Asset';
|
import type { VectorPointMode, VectorRing } from '../../src/core/model/Asset';
|
||||||
import {
|
import {
|
||||||
insideRing,
|
insideRing,
|
||||||
nearestOnSegment,
|
nearestOnSegment,
|
||||||
|
pointKind,
|
||||||
pointOnSegment,
|
pointOnSegment,
|
||||||
ringGripAt,
|
ringGripAt,
|
||||||
ringMoved,
|
ringMoved,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
ringSegments,
|
ringSegments,
|
||||||
withHandleMoved,
|
withHandleMoved,
|
||||||
withoutPoint,
|
withoutPoint,
|
||||||
|
withPointKind,
|
||||||
withPointMoved,
|
withPointMoved,
|
||||||
withPointOnSegment,
|
withPointOnSegment,
|
||||||
withSmoothToggled,
|
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', () => {
|
describe('what the pointer grabs', () => {
|
||||||
it('takes the nearest point', () => {
|
it('takes the nearest point', () => {
|
||||||
expect(ringGripAt(square(), { x: 98, y: 3 }, 8)).toEqual({ kind: 'point', index: 1 });
|
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 } });
|
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', () => {
|
it('writes a handle as an offset and mirrors the other one on a symmetric point', () => {
|
||||||
const ring: VectorRing = {
|
const bent = withHandleMoved(handled('symmetric'), 0, 'out', { x: 70, y: 60 });
|
||||||
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]!.out).toEqual({ x: 20, y: 10 });
|
||||||
expect(bent.points[0]!.in).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', () => {
|
it('takes a handle away when it is dropped on its own point', () => {
|
||||||
const straightened = withHandleMoved(arc(), 0, 'out', { x: 0, y: 0 });
|
const straightened = withHandleMoved(arc(), 0, 'out', { x: 0, y: 0 });
|
||||||
expect(straightened.points[0]!.out).toBeUndefined();
|
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', () => {
|
it('moves a whole ring', () => {
|
||||||
const moved = ringMoved(square(), 10, -5);
|
const moved = ringMoved(square(), 10, -5);
|
||||||
expect(moved.points.map((p) => [p.x, p.y])).toEqual([
|
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', () => {
|
describe('the inside of a ring', () => {
|
||||||
it('holds what lies in it and not what lies beside it', () => {
|
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: 50, y: 50 })).toBe(true);
|
||||||
|
|||||||
+118
-1
@@ -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 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,
|
* 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,
|
* 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;
|
const CANVAS = 256;
|
||||||
@@ -473,4 +474,120 @@ export async function vectorArt(t) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await shot('47-picture-picker');
|
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() }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user