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 -6
View File
@@ -215,9 +215,17 @@ design: because it is an ordinary property, instance overrides, **Apply to conta
One coin instance can wear a different picture than its nine siblings, and swapping a texture
at runtime is a graph node that already exists.
**Getting an image in.** Three ways, all ending in the same import: **+ → Texture** in the
asset panel, **dropping image files onto the canvas**, or **Ctrl+V** pasting one. Several files
at once are imported one after another; a file that fails does not stop the rest.
**Getting an image in.** **Dropping image files onto the canvas** or **Ctrl+V** pasting one
uploads it and makes the asset in one go; several files at once are imported one after another,
and a file that fails does not stop the rest.
**+ → Texture** asks a different question and so opens a different dialog: not the machine's
file system, but **every picture the server already holds**, as a grid of thumbnails. Files are
never collected (below), so a picture one project dropped is still there for the next to pick
up, and uploading the same bytes twice buys nothing. Picking one and pressing **Use image**
makes a texture asset on that file; **Upload…** is the way in for a picture 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 rather than making a second one.
**Where it goes.** The image is uploaded as raw bytes to `POST ?r=textures` and stored as
`data/textures/{sha256}.{ext}` — the file is named after its own content. That name buys four
@@ -440,7 +448,7 @@ npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run
npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # where the browser runs; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 318 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 330 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
```
@@ -516,7 +524,7 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` |
| PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{revision}` when stale |
| DELETE | `projects/{id}` | 204 |
| GET | `textures` | `[{file,bytes,updatedAt}]` |
| GET | `textures` | `[{file,width,height,bytes,updatedAt}]` – what the image picker offers |
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
| DELETE | `textures/{file}` | 204 |
@@ -545,4 +553,5 @@ Several scenes in one run (a `Load scene` node – screens are containers
today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to
the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no
nine-slice. Slicing cuts a regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected.
sit on the sheet. Unreferenced image files are never collected – deliberately, since the image
picker offers them again; nothing deletes a stored image except `DELETE ?r=textures/{file}`.