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

+39 -3
View File
@@ -37,8 +37,11 @@ export type Selection =
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] }
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
/** One shape of a vector art asset (the Vector art tab, #129). */
| { kind: 'shape'; assetId: string; shapeId: string }
/**
* One or more shapes of a vector art asset (the Vector art tab, #129). `shapeId` is the primary
* one, whose points and style are edited; `shapeIds` holds all of them for combining (#187).
*/
| { kind: 'shape'; assetId: string; shapeId: string; shapeIds: string[] }
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] }
/**
@@ -99,6 +102,12 @@ export function nodeSelection(
);
}
/** Builds a shape selection; the last id is the primary one unless given explicitly. */
export function shapeSelection(assetId: string, shapeIds: readonly string[], primary?: string): Selection {
const picked = pickIds(shapeIds, primary);
return picked && { kind: 'shape', assetId, shapeId: picked.primary, shapeIds: picked.ids };
}
/** Builds an annotation selection; the last id is the primary one unless given explicitly. */
export function annotationSelection(
assetId: string,
@@ -410,6 +419,9 @@ export class EditorState {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.holderId, [selection.objectId]);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'shape' && !selection.shapeIds)
selection = shapeSelection(selection.assetId, [selection.shapeId]);
// A graph selection carries two lists, and a short form may leave out either – `{ nodeId }`
// alone, or `{ nodeIds }` without the boxes. Filling both here keeps every reader of them
// from having to ask whether they are there.
@@ -447,6 +459,19 @@ export class EditorState {
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ctrl+click on a shape: in or out of the selection; emptied, the drawing itself stays selected. */
toggleShape(assetId: string, shapeId: string): void {
const s = this.selection;
const current = s?.kind === 'shape' && s.assetId === assetId ? s.shapeIds : [];
const held = current.includes(shapeId);
const next = held ? current.filter((id) => id !== shapeId) : [...current, shapeId];
this.select(shapeSelection(assetId, next, held ? undefined : shapeId) ?? { kind: 'asset', assetId });
}
get selectedShapeIds(): string[] {
return this.selection?.kind === 'shape' ? this.selection.shapeIds : [];
}
toggleNode(assetId: string, nodeId: string): void {
const s = this.selection;
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
@@ -520,6 +545,16 @@ export class EditorState {
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'shape') {
const asset = findAssetOf(this.project, s.assetId, 'vectorart');
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
if (!asset) this.select(null);
else {
const alive = s.shapeIds.filter((id) => asset.shapes.some((shape) => shape.id === id));
if (alive.length !== s.shapeIds.length)
this.select(shapeSelection(s.assetId, alive, s.shapeId) ?? { kind: 'asset', assetId: s.assetId });
}
}
if (s.kind === 'track') {
const asset = findAssetOf(this.project, s.assetId, 'animation');
if (!asset) this.select(null);
@@ -675,7 +710,8 @@ export function sameSelection(a: Selection, b: Selection): boolean {
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
if (a.kind === 'shape' && b.kind === 'shape') return a.assetId === b.assetId && a.shapeId === b.shapeId;
if (a.kind === 'shape' && b.kind === 'shape')
return a.assetId === b.assetId && a.shapeId === b.shapeId && sameIds(a.shapeIds, b.shapeIds);
if (a.kind === 'track' && b.kind === 'track')
return (
a.assetId === b.assetId &&