Vector shapes can be combined by union, subtract and more (#187)
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m51s

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:
adminandClaude Opus 5 committed 2026-09-21 11:56:41 +02:00
1 parent 3427f84a55
commit 1116818011
14 files changed
+796 -55

No files matched your search

+93 -1
View File
@@ -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 }))),
);
}