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
+39
-3
@@ -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 &&
|
||||
|
||||
Reference in new issue
Block a user