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>
This commit is contained in:
1 parent
3427f84a55
commit
1116818011
14 files changed
+796
-55
No files matched your search
@@ -14,7 +14,7 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
* the model, which is the only place a point's coordinates exist exactly. The last block, 47f,
|
||||
* walks the way a user walks instead: the asset comes out of the + menu, its row opens the tab,
|
||||
* and a sprite is given the drawing through its Texture row. 47g draws on that asset to measure the
|
||||
* four point types of #195.
|
||||
* four point types of #195, and 47h combines two shapes on it (#187).
|
||||
*/
|
||||
|
||||
const CANVAS = 256;
|
||||
@@ -590,4 +590,96 @@ export async function vectorArt(t) {
|
||||
straight.in === null && straight.out === null && (await pointType()).text === 'Point: Straight',
|
||||
JSON.stringify({ straight, header: await pointType() }),
|
||||
);
|
||||
|
||||
// 47h (#187). Two squares are combined the way a user does it: a click picks the lower one, a
|
||||
// Ctrl+click adds the upper one, and Subtract in the inspector cuts the upper out of the lower.
|
||||
// The squares go into the model – drawing them is what 47c measured.
|
||||
const square = (id, name, fill, x, y) => ({
|
||||
id,
|
||||
name,
|
||||
fill,
|
||||
opacity: 1,
|
||||
rings: [
|
||||
{
|
||||
closed: true,
|
||||
points: [
|
||||
{ x, y },
|
||||
{ x: x + 100, y },
|
||||
{ x: x + 100, y: y + 100 },
|
||||
{ x, y: y + 100 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
await mutate(`
|
||||
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
|
||||
art.shapes = ${JSON.stringify([square('va-left', 'Left', 0xcc3344, 40, 60), square('va-right', 'Right', 0x3344cc, 100, 100)])};
|
||||
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
|
||||
await settle();
|
||||
const selectedIds = () => json('__gm.vectorArt.selectedIds()');
|
||||
const leftOnly = await screenAt(60, 80);
|
||||
const rightOnly = await screenAt(180, 180);
|
||||
await click(leftOnly.x, leftOnly.y);
|
||||
await settle();
|
||||
check(
|
||||
'a click picks one shape, and the inspector asks for a second one to combine',
|
||||
JSON.stringify(await selectedIds()) === '["va-left"]' &&
|
||||
(await t.has('Ctrl+click a second shape to combine.')),
|
||||
JSON.stringify(await selectedIds()),
|
||||
);
|
||||
|
||||
await click(rightOnly.x, rightOnly.y, 'left', { ctrl: true });
|
||||
await settle();
|
||||
check(
|
||||
'Ctrl+click adds the other shape, and the inspector names the lowest as the one that stays',
|
||||
JSON.stringify(await selectedIds()) === '["va-left","va-right"]' &&
|
||||
(await t.has('The result keeps "Left", the lowest.')),
|
||||
JSON.stringify(await selectedIds()),
|
||||
);
|
||||
await shot('47-combine-selected');
|
||||
|
||||
await clickText('Subtract');
|
||||
await settle();
|
||||
const cut = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||||
const ring = cut?.[0]?.rings[0]?.points ?? [];
|
||||
const area = Math.abs(
|
||||
ring.reduce((sum, p, i) => {
|
||||
const q = ring[(i + 1) % ring.length];
|
||||
return sum + p.x * q.y - q.x * p.y;
|
||||
}, 0) / 2,
|
||||
);
|
||||
check(
|
||||
'Subtract leaves the lower square with the corner the upper one covered cut away',
|
||||
cut?.length === 1 &&
|
||||
cut[0].id === 'va-left' &&
|
||||
cut[0].fill === 0xcc3344 &&
|
||||
cut[0].rings.length === 1 &&
|
||||
ring.length === 6 &&
|
||||
near(area, 100 * 100 - 40 * 60) &&
|
||||
JSON.stringify(await selectedIds()) === '["va-left"]',
|
||||
JSON.stringify({ cut, area, selected: await selectedIds() }),
|
||||
);
|
||||
|
||||
// The tab paints the result: the fill where the lower square is left, none where it was cut away –
|
||||
// neither red nor the blue of the upper square, which lay there before and would pass for "not red".
|
||||
const keptPixel = await json(`__gm.pixelAt(${leftOnly.x}, ${leftOnly.y})`);
|
||||
const inCut = await screenAt(120, 130);
|
||||
const cutPixel = await json(`__gm.pixelAt(${inCut.x}, ${inCut.y})`);
|
||||
check(
|
||||
'the tab paints the cut: the fill remains beside it, and inside it the canvas is empty',
|
||||
colourDistance(keptPixel, '#cc3344') < 14 &&
|
||||
colourDistance(cutPixel, '#cc3344') > 60 &&
|
||||
colourDistance(cutPixel, '#3344cc') > 60,
|
||||
JSON.stringify({ keptPixel, cutPixel }),
|
||||
);
|
||||
await shot('47-combine-subtracted');
|
||||
|
||||
await undo();
|
||||
await settle();
|
||||
const restored = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||||
check(
|
||||
'one undo brings both squares back as they were',
|
||||
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 }))),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user