diff --git a/README.md b/README.md index f16d14ca..2c4149a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/model/vectorBoolean.ts b/src/core/model/vectorBoolean.ts index 5f58f66b..7e5026a6 100644 --- a/src/core/model/vectorBoolean.ts +++ b/src/core/model/vectorBoolean.ts @@ -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; } diff --git a/src/core/model/vectorCurves.ts b/src/core/model/vectorCurves.ts new file mode 100644 index 00000000..883bc8d8 --- /dev/null +++ b/src/core/model/vectorCurves.ts @@ -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; +} + +/** + * 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 }; +} diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 8a863039..255bda13 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -8,7 +8,7 @@ export const Theme = { fontSize: 13, smallFontSize: 11, viewportLabelSize: 10, - rowHeight: 22, + rowHeight: 24, controlHeight: 24, padding: 8, gap: 4, diff --git a/tests/core/vector-boolean.test.ts b/tests/core/vector-boolean.test.ts index 9fd5843b..ec7c2f23 100644 --- a/tests/core/vector-boolean.test.ts +++ b/tests/core/vector-boolean.test.ts @@ -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'); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index dcd6f248..0488d778 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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.