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
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1217 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1222 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
Generated
+10
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier2d-compat": "^0.20.0",
|
"@dimforge/rapier2d-compat": "^0.20.0",
|
||||||
|
"clipper2-ts": "2.0.1",
|
||||||
"pixi.js": "^8.6.0"
|
"pixi.js": "^8.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1633,6 +1634,15 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clipper2-ts": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clipper2-ts/-/clipper2-ts-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-raIgNMpYN/PFYk/T9iRzaG99rf5nlXBWL1FyAdR3wjEYSzJy+pPfolLH5bCdRqCFQqn/eYUsF1jb3cQEMxmWGw==",
|
||||||
|
"license": "BSL-1.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier2d-compat": "^0.20.0",
|
"@dimforge/rapier2d-compat": "^0.20.0",
|
||||||
|
"clipper2-ts": "2.0.1",
|
||||||
"pixi.js": "^8.6.0"
|
"pixi.js": "^8.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,8 @@ import { newId } from '../ids';
|
|||||||
import type { VectorRing, VectorShape, VectorStroke } from '../model/Asset';
|
import type { VectorRing, VectorShape, VectorStroke } from '../model/Asset';
|
||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
|
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 { type Command, CommandError } from './Command';
|
||||||
import { nextNumberedName } from './naming';
|
import { nextNumberedName } from './naming';
|
||||||
import { requireVectorArt, requireVectorShape } from './require';
|
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 {
|
export class RenameVectorShapeCommand implements Command {
|
||||||
readonly label = 'Rename shape';
|
readonly label = 'Rename shape';
|
||||||
private previous = '';
|
private previous = '';
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Boolean operations on vector art shapes (#187): union, subtract, intersect and exclude. The
|
||||||
|
* clipping itself is Clipper2 (`clipper2-ts`) – robust polygon clipping is a field of its own, and
|
||||||
|
* its even-odd fill rule is exactly how a shape's rings fill (#129).
|
||||||
|
*
|
||||||
|
* The work runs on the sampled outline: a curve is cut into straight pieces fine enough that the
|
||||||
|
* sampling stays under `FLATTEN_TOLERANCE`, and the result is corners only. The result is an ordinary
|
||||||
|
* shape again – no compound that remembers where it came from.
|
||||||
|
*
|
||||||
|
* Only the clipping engine is imported, not the package's `Clipper` namespace: that one pulls in
|
||||||
|
* offsetting, triangulation and Minkowski sums as well, twice the bytes in the bundle (measured:
|
||||||
|
* 101 kB against 50 kB minified).
|
||||||
|
*/
|
||||||
|
import { ClipType, FillRule, type PathD, type PathsD } from 'clipper2-ts/dist/Core.js';
|
||||||
|
import { ClipperD, type PolyPathD, PolyTreeD } from 'clipper2-ts/dist/Engine.js';
|
||||||
|
import type { VectorRing, VectorShape } from './Asset';
|
||||||
|
import type { Vec2 } from './Properties';
|
||||||
|
import { handleAt, isDrawableRing } from './vectorart';
|
||||||
|
|
||||||
|
export type ShapeBoolean = 'union' | 'subtract' | 'intersect' | 'exclude';
|
||||||
|
|
||||||
|
/** How far a sampled curve may stray from the true one, in canvas pixels. */
|
||||||
|
export const FLATTEN_TOLERANCE = 0.2;
|
||||||
|
const MAX_CURVE_STEPS = 100;
|
||||||
|
/** Decimal places Clipper keeps; a hundredth of a canvas pixel is finer than any supersampling. */
|
||||||
|
const PRECISION = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the shapes cannot be combined, or null when they can. `shapes` is the selection; an open line
|
||||||
|
* has no inside to add or cut away, and a half-drawn one (too short to draw) is simply passed over.
|
||||||
|
*/
|
||||||
|
export function combineBlocker(shapes: readonly VectorShape[]): string | null {
|
||||||
|
if (shapes.length < 2) return 'Ctrl+click a second shape to combine.';
|
||||||
|
if (shapes.some((shape) => shape.rings.some((ring) => !ring.closed && isDrawableRing(ring))))
|
||||||
|
return 'An open line cannot be combined.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rings of the combined shape; `shapes` in drawing order, the first one being the lowest. Subtract
|
||||||
|
* cuts every later shape out of the first. Empty when nothing is left.
|
||||||
|
*
|
||||||
|
* The rings come out in the order the rasteriser needs: Pixi takes a ring for a hole of the outline
|
||||||
|
* drawn last when that one contains it, so every outline is followed by its own holes, and an
|
||||||
|
* island inside a hole comes before the outline around it – after it, it would count as a hole too.
|
||||||
|
*/
|
||||||
|
export function combinedRings(shapes: readonly VectorShape[], op: ShapeBoolean): VectorRing[] {
|
||||||
|
const regions = shapes.map(shapeRegion);
|
||||||
|
const [first = [], ...rest] = regions;
|
||||||
|
let result: PathsD;
|
||||||
|
if (op === 'union') result = unite(regions.flat(), FillRule.NonZero);
|
||||||
|
else if (op === 'subtract')
|
||||||
|
result = clip(ClipType.Difference, first, unite(rest.flat(), FillRule.NonZero), FillRule.NonZero);
|
||||||
|
else {
|
||||||
|
const type = op === 'intersect' ? ClipType.Intersection : ClipType.Xor;
|
||||||
|
result = rest.reduce((acc, next) => clip(type, acc, next, FillRule.NonZero), first);
|
||||||
|
}
|
||||||
|
const clipper = new ClipperD(PRECISION);
|
||||||
|
clipper.addSubjectPaths(result);
|
||||||
|
const tree = new PolyTreeD();
|
||||||
|
clipper.execute(ClipType.Union, FillRule.NonZero, tree);
|
||||||
|
const rings: VectorRing[] = [];
|
||||||
|
collectOutlines(tree, rings);
|
||||||
|
return rings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every outline under `node`, each followed by its holes. An outline goes in only after the islands
|
||||||
|
* inside its holes have gone in – that is the island-first order `combinedRings` promises.
|
||||||
|
*/
|
||||||
|
function collectOutlines(node: PolyPathD, into: VectorRing[]): void {
|
||||||
|
for (let i = 0; i < node.count; i++) {
|
||||||
|
const outline = node.child(i);
|
||||||
|
const rings = [toRing(outline.poly)];
|
||||||
|
for (let j = 0; j < outline.count; j++) {
|
||||||
|
const hole = outline.child(j);
|
||||||
|
rings.push(toRing(hole.poly));
|
||||||
|
collectOutlines(hole, into);
|
||||||
|
}
|
||||||
|
into.push(...rings.filter((ring) => ring.points.length >= 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRing(path: PathD | null): VectorRing {
|
||||||
|
return { closed: true, points: (path ?? []).map((p) => ({ x: round(p.x), y: round(p.y) })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a shape covers, as Clipper paths: its closed rings under the even-odd rule the rasteriser
|
||||||
|
* uses, turned into outlines that do not overlap – so any number of shapes can then be put together
|
||||||
|
* under the non-zero rule without one shape's hole cancelling another's fill.
|
||||||
|
*/
|
||||||
|
function shapeRegion(shape: VectorShape): PathsD {
|
||||||
|
const rings = shape.rings.filter((ring) => ring.closed && isDrawableRing(ring)).map(flattenRing);
|
||||||
|
return unite(rings, FillRule.EvenOdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unite(paths: PathsD, fillRule: FillRule): PathsD {
|
||||||
|
return clip(ClipType.Union, paths, [], fillRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clip(type: ClipType, subject: PathsD, clipBy: PathsD, fillRule: FillRule): PathsD {
|
||||||
|
const clipper = new ClipperD(PRECISION);
|
||||||
|
clipper.addSubjectPaths(subject);
|
||||||
|
clipper.addClipPaths(clipBy);
|
||||||
|
const solution: PathsD = [];
|
||||||
|
clipper.execute(type, fillRule, solution);
|
||||||
|
return solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A closed ring as corners only: every curved segment cut into straight pieces. */
|
||||||
|
export function flattenRing(ring: VectorRing): Vec2[] {
|
||||||
|
const points = ring.points;
|
||||||
|
const out: Vec2[] = [];
|
||||||
|
for (const [i, from] of points.entries()) {
|
||||||
|
out.push({ x: from.x, y: from.y });
|
||||||
|
const to = points[(i + 1) % points.length]!;
|
||||||
|
if (!from.out && !to.in) continue;
|
||||||
|
const c1 = handleAt(from, 'out') ?? from;
|
||||||
|
const c2 = handleAt(to, 'in') ?? to;
|
||||||
|
const steps = curveSteps(from, c1, c2, to);
|
||||||
|
for (let step = 1; step < steps; step++) out.push(cubicAt(from, c1, c2, to, step / steps));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many straight pieces keep a cubic within the tolerance – Wang's formula: the largest second
|
||||||
|
* difference of the control points bounds how far a chord can stray from the curve.
|
||||||
|
*/
|
||||||
|
function curveSteps(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): number {
|
||||||
|
const m = Math.max(
|
||||||
|
Math.hypot(p0.x - 2 * p1.x + p2.x, p0.y - 2 * p1.y + p2.y),
|
||||||
|
Math.hypot(p1.x - 2 * p2.x + p3.x, p1.y - 2 * p2.y + p3.y),
|
||||||
|
);
|
||||||
|
return Math.min(MAX_CURVE_STEPS, Math.max(1, Math.ceil(Math.sqrt((0.75 * m) / FLATTEN_TOLERANCE))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cubicAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 {
|
||||||
|
const u = 1 - t;
|
||||||
|
const a = u * u * u;
|
||||||
|
const b = 3 * u * u * t;
|
||||||
|
const c = 3 * u * t * t;
|
||||||
|
const d = t * t * t;
|
||||||
|
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
|
||||||
|
}
|
||||||
@@ -518,6 +518,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''),
|
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
|
* 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.
|
* 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 === 'asset') buildAssetSection(this.host, s.assetId);
|
||||||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
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 === '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 === '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.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
|
||||||
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
|
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 === 'asset') return assetSignature(this.state.project, s.assetId);
|
||||||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
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 === '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 === '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);
|
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);
|
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
AddVectorShapeCommand,
|
AddVectorShapeCommand,
|
||||||
|
CombineVectorShapesCommand,
|
||||||
MoveVectorShapeCommand,
|
MoveVectorShapeCommand,
|
||||||
RemoveVectorShapeCommand,
|
RemoveVectorShapeCommand,
|
||||||
|
RemoveVectorShapesCommand,
|
||||||
RenameVectorShapeCommand,
|
RenameVectorShapeCommand,
|
||||||
SetVectorShapeStyleCommand,
|
SetVectorShapeStyleCommand,
|
||||||
} from '../../core/commands/vectorArtCommands';
|
} from '../../core/commands/vectorArtCommands';
|
||||||
@@ -24,8 +26,10 @@ import {
|
|||||||
type ShapeOrderMove,
|
type ShapeOrderMove,
|
||||||
shapeOrderTarget,
|
shapeOrderTarget,
|
||||||
} from '../../core/model/vectorart';
|
} from '../../core/model/vectorart';
|
||||||
|
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
|
||||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||||
import { Button, HStack, Theme } from '../../ui';
|
import { Button, HStack, Theme } from '../../ui';
|
||||||
|
import { shapeSelection } from '../state';
|
||||||
import { ShapeList } from '../vectorart/ShapeList';
|
import { ShapeList } from '../vectorart/ShapeList';
|
||||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||||
@@ -48,6 +52,13 @@ const ORDER_BUTTONS: { move: ShapeOrderMove; label: string }[] = [
|
|||||||
{ move: 'back', label: 'To back' },
|
{ 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 FILL_DEF: PropertyDef = { key: 'fill', label: 'Fill', kind: 'color', default: SHAPE_FILL_DEFAULT };
|
||||||
const OPACITY_DEF: PropertyDef = {
|
const OPACITY_DEF: PropertyDef = {
|
||||||
key: 'opacity',
|
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
|
* 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.
|
* 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 asset = findAssetOf(project, assetId, 'vectorart');
|
||||||
const shape = asset && findShape(asset, shapeId);
|
const shape = asset && findShape(asset, shapeId);
|
||||||
if (!asset || !shape) return 'missing';
|
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. */
|
/** The shapes of the asset, with nothing chosen: what the asset's own section shows underneath. */
|
||||||
export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void {
|
export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void {
|
||||||
addTitle(host, 'SHAPES');
|
addTitle(host, 'SHAPES');
|
||||||
addShapeList(host, asset, '');
|
addShapeList(host, asset, []);
|
||||||
addNote(host, 'The top row is drawn over the ones below it.', 'small');
|
addNote(host, 'The top row is drawn over the ones below it.', 'small');
|
||||||
addGap(host);
|
addGap(host);
|
||||||
host.body.add(addShapeButton(host, asset.id));
|
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 asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||||
const shape = asset && findShape(asset, shapeId);
|
const shape = asset && findShape(asset, shapeId);
|
||||||
if (!asset || !shape) return;
|
if (!asset || !shape) return;
|
||||||
|
|
||||||
addTitle(host, 'SHAPES');
|
addTitle(host, 'SHAPES');
|
||||||
addShapeList(host, asset, shapeId);
|
addShapeList(host, asset, shapeIds);
|
||||||
addNote(host, 'Drag a row to draw it earlier or later.', 'small');
|
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.
|
// 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);
|
host.body.add(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addCombineButtons(host, asset, shapeIds);
|
||||||
|
|
||||||
addTitle(host, 'VECTOR SHAPE');
|
addTitle(host, 'VECTOR SHAPE');
|
||||||
addShapeName(host, assetId, shape);
|
addShapeName(host, assetId, shape);
|
||||||
styleRow(
|
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');
|
} else addNote(host, 'Without an outline only the fill is drawn.', 'small');
|
||||||
|
|
||||||
addGap(host);
|
addGap(host);
|
||||||
|
const several = shapeIds.length > 1;
|
||||||
host.body.add(
|
host.body.add(
|
||||||
new Button('Delete shape', {
|
new Button(several ? 'Delete shapes' : 'Delete shape', {
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
onClick: () => {
|
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 });
|
host.state.select({ kind: 'asset', assetId });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -189,6 +227,32 @@ export function buildShapeSection(host: InspectorHost, assetId: string, shapeId:
|
|||||||
host.body.add(addShapeButton(host, assetId));
|
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). */
|
/** 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 {
|
export function refreshShapeSection(host: InspectorHost, assetId: string, shapeId: string): boolean {
|
||||||
const shape = currentShape(host, assetId, shapeId);
|
const shape = currentShape(host, assetId, shapeId);
|
||||||
@@ -202,7 +266,7 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
|
|||||||
return true;
|
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) {
|
if (asset.shapes.length === 0) {
|
||||||
built = null;
|
built = null;
|
||||||
addNote(host, 'Nothing drawn yet – take the Pen in the Vector art tab.');
|
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,
|
host.state,
|
||||||
asset.id,
|
asset.id,
|
||||||
asset.shapes.map((shape) => shape.id),
|
asset.shapes.map((shape) => shape.id),
|
||||||
selectedId,
|
selectedIds,
|
||||||
(shapeId) => host.state.select({ kind: 'shape', assetId: asset.id, shapeId }),
|
(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)),
|
(from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
|
||||||
);
|
);
|
||||||
built = list;
|
built = list;
|
||||||
@@ -224,8 +291,7 @@ function addShapeButton(host: InspectorHost, assetId: string): Button {
|
|||||||
return new Button('Add shape', {
|
return new Button('Add shape', {
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const command = new AddVectorShapeCommand(assetId);
|
const command = new AddVectorShapeCommand(assetId);
|
||||||
if (host.state.execute(command))
|
if (host.state.execute(command)) host.state.select(shapeSelection(assetId, [command.shapeId]));
|
||||||
host.state.select({ kind: 'shape', assetId, shapeId: command.shapeId });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-3
@@ -37,8 +37,11 @@ export type Selection =
|
|||||||
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] }
|
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] }
|
||||||
/** A frame rectangle inside a texture asset (the Texture tab). */
|
/** A frame rectangle inside a texture asset (the Texture tab). */
|
||||||
| { kind: 'frame'; assetId: string; frameId: string }
|
| { 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. */
|
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
|
||||||
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] }
|
| { 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. */
|
/** Builds an annotation selection; the last id is the primary one unless given explicitly. */
|
||||||
export function annotationSelection(
|
export function annotationSelection(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
@@ -410,6 +419,9 @@ export class EditorState {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (selection?.kind === 'object' && !selection.objectIds)
|
if (selection?.kind === 'object' && !selection.objectIds)
|
||||||
selection = objectSelection(selection.holderId, [selection.objectId]);
|
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 }`
|
// 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
|
// alone, or `{ nodeIds }` without the boxes. Filling both here keeps every reader of them
|
||||||
// from having to ask whether they are there.
|
// 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));
|
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 {
|
toggleNode(assetId: string, nodeId: string): void {
|
||||||
const s = this.selection;
|
const s = this.selection;
|
||||||
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
|
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))
|
else if (!asset.frames.some((f) => f.id === s.frameId))
|
||||||
this.select({ kind: 'asset', assetId: s.assetId });
|
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') {
|
if (s.kind === 'track') {
|
||||||
const asset = findAssetOf(this.project, s.assetId, 'animation');
|
const asset = findAssetOf(this.project, s.assetId, 'animation');
|
||||||
if (!asset) this.select(null);
|
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 === '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')
|
if (a.kind === 'track' && b.kind === 'track')
|
||||||
return (
|
return (
|
||||||
a.assetId === b.assetId &&
|
a.assetId === b.assetId &&
|
||||||
|
|||||||
@@ -54,12 +54,16 @@ export class ShapeList extends Widget {
|
|||||||
private drag: RowDrag | null = null;
|
private drag: RowDrag | null = null;
|
||||||
private readonly stop: () => void;
|
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(
|
constructor(
|
||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
shapeIds: readonly string[],
|
shapeIds: readonly string[],
|
||||||
private selectedId: string,
|
private readonly selectedIds: readonly string[],
|
||||||
private readonly onPick: (shapeId: string) => void,
|
private readonly onPick: (shapeId: string, toggle: boolean) => void,
|
||||||
private readonly onReorder: (from: number, to: number) => void,
|
private readonly onReorder: (from: number, to: number) => void,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -144,10 +148,11 @@ export class ShapeList extends Widget {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Picked right away rather than on release: the rows below show what the selected shape is.
|
// Picked right away rather than on release: the rows below show what the selected shape is.
|
||||||
const shapeId = this.shown[index]!;
|
const shapeId = this.shown[index]!;
|
||||||
if (shapeId !== this.selectedId) {
|
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||||
this.selectedId = shapeId;
|
this.onPick(shapeId, true);
|
||||||
this.onPick(shapeId);
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.selectedIds.length !== 1 || this.selectedIds[0] !== shapeId) this.onPick(shapeId, false);
|
||||||
this.drag = { from: index, startY: y, y, active: 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.position.set(0, y);
|
||||||
row.box.visible = Boolean(shape);
|
row.box.visible = Boolean(shape);
|
||||||
if (!shape) continue;
|
if (!shape) continue;
|
||||||
const selected = shapeId === this.selectedId;
|
const selected = this.selectedIds.includes(shapeId);
|
||||||
if (selected || index === this.hoveredRow)
|
if (selected || index === this.hoveredRow)
|
||||||
this.background
|
this.background
|
||||||
.rect(0, y, this.w, SHAPE_ROW_HEIGHT)
|
.rect(0, y, this.w, SHAPE_ROW_HEIGHT)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphi
|
|||||||
import {
|
import {
|
||||||
AddVectorShapeCommand,
|
AddVectorShapeCommand,
|
||||||
RemoveVectorShapeCommand,
|
RemoveVectorShapeCommand,
|
||||||
|
RemoveVectorShapesCommand,
|
||||||
SetVectorShapeRingsCommand,
|
SetVectorShapeRingsCommand,
|
||||||
} from '../../core/commands/vectorArtCommands';
|
} from '../../core/commands/vectorArtCommands';
|
||||||
import type { VectorArtAsset, VectorPoint, VectorRing, VectorShape } from '../../core/model/Asset';
|
import type { VectorArtAsset, VectorPoint, VectorRing, VectorShape } from '../../core/model/Asset';
|
||||||
@@ -48,7 +49,7 @@ import {
|
|||||||
withoutPoint,
|
withoutPoint,
|
||||||
withSmoothToggled,
|
withSmoothToggled,
|
||||||
} from '../polygonEdit';
|
} from '../polygonEdit';
|
||||||
import type { EditorState } from '../state';
|
import { type EditorState, shapeSelection } from '../state';
|
||||||
import { drawChecker } from '../texture/checker';
|
import { drawChecker } from '../texture/checker';
|
||||||
import { fitView, imageToLocal, localToImage, type SheetView, zoomAbout } from '../texture/sheetView';
|
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 : '';
|
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 {
|
private shape(shapeId: string): VectorShape | undefined {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
return asset ? findShape(asset, shapeId) : undefined;
|
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 {
|
override onKeyDown(event: KeyboardEvent): boolean {
|
||||||
if (event.key === 'Escape' && this.drawing) {
|
if (event.key === 'Escape' && this.drawing) {
|
||||||
this.endPen();
|
this.endPen();
|
||||||
@@ -255,10 +261,14 @@ export class VectorArtCanvas extends Widget {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (event.key !== 'Delete' && event.key !== 'Backspace') return super.onKeyDown(event);
|
if (event.key !== 'Delete' && event.key !== 'Backspace') return super.onKeyDown(event);
|
||||||
const shapeId = this.selectedShapeId();
|
const shapeIds = this.selectedShapeIds();
|
||||||
if (!shapeId) return super.onKeyDown(event);
|
if (shapeIds.length === 0) return super.onKeyDown(event);
|
||||||
this.endPen();
|
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 });
|
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -292,7 +302,7 @@ export class VectorArtCanvas extends Widget {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
const command = new AddVectorShapeCommand(asset.id, [{ points: [point], closed: false }]);
|
const command = new AddVectorShapeCommand(asset.id, [{ points: [point], closed: false }]);
|
||||||
if (!this.state.execute(command)) return;
|
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.pen = { shapeId: command.shapeId, ring: 0 };
|
||||||
this.startPenDrag(command.shapeId, 0, 0, local, point);
|
this.startPenDrag(command.shapeId, 0, 0, local, point);
|
||||||
return;
|
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
|
* 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
|
* 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
|
* 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(
|
private pressSelect(
|
||||||
asset: VectorArtAsset,
|
asset: VectorArtAsset,
|
||||||
@@ -351,6 +362,14 @@ export class VectorArtCanvas extends Widget {
|
|||||||
tolerance: number,
|
tolerance: number,
|
||||||
e: FederatedPointerEvent,
|
e: FederatedPointerEvent,
|
||||||
): void {
|
): 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());
|
const selected = this.shape(this.selectedShapeId());
|
||||||
if (selected) {
|
if (selected) {
|
||||||
for (const [ringIndex, ring] of selected.rings.entries()) {
|
for (const [ringIndex, ring] of selected.rings.entries()) {
|
||||||
@@ -360,13 +379,10 @@ export class VectorArtCanvas extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Last first: the later shape is drawn on top, so that is what a click on an overlap means.
|
const shape = this.shapeAt(asset, at);
|
||||||
for (let i = asset.shapes.length - 1; i >= 0; i--) {
|
if (shape) {
|
||||||
const shape = asset.shapes[i]!;
|
|
||||||
if (!shape.rings.some((ring) => insideRing(ring, at))) continue;
|
|
||||||
this.pick(null);
|
this.pick(null);
|
||||||
if (shape.id !== selected?.id)
|
this.state.select(shapeSelection(asset.id, [shape.id]));
|
||||||
this.state.select({ kind: 'shape', assetId: asset.id, shapeId: shape.id });
|
|
||||||
this.drag = {
|
this.drag = {
|
||||||
kind: 'shape',
|
kind: 'shape',
|
||||||
shapeId: shape.id,
|
shapeId: shape.id,
|
||||||
@@ -387,6 +403,15 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.redraw();
|
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(
|
private pressGrip(
|
||||||
asset: VectorArtAsset,
|
asset: VectorArtAsset,
|
||||||
shape: VectorShape,
|
shape: VectorShape,
|
||||||
@@ -597,6 +622,8 @@ export class VectorArtCanvas extends Widget {
|
|||||||
this.art.scale.set(this.view.zoom);
|
this.art.scale.set(this.view.zoom);
|
||||||
for (const drawing of vectorArtDrawings(asset)) paintDrawing(this.art, drawing);
|
for (const drawing of vectorArtDrawings(asset)) paintDrawing(this.art, drawing);
|
||||||
this.drawFrame(origin, size);
|
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.drawShape(this.shape(this.selectedShapeId()));
|
||||||
}
|
}
|
||||||
this.syncHint(asset);
|
this.syncHint(asset);
|
||||||
@@ -609,6 +636,15 @@ export class VectorArtCanvas extends Widget {
|
|||||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
.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. */
|
/** The selected shape's points and handles, in widget pixels so they keep their size. */
|
||||||
private drawShape(shape: VectorShape | undefined): void {
|
private drawShape(shape: VectorShape | undefined): void {
|
||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Boolean operations on vector art shapes (#187). The areas are measured by sampling the result
|
||||||
|
* under the even-odd rule the rasteriser fills with, so a test sees what would be painted – not
|
||||||
|
* merely how many rings came out.
|
||||||
|
*/
|
||||||
|
import { Polygon } from 'pixi.js';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import {
|
||||||
|
AddVectorShapeCommand,
|
||||||
|
CombineVectorShapesCommand,
|
||||||
|
RemoveVectorShapesCommand,
|
||||||
|
SetVectorShapeStyleCommand,
|
||||||
|
} from '../../src/core/commands/vectorArtCommands';
|
||||||
|
import type { VectorArtAsset, VectorRing, VectorShape } from '../../src/core/model/Asset';
|
||||||
|
import { findAsset } from '../../src/core/model/Project';
|
||||||
|
import {
|
||||||
|
combineBlocker,
|
||||||
|
combinedRings,
|
||||||
|
FLATTEN_TOLERANCE,
|
||||||
|
type ShapeBoolean,
|
||||||
|
} from '../../src/core/model/vectorBoolean';
|
||||||
|
import { makeProject } from '../helpers';
|
||||||
|
|
||||||
|
function square(x: number, y: number, side: number): VectorRing {
|
||||||
|
return {
|
||||||
|
closed: true,
|
||||||
|
points: [
|
||||||
|
{ x, y },
|
||||||
|
{ x: x + side, y },
|
||||||
|
{ x: x + side, y: y + side },
|
||||||
|
{ x, y: y + side },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A circle of four symmetric points, the handles a third of a quarter arc long (kappa). */
|
||||||
|
function circle(cx: number, cy: number, r: number): VectorRing {
|
||||||
|
const k = 0.5523 * r;
|
||||||
|
return {
|
||||||
|
closed: true,
|
||||||
|
points: [
|
||||||
|
{ x: cx + r, y: cy, in: { x: 0, y: -k }, out: { x: 0, y: k } },
|
||||||
|
{ x: cx, y: cy + r, in: { x: k, y: 0 }, out: { x: -k, y: 0 } },
|
||||||
|
{ x: cx - r, y: cy, in: { x: 0, y: k }, out: { x: 0, y: -k } },
|
||||||
|
{ x: cx, y: cy - r, in: { x: -k, y: 0 }, out: { x: k, y: 0 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shape(id: string, ...rings: VectorRing[]): VectorShape {
|
||||||
|
return { id, name: id, rings, fill: 0x808080, opacity: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inside(ring: VectorRing, x: number, y: number): boolean {
|
||||||
|
let hit = false;
|
||||||
|
const p = ring.points;
|
||||||
|
for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
|
||||||
|
const a = p[i]!;
|
||||||
|
const b = p[j]!;
|
||||||
|
if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit;
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The area painted under even-odd, counted at the centres of half-pixel cells. */
|
||||||
|
function paintedArea(rings: readonly VectorRing[], from = -5, to = 60): number {
|
||||||
|
let cells = 0;
|
||||||
|
for (let x = from + 0.25; x < to; x += 0.5)
|
||||||
|
for (let y = from + 0.25; y < to; y += 0.5)
|
||||||
|
if (rings.filter((ring) => inside(ring, x, y)).length % 2 === 1) cells += 1;
|
||||||
|
return cells * 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
const A = () => shape('a', square(0, 0, 20));
|
||||||
|
const B = () => shape('b', square(10, 10, 20));
|
||||||
|
|
||||||
|
describe('combining shapes', () => {
|
||||||
|
it.each<[ShapeBoolean, number]>([
|
||||||
|
['union', 700],
|
||||||
|
['subtract', 300],
|
||||||
|
['intersect', 100],
|
||||||
|
['exclude', 600],
|
||||||
|
])('%s of two overlapping squares paints %i square pixels', (op, area) => {
|
||||||
|
expect(paintedArea(combinedRings([A(), B()], op))).toBe(area);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subtracts every later shape from the first, whatever order they overlap in', () => {
|
||||||
|
const c = shape('c', square(0, 10, 5));
|
||||||
|
// 400 − the 100 B takes − the 25 C takes: C and B do not overlap inside A.
|
||||||
|
expect(paintedArea(combinedRings([A(), B(), c], 'subtract'))).toBe(275);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads each shape by the even-odd rule: an overlap of its own rings is a hole', () => {
|
||||||
|
const crossed = shape('x', square(0, 0, 20), square(10, 0, 20));
|
||||||
|
// The two rings overlap in 10×20 – even-odd leaves that empty: 800 − 2·200 = 400.
|
||||||
|
const far = shape('f', square(40, 40, 10));
|
||||||
|
expect(paintedArea(combinedRings([crossed, far], 'union'))).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts an island in a hole before its outline, so Pixi does not take it for a hole', () => {
|
||||||
|
const donut = shape('d', square(0, 0, 50), square(10, 10, 30));
|
||||||
|
const island = shape('i', square(20, 20, 10));
|
||||||
|
const rings = combinedRings([donut, island], 'union');
|
||||||
|
expect(paintedArea(rings)).toBe(2500 - 900 + 100);
|
||||||
|
// Pixi's own rule (ShapePath.addPath with checkForHoles): a polygon is a hole of the last
|
||||||
|
// outline when that one contains it. The island has to come out as an outline of its own.
|
||||||
|
let main: { poly: Polygon; holes: number } | null = null;
|
||||||
|
const outlines: { poly: Polygon; holes: number }[] = [];
|
||||||
|
for (const ring of rings) {
|
||||||
|
const poly = new Polygon(ring.points.flatMap((p) => [p.x, p.y]));
|
||||||
|
if (main && main.poly.containsPolygon(poly)) main.holes += 1;
|
||||||
|
else outlines.push((main = { poly, holes: 0 }));
|
||||||
|
}
|
||||||
|
expect(outlines.map((o) => o.holes).sort()).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('samples a curve within the tolerance and keeps its points few', () => {
|
||||||
|
const round = shape('r', circle(30, 30, 20));
|
||||||
|
const far = shape('f', square(100, 100, 5));
|
||||||
|
const rings = combinedRings([round, far], 'union');
|
||||||
|
const disc = rings.find((ring) => ring.points.every((p) => p.x < 60))!;
|
||||||
|
const radii = disc.points.map((p) => Math.hypot(p.x - 30, p.y - 30));
|
||||||
|
// Kappa bulges the true curve out by 0.03 % of r; rounding to a hundredth adds 0.01.
|
||||||
|
expect(Math.max(...radii)).toBeLessThan(20 + 0.02);
|
||||||
|
expect(Math.min(...radii)).toBeGreaterThan(20 - FLATTEN_TOLERANCE - 0.02);
|
||||||
|
expect(disc.points.length).toBeGreaterThan(16);
|
||||||
|
expect(disc.points.length).toBeLessThan(64);
|
||||||
|
expect(disc.points.every((p) => !p.in && !p.out)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses one shape alone and an open line, and says why', () => {
|
||||||
|
expect(combineBlocker([A()])).toMatch(/second shape/);
|
||||||
|
const line = shape('l', {
|
||||||
|
closed: false,
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 5, y: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(combineBlocker([A(), line])).toMatch(/open line/);
|
||||||
|
// A line of one point is still being drawn and draws nothing – it is passed over.
|
||||||
|
const stub = shape('s', square(40, 0, 5), { closed: false, points: [{ x: 0, y: 0 }] });
|
||||||
|
expect(combineBlocker([A(), stub])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawingWith(...rings: VectorRing[]) {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const create = new CreateAssetCommand('vectorart', 'Logo');
|
||||||
|
fixture.stack.execute(create);
|
||||||
|
const assetId = create.asset.id;
|
||||||
|
const ids = rings.map((ring) => {
|
||||||
|
const add = new AddVectorShapeCommand(assetId, [ring]);
|
||||||
|
fixture.stack.execute(add);
|
||||||
|
return add.shapeId;
|
||||||
|
});
|
||||||
|
const art = () => findAsset(fixture.project, assetId) as VectorArtAsset;
|
||||||
|
return { ...fixture, assetId, ids, art };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the combine command', () => {
|
||||||
|
it('writes the result into the lowest shape, keeps its look and place, and removes the rest', () => {
|
||||||
|
const f = drawingWith(square(40, 40, 10), square(0, 0, 20), square(10, 10, 20));
|
||||||
|
const [first, low, high] = f.ids as [string, string, string];
|
||||||
|
f.stack.execute(
|
||||||
|
new SetVectorShapeStyleCommand(f.assetId, low, { fill: 0xff0000, stroke: { color: 0, width: 2 } }),
|
||||||
|
);
|
||||||
|
// Picked top first: the order of the selection does not decide, the drawing order does.
|
||||||
|
const combine = new CombineVectorShapesCommand(f.assetId, [high, low], 'subtract');
|
||||||
|
f.stack.execute(combine);
|
||||||
|
expect(combine.shapeId).toBe(low);
|
||||||
|
expect(f.art().shapes.map((s) => s.id)).toEqual([first, low]);
|
||||||
|
const result = f.art().shapes[1]!;
|
||||||
|
expect(result).toMatchObject({ name: 'Shape 2', fill: 0xff0000, stroke: { color: 0, width: 2 } });
|
||||||
|
expect(paintedArea(result.rings)).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is one undo step that puts every shape back as it was', () => {
|
||||||
|
const f = drawingWith(square(0, 0, 20), square(10, 10, 20), square(40, 40, 10));
|
||||||
|
const before = JSON.stringify(f.art().shapes);
|
||||||
|
f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'union'));
|
||||||
|
expect(f.art().shapes).toHaveLength(1);
|
||||||
|
f.stack.undo();
|
||||||
|
expect(JSON.stringify(f.art().shapes)).toBe(before);
|
||||||
|
f.stack.redo();
|
||||||
|
expect(paintedArea(f.art().shapes[0]!.rings)).toBe(800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a result of nothing and leaves the drawing alone', () => {
|
||||||
|
const f = drawingWith(square(0, 0, 10), square(30, 30, 10));
|
||||||
|
const before = JSON.stringify(f.art().shapes);
|
||||||
|
expect(() => f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'intersect'))).toThrow(
|
||||||
|
/nothing in common/,
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(f.art().shapes)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes several shapes in one step and brings them back in their places', () => {
|
||||||
|
const f = drawingWith(square(0, 0, 5), square(10, 0, 5), square(20, 0, 5));
|
||||||
|
const before = f.art().shapes.map((s) => s.id);
|
||||||
|
f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [f.ids[0]!, f.ids[2]!]));
|
||||||
|
expect(f.art().shapes.map((s) => s.id)).toEqual([f.ids[1]]);
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.art().shapes.map((s) => s.id)).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { CanvasTextMetrics } from 'pixi.js';
|
import { CanvasTextMetrics } from 'pixi.js';
|
||||||
import { AddVectorShapeCommand } from '../../src/core/commands/vectorArtCommands';
|
import { AddVectorShapeCommand, RemoveVectorShapeCommand } from '../../src/core/commands/vectorArtCommands';
|
||||||
import type { VectorArtAsset } from '../../src/core/model/Asset';
|
import type { VectorArtAsset } from '../../src/core/model/Asset';
|
||||||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||||||
import { createEmptyProject, findAssetOf } from '../../src/core/model/Project';
|
import { createEmptyProject, findAssetOf } from '../../src/core/model/Project';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
ColorField,
|
ColorField,
|
||||||
Label,
|
Label,
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
shapeSignature,
|
shapeSignature,
|
||||||
} from '../../src/editor/inspector/shapeSection';
|
} from '../../src/editor/inspector/shapeSection';
|
||||||
import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows';
|
import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows';
|
||||||
import { EditorState } from '../../src/editor/state';
|
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||||||
|
|
||||||
const ROW = 30;
|
const ROW = 30;
|
||||||
|
|
||||||
@@ -124,14 +125,14 @@ describe('shape list order', () => {
|
|||||||
describe('shape rows', () => {
|
describe('shape rows', () => {
|
||||||
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
|
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
|
||||||
const { host, assetId, ids } = withShapes(1);
|
const { host, assetId, ids } = withShapes(1);
|
||||||
buildShapeSection(host, assetId, ids[0]!);
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
expect([...host.fields.keys()]).toEqual(['name', 'fill', 'opacity', 'outline']);
|
expect([...host.fields.keys()]).toEqual(['name', 'fill', 'opacity', 'outline']);
|
||||||
expect(texts(host)).toContain('Without an outline only the fill is drawn.');
|
expect(texts(host)).toContain('Without an outline only the fill is drawn.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes a fill as one undo step, a dragged colour previewing without one', () => {
|
it('writes a fill as one undo step, a dragged colour previewing without one', () => {
|
||||||
const { host, assetId, ids } = withShapes(1);
|
const { host, assetId, ids } = withShapes(1);
|
||||||
buildShapeSection(host, assetId, ids[0]!);
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
const before = shapeOf(host, assetId).fill;
|
const before = shapeOf(host, assetId).fill;
|
||||||
const fill = widgets(host, ColorField)[0]!;
|
const fill = widgets(host, ColorField)[0]!;
|
||||||
fill.onInput!(0xff0000);
|
fill.onInput!(0xff0000);
|
||||||
@@ -144,7 +145,7 @@ describe('shape rows', () => {
|
|||||||
|
|
||||||
it('writes the opacity, clamped to what an opacity can be', () => {
|
it('writes the opacity, clamped to what an opacity can be', () => {
|
||||||
const { host, assetId, ids } = withShapes(1);
|
const { host, assetId, ids } = withShapes(1);
|
||||||
buildShapeSection(host, assetId, ids[0]!);
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
|
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
|
||||||
const opacity = widgets(host, NumberField)[0]!;
|
const opacity = widgets(host, NumberField)[0]!;
|
||||||
opacity.onChange!('0.4');
|
opacity.onChange!('0.4');
|
||||||
@@ -155,7 +156,7 @@ describe('shape rows', () => {
|
|||||||
|
|
||||||
it('the outline switch adds a line and takes it away again', () => {
|
it('the outline switch adds a line and takes it away again', () => {
|
||||||
const { host, assetId, ids } = withShapes(1);
|
const { host, assetId, ids } = withShapes(1);
|
||||||
buildShapeSection(host, assetId, ids[0]!);
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
widgets(host, Checkbox)[0]!.toggle();
|
widgets(host, Checkbox)[0]!.toggle();
|
||||||
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x000000, width: 2 });
|
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x000000, width: 2 });
|
||||||
host.state.undo();
|
host.state.undo();
|
||||||
@@ -165,7 +166,7 @@ describe('shape rows', () => {
|
|||||||
it('shows the line rows once there is a line, and a width of nothing is not one', () => {
|
it('shows the line rows once there is a line, and a width of nothing is not one', () => {
|
||||||
const { host, assetId, ids } = withShapes(1);
|
const { host, assetId, ids } = withShapes(1);
|
||||||
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
|
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
|
||||||
buildShapeSection(host, assetId, ids[0]!);
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
expect([...host.fields.keys()]).toEqual([
|
expect([...host.fields.keys()]).toEqual([
|
||||||
'name',
|
'name',
|
||||||
'fill',
|
'fill',
|
||||||
@@ -184,25 +185,25 @@ describe('shape rows', () => {
|
|||||||
|
|
||||||
it('names the shapes and the outline in its signature, so both rebuild the rows', () => {
|
it('names the shapes and the outline in its signature, so both rebuild the rows', () => {
|
||||||
const { host, assetId, ids } = withShapes(2);
|
const { host, assetId, ids } = withShapes(2);
|
||||||
const before = shapeSignature(host.state.project, assetId, ids[0]!);
|
const before = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
|
||||||
shapeOf(host, assetId).name = 'Hat';
|
shapeOf(host, assetId).name = 'Hat';
|
||||||
const renamed = shapeSignature(host.state.project, assetId, ids[0]!);
|
const renamed = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
|
||||||
expect(renamed).not.toBe(before);
|
expect(renamed).not.toBe(before);
|
||||||
shapeOf(host, assetId).stroke = { color: 0, width: 1 };
|
shapeOf(host, assetId).stroke = { color: 0, width: 1 };
|
||||||
expect(shapeSignature(host.state.project, assetId, ids[0]!)).not.toBe(renamed);
|
expect(shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!])).not.toBe(renamed);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a shape that is gone asks for a rebuild instead of refreshing', () => {
|
it('a shape that is gone asks for a rebuild instead of refreshing', () => {
|
||||||
const { host, assetId, ids } = withShapes(1);
|
const { host, assetId, ids } = withShapes(1);
|
||||||
buildShapeSection(host, assetId, ids[0]!);
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true);
|
expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true);
|
||||||
expect(refreshShapeSection(host, assetId, 'gone')).toBe(false);
|
expect(refreshShapeSection(host, assetId, 'gone')).toBe(false);
|
||||||
expect(shapeSignature(host.state.project, assetId, 'gone')).toBe('missing');
|
expect(shapeSignature(host.state.project, assetId, 'gone', ['gone'])).toBe('missing');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshing pushes the model values back into the rows', () => {
|
it('refreshing pushes the model values back into the rows', () => {
|
||||||
const { host, assetId, ids } = withShapes(1);
|
const { host, assetId, ids } = withShapes(1);
|
||||||
buildShapeSection(host, assetId, ids[0]!);
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
const shape = shapeOf(host, assetId);
|
const shape = shapeOf(host, assetId);
|
||||||
shape.fill = 0x123456;
|
shape.fill = 0x123456;
|
||||||
shape.name = 'Cloud';
|
shape.name = 'Cloud';
|
||||||
@@ -226,3 +227,57 @@ describe('shape rows', () => {
|
|||||||
expect(texts(host).some((t) => t.startsWith('Nothing drawn yet'))).toBe(true);
|
expect(texts(host).some((t) => t.startsWith('Nothing drawn yet'))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('combining from the inspector (#187)', () => {
|
||||||
|
const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!;
|
||||||
|
|
||||||
|
it('Ctrl+click adds a shape and takes it out again; emptied, the drawing stays selected', () => {
|
||||||
|
const { host, assetId, ids } = withShapes(2);
|
||||||
|
host.state.select(shapeSelection(assetId, [ids[0]!]));
|
||||||
|
host.state.toggleShape(assetId, ids[1]!);
|
||||||
|
expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[0], ids[1]] });
|
||||||
|
host.state.toggleShape(assetId, ids[1]!);
|
||||||
|
host.state.toggleShape(assetId, ids[0]!);
|
||||||
|
expect(host.state.selection).toEqual({ kind: 'asset', assetId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the four buttons off for one shape and says how to get a second', () => {
|
||||||
|
const { host, assetId, ids } = withShapes(2);
|
||||||
|
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||||
|
expect(['Union', 'Subtract', 'Intersect', 'Exclude'].map((t) => button(host, t).eventMode)).toEqual([
|
||||||
|
'none',
|
||||||
|
'none',
|
||||||
|
'none',
|
||||||
|
'none',
|
||||||
|
]);
|
||||||
|
expect(texts(host)).toContain('Ctrl+click a second shape to combine.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines the selected shapes into the lowest one and selects it', () => {
|
||||||
|
const { host, assetId, ids } = withShapes(3);
|
||||||
|
host.state.select(shapeSelection(assetId, [ids[2]!, ids[1]!]));
|
||||||
|
buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]);
|
||||||
|
expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.');
|
||||||
|
button(host, 'Union').onClick!();
|
||||||
|
expect(findAssetOf(host.state.project, assetId, 'vectorart')!.shapes.map((s) => s.id)).toEqual([
|
||||||
|
ids[0],
|
||||||
|
ids[1],
|
||||||
|
]);
|
||||||
|
expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[1]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a selected shape that is gone and keeps the rest', () => {
|
||||||
|
const { host, assetId, ids } = withShapes(3);
|
||||||
|
host.state.select(shapeSelection(assetId, [ids[0]!, ids[2]!]));
|
||||||
|
host.state.execute(new RemoveVectorShapeCommand(assetId, ids[2]!));
|
||||||
|
expect(host.state.selection).toMatchObject({ shapeId: ids[0], shapeIds: [ids[0]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the selected shapes in the signature, so a Ctrl+click rebuilds the rows', () => {
|
||||||
|
const { host, assetId, ids } = withShapes(2);
|
||||||
|
const project = host.state.project;
|
||||||
|
expect(shapeSignature(project, assetId, ids[0]!, [ids[0]!])).not.toBe(
|
||||||
|
shapeSignature(project, assetId, ids[0]!, [ids[1]!, ids[0]!]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
|||||||
* the model, which is the only place a point's coordinates exist exactly. The last block, 47f,
|
* the model, which is the only place a point's coordinates exist exactly. The last block, 47f,
|
||||||
* walks the way a user walks instead: the asset comes out of the + menu, its row opens the tab,
|
* walks the way a user walks instead: the asset comes out of the + menu, its row opens the tab,
|
||||||
* and a sprite is given the drawing through its Texture row. 47g draws on that asset to measure the
|
* and a sprite is given the drawing through its Texture row. 47g draws on that asset to measure the
|
||||||
* four point types of #195.
|
* four point types of #195, and 47h combines two shapes on it (#187).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CANVAS = 256;
|
const CANVAS = 256;
|
||||||
@@ -590,4 +590,96 @@ export async function vectorArt(t) {
|
|||||||
straight.in === null && straight.out === null && (await pointType()).text === 'Point: Straight',
|
straight.in === null && straight.out === null && (await pointType()).text === 'Point: Straight',
|
||||||
JSON.stringify({ straight, header: await pointType() }),
|
JSON.stringify({ straight, header: await pointType() }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 47h (#187). Two squares are combined the way a user does it: a click picks the lower one, a
|
||||||
|
// Ctrl+click adds the upper one, and Subtract in the inspector cuts the upper out of the lower.
|
||||||
|
// The squares go into the model – drawing them is what 47c measured.
|
||||||
|
const square = (id, name, fill, x, y) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
fill,
|
||||||
|
opacity: 1,
|
||||||
|
rings: [
|
||||||
|
{
|
||||||
|
closed: true,
|
||||||
|
points: [
|
||||||
|
{ x, y },
|
||||||
|
{ x: x + 100, y },
|
||||||
|
{ x: x + 100, y: y + 100 },
|
||||||
|
{ x, y: y + 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await mutate(`
|
||||||
|
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
|
||||||
|
art.shapes = ${JSON.stringify([square('va-left', 'Left', 0xcc3344, 40, 60), square('va-right', 'Right', 0x3344cc, 100, 100)])};
|
||||||
|
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
|
||||||
|
await settle();
|
||||||
|
const selectedIds = () => json('__gm.vectorArt.selectedIds()');
|
||||||
|
const leftOnly = await screenAt(60, 80);
|
||||||
|
const rightOnly = await screenAt(180, 180);
|
||||||
|
await click(leftOnly.x, leftOnly.y);
|
||||||
|
await settle();
|
||||||
|
check(
|
||||||
|
'a click picks one shape, and the inspector asks for a second one to combine',
|
||||||
|
JSON.stringify(await selectedIds()) === '["va-left"]' &&
|
||||||
|
(await t.has('Ctrl+click a second shape to combine.')),
|
||||||
|
JSON.stringify(await selectedIds()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await click(rightOnly.x, rightOnly.y, 'left', { ctrl: true });
|
||||||
|
await settle();
|
||||||
|
check(
|
||||||
|
'Ctrl+click adds the other shape, and the inspector names the lowest as the one that stays',
|
||||||
|
JSON.stringify(await selectedIds()) === '["va-left","va-right"]' &&
|
||||||
|
(await t.has('The result keeps "Left", the lowest.')),
|
||||||
|
JSON.stringify(await selectedIds()),
|
||||||
|
);
|
||||||
|
await shot('47-combine-selected');
|
||||||
|
|
||||||
|
await clickText('Subtract');
|
||||||
|
await settle();
|
||||||
|
const cut = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||||||
|
const ring = cut?.[0]?.rings[0]?.points ?? [];
|
||||||
|
const area = Math.abs(
|
||||||
|
ring.reduce((sum, p, i) => {
|
||||||
|
const q = ring[(i + 1) % ring.length];
|
||||||
|
return sum + p.x * q.y - q.x * p.y;
|
||||||
|
}, 0) / 2,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Subtract leaves the lower square with the corner the upper one covered cut away',
|
||||||
|
cut?.length === 1 &&
|
||||||
|
cut[0].id === 'va-left' &&
|
||||||
|
cut[0].fill === 0xcc3344 &&
|
||||||
|
cut[0].rings.length === 1 &&
|
||||||
|
ring.length === 6 &&
|
||||||
|
near(area, 100 * 100 - 40 * 60) &&
|
||||||
|
JSON.stringify(await selectedIds()) === '["va-left"]',
|
||||||
|
JSON.stringify({ cut, area, selected: await selectedIds() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The tab paints the result: the fill where the lower square is left, none where it was cut away –
|
||||||
|
// neither red nor the blue of the upper square, which lay there before and would pass for "not red".
|
||||||
|
const keptPixel = await json(`__gm.pixelAt(${leftOnly.x}, ${leftOnly.y})`);
|
||||||
|
const inCut = await screenAt(120, 130);
|
||||||
|
const cutPixel = await json(`__gm.pixelAt(${inCut.x}, ${inCut.y})`);
|
||||||
|
check(
|
||||||
|
'the tab paints the cut: the fill remains beside it, and inside it the canvas is empty',
|
||||||
|
colourDistance(keptPixel, '#cc3344') < 14 &&
|
||||||
|
colourDistance(cutPixel, '#cc3344') > 60 &&
|
||||||
|
colourDistance(cutPixel, '#3344cc') > 60,
|
||||||
|
JSON.stringify({ keptPixel, cutPixel }),
|
||||||
|
);
|
||||||
|
await shot('47-combine-subtracted');
|
||||||
|
|
||||||
|
await undo();
|
||||||
|
await settle();
|
||||||
|
const restored = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||||||
|
check(
|
||||||
|
'one undo brings both squares back as they were',
|
||||||
|
restored?.map((s) => s.id).join(',') === 'va-left,va-right' && restored[0].rings[0].points.length === 4,
|
||||||
|
JSON.stringify(restored?.map((s) => ({ id: s.id, points: s.rings[0]?.points.length }))),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user