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:
1 parent
2f4dc843d7
commit
88ec367ab0
16 files changed
+774
-56
No files matched your search
+48
-20
@@ -1,7 +1,7 @@
|
||||
import { CreateTextureCommand } from '../core/commands/assetCommands';
|
||||
import { nextAssetName } from '../core/commands/naming';
|
||||
import type { TextureAsset } from '../core/model/Asset';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import type { TextureStore, UploadedTexture } from '../core/store/TextureStore';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
||||
@@ -110,10 +110,7 @@ export async function importImages(
|
||||
if (files.length === 0) return [];
|
||||
const outcomes: ImportOutcome[] = [];
|
||||
for (const file of files) outcomes.push(await importImage(file, deps));
|
||||
|
||||
const created = outcomes.filter((outcome) => outcome.assetId !== null);
|
||||
const last = created[created.length - 1];
|
||||
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
|
||||
selectLast(outcomes, deps);
|
||||
deps.state.setNotice(importNotice(outcomes));
|
||||
return outcomes;
|
||||
}
|
||||
@@ -122,26 +119,57 @@ async function importImage(file: File, deps: TextureImportDeps): Promise<ImportO
|
||||
try {
|
||||
const image = await prepareImage(file);
|
||||
const uploaded = await deps.textures.upload(image.blob, image.type);
|
||||
|
||||
// The server stores by content hash, so a re-imported picture comes back as the file the
|
||||
// project already references; two assets on one file would disagree about `filter`.
|
||||
const existing = deps.state.project.assets.find(
|
||||
(asset): asset is TextureAsset => asset.type === 'texture' && asset.file === uploaded.file,
|
||||
);
|
||||
if (existing) {
|
||||
return { name: file.name, assetId: existing.id, error: null, reused: true };
|
||||
}
|
||||
|
||||
const name = nextAssetName(deps.state.project, assetNameFor(file));
|
||||
const command = new CreateTextureCommand(name, uploaded);
|
||||
// A rejected command has already put its reason into the notice; that is the message here.
|
||||
if (!deps.state.execute(command)) return { name: file.name, assetId: null, error: deps.state.notice };
|
||||
return { name: file.name, assetId: command.asset.id, error: null, reused: false };
|
||||
return adopt(uploaded, assetNameFor(file), file.name, deps);
|
||||
} catch (error) {
|
||||
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an asset for a file the server holds. `label` names the source in messages, `nameBase` is
|
||||
* what the asset is called.
|
||||
*/
|
||||
function adopt(
|
||||
image: UploadedTexture,
|
||||
nameBase: string,
|
||||
label: string,
|
||||
deps: TextureImportDeps,
|
||||
): ImportOutcome {
|
||||
// The server stores by content hash, so a re-imported picture comes back as the file the
|
||||
// project already references; two assets on one file would disagree about `filter`.
|
||||
const existing = deps.state.project.assets.find(
|
||||
(asset): asset is TextureAsset => asset.type === 'texture' && asset.file === image.file,
|
||||
);
|
||||
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
|
||||
|
||||
const name = nextAssetName(deps.state.project, nameBase);
|
||||
const command = new CreateTextureCommand(name, image);
|
||||
// A rejected command has already put its reason into the notice; that is the message here.
|
||||
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
|
||||
return { name: label, assetId: command.asset.id, error: null, reused: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts a picture the server already holds – no upload, the bytes are there. Nothing collects
|
||||
* unreferenced files, so a picture one project dropped is still there for the next to pick up.
|
||||
*/
|
||||
export function importStoredImage(image: UploadedTexture, deps: TextureImportDeps): ImportOutcome {
|
||||
const outcome = adopt(image, 'Texture', image.file, deps);
|
||||
selectLast([outcome], deps);
|
||||
deps.state.setNotice(
|
||||
outcome.error ??
|
||||
(outcome.reused ? 'That image is already in the project.' : 'Added the image to the project.'),
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Selects the asset the batch created last, so the panel shows what just arrived. */
|
||||
function selectLast(outcomes: readonly ImportOutcome[], deps: TextureImportDeps): void {
|
||||
const created = outcomes.filter((outcome) => outcome.assetId !== null);
|
||||
const last = created[created.length - 1];
|
||||
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
|
||||
}
|
||||
|
||||
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
|
||||
export function importNotice(outcomes: readonly ImportOutcome[]): string {
|
||||
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
|
||||
|
||||
Reference in new issue
Block a user