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

+83 -1
View File
@@ -12,7 +12,8 @@ import { newId } from '../ids';
import type { VectorRing, VectorShape, VectorStroke } from '../model/Asset';
import type { Project } from '../model/Project';
import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { nextNumberedName } from './naming';
import { requireVectorArt, requireVectorShape } from './require';
@@ -71,6 +72,87 @@ export class RemoveVectorShapeCommand implements Command {
}
}
/** Takes several shapes out in one step – Delete on a selection of more than one (#187). */
export class RemoveVectorShapesCommand implements Command {
readonly label = 'Remove shapes';
private removed: Removed<VectorShape>[] = [];
constructor(
private readonly assetId: string,
private readonly shapeIds: readonly string[],
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
this.removed = removeWhere(asset.shapes, (shape) => this.shapeIds.includes(shape.id));
if (this.removed.length === 0) throw new CommandError('No such shape');
}
undo(project: Project): void {
restoreAt(requireVectorArt(project, this.assetId).shapes, this.removed);
}
}
const COMBINE_LABELS: Record<ShapeBoolean, string> = {
union: 'Union shapes',
subtract: 'Subtract shapes',
intersect: 'Intersect shapes',
exclude: 'Exclude shapes',
};
const NOTHING_LEFT: Record<ShapeBoolean, string> = {
union: 'The shapes cover nothing',
subtract: 'Nothing is left – the shapes above cover the lowest one completely',
intersect: 'The shapes have nothing in common',
exclude: 'The shapes cancel each other out completely',
};
/**
* Combines shapes into one (#187). The result takes the place, name and look of the lowest of them –
* the one the others are cut out of when subtracting – and the others go. It is an ordinary shape of
* corners afterwards; an undo is the way back to the shapes it was made of.
*/
export class CombineVectorShapesCommand implements Command {
readonly label: string;
private removed: Removed<VectorShape>[] = [];
private previous: VectorRing[] = [];
private resultId = '';
constructor(
private readonly assetId: string,
private readonly shapeIds: readonly string[],
private readonly op: ShapeBoolean,
) {
this.label = COMBINE_LABELS[op];
}
/** The shape that holds the result: the lowest of the combined ones. Empty until executed. */
get shapeId(): string {
return this.resultId;
}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const shapes = asset.shapes.filter((shape) => this.shapeIds.includes(shape.id));
if (shapes.length !== new Set(this.shapeIds).size) throw new CommandError('No such shape');
const blocked = combineBlocker(shapes);
if (blocked) throw new CommandError(blocked);
const rings = combinedRings(shapes, this.op);
if (rings.length === 0) throw new CommandError(NOTHING_LEFT[this.op]);
const base = shapes[0]!;
this.resultId = base.id;
this.previous = base.rings;
base.rings = rings;
this.removed = removeWhere(asset.shapes, (shape) => shape !== base && this.shapeIds.includes(shape.id));
}
undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
restoreAt(asset.shapes, this.removed);
requireVectorShape(project, this.assetId, this.resultId).rings = this.previous;
}
}
export class RenameVectorShapeCommand implements Command {
readonly label = 'Rename shape';
private previous = '';
+150
View File
@@ -0,0 +1,150 @@
/**
* Boolean operations on vector art shapes (#187): union, subtract, intersect and exclude. The
* clipping itself is Clipper2 (`clipper2-ts`) – robust polygon clipping is a field of its own, and
* its even-odd fill rule is exactly how a shape's rings fill (#129).
*
* The work runs on the sampled outline: a curve is cut into straight pieces fine enough that the
* sampling stays under `FLATTEN_TOLERANCE`, and the result is corners only. The result is an ordinary
* shape again – no compound that remembers where it came from.
*
* Only the clipping engine is imported, not the package's `Clipper` namespace: that one pulls in
* offsetting, triangulation and Minkowski sums as well, twice the bytes in the bundle (measured:
* 101 kB against 50 kB minified).
*/
import { ClipType, FillRule, type PathD, type PathsD } from 'clipper2-ts/dist/Core.js';
import { ClipperD, type PolyPathD, PolyTreeD } from 'clipper2-ts/dist/Engine.js';
import type { VectorRing, VectorShape } from './Asset';
import type { Vec2 } from './Properties';
import { handleAt, isDrawableRing } from './vectorart';
export type ShapeBoolean = 'union' | 'subtract' | 'intersect' | 'exclude';
/** How far a sampled curve may stray from the true one, in canvas pixels. */
export const FLATTEN_TOLERANCE = 0.2;
const MAX_CURVE_STEPS = 100;
/** Decimal places Clipper keeps; a hundredth of a canvas pixel is finer than any supersampling. */
const PRECISION = 2;
/**
* Why the shapes cannot be combined, or null when they can. `shapes` is the selection; an open line
* has no inside to add or cut away, and a half-drawn one (too short to draw) is simply passed over.
*/
export function combineBlocker(shapes: readonly VectorShape[]): string | null {
if (shapes.length < 2) return 'Ctrl+click a second shape to combine.';
if (shapes.some((shape) => shape.rings.some((ring) => !ring.closed && isDrawableRing(ring))))
return 'An open line cannot be combined.';
return null;
}
/**
* The rings of the combined shape; `shapes` in drawing order, the first one being the lowest. Subtract
* cuts every later shape out of the first. Empty when nothing is left.
*
* The rings come out in the order the rasteriser needs: Pixi takes a ring for a hole of the outline
* drawn last when that one contains it, so every outline is followed by its own holes, and an
* island inside a hole comes before the outline around it – after it, it would count as a hole too.
*/
export function combinedRings(shapes: readonly VectorShape[], op: ShapeBoolean): VectorRing[] {
const regions = shapes.map(shapeRegion);
const [first = [], ...rest] = regions;
let result: PathsD;
if (op === 'union') result = unite(regions.flat(), FillRule.NonZero);
else if (op === 'subtract')
result = clip(ClipType.Difference, first, unite(rest.flat(), FillRule.NonZero), FillRule.NonZero);
else {
const type = op === 'intersect' ? ClipType.Intersection : ClipType.Xor;
result = rest.reduce((acc, next) => clip(type, acc, next, FillRule.NonZero), first);
}
const clipper = new ClipperD(PRECISION);
clipper.addSubjectPaths(result);
const tree = new PolyTreeD();
clipper.execute(ClipType.Union, FillRule.NonZero, tree);
const rings: VectorRing[] = [];
collectOutlines(tree, rings);
return rings;
}
/**
* Every outline under `node`, each followed by its holes. An outline goes in only after the islands
* inside its holes have gone in – that is the island-first order `combinedRings` promises.
*/
function collectOutlines(node: PolyPathD, into: VectorRing[]): void {
for (let i = 0; i < node.count; i++) {
const outline = node.child(i);
const rings = [toRing(outline.poly)];
for (let j = 0; j < outline.count; j++) {
const hole = outline.child(j);
rings.push(toRing(hole.poly));
collectOutlines(hole, into);
}
into.push(...rings.filter((ring) => ring.points.length >= 3));
}
}
function toRing(path: PathD | null): VectorRing {
return { closed: true, points: (path ?? []).map((p) => ({ x: round(p.x), y: round(p.y) })) };
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
/**
* What a shape covers, as Clipper paths: its closed rings under the even-odd rule the rasteriser
* uses, turned into outlines that do not overlap – so any number of shapes can then be put together
* under the non-zero rule without one shape's hole cancelling another's fill.
*/
function shapeRegion(shape: VectorShape): PathsD {
const rings = shape.rings.filter((ring) => ring.closed && isDrawableRing(ring)).map(flattenRing);
return unite(rings, FillRule.EvenOdd);
}
function unite(paths: PathsD, fillRule: FillRule): PathsD {
return clip(ClipType.Union, paths, [], fillRule);
}
function clip(type: ClipType, subject: PathsD, clipBy: PathsD, fillRule: FillRule): PathsD {
const clipper = new ClipperD(PRECISION);
clipper.addSubjectPaths(subject);
clipper.addClipPaths(clipBy);
const solution: PathsD = [];
clipper.execute(type, fillRule, solution);
return solution;
}
/** A closed ring as corners only: every curved segment cut into straight pieces. */
export function flattenRing(ring: VectorRing): Vec2[] {
const points = ring.points;
const out: Vec2[] = [];
for (const [i, from] of points.entries()) {
out.push({ x: from.x, y: from.y });
const to = points[(i + 1) % points.length]!;
if (!from.out && !to.in) continue;
const c1 = handleAt(from, 'out') ?? from;
const c2 = handleAt(to, 'in') ?? to;
const steps = curveSteps(from, c1, c2, to);
for (let step = 1; step < steps; step++) out.push(cubicAt(from, c1, c2, to, step / steps));
}
return out;
}
/**
* How many straight pieces keep a cubic within the tolerance – Wang's formula: the largest second
* difference of the control points bounds how far a chord can stray from the curve.
*/
function curveSteps(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): number {
const m = Math.max(
Math.hypot(p0.x - 2 * p1.x + p2.x, p0.y - 2 * p1.y + p2.y),
Math.hypot(p1.x - 2 * p2.x + p3.x, p1.y - 2 * p2.y + p3.y),
);
return Math.min(MAX_CURVE_STEPS, Math.max(1, Math.ceil(Math.sqrt((0.75 * m) / FLATTEN_TOLERANCE))));
}
function cubicAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
}
+1
View File
@@ -518,6 +518,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
}));
},
selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''),
selectedIds: () => state.selectedShapeIds,
/**
* The shape list in the inspector (#129, stage 4): the order it shows, top first, and where a
* row sits. A row carries a name that also stands in the tab, so a check cannot click it by text.
+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]));
},
});
}
+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 &&
+11 -6
View File
@@ -54,12 +54,16 @@ export class ShapeList extends Widget {
private drag: RowDrag | null = null;
private readonly stop: () => void;
/**
* `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with
* Ctrl or Shift, which adds the shape to the selection or takes it out (#187).
*/
constructor(
private readonly state: EditorState,
private readonly assetId: string,
shapeIds: readonly string[],
private selectedId: string,
private readonly onPick: (shapeId: string) => void,
private readonly selectedIds: readonly string[],
private readonly onPick: (shapeId: string, toggle: boolean) => void,
private readonly onReorder: (from: number, to: number) => void,
) {
super();
@@ -144,10 +148,11 @@ export class ShapeList extends Widget {
e.stopPropagation();
// Picked right away rather than on release: the rows below show what the selected shape is.
const shapeId = this.shown[index]!;
if (shapeId !== this.selectedId) {
this.selectedId = shapeId;
this.onPick(shapeId);
if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.onPick(shapeId, true);
return;
}
if (this.selectedIds.length !== 1 || this.selectedIds[0] !== shapeId) this.onPick(shapeId, false);
this.drag = { from: index, startY: y, y, active: false };
}
@@ -181,7 +186,7 @@ export class ShapeList extends Widget {
row.box.position.set(0, y);
row.box.visible = Boolean(shape);
if (!shape) continue;
const selected = shapeId === this.selectedId;
const selected = this.selectedIds.includes(shapeId);
if (selected || index === this.hoveredRow)
this.background
.rect(0, y, this.w, SHAPE_ROW_HEIGHT)
+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;