Offer the pictures the server already holds when adding a texture

"+ → Texture" opened the machine's file dialog, so a picture that was uploaded once
but lost its asset – an undone import, a deleted asset – was unreachable from the
editor while still sitting on the server. Nothing collects those files, by design.

It now opens a grid of every picture the server holds, with thumbnails: pick one and
"Use image" makes a texture asset on that file, "Upload…" is the way in for bytes the
server does not have yet and lands in the same import as a drop. A tile captioned with
an asset name is one this project already wears – picking it selects that asset instead
of making a second one, since two assets on one file would fight over `filter`.

- `GET ?r=textures` now reports `width`/`height`; a texture asset holds the pixel size,
  and reading it off the GPU texture would tie the asset's correctness to load timing.
- `TextureLibrary.load(file)` loads without touching `scaleMode` – a thumbnail must not
  restage the filter of a picture the scene is drawing with.
- The grid geometry lives in `texture/imageGrid.ts` as pure functions; inside the widget
  no test could reach it.
- The part after an upload is now one function shared by the drop import and the picker,
  so "one file = one asset" is stated in a single place.

12 new E2E checks; the strongest one undoes an import, leaving the file on the server
with no asset naming it, and picks exactly that file back out of the dialog.
This commit is contained in:
root committed 2026-09-09 15:55:15 +02:00
1 parent 2f4dc843d7
commit 88ec367ab0
16 files changed
+774 -56

No files matched your search

+126
View File
@@ -19,6 +19,23 @@ const PASTE_IMAGE = `(async () => {
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/**
* A second, plainly different picture. Its only job is to exist on the server under a hash of
* its own, so the picker has something to offer that the project does not already wear.
*/
const PASTE_SPARE = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 24;
canvas.height = 24;
const g = canvas.getContext('2d');
g.fillStyle = '#ffcc00'; g.fillRect(0, 0, 24, 24);
g.fillStyle = '#202020'; g.fillRect(6, 6, 12, 12);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'spare.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
@@ -28,6 +45,7 @@ function colourDistance(a, b) {
export async function textures(t) {
const { check, sleep, json, evaluate, shot, mutate, press } = t;
const { api, click, clickText, doubleClick, locate, undo } = t;
// The previous step leaves a picker open and a text field focused, and paste is deliberately
// ignored while an input has focus, so that Ctrl+V inside a field still edits text.
@@ -191,4 +209,112 @@ export async function textures(t) {
JSON.stringify({ fallback }),
);
}
// 19e. "+ → Texture" offers the pictures the server already holds instead of the machine's
// file system. Nothing collects unreferenced files, so a picture one project dropped is
// still there for the next – which is exactly what this drives: paste a second picture,
// undo the asset (the file stays), then pick that file out of the dialog.
await evaluate(PASTE_SPARE);
await sleep(2500);
const two = await json("__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)");
const spare = two[1] ?? '';
check(
'a second picture is stored under a hash of its own',
two.length === 2 && spare !== two[0],
JSON.stringify(two),
);
await undo();
await sleep(300);
const afterUndo = await json(
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)",
);
check(
'undoing the import leaves the file on the server with no asset naming it',
afterUndo.length === 1 && !afterUndo.includes(spare),
JSON.stringify(afterUndo),
);
await clickText('+');
// By position, not by text: the menu entry "Texture" shares its label with the centre tab, and
// `locate` would hand back whichever it walked into first.
const entry = await locate('Texture', true);
await click(entry.x, entry.y);
await sleep(1500);
const picker = await json('__gm.imagePicker()');
const stored = (await api('textures')).map((item) => item.file);
check(
'the Texture entry opens the image picker instead of the file dialog',
picker !== null,
JSON.stringify(picker),
);
check(
'it offers exactly the files the server holds',
JSON.stringify(picker?.files ?? []) === JSON.stringify(stored),
JSON.stringify({ shown: picker?.files.length, stored: stored.length }),
);
check(
'the picture the project wears is marked as already in it, the dropped one is not',
(picker?.used ?? []).includes(texture.file) && !(picker?.used ?? []).includes(spare),
JSON.stringify(picker?.used),
);
await shot('34-image-picker');
const tile = await json(`__gm.imagePickerTile(${JSON.stringify(spare)})`);
await click(tile.x, tile.y);
check(
'clicking a tile picks that image',
(await json('__gm.imagePicker()'))?.selected === spare,
JSON.stringify(await json('__gm.imagePicker()')),
);
await shot('35-image-picked');
await clickText('Use image');
await sleep(600);
const adopted = await json(
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => ({ name: a.name, file: a.file, w: a.width, h: a.height }))",
);
check('the dialog closes on the pick', (await json('__gm.imagePicker()')) === null);
check(
'the picked file becomes a texture asset, sized from the server record',
adopted.length === 2 && adopted[1]?.file === spare && adopted[1]?.w === 24 && adopted[1]?.h === 24,
JSON.stringify(adopted),
);
check(
'and the notice says it was added rather than imported',
(await json('__gm.state.notice')) === 'Added the image to the project.',
JSON.stringify(await json('__gm.state.notice')),
);
// Reusing a file already in the project must not make a second asset on it – two assets on one
// picture would disagree about its filter.
await clickText('+');
const entryAgain = await locate('Texture', true);
await click(entryAgain.x, entryAgain.y);
await sleep(1500);
const reopened = await json('__gm.imagePicker()');
check(
'the adopted file is marked as in the project when the dialog is opened again',
(reopened?.used ?? []).includes(spare),
JSON.stringify(reopened?.used),
);
const sameTile = await json(`__gm.imagePickerTile(${JSON.stringify(spare)})`);
await doubleClick(sameTile.x, sameTile.y);
await sleep(600);
const afterSecondPick = await json(
`__gm.state.project.assets.filter((a) => a.file === ${JSON.stringify(spare)}).length`,
);
check(
'double-clicking a picture the project already has keeps it at one asset',
afterSecondPick === 1 && (await json('__gm.state.notice')) === 'That image is already in the project.',
JSON.stringify({ assets: afterSecondPick, notice: await json('__gm.state.notice') }),
);
// Back to one texture asset, the way the later steps expect the fixture.
await undo();
await sleep(300);
check(
'undo takes the adopted asset back out',
(await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1,
);
}