Building a shape out of simpler ones meant drawing the result point by point. The Vector Art tab now selects several shapes at once and combines them with a boolean operation into one ordinary shape. - Ctrl/Cmd+click in the tab and Ctrl or Shift+click in the shape list add a shape to the selection. Shift stays pan in the canvas. - A COMBINE block in the inspector offers Union, Subtract, Intersect and Exclude. Subtract removes every upper shape from the lowest one. - The result replaces the lowest selected shape and keeps its name, colours, stroke and place in the order. It is one undo step. - Curves are flattened to 0.2 canvas pixels, a circle gets about 48 points. Open lines and single shapes cannot be combined, the buttons are off and a hint gives the reason. An empty result is rejected. - Delete now removes every selected shape, and the selection drops shapes that no longer exist. - The geometry comes from clipper2-ts 2.0.1, pinned. It has no dependencies and speaks even-odd directly. Only the engine module is imported, the bundle grows by 55 kB instead of 110 kB. Findings: - clipper2-js was tried and dropped. It pulls in Angular 15 with a high npm audit finding and no fix. - Pixi finds holes by containment, not by even-odd depth. An island inside a hole would be drawn as a hole again. Result rings are emitted outer ring before its islands, and a test mirrors Pixi's rule. - A depth sort turned out to be dead code in a counter-check and was removed. A pixel check in 47h stayed green without the subtract and now checks both colours. Tests: - ./check.sh: 134 files, 1986 tests, exit 0 (before 133 / 1968) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 44 checks (before 39) - 18 new unit tests, 5 new e2e checks in block 47h - Five counter-checks bite, originals restored and verified with cmp - Check sum 1222 in README and wiki, calculated (1217 + 5) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
6.4 KiB
TypeScript
151 lines
6.4 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|