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

+15 -4
View File
@@ -10,8 +10,9 @@ declare(strict_types=1);
* of embedding megabytes of base64, and deleting a texture asset is undoable for free
* because nothing here is removed when it stops being referenced.
*
* The flip side is that files outlive the projects that used them; `tools/gc-textures.mjs`
* collects the unreferenced ones.
* The flip side is that files outlive the projects that used them: nothing collects the
* unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the editor's
* image picker offers, so a picture one project dropped is still there for the next.
*/
final class TextureStore
{
@@ -91,8 +92,15 @@ final class TextureStore
return ['file' => $file, 'width' => $width, 'height' => $height, 'bytes' => strlen($bytes)];
}
/** Every stored file, newest first – the input of the garbage collector. */
/** @return list<array{file:string,bytes:int,updatedAt:string}> */
/**
* Every stored file, newest first – what the image picker offers.
*
* The pixel size is read from each header rather than left to the client: a texture asset
* records the size, so a picker that only knew the file name could not create one without
* decoding the picture first. An unreadable file reports 0 × 0 and the picker skips it.
*
* @return list<array{file:string,width:int,height:int,bytes:int,updatedAt:string}>
*/
public function list(): array
{
$items = [];
@@ -101,8 +109,11 @@ final class TextureStore
if (!self::isValidName($file)) {
continue;
}
$size = @getimagesize($path);
$items[] = [
'file' => $file,
'width' => (int) ($size[0] ?? 0),
'height' => (int) ($size[1] ?? 0),
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];