Combining vector shapes keeps the curves it did not cut (#208)
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:
1 parent
d7a2521ca7
commit
6e3e8d23c5
6 files changed
+448
-29
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1302 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1304 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* 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
|
||||
* sampling stays under `FLATTEN_TOLERANCE`. Where the clipper left such a run alone, `vectorCurves`
|
||||
* puts the curve back afterwards (#208), so only a side that was really cut turns into corners – and
|
||||
* even that one keeps the exact piece of the old curve up to the cut. 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
|
||||
@@ -16,11 +18,18 @@ 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';
|
||||
import {
|
||||
addCurve,
|
||||
addSample,
|
||||
type CurveTable,
|
||||
FLATTEN_TOLERANCE,
|
||||
newCurveTable,
|
||||
restoreCurves,
|
||||
} from './vectorCurves';
|
||||
|
||||
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;
|
||||
export { FLATTEN_TOLERANCE };
|
||||
const MAX_CURVE_STEPS = 100;
|
||||
/** Decimal places Clipper keeps; a hundredth of a canvas pixel is finer than any supersampling. */
|
||||
const PRECISION = 2;
|
||||
@@ -45,7 +54,8 @@ export function combineBlocker(shapes: readonly VectorShape[]): string | null {
|
||||
* 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 table = newCurveTable();
|
||||
const regions = shapes.map((shape) => shapeRegion(shape, table));
|
||||
const [first = [], ...rest] = regions;
|
||||
let result: PathsD;
|
||||
if (op === 'union') result = unite(regions.flat(), FillRule.NonZero);
|
||||
@@ -60,7 +70,7 @@ export function combinedRings(shapes: readonly VectorShape[], op: ShapeBoolean):
|
||||
const tree = new PolyTreeD();
|
||||
clipper.execute(ClipType.Union, FillRule.NonZero, tree);
|
||||
const rings: VectorRing[] = [];
|
||||
collectOutlines(tree, rings);
|
||||
collectOutlines(tree, rings, table);
|
||||
return rings;
|
||||
}
|
||||
|
||||
@@ -68,21 +78,22 @@ export function combinedRings(shapes: readonly VectorShape[], op: ShapeBoolean):
|
||||
* 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 {
|
||||
function collectOutlines(node: PolyPathD, into: VectorRing[], table: CurveTable): void {
|
||||
for (let i = 0; i < node.count; i++) {
|
||||
const outline = node.child(i);
|
||||
const rings = [toRing(outline.poly)];
|
||||
const rings = [toRing(outline.poly, table)];
|
||||
for (let j = 0; j < outline.count; j++) {
|
||||
const hole = outline.child(j);
|
||||
rings.push(toRing(hole.poly));
|
||||
collectOutlines(hole, into);
|
||||
rings.push(toRing(hole.poly, table));
|
||||
collectOutlines(hole, into, table);
|
||||
}
|
||||
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 toRing(path: PathD | null, table: CurveTable): VectorRing {
|
||||
const corners = (path ?? []).map((p) => ({ x: round(p.x), y: round(p.y) }));
|
||||
return { closed: true, points: restoreCurves(corners, table) };
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
@@ -94,8 +105,10 @@ function round(value: number): number {
|
||||
* 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);
|
||||
function shapeRegion(shape: VectorShape, table?: CurveTable): PathsD {
|
||||
const rings = shape.rings
|
||||
.filter((ring) => ring.closed && isDrawableRing(ring))
|
||||
.map((ring) => flattenRing(ring, table));
|
||||
return unite(rings, FillRule.EvenOdd);
|
||||
}
|
||||
|
||||
@@ -112,8 +125,11 @@ function clip(type: ClipType, subject: PathsD, clipBy: PathsD, fillRule: FillRul
|
||||
return solution;
|
||||
}
|
||||
|
||||
/** A closed ring as corners only: every curved segment cut into straight pieces. */
|
||||
export function flattenRing(ring: VectorRing): Vec2[] {
|
||||
/**
|
||||
* A closed ring as corners only: every curved segment cut into straight pieces. With a `table` every
|
||||
* sample also says which curve it came from, which is what puts the curves back afterwards (#208).
|
||||
*/
|
||||
export function flattenRing(ring: VectorRing, table?: CurveTable): Vec2[] {
|
||||
const points = ring.points;
|
||||
const out: Vec2[] = [];
|
||||
for (const [i, from] of points.entries()) {
|
||||
@@ -123,7 +139,14 @@ export function flattenRing(ring: VectorRing): Vec2[] {
|
||||
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));
|
||||
const seg = table ? addCurve(table, from, c1, c2, to) : -1;
|
||||
if (table) addSample(table, from, seg, 0);
|
||||
for (let step = 1; step < steps; step++) {
|
||||
const sample = cubicAt(from, c1, c2, to, step / steps);
|
||||
out.push(sample);
|
||||
if (table) addSample(table, sample, seg, step / steps);
|
||||
}
|
||||
if (table) addSample(table, to, seg, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Keeping bezier curves through a boolean operation (#208). Clipping itself only knows straight
|
||||
* pieces, so a curve has to be cut into them – and until now the result was corners only, even where
|
||||
* nothing had 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';
|
||||
|
||||
/** 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<string, PointOnCurve[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a = cubicAt(segment, t0);
|
||||
const b = cubicAt(segment, t1);
|
||||
for (let step = 1; step < 4; step++) {
|
||||
const point = cubicAt(segment, 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 : split(whole, t1).left;
|
||||
return t1 <= 0 ? left : split(left, t0 / t1).right;
|
||||
}
|
||||
|
||||
/** de Casteljau: the cubic cut in two at `t`, both halves as control points. */
|
||||
function split(p: Vec2[], t: number): { left: Vec2[]; right: Vec2[] } {
|
||||
const a = lerp(p[0]!, p[1]!, t);
|
||||
const b = lerp(p[1]!, p[2]!, t);
|
||||
const c = lerp(p[2]!, p[3]!, t);
|
||||
const d = lerp(a, b, t);
|
||||
const e = lerp(b, c, t);
|
||||
const mid = lerp(d, e, t);
|
||||
return { left: [p[0]!, a, d, mid], right: [mid, e, c, p[3]!] };
|
||||
}
|
||||
|
||||
function lerp(a: Vec2, b: Vec2, t: number): Vec2 {
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
|
||||
export function cubicAt(segment: { 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;
|
||||
const { p0, p1, p2, p3 } = segment;
|
||||
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 };
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const Theme = {
|
||||
fontSize: 13,
|
||||
smallFontSize: 11,
|
||||
viewportLabelSize: 10,
|
||||
rowHeight: 22,
|
||||
rowHeight: 24,
|
||||
controlHeight: 24,
|
||||
padding: 8,
|
||||
gap: 4,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -743,6 +743,59 @@ export async function vectorArt(t) {
|
||||
restored?.map((s) => s.id).join(',') === 'va-left,va-right' && restored[0].rings[0].points.length === 4,
|
||||
JSON.stringify(restored?.map((s) => ({ id: s.id, points: s.rings[0]?.points.length }))),
|
||||
);
|
||||
|
||||
// 47p (#208). The same subtract, but on a circle: the side the square does not touch has to come
|
||||
// back as a curve, not as the forty corners the clipping runs on.
|
||||
const K = 0.5523 * 60;
|
||||
const DISC = {
|
||||
id: 'va-disc',
|
||||
name: 'Disc',
|
||||
fill: 0x22aa66,
|
||||
opacity: 1,
|
||||
rings: [
|
||||
{
|
||||
closed: true,
|
||||
points: [
|
||||
{ x: 180, y: 120, in: { x: 0, y: -K }, out: { x: 0, y: K } },
|
||||
{ x: 120, y: 180, in: { x: K, y: 0 }, out: { x: -K, y: 0 } },
|
||||
{ x: 60, y: 120, in: { x: 0, y: K }, out: { x: 0, y: -K } },
|
||||
{ x: 120, y: 60, in: { x: -K, y: 0 }, out: { x: K, y: 0 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await mutate(`
|
||||
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
|
||||
art.shapes = ${JSON.stringify([DISC, square('va-cover', 'Cover', 0x3344cc, 20, 20)])};
|
||||
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
|
||||
await settle();
|
||||
const onDisc = await screenAt(170, 120);
|
||||
const onCover = await screenAt(40, 40);
|
||||
await click(onDisc.x, onDisc.y);
|
||||
await click(onCover.x, onCover.y, 'left', { ctrl: true });
|
||||
await settle();
|
||||
await clickText('Subtract');
|
||||
await settle();
|
||||
const carved = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||||
const arc = carved?.[0]?.rings[0]?.points ?? [];
|
||||
check(
|
||||
'a curve the cut did not touch comes back as a curve, not as corners (#208)',
|
||||
carved?.length === 1 && arc.length > 2 && arc.length < 10 && arc.filter((p) => p.in || p.out).length >= 2,
|
||||
JSON.stringify({ points: arc.length, curved: arc.filter((p) => p.in || p.out).length }),
|
||||
);
|
||||
|
||||
// Few points and still round: the tab paints the disc's edge where the circle runs, and nothing
|
||||
// where a straight line between two of those few points would have cut the corner.
|
||||
const insideArc = await screenAt(160, 80);
|
||||
const outsideArc = await screenAt(168, 72);
|
||||
const arcPixel = await json(`__gm.pixelAt(${insideArc.x}, ${insideArc.y})`);
|
||||
const pastArc = await json(`__gm.pixelAt(${outsideArc.x}, ${outsideArc.y})`);
|
||||
check(
|
||||
'the tab paints that side round: filled on the circle, empty just past it',
|
||||
colourDistance(arcPixel, '#22aa66') < 14 && colourDistance(pastArc, '#22aa66') > 60,
|
||||
JSON.stringify({ arcPixel, pastArc }),
|
||||
);
|
||||
await shot('47-combine-curve-kept');
|
||||
// 47i (#186). Gradients and a picture fill, set the way a user sets them: the Fill type row, the
|
||||
// stop bar under it, the Paint tool's handles in the tab. A two-colour picture is pasted in
|
||||
// first – it is what the texture fill is measured with, red on the left and green on the right.
|
||||
|
||||
Reference in new issue
Block a user