/** * Keeping bezier curves through a boolean operation (#208). Clipping itself only knows straight * pieces, so a curve has to be cut into them – and on its own the result would be corners only, even * where nothing crossed the curve at all. * * The way back is to remember where every sampled point came from: which cubic, and at which `t` on * it. A run of result points that walks one cubic in one direction is then that cubic again, cut at * both ends with de Casteljau – the piece is exact, not fitted. A point the clipper made up (an * intersection) has no memory, so its `t` is read off the chord it landed on; the point itself keeps * the position the clipper gave it, which is why no gap can open at a cut. */ import type { VectorPoint } from './Asset'; import type { Vec2 } from './Properties'; import { cubicAt, splitCubic } from '../util/vec'; /** How far a sampled curve may stray from the true one, in canvas pixels. */ export const FLATTEN_TOLERANCE = 0.2; /** One cubic of the input, with the samples it was cut into. */ interface CurveSegment { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2; samples: { t: number; x: number; y: number }[]; } /** Where a result point sits on an input curve. */ interface PointOnCurve { seg: number; t: number; } /** One edge of the result, and the piece of a cubic it runs along. */ interface CurveEdge { seg: number; from: number; to: number; } /** What the flattening remembered and the rebuilding reads back. */ export interface CurveTable { segments: CurveSegment[]; at: Map; } /** * How far the clipper may have moved a point and it still counts as sitting on a chord. It rounds to * a hundredth of a pixel, so half of that is the whole error of a point it kept; the rest is room * for one it made up. */ const ON_CHORD = 0.02; /** How far the curve between two result points may bulge out of their edge and they still neighbour. */ const CHORD_SLACK = 1.5 * FLATTEN_TOLERANCE; export function newCurveTable(): CurveTable { return { segments: [], at: new Map() }; } /** Opens a cubic in the table and returns its index; its samples are added as the curve is walked. */ export function addCurve(table: CurveTable, p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): number { table.segments.push({ p0, p1, p2, p3, samples: [] }); return table.segments.length - 1; } /** Notes that this point is the curve `seg` at `t`. */ export function addSample(table: CurveTable, point: Vec2, seg: number, t: number): void { const segment = table.segments[seg]; if (!segment) return; segment.samples.push({ t, x: point.x, y: point.y }); const key = pointKey(point); const list = table.at.get(key); if (list) list.push({ seg, t }); else table.at.set(key, [{ seg, t }]); } /** The same rounding the clipper does, so a point it handed back finds its own sample again. */ function pointKey(point: Vec2): string { return `${Math.round(point.x * 100) / 100},${Math.round(point.y * 100) / 100}`; } /** * The ring with its curves back: every run of points that walks one input cubic becomes that cubic * again, everything else stays a corner. Never returns fewer than three points – a run is left open * at one more point rather than letting a ring shrink out of being drawable. */ export function restoreCurves(points: readonly Vec2[], table: CurveTable): VectorPoint[] { const n = points.length; if (n < 3 || table.segments.length === 0) return points.map((p) => ({ x: p.x, y: p.y })); const places = points.map((point) => placesOf(point, table)); const edges = points.map((_, i) => edgeOn(places[i]!, places[(i + 1) % n]!, table)); return build(points, edges, keptPoints(edges), table); } /** Every input curve this result point could be a point of. */ function placesOf(point: Vec2, table: CurveTable): PointOnCurve[] { return table.at.get(pointKey(point)) ?? projectAll(point, table); } /** * Where a point the clipper made up sits: on the chord between two neighbouring samples, so its `t` * is read off that chord. An intersection of two curves lies on both, and both are returned. */ function projectAll(point: Vec2, table: CurveTable): PointOnCurve[] { const found: PointOnCurve[] = []; for (const [seg, segment] of table.segments.entries()) { let best: PointOnCurve | null = null; let bestGap = ON_CHORD; for (let i = 1; i < segment.samples.length; i++) { const a = segment.samples[i - 1]!; const b = segment.samples[i]!; const along = onChord(a, b, point); if (!along || along.gap > bestGap) continue; bestGap = along.gap; best = { seg, t: a.t + (b.t - a.t) * along.u }; } if (best) found.push(best); } return found; } /** How far along `a`→`b` the point sits and how far off that line it is; null when it is not between. */ function onChord(a: Vec2, b: Vec2, point: Vec2): { u: number; gap: number } | null { const dx = b.x - a.x; const dy = b.y - a.y; const length = dx * dx + dy * dy; if (length === 0) return null; const u = ((point.x - a.x) * dx + (point.y - a.y) * dy) / length; if (u < 0 || u > 1) return null; return { u, gap: Math.hypot(a.x + dx * u - point.x, a.y + dy * u - point.y) }; } /** * The piece of a cubic this edge runs along, or null when the edge is a straight one. Two points of * the same cubic only neighbour on it when the curve between them stays within the sampling * tolerance of the edge – otherwise it is a chord the clipper cut clean across, and no curve belongs * there. Of several candidates the shortest piece wins. */ function edgeOn(from: PointOnCurve[], to: PointOnCurve[], table: CurveTable): CurveEdge | null { let best: CurveEdge | null = null; for (const a of from) for (const b of to) { if (a.seg !== b.seg || a.t === b.t) continue; if (best && Math.abs(b.t - a.t) >= Math.abs(best.to - best.from)) continue; if (!staysOnCurve(table.segments[a.seg]!, a.t, b.t)) continue; best = { seg: a.seg, from: a.t, to: b.t }; } return best; } /** Whether the piece of `segment` from `t0` to `t1` hugs the straight line between its two ends. */ function staysOnCurve(segment: CurveSegment, t0: number, t1: number): boolean { const at = (t: number) => cubicAt(segment.p0, segment.p1, segment.p2, segment.p3, t); const a = at(t0); const b = at(t1); for (let step = 1; step < 4; step++) { const point = at(t0 + (t1 - t0) * (step / 4)); const along = onChord(a, b, point); if (!along || along.gap > CHORD_SLACK) return false; } return true; } /** * Which points stay a point of the ring. One falls away when the edge into it and the edge out of it * walk the same cubic the same way without a break – then it is a sample in the middle of a run. */ function keptPoints(edges: readonly (CurveEdge | null)[]): boolean[] { const n = edges.length; const keep = edges.map((_, i) => !continues(edges[(i - 1 + n) % n] ?? null, edges[i] ?? null)); for (let i = 0; keep.filter(Boolean).length < 3 && i < n; i++) keep[i] = true; return keep; } function continues(into: CurveEdge | null, out: CurveEdge | null): boolean { if (!into || !out || into.seg !== out.seg || into.to !== out.from) return false; return into.to > into.from === out.to > out.from; } /** The ring as points again: one point per kept index, with the handles of the piece between two. */ function build( points: readonly Vec2[], edges: readonly (CurveEdge | null)[], keep: readonly boolean[], table: CurveTable, ): VectorPoint[] { const n = points.length; const kept = keep.flatMap((yes, i) => (yes ? [i] : [])); const out: VectorPoint[] = kept.map((i) => ({ x: points[i]!.x, y: points[i]!.y })); for (const [j, start] of kept.entries()) { const edge = edges[start]; if (!edge) continue; const end = kept[(j + 1) % kept.length]!; const last = edges[(end - 1 + n) % n]!; const piece = subCurve(table.segments[edge.seg]!, edge.from, last.to); const head = out[j]!; const tail = out[(j + 1) % kept.length]!; head.out = { x: piece[1]!.x - head.x, y: piece[1]!.y - head.y }; tail.in = { x: piece[2]!.x - tail.x, y: piece[2]!.y - tail.y }; } return out; } /** The control points of the piece from `t0` to `t1`, walked in that direction. */ function subCurve(segment: CurveSegment, t0: number, t1: number): Vec2[] { if (t0 > t1) return subCurve(segment, t1, t0).reverse(); const whole = [segment.p0, segment.p1, segment.p2, segment.p3]; const left = t1 >= 1 ? whole : splitCubic(whole, t1).left; return t1 <= 0 ? left : splitCubic(left, t0 / t1).right; }