Files
feuerwerk/src/core/model/vectorBoolean.ts
T
adminandClaude Opus 5 1116818011
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m51s
Vector shapes can be combined by union, subtract and more (#187)
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>
2026-09-21 11:56:41 +02:00

151 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}