From 11168180119f5c895363ea418cbc6ce3c825941b Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 21 Sep 2026 11:56:41 +0200 Subject: [PATCH] Vector shapes can be combined by union, subtract and more (#187) 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 --- README.md | 2 +- package-lock.json | 10 ++ package.json | 1 + src/core/commands/vectorArtCommands.ts | 84 +++++++++- src/core/model/vectorBoolean.ts | 150 +++++++++++++++++ src/editor/debugHook.ts | 1 + src/editor/inspector/Inspector.ts | 4 +- src/editor/inspector/shapeSection.ts | 96 +++++++++-- src/editor/state.ts | 42 ++++- src/editor/vectorart/ShapeList.ts | 17 +- src/editor/vectorart/VectorArtCanvas.ts | 62 +++++-- tests/core/vector-boolean.test.ts | 207 ++++++++++++++++++++++++ tests/editor/shape-rows.test.ts | 81 ++++++++-- tools/e2e/47-vectorart.mjs | 94 ++++++++++- 14 files changed, 796 insertions(+), 55 deletions(-) create mode 100644 src/core/model/vectorBoolean.ts create mode 100644 tests/core/vector-boolean.test.ts diff --git a/README.md b/README.md index 9c599b45..6c4cd7fe 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1217 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1222 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/package-lock.json b/package-lock.json index 8d9bf398..8981a331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@dimforge/rapier2d-compat": "^0.20.0", + "clipper2-ts": "2.0.1", "pixi.js": "^8.6.0" }, "devDependencies": { @@ -1633,6 +1634,15 @@ "node": ">= 16" } }, + "node_modules/clipper2-ts": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clipper2-ts/-/clipper2-ts-2.0.1.tgz", + "integrity": "sha512-raIgNMpYN/PFYk/T9iRzaG99rf5nlXBWL1FyAdR3wjEYSzJy+pPfolLH5bCdRqCFQqn/eYUsF1jb3cQEMxmWGw==", + "license": "BSL-1.0", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index dc6dc1af..5456cfaf 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@dimforge/rapier2d-compat": "^0.20.0", + "clipper2-ts": "2.0.1", "pixi.js": "^8.6.0" } } diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index 612c38fb..cb3409da 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -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[] = []; + + 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 = { + union: 'Union shapes', + subtract: 'Subtract shapes', + intersect: 'Intersect shapes', + exclude: 'Exclude shapes', +}; + +const NOTHING_LEFT: Record = { + 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[] = []; + 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 = ''; diff --git a/src/core/model/vectorBoolean.ts b/src/core/model/vectorBoolean.ts new file mode 100644 index 00000000..5f58f66b --- /dev/null +++ b/src/core/model/vectorBoolean.ts @@ -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 }; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 153def6f..dd2c5c97 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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. diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index a9ba31d1..0c6c52c8 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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); diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index ee0ed4e9..746020bb 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -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])); }, }); } diff --git a/src/editor/state.ts b/src/editor/state.ts index 8b3bd887..b715a951 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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 && diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index a00f5681..54a92cc3 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -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) diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index f59208c2..38a2cecb 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -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; diff --git a/tests/core/vector-boolean.test.ts b/tests/core/vector-boolean.test.ts new file mode 100644 index 00000000..9fd5843b --- /dev/null +++ b/tests/core/vector-boolean.test.ts @@ -0,0 +1,207 @@ +/** + * Boolean operations on vector art shapes (#187). The areas are measured by sampling the result + * under the even-odd rule the rasteriser fills with, so a test sees what would be painted – not + * merely how many rings came out. + */ +import { Polygon } from 'pixi.js'; +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { + AddVectorShapeCommand, + CombineVectorShapesCommand, + RemoveVectorShapesCommand, + SetVectorShapeStyleCommand, +} from '../../src/core/commands/vectorArtCommands'; +import type { VectorArtAsset, VectorRing, VectorShape } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import { + combineBlocker, + combinedRings, + FLATTEN_TOLERANCE, + type ShapeBoolean, +} from '../../src/core/model/vectorBoolean'; +import { makeProject } from '../helpers'; + +function square(x: number, y: number, side: number): VectorRing { + return { + closed: true, + points: [ + { x, y }, + { x: x + side, y }, + { x: x + side, y: y + side }, + { x, y: y + side }, + ], + }; +} + +/** A circle of four symmetric points, the handles a third of a quarter arc long (kappa). */ +function circle(cx: number, cy: number, r: number): VectorRing { + const k = 0.5523 * r; + return { + closed: true, + points: [ + { x: cx + r, y: cy, in: { x: 0, y: -k }, out: { x: 0, y: k } }, + { x: cx, y: cy + r, in: { x: k, y: 0 }, out: { x: -k, y: 0 } }, + { x: cx - r, y: cy, in: { x: 0, y: k }, out: { x: 0, y: -k } }, + { x: cx, y: cy - r, in: { x: -k, y: 0 }, out: { x: k, y: 0 } }, + ], + }; +} + +function shape(id: string, ...rings: VectorRing[]): VectorShape { + return { id, name: id, rings, fill: 0x808080, opacity: 1 }; +} + +function inside(ring: VectorRing, x: number, y: number): boolean { + let hit = false; + const p = ring.points; + for (let i = 0, j = p.length - 1; i < p.length; j = i++) { + const a = p[i]!; + const b = p[j]!; + if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit; + } + return hit; +} + +/** The area painted under even-odd, counted at the centres of half-pixel cells. */ +function paintedArea(rings: readonly VectorRing[], from = -5, to = 60): number { + let cells = 0; + for (let x = from + 0.25; x < to; x += 0.5) + for (let y = from + 0.25; y < to; y += 0.5) + if (rings.filter((ring) => inside(ring, x, y)).length % 2 === 1) cells += 1; + return cells * 0.25; +} + +const A = () => shape('a', square(0, 0, 20)); +const B = () => shape('b', square(10, 10, 20)); + +describe('combining shapes', () => { + it.each<[ShapeBoolean, number]>([ + ['union', 700], + ['subtract', 300], + ['intersect', 100], + ['exclude', 600], + ])('%s of two overlapping squares paints %i square pixels', (op, area) => { + expect(paintedArea(combinedRings([A(), B()], op))).toBe(area); + }); + + it('subtracts every later shape from the first, whatever order they overlap in', () => { + const c = shape('c', square(0, 10, 5)); + // 400 − the 100 B takes − the 25 C takes: C and B do not overlap inside A. + expect(paintedArea(combinedRings([A(), B(), c], 'subtract'))).toBe(275); + }); + + it('reads each shape by the even-odd rule: an overlap of its own rings is a hole', () => { + const crossed = shape('x', square(0, 0, 20), square(10, 0, 20)); + // The two rings overlap in 10×20 – even-odd leaves that empty: 800 − 2·200 = 400. + const far = shape('f', square(40, 40, 10)); + expect(paintedArea(combinedRings([crossed, far], 'union'))).toBe(500); + }); + + it('puts an island in a hole before its outline, so Pixi does not take it for a hole', () => { + const donut = shape('d', square(0, 0, 50), square(10, 10, 30)); + const island = shape('i', square(20, 20, 10)); + const rings = combinedRings([donut, island], 'union'); + expect(paintedArea(rings)).toBe(2500 - 900 + 100); + // Pixi's own rule (ShapePath.addPath with checkForHoles): a polygon is a hole of the last + // outline when that one contains it. The island has to come out as an outline of its own. + let main: { poly: Polygon; holes: number } | null = null; + const outlines: { poly: Polygon; holes: number }[] = []; + for (const ring of rings) { + const poly = new Polygon(ring.points.flatMap((p) => [p.x, p.y])); + if (main && main.poly.containsPolygon(poly)) main.holes += 1; + else outlines.push((main = { poly, holes: 0 })); + } + expect(outlines.map((o) => o.holes).sort()).toEqual([0, 1]); + }); + + it('samples a curve within the tolerance and keeps its points few', () => { + const round = shape('r', circle(30, 30, 20)); + const far = shape('f', square(100, 100, 5)); + const rings = combinedRings([round, far], 'union'); + const disc = rings.find((ring) => ring.points.every((p) => p.x < 60))!; + const radii = disc.points.map((p) => Math.hypot(p.x - 30, p.y - 30)); + // Kappa bulges the true curve out by 0.03 % of r; rounding to a hundredth adds 0.01. + expect(Math.max(...radii)).toBeLessThan(20 + 0.02); + expect(Math.min(...radii)).toBeGreaterThan(20 - FLATTEN_TOLERANCE - 0.02); + expect(disc.points.length).toBeGreaterThan(16); + expect(disc.points.length).toBeLessThan(64); + expect(disc.points.every((p) => !p.in && !p.out)).toBe(true); + }); + + it('refuses one shape alone and an open line, and says why', () => { + expect(combineBlocker([A()])).toMatch(/second shape/); + const line = shape('l', { + closed: false, + points: [ + { x: 0, y: 0 }, + { x: 5, y: 5 }, + ], + }); + expect(combineBlocker([A(), line])).toMatch(/open line/); + // A line of one point is still being drawn and draws nothing – it is passed over. + const stub = shape('s', square(40, 0, 5), { closed: false, points: [{ x: 0, y: 0 }] }); + expect(combineBlocker([A(), stub])).toBeNull(); + }); +}); + +function drawingWith(...rings: VectorRing[]) { + const fixture = makeProject(); + const create = new CreateAssetCommand('vectorart', 'Logo'); + fixture.stack.execute(create); + const assetId = create.asset.id; + const ids = rings.map((ring) => { + const add = new AddVectorShapeCommand(assetId, [ring]); + fixture.stack.execute(add); + return add.shapeId; + }); + const art = () => findAsset(fixture.project, assetId) as VectorArtAsset; + return { ...fixture, assetId, ids, art }; +} + +describe('the combine command', () => { + it('writes the result into the lowest shape, keeps its look and place, and removes the rest', () => { + const f = drawingWith(square(40, 40, 10), square(0, 0, 20), square(10, 10, 20)); + const [first, low, high] = f.ids as [string, string, string]; + f.stack.execute( + new SetVectorShapeStyleCommand(f.assetId, low, { fill: 0xff0000, stroke: { color: 0, width: 2 } }), + ); + // Picked top first: the order of the selection does not decide, the drawing order does. + const combine = new CombineVectorShapesCommand(f.assetId, [high, low], 'subtract'); + f.stack.execute(combine); + expect(combine.shapeId).toBe(low); + expect(f.art().shapes.map((s) => s.id)).toEqual([first, low]); + const result = f.art().shapes[1]!; + expect(result).toMatchObject({ name: 'Shape 2', fill: 0xff0000, stroke: { color: 0, width: 2 } }); + expect(paintedArea(result.rings)).toBe(300); + }); + + it('is one undo step that puts every shape back as it was', () => { + const f = drawingWith(square(0, 0, 20), square(10, 10, 20), square(40, 40, 10)); + const before = JSON.stringify(f.art().shapes); + f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'union')); + expect(f.art().shapes).toHaveLength(1); + f.stack.undo(); + expect(JSON.stringify(f.art().shapes)).toBe(before); + f.stack.redo(); + expect(paintedArea(f.art().shapes[0]!.rings)).toBe(800); + }); + + it('refuses a result of nothing and leaves the drawing alone', () => { + const f = drawingWith(square(0, 0, 10), square(30, 30, 10)); + const before = JSON.stringify(f.art().shapes); + expect(() => f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'intersect'))).toThrow( + /nothing in common/, + ); + expect(JSON.stringify(f.art().shapes)).toBe(before); + }); + + it('removes several shapes in one step and brings them back in their places', () => { + const f = drawingWith(square(0, 0, 5), square(10, 0, 5), square(20, 0, 5)); + const before = f.art().shapes.map((s) => s.id); + f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [f.ids[0]!, f.ids[2]!])); + expect(f.art().shapes.map((s) => s.id)).toEqual([f.ids[1]]); + f.stack.undo(); + expect(f.art().shapes.map((s) => s.id)).toEqual(before); + }); +}); diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts index 3da0fcd6..315bba40 100644 --- a/tests/editor/shape-rows.test.ts +++ b/tests/editor/shape-rows.test.ts @@ -5,11 +5,12 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; import { CanvasTextMetrics } from 'pixi.js'; -import { AddVectorShapeCommand } from '../../src/core/commands/vectorArtCommands'; +import { AddVectorShapeCommand, RemoveVectorShapeCommand } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset } from '../../src/core/model/Asset'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { createEmptyProject, findAssetOf } from '../../src/core/model/Project'; import { + Button, Checkbox, ColorField, Label, @@ -27,7 +28,7 @@ import { shapeSignature, } from '../../src/editor/inspector/shapeSection'; import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows'; -import { EditorState } from '../../src/editor/state'; +import { EditorState, shapeSelection } from '../../src/editor/state'; const ROW = 30; @@ -124,14 +125,14 @@ describe('shape list order', () => { describe('shape rows', () => { it('shows fill, opacity and an outline switch, and no line rows without an outline', () => { const { host, assetId, ids } = withShapes(1); - buildShapeSection(host, assetId, ids[0]!); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect([...host.fields.keys()]).toEqual(['name', 'fill', 'opacity', 'outline']); expect(texts(host)).toContain('Without an outline only the fill is drawn.'); }); it('writes a fill as one undo step, a dragged colour previewing without one', () => { const { host, assetId, ids } = withShapes(1); - buildShapeSection(host, assetId, ids[0]!); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); const before = shapeOf(host, assetId).fill; const fill = widgets(host, ColorField)[0]!; fill.onInput!(0xff0000); @@ -144,7 +145,7 @@ describe('shape rows', () => { it('writes the opacity, clamped to what an opacity can be', () => { const { host, assetId, ids } = withShapes(1); - buildShapeSection(host, assetId, ids[0]!); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); // A NumberField reads text: its own `onChange` is the one a typed value goes through. const opacity = widgets(host, NumberField)[0]!; opacity.onChange!('0.4'); @@ -155,7 +156,7 @@ describe('shape rows', () => { it('the outline switch adds a line and takes it away again', () => { const { host, assetId, ids } = withShapes(1); - buildShapeSection(host, assetId, ids[0]!); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); widgets(host, Checkbox)[0]!.toggle(); expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x000000, width: 2 }); host.state.undo(); @@ -165,7 +166,7 @@ describe('shape rows', () => { it('shows the line rows once there is a line, and a width of nothing is not one', () => { const { host, assetId, ids } = withShapes(1); shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 }; - buildShapeSection(host, assetId, ids[0]!); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect([...host.fields.keys()]).toEqual([ 'name', 'fill', @@ -184,25 +185,25 @@ describe('shape rows', () => { it('names the shapes and the outline in its signature, so both rebuild the rows', () => { const { host, assetId, ids } = withShapes(2); - const before = shapeSignature(host.state.project, assetId, ids[0]!); + const before = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]); shapeOf(host, assetId).name = 'Hat'; - const renamed = shapeSignature(host.state.project, assetId, ids[0]!); + const renamed = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]); expect(renamed).not.toBe(before); shapeOf(host, assetId).stroke = { color: 0, width: 1 }; - expect(shapeSignature(host.state.project, assetId, ids[0]!)).not.toBe(renamed); + expect(shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!])).not.toBe(renamed); }); it('a shape that is gone asks for a rebuild instead of refreshing', () => { const { host, assetId, ids } = withShapes(1); - buildShapeSection(host, assetId, ids[0]!); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true); expect(refreshShapeSection(host, assetId, 'gone')).toBe(false); - expect(shapeSignature(host.state.project, assetId, 'gone')).toBe('missing'); + expect(shapeSignature(host.state.project, assetId, 'gone', ['gone'])).toBe('missing'); }); it('refreshing pushes the model values back into the rows', () => { const { host, assetId, ids } = withShapes(1); - buildShapeSection(host, assetId, ids[0]!); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); const shape = shapeOf(host, assetId); shape.fill = 0x123456; shape.name = 'Cloud'; @@ -226,3 +227,57 @@ describe('shape rows', () => { expect(texts(host).some((t) => t.startsWith('Nothing drawn yet'))).toBe(true); }); }); + +describe('combining from the inspector (#187)', () => { + const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!; + + it('Ctrl+click adds a shape and takes it out again; emptied, the drawing stays selected', () => { + const { host, assetId, ids } = withShapes(2); + host.state.select(shapeSelection(assetId, [ids[0]!])); + host.state.toggleShape(assetId, ids[1]!); + expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[0], ids[1]] }); + host.state.toggleShape(assetId, ids[1]!); + host.state.toggleShape(assetId, ids[0]!); + expect(host.state.selection).toEqual({ kind: 'asset', assetId }); + }); + + it('switches the four buttons off for one shape and says how to get a second', () => { + const { host, assetId, ids } = withShapes(2); + buildShapeSection(host, assetId, ids[0]!, [ids[0]!]); + expect(['Union', 'Subtract', 'Intersect', 'Exclude'].map((t) => button(host, t).eventMode)).toEqual([ + 'none', + 'none', + 'none', + 'none', + ]); + expect(texts(host)).toContain('Ctrl+click a second shape to combine.'); + }); + + it('combines the selected shapes into the lowest one and selects it', () => { + const { host, assetId, ids } = withShapes(3); + host.state.select(shapeSelection(assetId, [ids[2]!, ids[1]!])); + buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]); + expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.'); + button(host, 'Union').onClick!(); + expect(findAssetOf(host.state.project, assetId, 'vectorart')!.shapes.map((s) => s.id)).toEqual([ + ids[0], + ids[1], + ]); + expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[1]] }); + }); + + it('drops a selected shape that is gone and keeps the rest', () => { + const { host, assetId, ids } = withShapes(3); + host.state.select(shapeSelection(assetId, [ids[0]!, ids[2]!])); + host.state.execute(new RemoveVectorShapeCommand(assetId, ids[2]!)); + expect(host.state.selection).toMatchObject({ shapeId: ids[0], shapeIds: [ids[0]] }); + }); + + it('names the selected shapes in the signature, so a Ctrl+click rebuilds the rows', () => { + const { host, assetId, ids } = withShapes(2); + const project = host.state.project; + expect(shapeSignature(project, assetId, ids[0]!, [ids[0]!])).not.toBe( + shapeSignature(project, assetId, ids[0]!, [ids[1]!, ids[0]!]), + ); + }); +}); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 8f435a3a..8f917dd5 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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 }))), + ); }