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

+49 -13
View File
@@ -14,6 +14,7 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphi
import {
AddVectorShapeCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
SetVectorShapeRingsCommand,
} from '../../core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorPoint, VectorRing, VectorShape } from '../../core/model/Asset';
@@ -48,7 +49,7 @@ import {
withoutPoint,
withSmoothToggled,
} from '../polygonEdit';
import type { EditorState } from '../state';
import { type EditorState, shapeSelection } from '../state';
import { drawChecker } from '../texture/checker';
import { fitView, imageToLocal, localToImage, type SheetView, zoomAbout } from '../texture/sheetView';
@@ -242,12 +243,17 @@ export class VectorArtCanvas extends Widget {
return s?.kind === 'shape' && s.assetId === this.assetId ? s.shapeId : '';
}
private selectedShapeIds(): string[] {
const s = this.state.selection;
return s?.kind === 'shape' && s.assetId === this.assetId ? s.shapeIds : [];
}
private shape(shapeId: string): VectorShape | undefined {
const asset = this.asset();
return asset ? findShape(asset, shapeId) : undefined;
}
/** Delete takes the selected shape away – the canvas edits shapes, so that is what it removes. */
/** Delete takes the selected shapes away – the canvas edits shapes, so that is what it removes. */
override onKeyDown(event: KeyboardEvent): boolean {
if (event.key === 'Escape' && this.drawing) {
this.endPen();
@@ -255,10 +261,14 @@ export class VectorArtCanvas extends Widget {
return true;
}
if (event.key !== 'Delete' && event.key !== 'Backspace') return super.onKeyDown(event);
const shapeId = this.selectedShapeId();
if (!shapeId) return super.onKeyDown(event);
const shapeIds = this.selectedShapeIds();
if (shapeIds.length === 0) return super.onKeyDown(event);
this.endPen();
this.state.execute(new RemoveVectorShapeCommand(this.assetId, shapeId));
this.state.execute(
shapeIds.length === 1
? new RemoveVectorShapeCommand(this.assetId, shapeIds[0]!)
: new RemoveVectorShapesCommand(this.assetId, shapeIds),
);
this.state.select({ kind: 'asset', assetId: this.assetId });
return true;
}
@@ -292,7 +302,7 @@ export class VectorArtCanvas extends Widget {
if (!open) {
const command = new AddVectorShapeCommand(asset.id, [{ points: [point], closed: false }]);
if (!this.state.execute(command)) return;
this.state.select({ kind: 'shape', assetId: asset.id, shapeId: command.shapeId });
this.state.select(shapeSelection(asset.id, [command.shapeId]));
this.pen = { shapeId: command.shapeId, ring: 0 };
this.startPenDrag(command.shapeId, 0, 0, local, point);
return;
@@ -342,7 +352,8 @@ export class VectorArtCanvas extends Widget {
* A press with the arrow. On the selected shape first, so its points stay reachable under a shape
* drawn later: a double-click on a point takes it away, Alt makes it round or square again, a
* press on a point or a handle drags it, one on a line puts a point there. Inside a shape's fill
* the whole shape is dragged; on nothing the selection is cleared.
* the whole shape is dragged; on nothing the selection is cleared. Ctrl adds a shape to the
* selection or takes it out again, which is how shapes are picked for combining (#187).
*/
private pressSelect(
asset: VectorArtAsset,
@@ -351,6 +362,14 @@ export class VectorArtCanvas extends Widget {
tolerance: number,
e: FederatedPointerEvent,
): void {
if (e.ctrlKey || e.metaKey) {
const hit = this.shapeAt(asset, at);
if (hit) {
this.pick(null);
this.state.toggleShape(asset.id, hit.id);
}
return;
}
const selected = this.shape(this.selectedShapeId());
if (selected) {
for (const [ringIndex, ring] of selected.rings.entries()) {
@@ -360,13 +379,10 @@ export class VectorArtCanvas extends Widget {
return;
}
}
// Last first: the later shape is drawn on top, so that is what a click on an overlap means.
for (let i = asset.shapes.length - 1; i >= 0; i--) {
const shape = asset.shapes[i]!;
if (!shape.rings.some((ring) => insideRing(ring, at))) continue;
const shape = this.shapeAt(asset, at);
if (shape) {
this.pick(null);
if (shape.id !== selected?.id)
this.state.select({ kind: 'shape', assetId: asset.id, shapeId: shape.id });
this.state.select(shapeSelection(asset.id, [shape.id]));
this.drag = {
kind: 'shape',
shapeId: shape.id,
@@ -387,6 +403,15 @@ export class VectorArtCanvas extends Widget {
this.redraw();
}
/** The shape under a canvas pixel. Last first: a later shape is drawn on top of an earlier one. */
private shapeAt(asset: VectorArtAsset, at: Vec2): VectorShape | undefined {
for (let i = asset.shapes.length - 1; i >= 0; i--) {
const shape = asset.shapes[i]!;
if (shape.rings.some((ring) => insideRing(ring, at))) return shape;
}
return undefined;
}
private pressGrip(
asset: VectorArtAsset,
shape: VectorShape,
@@ -597,6 +622,8 @@ export class VectorArtCanvas extends Widget {
this.art.scale.set(this.view.zoom);
for (const drawing of vectorArtDrawings(asset)) paintDrawing(this.art, drawing);
this.drawFrame(origin, size);
for (const shapeId of this.selectedShapeIds())
if (shapeId !== this.selectedShapeId()) this.drawOutline(this.shape(shapeId));
this.drawShape(this.shape(this.selectedShapeId()));
}
this.syncHint(asset);
@@ -609,6 +636,15 @@ export class VectorArtCanvas extends Widget {
.stroke({ width: 1, color: Theme.colors.marquee });
}
/** A shape selected alongside the primary one: its outline only, its points are not editable. */
private drawOutline(shape: VectorShape | undefined): void {
for (const ring of shape?.rings ?? []) {
const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y));
if (screen.length >= 2)
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
}
}
/** The selected shape's points and handles, in widget pixels so they keep their size. */
private drawShape(shape: VectorShape | undefined): void {
if (!shape) return;