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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3427f84a55
commit
1116818011
14 files changed
+796
-55
No files matched your search
@@ -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 = '';
|
||||
|
||||
Reference in new issue
Block a user