Editing a vector drawing made Pixi warn "destroyed while still bound
to a shader", twice with every change, once it had begun. It began
where an object wears the drawing through a shader asset. Each
change draws the picture anew and destroyed the one before it, but
the mesh of that object keeps its picture bound until it syncs. The
issue also asks for the button "Add shape" to go: a shape without
points cannot be given any, the Pen draws a new one.
- GeneratedPictures takes two more callbacks. inUse says that a
picture cannot be freed yet, soon runs a retry once whoever asked
is done.
- A replaced picture that is still in use waits in a list of its
own. It is freed right after the redraw that asked, as a rule. On
a later ask where its wearer was not part of that redraw.
- TextureLibrary tells a bound picture by the listeners for change
on its source. Under WebGL the bind groups are the only ones that
listen there. The retry is a microtask.
- The rule is asked for every drawn picture alike, whoever wears it.
- The inspector of a drawing and of a shape has no "Add shape" any
more. "Delete shapes" for several selected shapes stays.
Decided here:
- Under the list of shapes a second note says "The Pen draws a new
shape." while only the asset is selected.
Found on the way:
- Reproduced on a throwaway copy of the project the report came
from: 6 warnings for 3 changes with the shader override on the
object, none without it. After the change none, and no replaced
picture is left waiting.
- The fixture project of the tests never showed the warning. No
object there wore a drawing through a shader.
Tests:
- Gate on melektaus-atx: 211 files, 3219 tests (3209 + 10), exit 0
- e2e 47,48 on melektaus-atx: 113 checks, all pass
- e2e 28,30,31,39,40,47,48: 350 checks, all pass, run before the
wording of the new note changed
- The first gate was red: an existing test read the wording of the
note that had changed. Only three test files had been run after
the change.
- No full run and no smokes. The sum of 1668 (1667 + 1) is reckoned,
1667 is what CI counted for ec2fd90.
- Ten unit tests, five of them against the real bind group of Pixi
in tests/viewport/bound-pictures.test.ts
- One e2e check in block 47b-ii of module 47: a drawing worn through
a shader, three changes, no warning. The check on "Add shape" now
reads that there is no such button.
- Nine unit counter-probes and two browser counter-probes all fail
their tests. Without the change the new check counts 6 warnings.
- Not checked: a tilemap or a particle emitter holding a picture
bound the same way. Nothing was looked at after the build, the
new note included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
439 lines
17 KiB
TypeScript
439 lines
17 KiB
TypeScript
/**
|
||
* Layers and groups of vector art in the editor (#190): the two lists of the inspector, the buttons
|
||
* that group, and what a press in the tab does to a shape that stands in a group or on a hidden layer.
|
||
*/
|
||
import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js';
|
||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddVectorLayerCommand,
|
||
GroupVectorShapesCommand,
|
||
SetVectorLayerHiddenCommand,
|
||
} from '../../src/core/commands/vectorArtCommands';
|
||
import type { VectorArtAsset, VectorItem, VectorShape } from '../../src/core/model/Asset';
|
||
import { createEmptyProject } from '../../src/core/model/Project';
|
||
import type { Vec2 } from '../../src/core/model/Properties';
|
||
import { allShapes } from '../../src/core/model/vectorTree';
|
||
import type { InspectorHost } from '../../src/editor/inspector/host';
|
||
import {
|
||
buildShapeListSection,
|
||
buildShapeSection,
|
||
currentLayerList,
|
||
currentShapeList,
|
||
moveShapesToLayer,
|
||
pickShapeRow,
|
||
shapeListSignature,
|
||
shapeSignature,
|
||
} from '../../src/editor/inspector/shapeSection';
|
||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||
import { activeLayerOf, pickVectorLayer } from '../../src/editor/vectorart/activeLayer';
|
||
import { groupShapes, ungroupShapes } from '../../src/editor/vectorart/grouping';
|
||
import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
|
||
import { Button, Label, VStack, type Widget } from '../../src/ui';
|
||
import { resetRenderLoop } from '../../src/ui/render';
|
||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||
|
||
beforeAll(() => {
|
||
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
||
PaintCache.prototype.gradient = (() => null) as never;
|
||
});
|
||
beforeEach(() => resetRenderLoop());
|
||
|
||
/** A square of 20 pixels with its corner at `x`, `y`. */
|
||
function square(id: string, x: number, y = 0): VectorShape {
|
||
return {
|
||
id,
|
||
name: id.toUpperCase(),
|
||
fill: 0x336699,
|
||
opacity: 1,
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x, y },
|
||
{ x: x + 20, y },
|
||
{ x: x + 20, y: y + 20 },
|
||
{ x, y: y + 20 },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function text(items: readonly VectorItem[]): string {
|
||
return items.map((item) => ('items' in item ? `${item.id}[${text(item.items)}]` : item.id)).join(' ');
|
||
}
|
||
|
||
/** A drawing with the squares a, b and c side by side on its one layer. */
|
||
function rig() {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||
asset.layers[0]!.items.push(square('a', 0), square('b', 40), square('c', 80));
|
||
state.project.assets.push(asset);
|
||
const host: InspectorHost = {
|
||
state,
|
||
body: new VStack(),
|
||
fields: new Map(),
|
||
nameField: null,
|
||
project: () => state.project,
|
||
liveVariables: () => null,
|
||
};
|
||
/** The rows of the current selection, built anew as the inspector does after a change. */
|
||
const build = (): void => {
|
||
host.body.removeChildren();
|
||
host.fields.clear();
|
||
const s = state.selection;
|
||
if (s?.kind === 'shape') buildShapeSection(host, asset.id, s.shapeId, s.shapeIds);
|
||
else buildShapeListSection(host, asset);
|
||
};
|
||
const widgets = <T extends Widget>(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 button = (label: string) => widgets(Button).find((b) => b.text === label)!;
|
||
const texts = () => widgets(Label).map((label) => label.text);
|
||
const group = (...ids: string[]): string => {
|
||
const command = new GroupVectorShapesCommand(asset.id, ids);
|
||
state.execute(command);
|
||
return command.groupId;
|
||
};
|
||
const addLayer = (): string => {
|
||
const command = new AddVectorLayerCommand(asset.id);
|
||
state.execute(command);
|
||
return command.layerId;
|
||
};
|
||
const tree = () => asset.layers.map((layer) => text(layer.items));
|
||
return { state, asset, host, build, button, texts, group, addLayer, tree };
|
||
}
|
||
|
||
describe('the lists of the inspector', () => {
|
||
it('lists the layers top first and marks the one worked on', () => {
|
||
const r = rig();
|
||
const base = r.asset.layers[0]!.id;
|
||
const upper = r.addLayer();
|
||
r.build();
|
||
expect(currentLayerList()!.shownIds).toEqual([upper, base]);
|
||
// Nothing was picked yet, so the topmost layer is the one worked on – and it is empty.
|
||
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
|
||
expect(currentShapeList()).toBeNull();
|
||
expect(r.texts()).toContain('Nothing on this layer yet – the Pen draws onto it.');
|
||
pickVectorLayer(r.state, r.asset.id, base);
|
||
r.build();
|
||
expect(currentShapeList()!.shownIds).toEqual(['c', 'b', 'a']);
|
||
});
|
||
|
||
it('follows the selected shape onto its layer, and remembers it once the shape is let go', () => {
|
||
const r = rig();
|
||
const base = r.asset.layers[0]!.id;
|
||
r.addLayer();
|
||
r.state.select(shapeSelection(r.asset.id, ['b']));
|
||
expect(activeLayerOf(r.state, r.asset)?.id).toBe(base);
|
||
r.state.select({ kind: 'asset', assetId: r.asset.id });
|
||
expect(activeLayerOf(r.state, r.asset)?.id).toBe(base);
|
||
});
|
||
|
||
it('lets go of the shapes of another layer when a layer is picked', () => {
|
||
const r = rig();
|
||
const upper = r.addLayer();
|
||
r.state.select(shapeSelection(r.asset.id, ['b']));
|
||
pickVectorLayer(r.state, r.asset.id, upper);
|
||
expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id });
|
||
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
|
||
});
|
||
|
||
it('rebuilds the rows for another layer, a group and a hidden layer', () => {
|
||
const r = rig();
|
||
const base = r.asset.layers[0]!.id;
|
||
pickVectorLayer(r.state, r.asset.id, base);
|
||
const flat = shapeListSignature(r.asset);
|
||
r.group('a', 'b');
|
||
const grouped = shapeListSignature(r.asset);
|
||
expect(grouped).not.toBe(flat);
|
||
r.state.execute(new SetVectorLayerHiddenCommand(r.asset.id, base, true));
|
||
const hidden = shapeListSignature(r.asset);
|
||
expect(hidden).not.toBe(grouped);
|
||
const upper = r.addLayer();
|
||
pickVectorLayer(r.state, r.asset.id, base);
|
||
const onBase = shapeListSignature(r.asset);
|
||
pickVectorLayer(r.state, r.asset.id, upper);
|
||
expect(shapeListSignature(r.asset)).not.toBe(onBase);
|
||
expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).toContain(`@${base}[`);
|
||
});
|
||
|
||
it('shows a group over its shapes and selects all of them from its row', () => {
|
||
const r = rig();
|
||
const g = r.group('a', 'b');
|
||
r.state.select(shapeSelection(r.asset.id, ['a']));
|
||
r.build();
|
||
expect(currentShapeList()!.shownIds).toEqual(['c', g, 'b', 'a']);
|
||
pickShapeRow(r.state, r.asset.id, g, false);
|
||
expect(r.state.selectedShapeIds).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('adds a group to the selection with Ctrl and takes it out again as a whole', () => {
|
||
const r = rig();
|
||
const g = r.group('a', 'b');
|
||
r.state.select(shapeSelection(r.asset.id, ['c']));
|
||
pickShapeRow(r.state, r.asset.id, g, true);
|
||
expect(r.state.selectedShapeIds).toEqual(['c', 'a', 'b']);
|
||
pickShapeRow(r.state, r.asset.id, g, true);
|
||
expect(r.state.selectedShapeIds).toEqual(['c']);
|
||
pickShapeRow(r.state, r.asset.id, 'c', true);
|
||
expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id });
|
||
});
|
||
|
||
it('offers no button that adds a shape: the Pen draws one (#293)', () => {
|
||
const r = rig();
|
||
r.build();
|
||
expect(r.button('Add shape')).toBeUndefined();
|
||
expect(r.texts().some((line) => line.includes('The Pen draws a new shape'))).toBe(true);
|
||
r.state.select(shapeSelection(r.asset.id, ['a']));
|
||
r.build();
|
||
expect(r.button('Add shape')).toBeUndefined();
|
||
// Several shapes still go at once from here.
|
||
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
|
||
r.build();
|
||
expect(r.button('Delete shapes')).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe('grouping from the inspector', () => {
|
||
it('groups the selection, which then reads as the group, and ungroups it again', () => {
|
||
const r = rig();
|
||
r.state.select(shapeSelection(r.asset.id, ['a', 'c']));
|
||
r.build();
|
||
expect(r.button('Group').enabled).toBe(true);
|
||
expect(r.button('Ungroup').enabled).toBe(false);
|
||
r.button('Group').onClick!();
|
||
const made = r.asset.layers[0]!.items[1]!;
|
||
expect(r.tree()).toEqual([`b ${made.id}[a c]`]);
|
||
expect(r.state.selectedShapeIds).toEqual(['a', 'c']);
|
||
r.build();
|
||
expect(r.button('Ungroup').enabled).toBe(true);
|
||
r.button('Ungroup').onClick!();
|
||
expect(r.tree()).toEqual(['b a c']);
|
||
r.state.undo();
|
||
r.state.undo();
|
||
expect(r.tree()).toEqual(['a b c']);
|
||
});
|
||
|
||
it('ungroups the group the primary shape stands in when no group is picked as a whole', () => {
|
||
const r = rig();
|
||
r.group('a', 'b');
|
||
r.state.select(shapeSelection(r.asset.id, ['a']));
|
||
r.build();
|
||
expect(r.button('Ungroup').enabled).toBe(true);
|
||
r.button('Ungroup').onClick!();
|
||
expect(r.tree()).toEqual(['a b c']);
|
||
});
|
||
|
||
it('moves a whole picked group with the order buttons, and a lone shape inside its group', () => {
|
||
const r = rig();
|
||
const g = r.group('a', 'b');
|
||
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
|
||
r.build();
|
||
r.button('To front').onClick!();
|
||
expect(r.tree()).toEqual([`c ${g}[a b]`]);
|
||
r.state.select(shapeSelection(r.asset.id, ['a']));
|
||
r.build();
|
||
expect(r.button('Backward').enabled).toBe(false);
|
||
r.button('Forward').onClick!();
|
||
expect(r.tree()).toEqual([`c ${g}[b a]`]);
|
||
});
|
||
|
||
it('switches Group off across two layers', () => {
|
||
const r = rig();
|
||
const upper = r.addLayer();
|
||
moveShapesToLayer(r.state, r.asset.id, ['c'], upper);
|
||
r.state.select(shapeSelection(r.asset.id, ['a', 'c']));
|
||
r.build();
|
||
expect(r.button('Group').enabled).toBe(false);
|
||
});
|
||
|
||
it('offers the Layer row from two layers on and moves a picked group as a whole', () => {
|
||
const r = rig();
|
||
const g = r.group('a', 'b');
|
||
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
|
||
r.build();
|
||
expect(r.host.fields.has('shapeLayer')).toBe(false);
|
||
const upper = r.addLayer();
|
||
r.build();
|
||
expect(r.host.fields.has('shapeLayer')).toBe(true);
|
||
moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper);
|
||
expect(r.tree()).toEqual(['c', `${g}[a b]`]);
|
||
// The selection went along, so the layer worked on is now the upper one.
|
||
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
|
||
// Already there: no second step, so one undo takes the move back.
|
||
moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper);
|
||
r.state.undo();
|
||
expect(r.tree()).toEqual([`${g}[a b] c`, '']);
|
||
});
|
||
});
|
||
|
||
/** Presses run on one clock, far enough apart that no two join into a double-click. */
|
||
let clock = 0;
|
||
|
||
function pressAt(global: Vec2, held: 'ctrl' | 'shift' | null = null) {
|
||
clock += 1000;
|
||
return {
|
||
timeStamp: clock,
|
||
button: 0,
|
||
global,
|
||
altKey: false,
|
||
ctrlKey: held === 'ctrl',
|
||
metaKey: false,
|
||
shiftKey: held === 'shift',
|
||
stopPropagation: () => undefined,
|
||
} as unknown as FederatedPointerEvent;
|
||
}
|
||
|
||
interface Pressable {
|
||
shiftPresses(asset: VectorArtAsset, local: Vec2): boolean;
|
||
press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void;
|
||
move(local: Vec2): void;
|
||
release(): void;
|
||
}
|
||
|
||
function tab() {
|
||
const r = rig();
|
||
const canvas = new VectorArtCanvas(r.state);
|
||
canvas.show(r.asset.id);
|
||
canvas.resize(400, 400);
|
||
canvas.redraw();
|
||
const target = canvas as unknown as Pressable;
|
||
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
|
||
// A Shift press only reaches the tab where it is not a pan, which the tab says itself.
|
||
const click = (point: Vec2, held: 'ctrl' | 'shift' | null = null) => {
|
||
if (held === 'shift' && !target.shiftPresses(r.asset, point)) return;
|
||
target.press(r.asset, pressAt(point, held), point);
|
||
target.release();
|
||
};
|
||
const drag = (from: Vec2, to: Vec2) => {
|
||
target.press(r.asset, pressAt(from), from);
|
||
target.move(to);
|
||
target.release();
|
||
};
|
||
const corner = (id: string) => allShapes(r.asset).find((s) => s.id === id)!.rings[0]!.points[0];
|
||
return { ...r, canvas, at, click, drag, corner };
|
||
}
|
||
|
||
describe('the tab', () => {
|
||
it('selects the whole group on a press on one of its shapes, the pressed one first', () => {
|
||
const t = tab();
|
||
t.group('a', 'b');
|
||
t.click(t.at(50, 10));
|
||
expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] });
|
||
t.click(t.at(90, 10));
|
||
expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['c'] });
|
||
});
|
||
|
||
it('selects the outermost group of a shape that stands two groups deep', () => {
|
||
const t = tab();
|
||
const inner = t.group('a');
|
||
t.group(inner, 'b');
|
||
t.click(t.at(10, 10));
|
||
expect(t.state.selection).toMatchObject({ shapeId: 'a', shapeIds: ['a', 'b'] });
|
||
});
|
||
|
||
it('moves a group by dragging one of its shapes, as one undo step', () => {
|
||
const t = tab();
|
||
t.group('a', 'b');
|
||
t.drag(t.at(10, 10), t.at(15, 40));
|
||
expect(t.corner('a')).toMatchObject({ x: 5, y: 30 });
|
||
expect(t.corner('b')).toMatchObject({ x: 45, y: 30 });
|
||
expect(t.corner('c')).toMatchObject({ x: 80, y: 0 });
|
||
t.state.undo();
|
||
expect(t.corner('a')).toMatchObject({ x: 0, y: 0 });
|
||
expect(t.corner('b')).toMatchObject({ x: 40, y: 0 });
|
||
});
|
||
|
||
it('carries every selected shape when one of them is dragged, grouped or not', () => {
|
||
const t = tab();
|
||
t.state.select(shapeSelection(t.asset.id, ['a', 'c']));
|
||
t.drag(t.at(90, 10), t.at(90, 30));
|
||
expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['a', 'c'] });
|
||
expect(t.corner('a')).toMatchObject({ x: 0, y: 20 });
|
||
expect(t.corner('c')).toMatchObject({ x: 80, y: 20 });
|
||
expect(t.corner('b')).toMatchObject({ x: 40, y: 0 });
|
||
});
|
||
|
||
it.each(['ctrl', 'shift'] as const)(
|
||
'adds a shape to the selection with %s held and takes it out again',
|
||
(held) => {
|
||
const t = tab();
|
||
t.click(t.at(10, 10));
|
||
t.click(t.at(90, 10), held);
|
||
expect(t.state.selectedShapeIds).toEqual(['a', 'c']);
|
||
t.click(t.at(50, 10), held);
|
||
expect(t.state.selectedShapeIds).toEqual(['a', 'c', 'b']);
|
||
t.click(t.at(90, 10), held);
|
||
expect(t.state.selectedShapeIds).toEqual(['a', 'b']);
|
||
// Nothing was moved or changed on the way.
|
||
expect(t.corner('c')).toMatchObject({ x: 80, y: 0 });
|
||
},
|
||
);
|
||
|
||
it('leaves a Shift press to the pan beside the shapes and with another tool', () => {
|
||
const t = tab();
|
||
const target = t.canvas as unknown as Pressable;
|
||
expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(true);
|
||
expect(target.shiftPresses(t.asset, t.at(10, 60))).toBe(false);
|
||
t.canvas.setTool('pen');
|
||
expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(false);
|
||
});
|
||
|
||
it('leaves the selection as it is on a click that moves nothing', () => {
|
||
const t = tab();
|
||
t.group('a', 'b');
|
||
t.click(t.at(10, 10));
|
||
t.click(t.at(50, 10));
|
||
expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] });
|
||
expect(t.corner('a')).toMatchObject({ x: 0, y: 0 });
|
||
// No step was made: the last one is still the grouping.
|
||
t.state.undo();
|
||
expect(t.tree()).toEqual(['a b c']);
|
||
});
|
||
|
||
it('groups and ungroups the selection, and does nothing while only the drawing is selected', () => {
|
||
const t = tab();
|
||
t.state.select(shapeSelection(t.asset.id, ['a', 'b']));
|
||
expect(groupShapes(t.state, t.asset.id)).toBe(true);
|
||
expect(t.tree()[0]).toMatch(/^\S+\[a b\] c$/);
|
||
expect(ungroupShapes(t.state, t.asset.id)).toBe(true);
|
||
expect(t.tree()).toEqual(['a b c']);
|
||
expect(ungroupShapes(t.state, t.asset.id)).toBe(false);
|
||
t.state.select({ kind: 'asset', assetId: t.asset.id });
|
||
expect(groupShapes(t.state, t.asset.id)).toBe(false);
|
||
});
|
||
|
||
it('does not let a shape on a hidden layer be pressed', () => {
|
||
const t = tab();
|
||
const base = t.asset.layers[0]!.id;
|
||
t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, base, true));
|
||
t.click(t.at(10, 10));
|
||
expect(t.state.selection).toEqual({ kind: 'asset', assetId: t.asset.id });
|
||
});
|
||
|
||
it('draws with the pen onto the layer worked on, and nothing onto a hidden one', () => {
|
||
const t = tab();
|
||
const base = t.asset.layers[0]!.id;
|
||
const upper = t.addLayer();
|
||
pickVectorLayer(t.state, t.asset.id, base);
|
||
t.canvas.setTool('pen');
|
||
t.click(t.at(150, 150));
|
||
expect(t.asset.layers[0]!.items).toHaveLength(4);
|
||
expect(t.asset.layers[1]!.items).toHaveLength(0);
|
||
t.canvas.onKeyDown({ key: 'Escape' } as KeyboardEvent);
|
||
t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, upper, true));
|
||
pickVectorLayer(t.state, t.asset.id, upper);
|
||
t.click(t.at(170, 170));
|
||
expect(allShapes(t.asset)).toHaveLength(4);
|
||
});
|
||
});
|