A picture in the editor may now be drawn instead of uploaded, which is the piece #129 (Vector art) and #130 (Pixel editor) both need and neither could build alone: TextureRef.file has been a file name on the server, isTextureFile() checking a sha256 hash plus an extension and TextureLibrary fetching it over HTTP, so an asset that paints its own image had nowhere to put itself - the key now also takes the form gen:<assetId>@<revision>, which an uploaded file can never look like, and everything that only passes a ref along (sprite, tile, tilemap mesh, particle source, shaders, aspect ratio) is untouched because it still sees a Texture; the revision is a string, not a counter, which is the one deviation from the plan in the issue and costs nothing - gen:a1@7 and gen:a1@c0ffee go through the same door, so a generator may fingerprint its own content instead of relying on every command to remember to bump a number; two registries keyed by asset type as a plain string, so a plugin may bring its own type - registerPictureSource(type, read) in the new core/model/generated.ts tells the model canvas size and revision and resolvePicture() builds the ref from it (no rect, no slice, smooth from props.filter like a texture), and registerPictureGenerator(type, draw) in the new viewport/generatedPictures.ts hands out the finished texture, projectPictureFactory(() => state.project) tying the two together per session in App.ts; invalidation is a rule instead of guesswork at twenty call sites: GeneratedPictures<T> holds one picture per asset keyed on the revision and frees the old one as soon as the new is drawn, TextureLibrary destroying it and dropping the crops cut from it, while a generator that cannot draw yet is not remembered but asked again next time - the class imports Pixi as a type only, so the whole bookkeeping is measurable without a GPU; carried along are the picture picker (a drawn asset sits among the textures by name, without frames), the delete warning (counts tiles and animations for it too) and the asset context menu, where "Create sprite from picture" now covers it while "Create animation from texture" stays texture-only because it needs frames; one finding while probing - crop() could not have cut a generated texture at all, a RenderTexture having no source.resource to draw back from and drawImage running onto null, so it is handed out whole, a drawn picture having no sheet whose neighbouring row could bleed in; 18 new unit tests, 10 in tests/core/generated-picture.test.ts (building and splitting the key, an uploaded file is not one, no registry means no picture, resolving to canvas size, a new revision is a new key, a canvas without a size, the filter, the sprite size, picker order) and 8 in tests/viewport/generated-pictures.test.ts (drawing once, freeing the old revision and only for its own asset, null is not remembered, no key means nothing drawn, a session change gives everything back, peek), and three counter-checks hold: dropping generated assets from resolvePicture fails 5 of the 10 core tests, not freeing the old revision fails 1 viewport test, and narrowing the picker back to textures fails 3; no e2e check and the check sum stays 1157, because without a generator the groundwork shows nothing in the browser - that evidence comes with #129 (#185)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 20:09:42 +02:00
1 parent eac50e4194
commit cb1804dcf2
10 files changed
+548 -20

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
/**
* Pictures that are drawn rather than uploaded – vector art (#129), the pixel editor (#130).
*
* A picture is named by `TextureRef.file` everywhere: the sprite, the tile, the tilemap mesh, the
* particle source and the picker all pass that string around. A generated picture therefore gets a
* key of its own, `gen:<assetId>@<revision>`, which no uploaded file can look like (those are
* `<sha256>.<ext>`, see `isTextureFile`). Whoever draws it is asked for it under that key.
*
* The revision is the other half of the contract: it changes whenever the drawing changes, so a
* changed picture is a new key. Nothing has to ask "is this still valid" – the library rasterises
* the key it is given and lets the old one go. A counter and a fingerprint of the content both
* work; a fingerprint cannot be forgotten in a command, which is why the token is a string.
*/
import type { Asset } from './Asset';
const PREFIX = 'gen:';
/** Anything but `@`, which separates the two halves, and nothing empty. */
const TOKEN = /^[^@]+$/;
/** What an asset that draws its own picture tells the picture path. */
export interface GeneratedPicture {
/** Canvas size in pixels; the sprite takes its size from it, as it does from an image. */
width: number;
height: number;
/** Changes with the drawing – a counter or a fingerprint of the content. */
revision: string | number;
}
/** Reads the canvas of one asset type; null when this asset draws nothing (yet). */
export type PictureSource = (asset: Asset) => GeneratedPicture | null;
/**
* Keyed by asset type as a plain string, not by `AssetType`: a plugin may bring an asset type of
* its own (#126), and the picture path has no reason to know the closed list.
*/
const sources = new Map<string, PictureSource>();
/** Registers the reader for an asset type; the returned function takes it back out again. */
export function registerPictureSource(type: string, read: PictureSource): () => void {
sources.set(type, read);
return () => {
if (sources.get(type) === read) sources.delete(type);
};
}
export function generatesPictures(type: string): boolean {
return sources.has(type);
}
/** The canvas this asset draws, or null when its type draws none. */
export function generatedPictureOf(asset: Asset): GeneratedPicture | null {
const picture = sources.get(asset.type)?.(asset) ?? null;
if (!picture) return null;
return picture.width > 0 && picture.height > 0 ? picture : null;
}
export function generatedKey(assetId: string, revision: string | number): string {
return `${PREFIX}${assetId}@${revision}`;
}
export function isGeneratedFile(file: string): boolean {
return parseGeneratedKey(file) !== null;
}
/** The two halves of a key, or null for anything that is not one – an uploaded file included. */
export function parseGeneratedKey(file: string): { assetId: string; revision: string } | null {
if (!file.startsWith(PREFIX)) return null;
const rest = file.slice(PREFIX.length);
const at = rest.lastIndexOf('@');
if (at <= 0) return null;
const assetId = rest.slice(0, at);
const revision = rest.slice(at + 1);
if (!TOKEN.test(assetId) || !TOKEN.test(revision)) return null;
return { assetId, revision };
}
+22 -3
View File
@@ -1,4 +1,5 @@
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
import type { Asset, FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
import { generatedKey, generatedPictureOf } from './generated';
import { type PropertyBag, textOf, type Vec2 } from './Properties';
import { findAsset, type Project } from './Project';
@@ -85,15 +86,33 @@ export function resolveTexture(project: Project, props: PropertyBag): TextureRef
return resolvePicture(project, textOf(props.texture ?? ''));
}
/** A texture or frame id; any other asset's id is no picture. */
/** A texture, a frame or a generating asset; any other asset's id is no picture. */
export function resolvePicture(project: Project, id: string): TextureRef | null {
if (!id) return null;
const asset = findAsset(project, id);
if (asset) return asset.type === 'texture' ? refFor(asset, null) : null;
if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset);
const found = findFrame(project, id);
return found ? refFor(found.asset, found.frame) : null;
}
/**
* An asset that draws its own picture (#185) reads like a texture of its canvas size: the key
* stands where the file name stands, and the size is what a new sprite is measured by. No frames
* and no slicing – those are cut out of an uploaded sheet.
*/
function generatedRef(asset: Asset): TextureRef | null {
const picture = generatedPictureOf(asset);
if (!picture) return null;
return {
file: generatedKey(asset.id, picture.revision),
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
rect: null,
sourceWidth: picture.width,
sourceHeight: picture.height,
slice: null,
};
}
function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null {
if (!isTextureFile(asset.file)) return null;
return {