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
@@ -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);
|
||||
|
||||
@@ -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]));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user