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
@@ -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