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