Files
feuerwerk/tests/editor/shape-rows.test.ts
T
adminandClaude Opus 5 1116818011
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m51s
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>
2026-09-21 11:56:41 +02:00

284 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The inspector side of vector art (#129, stage 4): the arithmetic of the shape list, which rows a
* shape shows, and what editing one of them writes into the model. The rows are driven through the
* widgets' own callbacks – that is the path a click takes, `setValue` only shows a value.
*/
import { beforeAll, describe, expect, it } from 'vitest';
import { CanvasTextMetrics } from 'pixi.js';
import { AddVectorShapeCommand, RemoveVectorShapeCommand } from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset } from '../../src/core/model/Asset';
import { buildAsset } from '../../src/core/commands/assetCommands';
import { createEmptyProject, findAssetOf } from '../../src/core/model/Project';
import {
Button,
Checkbox,
ColorField,
Label,
NumberField,
TextField,
toHex,
VStack,
type Widget,
} from '../../src/ui';
import type { InspectorHost } from '../../src/editor/inspector/host';
import {
buildShapeListSection,
buildShapeSection,
refreshShapeSection,
shapeSignature,
} from '../../src/editor/inspector/shapeSection';
import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows';
import { EditorState, shapeSelection } from '../../src/editor/state';
const ROW = 30;
/**
* These rows really are built, and a widget repaints itself one microtask after a value goes into
* it – text width included, which Pixi measures on a canvas of the browser. The suite runs on node,
* so the measure is answered here instead of the whole test avoiding the widgets.
*/
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
});
function makeHost(): InspectorHost {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
return {
state,
body: new VStack(),
fields: new Map(),
nameField: null,
project: () => state.project,
liveVariables: () => null,
};
}
/** A vector art asset with `count` triangles in it, and a host to build its rows into. */
function withShapes(count: number): { host: InspectorHost; assetId: string; ids: string[] } {
const host = makeHost();
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
host.state.project.assets.push(asset);
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const command = new AddVectorShapeCommand(asset.id, [
{
closed: true,
points: [
{ x: i, y: 0 },
{ x: i + 20, y: 0 },
{ x: i, y: 20 },
],
},
]);
host.state.execute(command);
ids.push(command.shapeId);
}
return { host, assetId: asset.id, ids };
}
const shapeOf = (host: InspectorHost, assetId: string, index = 0) =>
findAssetOf(host.state.project, assetId, 'vectorart')!.shapes[index]!;
function widgets<T extends Widget>(host: InspectorHost, type: new (...args: never[]) => T): T[] {
const found: T[] = [];
const walk = (widget: Widget): void => {
if (widget instanceof type) found.push(widget);
for (const child of widget.children) walk(child as Widget);
};
walk(host.body);
return found;
}
const texts = (host: InspectorHost): string[] => widgets(host, Label).map((label) => label.text);
describe('shape list order', () => {
it('shows the drawing order upside down – the shape drawn last is the top row', () => {
expect(shapeRowOrder(['bottom', 'middle', 'top'])).toEqual(['top', 'middle', 'bottom']);
});
it('drops into the gap the pointer is nearest to', () => {
expect(shapeDropGap(3, 2, ROW)).toBe(0);
expect(shapeDropGap(3, 20, ROW)).toBe(1);
expect(shapeDropGap(3, 44, ROW)).toBe(1);
expect(shapeDropGap(3, 46, ROW)).toBe(2);
// Outside the list there is no gap: a drop above it is the first, below it the last.
expect(shapeDropGap(3, 400, ROW)).toBe(3);
expect(shapeDropGap(3, -40, ROW)).toBe(0);
});
it('turns a drop into a move of the model, the top row being the last shape', () => {
// Three shapes, so the rows are the model indices [2, 1, 0]: the top row dropped at the
// bottom gap becomes the first shape of the model, the one drawn first.
expect(shapeDropMove(3, 0, 3)).toEqual({ from: 2, to: 0 });
expect(shapeDropMove(3, 2, 0)).toEqual({ from: 0, to: 2 });
expect(shapeDropMove(3, 1, 0)).toEqual({ from: 1, to: 2 });
});
it('is no move when the row lands where it already is', () => {
expect(shapeDropMove(3, 1, 1)).toBeNull();
expect(shapeDropMove(3, 1, 2)).toBeNull();
expect(shapeDropMove(3, 5, 1)).toBeNull();
});
});
describe('shape rows', () => {
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect([...host.fields.keys()]).toEqual(['name', 'fill', 'opacity', 'outline']);
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', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
const before = shapeOf(host, assetId).fill;
const fill = widgets(host, ColorField)[0]!;
fill.onInput!(0xff0000);
expect(shapeOf(host, assetId).fill).toBe(0xff0000);
fill.onChange!(0x00ff00);
expect(shapeOf(host, assetId).fill).toBe(0x00ff00);
host.state.undo();
expect(shapeOf(host, assetId).fill).toBe(before);
});
it('writes the opacity, clamped to what an opacity can be', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
const opacity = widgets(host, NumberField)[0]!;
opacity.onChange!('0.4');
expect(shapeOf(host, assetId).opacity).toBeCloseTo(0.4);
opacity.onChange!('5');
expect(shapeOf(host, assetId).opacity).toBe(1);
});
it('the outline switch adds a line and takes it away again', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
widgets(host, Checkbox)[0]!.toggle();
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x000000, width: 2 });
host.state.undo();
expect(shapeOf(host, assetId).stroke).toBeUndefined();
});
it('shows the line rows once there is a line, and a width of nothing is not one', () => {
const { host, assetId, ids } = withShapes(1);
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect([...host.fields.keys()]).toEqual([
'name',
'fill',
'opacity',
'outline',
'strokeColor',
'strokeWidth',
]);
const width = widgets(host, NumberField)[1]!;
// The command refuses a line of no width, so the row never sends one.
width.onChange!('0');
expect(shapeOf(host, assetId).stroke?.width).toBe(0.1);
widgets(host, ColorField)[1]!.onChange!(0xabcdef);
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0xabcdef, width: 0.1 });
});
it('names the shapes and the outline in its signature, so both rebuild the rows', () => {
const { host, assetId, ids } = withShapes(2);
const before = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
shapeOf(host, assetId).name = 'Hat';
const renamed = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
expect(renamed).not.toBe(before);
shapeOf(host, assetId).stroke = { color: 0, width: 1 };
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', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true);
expect(refreshShapeSection(host, assetId, 'gone')).toBe(false);
expect(shapeSignature(host.state.project, assetId, 'gone', ['gone'])).toBe('missing');
});
it('refreshing pushes the model values back into the rows', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
const shape = shapeOf(host, assetId);
shape.fill = 0x123456;
shape.name = 'Cloud';
refreshShapeSection(host, assetId, ids[0]!);
// The name field first, then the hex text inside the fill row – a name is a field, not a label.
const fields = widgets(host, TextField);
expect(fields[0]!.value).toBe('Cloud');
expect(fields[1]!.value).toBe(toHex(0x123456));
});
it('the asset itself shows the list and the way to add to it', () => {
const { host, assetId } = withShapes(2);
buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!);
expect(texts(host)).toContain('SHAPES');
expect(texts(host)).toContain('The top row is drawn over the ones below it.');
});
it('an empty drawing says so instead of showing an empty list', () => {
const { host, assetId } = withShapes(0);
buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!);
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]!]),
);
});
});