/** * Boolean operations on vector art shapes (#187): union, subtract, intersect and exclude. The * clipping itself is Clipper2 (`clipper2-ts`) – robust polygon clipping is a field of its own, and * its even-odd fill rule is exactly how a shape's rings fill (#129). * * The work runs on the sampled outline: a curve is cut into straight pieces fine enough that the * sampling stays under `FLATTEN_TOLERANCE`, and the result is corners only. The result is an ordinary * shape again – no compound that remembers where it came from. * * Only the clipping engine is imported, not the package's `Clipper` namespace: that one pulls in * offsetting, triangulation and Minkowski sums as well, twice the bytes in the bundle (measured: * 101 kB against 50 kB minified). */ import { ClipType, FillRule, type PathD, type PathsD } from 'clipper2-ts/dist/Core.js'; import { ClipperD, type PolyPathD, PolyTreeD } from 'clipper2-ts/dist/Engine.js'; import type { VectorRing, VectorShape } from './Asset'; import type { Vec2 } from './Properties'; import { handleAt, isDrawableRing } from './vectorart'; export type ShapeBoolean = 'union' | 'subtract' | 'intersect' | 'exclude'; /** How far a sampled curve may stray from the true one, in canvas pixels. */ export const FLATTEN_TOLERANCE = 0.2; const MAX_CURVE_STEPS = 100; /** Decimal places Clipper keeps; a hundredth of a canvas pixel is finer than any supersampling. */ const PRECISION = 2; /** * Why the shapes cannot be combined, or null when they can. `shapes` is the selection; an open line * has no inside to add or cut away, and a half-drawn one (too short to draw) is simply passed over. */ export function combineBlocker(shapes: readonly VectorShape[]): string | null { if (shapes.length < 2) return 'Ctrl+click a second shape to combine.'; if (shapes.some((shape) => shape.rings.some((ring) => !ring.closed && isDrawableRing(ring)))) return 'An open line cannot be combined.'; return null; } /** * The rings of the combined shape; `shapes` in drawing order, the first one being the lowest. Subtract * cuts every later shape out of the first. Empty when nothing is left. * * The rings come out in the order the rasteriser needs: Pixi takes a ring for a hole of the outline * drawn last when that one contains it, so every outline is followed by its own holes, and an * island inside a hole comes before the outline around it – after it, it would count as a hole too. */ export function combinedRings(shapes: readonly VectorShape[], op: ShapeBoolean): VectorRing[] { const regions = shapes.map(shapeRegion); const [first = [], ...rest] = regions; let result: PathsD; if (op === 'union') result = unite(regions.flat(), FillRule.NonZero); else if (op === 'subtract') result = clip(ClipType.Difference, first, unite(rest.flat(), FillRule.NonZero), FillRule.NonZero); else { const type = op === 'intersect' ? ClipType.Intersection : ClipType.Xor; result = rest.reduce((acc, next) => clip(type, acc, next, FillRule.NonZero), first); } const clipper = new ClipperD(PRECISION); clipper.addSubjectPaths(result); const tree = new PolyTreeD(); clipper.execute(ClipType.Union, FillRule.NonZero, tree); const rings: VectorRing[] = []; collectOutlines(tree, rings); return rings; } /** * Every outline under `node`, each followed by its holes. An outline goes in only after the islands * inside its holes have gone in – that is the island-first order `combinedRings` promises. */ function collectOutlines(node: PolyPathD, into: VectorRing[]): void { for (let i = 0; i < node.count; i++) { const outline = node.child(i); const rings = [toRing(outline.poly)]; for (let j = 0; j < outline.count; j++) { const hole = outline.child(j); rings.push(toRing(hole.poly)); collectOutlines(hole, into); } into.push(...rings.filter((ring) => ring.points.length >= 3)); } } function toRing(path: PathD | null): VectorRing { return { closed: true, points: (path ?? []).map((p) => ({ x: round(p.x), y: round(p.y) })) }; } function round(value: number): number { return Math.round(value * 100) / 100; } /** * What a shape covers, as Clipper paths: its closed rings under the even-odd rule the rasteriser * uses, turned into outlines that do not overlap – so any number of shapes can then be put together * under the non-zero rule without one shape's hole cancelling another's fill. */ function shapeRegion(shape: VectorShape): PathsD { const rings = shape.rings.filter((ring) => ring.closed && isDrawableRing(ring)).map(flattenRing); return unite(rings, FillRule.EvenOdd); } function unite(paths: PathsD, fillRule: FillRule): PathsD { return clip(ClipType.Union, paths, [], fillRule); } function clip(type: ClipType, subject: PathsD, clipBy: PathsD, fillRule: FillRule): PathsD { const clipper = new ClipperD(PRECISION); clipper.addSubjectPaths(subject); clipper.addClipPaths(clipBy); const solution: PathsD = []; clipper.execute(type, fillRule, solution); return solution; } /** A closed ring as corners only: every curved segment cut into straight pieces. */ export function flattenRing(ring: VectorRing): Vec2[] { const points = ring.points; const out: Vec2[] = []; for (const [i, from] of points.entries()) { out.push({ x: from.x, y: from.y }); const to = points[(i + 1) % points.length]!; if (!from.out && !to.in) continue; const c1 = handleAt(from, 'out') ?? from; const c2 = handleAt(to, 'in') ?? to; const steps = curveSteps(from, c1, c2, to); for (let step = 1; step < steps; step++) out.push(cubicAt(from, c1, c2, to, step / steps)); } return out; } /** * How many straight pieces keep a cubic within the tolerance – Wang's formula: the largest second * difference of the control points bounds how far a chord can stray from the curve. */ function curveSteps(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): number { const m = Math.max( Math.hypot(p0.x - 2 * p1.x + p2.x, p0.y - 2 * p1.y + p2.y), Math.hypot(p1.x - 2 * p2.x + p3.x, p1.y - 2 * p2.y + p3.y), ); return Math.min(MAX_CURVE_STEPS, Math.max(1, Math.ceil(Math.sqrt((0.75 * m) / FLATTEN_TOLERANCE)))); } function cubicAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 { const u = 1 - t; const a = u * u * u; const b = 3 * u * u * t; const c = 3 * u * t * t; const d = t * t * t; return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y }; }