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
@@ -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.
|
||||
|
||||
@@ -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]));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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