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

+2 -2
View File
@@ -98,7 +98,7 @@ export class Inspector extends Panel {
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId);
else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds);
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
@@ -112,7 +112,7 @@ export class Inspector extends Panel {
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId);
if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
+81 -15
View File
@@ -10,8 +10,10 @@
*/
import {
AddVectorShapeCommand,
CombineVectorShapesCommand,
MoveVectorShapeCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
RenameVectorShapeCommand,
SetVectorShapeStyleCommand,
} from '../../core/commands/vectorArtCommands';
@@ -24,8 +26,10 @@ import {
type ShapeOrderMove,
shapeOrderTarget,
} from '../../core/model/vectorart';
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, Theme } from '../../ui';
import { shapeSelection } from '../state';
import { ShapeList } from '../vectorart/ShapeList';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
@@ -48,6 +52,13 @@ const ORDER_BUTTONS: { move: ShapeOrderMove; label: string }[] = [
{ move: 'back', label: 'To back' },
];
const COMBINE_BUTTONS: { op: ShapeBoolean; label: string }[] = [
{ op: 'union', label: 'Union' },
{ op: 'subtract', label: 'Subtract' },
{ op: 'intersect', label: 'Intersect' },
{ op: 'exclude', label: 'Exclude' },
];
const FILL_DEF: PropertyDef = { key: 'fill', label: 'Fill', kind: 'color', default: SHAPE_FILL_DEFAULT };
const OPACITY_DEF: PropertyDef = {
key: 'opacity',
@@ -90,33 +101,53 @@ export function shapeListSignature(asset: VectorArtAsset): string {
}
/**
* Structure-defining facts of a shape selection: the list above it, and whether the shape has an
* outline – its colour and width are rows of their own and appear with it.
* Structure-defining facts of a shape selection: the list above it, whether the shape has an
* outline – its colour and width are rows of their own and appear with it – and whether the
* selected shapes can be combined, which switches the four buttons and the note under them.
*/
export function shapeSignature(project: Project, assetId: string, shapeId: string): string {
export function shapeSignature(
project: Project,
assetId: string,
shapeId: string,
shapeIds: readonly string[],
): string {
const asset = findAssetOf(project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return 'missing';
return `${shapeListSignature(asset)}|${shape.stroke ? 'stroke' : 'none'}`;
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
return `${shapeListSignature(asset)}|${shape.stroke ? 'stroke' : 'none'}|${shapeIds.join(',')}|${blocked}`;
}
/** The selected shapes in drawing order, the lowest first – the order they are combined in. */
function selectedShapes(asset: VectorArtAsset, shapeIds: readonly string[]): VectorShape[] {
return asset.shapes.filter((shape) => shapeIds.includes(shape.id));
}
/** The shapes of the asset, with nothing chosen: what the asset's own section shows underneath. */
export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void {
addTitle(host, 'SHAPES');
addShapeList(host, asset, '');
addShapeList(host, asset, []);
addNote(host, 'The top row is drawn over the ones below it.', 'small');
addGap(host);
host.body.add(addShapeButton(host, asset.id));
}
/** Name, place in the drawing order and style of one shape, under the list it is picked from. */
export function buildShapeSection(host: InspectorHost, assetId: string, shapeId: string): void {
/**
* Name, place in the drawing order and style of the primary shape, under the list it is picked
* from, and the buttons that combine all selected shapes into one (#187).
*/
export function buildShapeSection(
host: InspectorHost,
assetId: string,
shapeId: string,
shapeIds: readonly string[],
): void {
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return;
addTitle(host, 'SHAPES');
addShapeList(host, asset, shapeId);
addShapeList(host, asset, shapeIds);
addNote(host, 'Drag a row to draw it earlier or later.', 'small');
// Two rows of two: four of them side by side cut "Backward" down to "Backw…" at the panel's width.
@@ -134,6 +165,8 @@ export function buildShapeSection(host: InspectorHost, assetId: string, shapeId:
host.body.add(order);
}
addCombineButtons(host, asset, shapeIds);
addTitle(host, 'VECTOR SHAPE');
addShapeName(host, assetId, shape);
styleRow(
@@ -177,11 +210,16 @@ export function buildShapeSection(host: InspectorHost, assetId: string, shapeId:
} else addNote(host, 'Without an outline only the fill is drawn.', 'small');
addGap(host);
const several = shapeIds.length > 1;
host.body.add(
new Button('Delete shape', {
new Button(several ? 'Delete shapes' : 'Delete shape', {
variant: 'danger',
onClick: () => {
host.state.execute(new RemoveVectorShapeCommand(assetId, shapeId));
host.state.execute(
several
? new RemoveVectorShapesCommand(assetId, shapeIds)
: new RemoveVectorShapeCommand(assetId, shapeId),
);
host.state.select({ kind: 'asset', assetId });
},
}),
@@ -189,6 +227,32 @@ export function buildShapeSection(host: InspectorHost, assetId: string, shapeId:
host.body.add(addShapeButton(host, assetId));
}
/**
* Union, subtract, intersect, exclude – two rows of two, like the order buttons. They are there with
* one shape selected too, switched off, so the note under them can say how to get a second one.
*/
function addCombineButtons(host: InspectorHost, asset: VectorArtAsset, shapeIds: readonly string[]): void {
const shapes = selectedShapes(asset, shapeIds);
const blocked = combineBlocker(shapes);
addTitle(host, 'COMBINE');
for (let start = 0; start < COMBINE_BUTTONS.length; start += 2) {
const row = new HStack({ gap: Theme.gap });
for (const { op, label } of COMBINE_BUTTONS.slice(start, start + 2)) {
const button = new Button(label, {
variant: 'ghost',
onClick: () => {
const command = new CombineVectorShapesCommand(asset.id, shapeIds, op);
if (host.state.execute(command)) host.state.select(shapeSelection(asset.id, [command.shapeId]));
},
});
button.setEnabled(blocked === null);
row.add(button, 1);
}
host.body.add(row);
}
addNote(host, blocked ?? `The result keeps "${shapes[0]?.name ?? ''}", the lowest.`, 'small');
}
/** Pushes the shape's current name and style into the rows. False when it is gone (rebuild). */
export function refreshShapeSection(host: InspectorHost, assetId: string, shapeId: string): boolean {
const shape = currentShape(host, assetId, shapeId);
@@ -202,7 +266,7 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
return true;
}
function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedId: string): void {
function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: readonly string[]): void {
if (asset.shapes.length === 0) {
built = null;
addNote(host, 'Nothing drawn yet – take the Pen in the Vector art tab.');
@@ -212,8 +276,11 @@ function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedId: st
host.state,
asset.id,
asset.shapes.map((shape) => shape.id),
selectedId,
(shapeId) => host.state.select({ kind: 'shape', assetId: asset.id, shapeId }),
selectedIds,
(shapeId, toggle) =>
toggle
? host.state.toggleShape(asset.id, shapeId)
: host.state.select(shapeSelection(asset.id, [shapeId])),
(from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
);
built = list;
@@ -224,8 +291,7 @@ function addShapeButton(host: InspectorHost, assetId: string): Button {
return new Button('Add shape', {
onClick: () => {
const command = new AddVectorShapeCommand(assetId);
if (host.state.execute(command))
host.state.select({ kind: 'shape', assetId, shapeId: command.shapeId });
if (host.state.execute(command)) host.state.select(shapeSelection(assetId, [command.shapeId]));
},
});
}