/** * 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, VectorPointMode, VectorRing } from '../core/model/Asset'; import type { Vec2 } from '../core/model/Properties'; import { closestOnSegment } from '../core/util/vec'; // Where a handle sits in canvas pixels; it is stored as an offset, and the model says so once. import { cloneVectorPoint, cloneVectorRing, handleAt } from '../core/model/vectorart'; /** What lies under the pointer: a corner, or the edge from corner `index` to the next one. */ export type PolygonGrip = { kind: 'corner'; index: number } | { kind: 'edge'; index: number; at: Vec2 }; /** Fewer corners than this are no polygon; the last three cannot be taken away. */ export const MIN_CORNERS = 3; /** * The corner or edge within `tolerance` of `p`, the nearest one; a corner wins over an edge, since * every corner lies on two edges. An outline of one or two corners has no edge to press on yet. */ export function gripAt(points: readonly Vec2[], p: Vec2, tolerance: number): PolygonGrip | null { let best: PolygonGrip | null = null; let nearest = tolerance; for (const [index, corner] of points.entries()) { const distance = Math.hypot(corner.x - p.x, corner.y - p.y); if (distance > nearest) continue; best = { kind: 'corner', index }; nearest = distance; } if (best || points.length < MIN_CORNERS) return best; for (const [index, a] of points.entries()) { const at = closestOnSegment(p, a, points[(index + 1) % points.length]!); const distance = Math.hypot(at.x - p.x, at.y - p.y); if (distance > nearest) continue; best = { kind: 'edge', index, at }; nearest = distance; } return best; } /** Whether `p` lies inside the outline (even-odd); fewer than three corners enclose nothing. */ export function insideOutline(points: readonly Vec2[], p: Vec2): boolean { if (points.length < MIN_CORNERS) return false; let inside = false; for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const a = points[i]!; const b = points[j]!; if (a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside; } return inside; } /** A corner put at `at` on the edge after corner `index`; it becomes corner `index + 1`. */ export function withCornerAfter(points: readonly Vec2[], index: number, at: Vec2): Vec2[] { return [...points.slice(0, index + 1), { ...at }, ...points.slice(index + 1)]; } export function withCornerMoved(points: readonly Vec2[], index: number, to: Vec2): Vec2[] { return points.map((p, i) => (i === index ? { ...to } : { ...p })); } /** The outline without corner `index`; null when that would leave it fewer than `min` corners. */ export function withoutCorner(points: readonly Vec2[], index: number, min = MIN_CORNERS): Vec2[] | null { 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)); } /** * What a point is to the user (#195): straight has no handle at all, the other three are the mode * its handles move in. A point with handles and no mode is a corner. */ export type PointKind = 'straight' | VectorPointMode; export const POINT_KINDS: readonly PointKind[] = ['straight', 'corner', 'smooth', 'symmetric']; export function pointKind(point: VectorPoint): PointKind { return point.in || point.out ? (point.mode ?? 'corner') : 'straight'; } /** * A handle pulled to `to`, in canvas pixels; the other handle follows the point's mode. A corner * leaves it where it is, a smooth point turns it to the opposite direction at its own length, a * symmetric point mirrors it – and grows it, if it was missing, which is how the pen draws a curve. * A handle dropped back onto its point is taken away; on a symmetric point its mirror goes with it. * `mode` overrides the point's own and is written onto it. */ export function withHandleMoved( ring: VectorRing, index: number, side: 'in' | 'out', to: Vec2, mode?: VectorPointMode, ): VectorRing { return mapPoints(ring, (point, i) => { if (i !== index) return point; const offset = { x: to.x - point.x, y: to.y - point.y }; const length = Math.hypot(offset.x, offset.y); const next: VectorPoint = { ...point }; const other = side === 'in' ? 'out' : 'in'; const how = mode ?? point.mode ?? 'corner'; if (mode) next.mode = mode; if (length < 1e-6) { delete next[side]; if (how === 'symmetric') delete next[other]; return next; } next[side] = offset; const across = next[other]; if (how === 'symmetric') next[other] = { x: -offset.x, y: -offset.y }; else if (how === 'smooth' && across) { const keep = Math.hypot(across.x, across.y); next[other] = { x: (-offset.x / length) * keep, y: (-offset.y / length) * keep }; } return next; }); } /** * The point turned into `kind` (#195). Straight takes both handles away. A point without handles * that becomes one of the other three grows them first, along the line between its neighbours at a * third of the way to each – the curve one expects, without pulling two handles out by hand. Smooth * and symmetric then line the handles up on the bisector of the two, so the curve keeps its general * direction: smooth keeps each length, symmetric gives both their mean. A missing second handle is * grown opposite the first at the same length. */ export function withPointKind(ring: VectorRing, index: number, kind: PointKind): VectorRing { const point = ring.points[index]; if (!point) return cloneVectorRing(ring); if (kind === 'straight') return mapPoints(ring, (p, i) => (i === index ? { x: p.x, y: p.y } : p)); const had = Boolean(point.in || point.out); // Grown handles are in line and of one length already; lining them up again would only add rounding. let handles = had ? { in: point.in, out: point.out } : grownHandles(ring, index); if (!handles) return cloneVectorRing(ring); if (had && kind !== 'corner') handles = linedUp(handles, kind); const next: VectorPoint = { x: point.x, y: point.y, mode: kind }; if (handles.in) next.in = handles.in; if (handles.out) next.out = handles.out; return mapPoints(ring, (p, i) => (i === index ? next : p)); } function grownHandles(ring: VectorRing, index: number): { in: Vec2; out: Vec2 } | null { const point = ring.points[index]!; const count = ring.points.length; // An open line's ends have one neighbour: the missing one stands on the point itself. const before = ring.closed || index > 0 ? ring.points[(index - 1 + count) % count]! : point; const after = ring.closed || index < count - 1 ? ring.points[(index + 1) % count]! : point; const dx = (after.x - before.x) / 3; const dy = (after.y - before.y) / 3; if (Math.hypot(dx, dy) < 1e-6) return null; return { in: { x: -dx, y: -dy }, out: { x: dx, y: dy } }; } function linedUp(handles: { in?: Vec2; out?: Vec2 }, kind: 'smooth' | 'symmetric'): { in: Vec2; out: Vec2 } { const outLength = handles.out ? Math.hypot(handles.out.x, handles.out.y) : 0; const inLength = handles.in ? Math.hypot(handles.in.x, handles.in.y) : 0; const ahead = handles.out ? unit(handles.out) : { x: 0, y: 0 }; const behind = handles.in ? unit(handles.in) : { x: 0, y: 0 }; // The way out, averaged with the way in turned round; handles folded onto each other keep `out`'s. let direction = unit({ x: ahead.x - behind.x, y: ahead.y - behind.y }); if (direction.x === 0 && direction.y === 0) direction = handles.out ? ahead : { x: -behind.x, y: -behind.y }; let lengthIn = inLength || outLength; let lengthOut = outLength || inLength; if (kind === 'symmetric') lengthIn = lengthOut = (lengthIn + lengthOut) / 2; return { in: { x: -direction.x * lengthIn, y: -direction.y * lengthIn }, out: { x: direction.x * lengthOut, y: direction.y * lengthOut }, }; } function unit(v: Vec2): Vec2 { const length = Math.hypot(v.x, v.y); return length < 1e-9 ? { x: 0, y: 0 } : { x: v.x / length, y: v.y / length }; } /** * A point put on the segment after `index`, at `t` along it. A curved segment is split rather than * cut: the two halves together draw exactly the curve that was there (de Casteljau), so a point put * 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); const next = points[(index + 1) % ring.points.length]!; setHandle(points[index]!, 'out', a); setHandle(next, 'in', c); // The split keeps the directions but shortens one handle of each end: no longer symmetric, // still smooth. The new point sits on a curve, so its handles are in line – smooth as well. for (const end of [points[index]!, next]) if (end.mode === 'symmetric') end.mode = 'smooth'; inserted = { x: at.x, y: at.y, mode: 'smooth' }; setHandle(inserted, 'in', d); setHandle(inserted, 'out', e); if (!inserted.in && !inserted.out) delete inserted.mode; } points.splice(index + 1, 0, inserted); return { closed: ring.closed, points }; } /** 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) }; } /** Alt-click: a straight point made round (symmetric), and one with handles made straight again. */ export function withSmoothToggled(ring: VectorRing, index: number): VectorRing { const point = ring.points[index]; if (!point) return cloneVectorRing(ring); return withPointKind(ring, index, pointKind(point) === 'straight' ? 'symmetric' : 'straight'); } /** 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))) }; } /** 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 { const copy = cloneVectorPoint(point); if (!copy.in && !copy.out) delete copy.mode; return copy; } 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 }; }