Combining vector shapes keeps the curves it did not cut (#208)
game-maker / gate (push) Successful in 2m44s
game-maker / suite (push) Successful in 8m42s

Until now every boolean operation on vector art threw the curves away.
The shapes were flattened into line segments, the operation ran on those
segments, and the result came back as a polygon. A circle that nothing
even touched came out of an Exclude with 40 corners instead of its four
points and handles. Now the result carries the original curves again,
and only the edges the operation really drew stay straight.

- While flattening, every sample point remembers which curve it came
  from and where on that curve it sits. The operation runs on the same
  segments as before, so nothing about the boolean result changes.
- When a run of result points walks along one of those curves, that
  piece is cut out of the original curve again with de Casteljau. This
  is an exact cut, not a fit through the points.
- An edge whose two ends sit on the same curve but whose middle does
  not follow it stays straight. Without that rule a cut-off cap would
  bulge back into the shape.
- A closed contour keeps at least three points, so a lens shape does
  not collapse into two.
- The rule lives in its own module, src/core/model/vectorCurves.ts.
  vectorBoolean.ts only hands it the table of sampled points.

Findings:

- The two intersection points themselves sit up to 0.09 canvas pixels
  off the original curve. Clipper cuts the flattened segments, not the
  curves, so this was the case before as well and stays inside the
  0.2 px sampling tolerance.
- Refining those points with Newton steps on the curve was measured and
  gave nothing back (0.02952 px instead of 0.02978 px), because the
  error is in the intersection itself and not in the estimate. The code
  was removed again.
- Theme.ts rowHeight 22 -> 24 is in this commit. The change is not mine,
  it was already in the working tree; gate and module 47 are green with
  it.

Tests:

- ./check.sh: 143 files, 2133 tests, exit 0 (was 2127)
- e2e module 47 on a fresh seed: all passed, 59 checks (was 57)
- 6 new unit tests: untouched circle, untouched side plus a straight cut
  edge, a cut in the middle of a curve, a chord crossing one curve
  twice, two overlapping circles, the three point minimum
- 2 new e2e checks in block 47p: point count and handles from the model,
  plus a pixel pair on the round edge
- Four counter-proofs, all bite: no reconstruction -> 5 unit tests and
  the e2e check fail with 35 points and nothing curved; no chord test ->
  the cap bulges back to 28.28; no three point rule -> the lens test
  fails; handles halved -> the pixel check reads background

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-23 05:31:05 +02:00
1 parent d7a2521ca7
commit 6e3e8d23c5
6 files changed
+448 -29

No files matched your search

+121 -11
View File
@@ -13,11 +13,13 @@ import {
SetVectorShapeStyleCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorRing, VectorShape } from '../../src/core/model/Asset';
import type { Vec2 } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import {
combineBlocker,
combinedRings,
FLATTEN_TOLERANCE,
flattenRing,
type ShapeBoolean,
} from '../../src/core/model/vectorBoolean';
import { makeProject } from '../helpers';
@@ -52,9 +54,8 @@ function shape(id: string, ...rings: VectorRing[]): VectorShape {
return { id, name: id, rings, fill: 0x808080, opacity: 1 };
}
function inside(ring: VectorRing, x: number, y: number): boolean {
function inside(p: readonly Vec2[], x: number, y: number): boolean {
let hit = false;
const p = ring.points;
for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
const a = p[i]!;
const b = p[j]!;
@@ -63,12 +64,16 @@ function inside(ring: VectorRing, x: number, y: number): boolean {
return hit;
}
/** The area painted under even-odd, counted at the centres of half-pixel cells. */
/**
* The area painted under even-odd, counted at the centres of half-pixel cells. The rings are
* sampled into corners first, so a result that came back as curves is measured along its curves.
*/
function paintedArea(rings: readonly VectorRing[], from = -5, to = 60): number {
const outlines = rings.map((ring) => flattenRing(ring));
let cells = 0;
for (let x = from + 0.25; x < to; x += 0.5)
for (let y = from + 0.25; y < to; y += 0.5)
if (rings.filter((ring) => inside(ring, x, y)).length % 2 === 1) cells += 1;
if (outlines.filter((outline) => inside(outline, x, y)).length % 2 === 1) cells += 1;
return cells * 0.25;
}
@@ -108,25 +113,25 @@ describe('combining shapes', () => {
let main: { poly: Polygon; holes: number } | null = null;
const outlines: { poly: Polygon; holes: number }[] = [];
for (const ring of rings) {
const poly = new Polygon(ring.points.flatMap((p) => [p.x, p.y]));
const poly = new Polygon(flattenRing(ring).flatMap((p) => [p.x, p.y]));
if (main && main.poly.containsPolygon(poly)) main.holes += 1;
else outlines.push((main = { poly, holes: 0 }));
}
expect(outlines.map((o) => o.holes).sort()).toEqual([0, 1]);
});
it('samples a curve within the tolerance and keeps its points few', () => {
it('gives a curve nothing crossed back as the same four points and handles (#208)', () => {
const round = shape('r', circle(30, 30, 20));
const far = shape('f', square(100, 100, 5));
const rings = combinedRings([round, far], 'union');
const disc = rings.find((ring) => ring.points.every((p) => p.x < 60))!;
const radii = disc.points.map((p) => Math.hypot(p.x - 30, p.y - 30));
// Kappa bulges the true curve out by 0.03 % of r; rounding to a hundredth adds 0.01.
expect(disc.points).toHaveLength(4);
expect(disc.points.every((p) => p.in && p.out)).toBe(true);
// The drawn curve, not merely its points: every sample of it sits on the circle.
const radii = flattenRing(disc).map((p) => Math.hypot(p.x - 30, p.y - 30));
expect(Math.max(...radii)).toBeLessThan(20 + 0.02);
expect(Math.min(...radii)).toBeGreaterThan(20 - FLATTEN_TOLERANCE - 0.02);
expect(disc.points.length).toBeGreaterThan(16);
expect(disc.points.length).toBeLessThan(64);
expect(disc.points.every((p) => !p.in && !p.out)).toBe(true);
expect(flattenRing(disc).length).toBeGreaterThan(16);
});
it('refuses one shape alone and an open line, and says why', () => {
@@ -145,6 +150,111 @@ describe('combining shapes', () => {
});
});
/** How far the ring's own outline strays from the circle it should be part of. */
function offCircle(ring: VectorRing, cx: number, cy: number, r: number, where: (p: Vec2) => boolean) {
const gaps = flattenRing(ring)
.filter(where)
.map((p) => Math.abs(Math.hypot(p.x - cx, p.y - cy) - r));
return { worst: Math.max(...gaps), samples: gaps.length };
}
describe('curves through a boolean operation (#208)', () => {
it('keeps the untouched side of a cut circle a curve, and the cut itself straight', () => {
// The square covers the left half, so only the left side of the circle is really cut.
const round = shape('r', circle(30, 30, 20));
const box = shape('b', square(-30, -10, 60));
const rings = combinedRings([round, box], 'subtract');
expect(rings).toHaveLength(1);
const points = rings[0]!.points;
expect(points.length).toBeLessThan(8);
expect(points.some((p) => p.in ?? p.out)).toBe(true);
// The right half is still the circle, sampled along the curve that came back.
const right = offCircle(rings[0]!, 30, 30, 20, (p) => p.x > 31);
expect(right.samples).toBeGreaterThan(8);
expect(right.worst).toBeLessThan(0.05);
// The cut runs down x = 30: two corners there, nothing bulging past it.
expect(Math.max(...flattenRing(rings[0]!).map((p) => 30 - p.x))).toBeLessThan(0.02);
});
it('cuts a curve where it is crossed and keeps that piece on the old curve', () => {
const round = shape('r', circle(30, 30, 20));
const box = shape('b', square(-10, -10, 35));
// The square's corner reaches into the circle and cuts two of its four curves in the middle.
const rings = combinedRings([round, box], 'subtract');
// A handful of points, not the forty of the sampled outline, and most of them still curved.
expect(rings[0]!.points.length).toBeLessThan(10);
expect(rings[0]!.points.filter((p) => p.in ?? p.out).length).toBeGreaterThanOrEqual(4);
// Everything outside the square's two cutting edges is circle, right up to the cuts.
const kept = offCircle(rings[0]!, 30, 30, 20, (p) => p.x > 25.05 || p.y > 25.05);
expect(kept.samples).toBeGreaterThan(15);
expect(kept.worst).toBeLessThan(0.05);
});
it('leaves a chord that cuts one curve twice a straight one', () => {
const round = shape('r', circle(30, 30, 20));
// A wedge that slices a cap off the upper right quarter: both its cuts land on that one curve,
// so the edge between them is a chord the clipper drew, not a piece of the circle.
const wedge = shape('w', {
closed: true,
points: [
{ x: 30, y: 6.83 },
{ x: 55, y: 31.83 },
{ x: 90, y: -30 },
],
});
const rings = combinedRings([round, wedge], 'subtract');
expect(rings).toHaveLength(1);
// Nothing of the outline reaches past the chord: the cap of 3.6 pixels is gone and no curve
// bulges back over it. The chord runs along x − y = 23.17, the centre lies below it.
expect(Math.max(...flattenRing(rings[0]!).map((p) => p.x - p.y))).toBeLessThan(23.3);
// The rest of the circle is still curved.
expect(rings[0]!.points.filter((p) => p.in ?? p.out).length).toBeGreaterThanOrEqual(4);
});
it('leaves a square a square: no handle grows on a straight edge', () => {
const rings = combinedRings([A(), B()], 'union');
expect(rings).toHaveLength(1);
expect(rings[0]!.points.every((p) => !p.in && !p.out)).toBe(true);
expect(rings[0]!.points).toHaveLength(8);
});
it('keeps both curves of two circles that overlap, and paints the same area as before', () => {
const left = shape('l', circle(25, 30, 15));
const right = shape('r', circle(40, 30, 15));
const rings = combinedRings([left, right], 'union');
expect(rings).toHaveLength(1);
// Six of the eight points are the circles' own anchors, two are the intersections.
expect(rings[0]!.points).toHaveLength(8);
// Every drawn point sits on one of the two circles – the whole outline, intersections included,
// not merely the sides nothing crossed.
const off = flattenRing(rings[0]!).map((p) =>
Math.min(Math.abs(Math.hypot(p.x - 25, p.y - 30) - 15), Math.abs(Math.hypot(p.x - 40, p.y - 30) - 15)),
);
expect(Math.max(...off)).toBeLessThan(0.05);
});
it('never leaves a ring with fewer than three points', () => {
// Two circles offset diagonally: no anchor of either one lies inside the other, so the lens
// between them is geometrically two points and two curves.
const lens = combinedRings([shape('a', circle(20, 20, 15)), shape('b', circle(40, 40, 15))], 'intersect');
expect(lens).toHaveLength(1);
// A ring of two is not drawable, so one curve is left open at a third point instead.
expect(lens[0]!.points).toHaveLength(3);
expect(lens[0]!.points.filter((p) => p.in ?? p.out)).toHaveLength(3);
// Both curves of the lens are still the circles they were cut from. The two cut points
// themselves are the clipper's, and it crosses the sampled chords, not the curves – measured
// 0.09 out, which is what the sampling tolerance allows and what it always was.
const knots = new Set(lens[0]!.points.map((p) => `${p.x},${p.y}`));
for (const p of flattenRing(lens[0]!)) {
const off = Math.min(
Math.abs(Math.hypot(p.x - 20, p.y - 20) - 15),
Math.abs(Math.hypot(p.x - 40, p.y - 40) - 15),
);
expect(off).toBeLessThan(knots.has(`${p.x},${p.y}`) ? FLATTEN_TOLERANCE : 0.05);
}
});
});
function drawingWith(...rings: VectorRing[]) {
const fixture = makeProject();
const create = new CreateAssetCommand('vectorart', 'Logo');