Add textures for sprites
Images are uploaded to a content-addressed blob endpoint and stored as data/textures/<sha256>.<ext>; the project only keeps the file name. The sprite points at a texture asset through an ordinary property of the new kind `asset`, so instance overrides, "Apply to container", "Reset to asset", undo and `Set property` work without any extra code. The new kind also replaces the ad-hoc asset picker the graph nodes faked as an enum. Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V. Images wider than 2048px are downscaled client-side and re-encoded to WebP if needed, because nginx caps uploads at 1MB; an image that already fits is uploaded byte for byte so pixel art is never resampled. The sprite colour default changes from red to white, since colour tints the texture multiplicatively and a tint of white shows an image as it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6168df41f8
commit
5a6af74e44
47 files changed
+1995
-110
No files matched your search
@@ -1,21 +1,28 @@
|
||||
import { createEmptyGraph } from '../graph/Graph';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { Asset, TextureAsset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { objectHolders, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
|
||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requirePropDef } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
/** A new asset of the given type with default properties (and empty content for scenes, behaviors, containers). */
|
||||
/**
|
||||
* A new asset of the given type with default properties (and empty content for scenes,
|
||||
* behaviors, containers). A texture starts without a picture; the upload fills it in, which
|
||||
* is why `CreateTextureCommand` exists rather than a plain create followed by an edit.
|
||||
*/
|
||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
const base = { id, name, props: defaultsFor(type) };
|
||||
switch (type) {
|
||||
case 'texture':
|
||||
return { ...base, type: 'texture', file: '', width: 0, height: 0 };
|
||||
case 'scene':
|
||||
return { ...base, type: 'scene', objects: [] };
|
||||
case 'camera':
|
||||
@@ -56,6 +63,40 @@ export class CreateAssetCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** The upload result a texture asset is built from (see `TextureStore.upload`). */
|
||||
export interface TextureImage {
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a texture asset for an image that is already stored on the server. Uploading is I/O and
|
||||
* therefore happens before the command: this one only records the result, so undo and redo of
|
||||
* an import cost nothing and never touch the network.
|
||||
*/
|
||||
export class CreateTextureCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly asset: TextureAsset;
|
||||
|
||||
constructor(name: string, image: TextureImage) {
|
||||
this.asset = { ...buildAsset('texture', name), type: 'texture', ...image };
|
||||
this.label = `Import texture "${name}"`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
// The name is the hash the server derived from the bytes; anything else never was uploaded
|
||||
// and would leave a texture that silently draws nothing.
|
||||
if (!isTextureFile(this.asset.file))
|
||||
throw new CommandError(`"${this.asset.file}" is not a stored image file`);
|
||||
project.assets.push(this.asset);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = removeById(project.assets, this.asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
|
||||
* objects parented below those (a subtree is removed as a whole). Container templates
|
||||
|
||||
@@ -36,14 +36,11 @@ export const variableParam: PropertyDef = {
|
||||
options: [],
|
||||
};
|
||||
|
||||
/** Options are empty on purpose: the graph inspector fills in the project's placeable assets. */
|
||||
export const assetParam: PropertyDef = {
|
||||
key: 'asset',
|
||||
label: 'Asset',
|
||||
kind: 'enum',
|
||||
default: '',
|
||||
options: [],
|
||||
};
|
||||
/**
|
||||
* A reference to an asset the node acts on (spawns, compares against). No `assetTypes`: any
|
||||
* placeable asset is a valid answer, and restricting them would be guessing for the user.
|
||||
*/
|
||||
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
|
||||
|
||||
// --- node factories --------------------------------------------------------------------------
|
||||
|
||||
|
||||
+33
-1
@@ -65,9 +65,41 @@ export interface ContainerAsset extends AssetBase {
|
||||
objects: SceneObject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded image. `file` names it in `data/textures/` and is content-addressed
|
||||
* (`{sha256}.{ext}`), so the same picture uploaded twice is one file and a stored file can be
|
||||
* cached forever – its name cannot ever come to mean different bytes.
|
||||
*
|
||||
* Like a scene's objects and a behavior's graph, this is intrinsic data rather than a
|
||||
* property: nothing about it is editable or overridable per instance. What *is* a property
|
||||
* is the reference to it – sprites carry `texture` (see PropertySchema), which is how
|
||||
* instance overrides, "Apply to container" and `Set property` work on it for free.
|
||||
*
|
||||
* `width`/`height` are the pixel size as uploaded, kept so a sprite can be sized to its
|
||||
* picture without waiting for the image to load. An empty `file` means the upload failed or
|
||||
* has not happened yet; such a texture draws as nothing.
|
||||
*/
|
||||
export interface TextureAsset extends AssetBase {
|
||||
type: 'texture';
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type Asset =
|
||||
SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
|
||||
| SpriteAsset
|
||||
| CameraAsset
|
||||
| TextAsset
|
||||
| SceneAsset
|
||||
| BehaviorAsset
|
||||
| ColliderAsset
|
||||
| ContainerAsset
|
||||
| TextureAsset;
|
||||
|
||||
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
||||
return asset.type === 'scene';
|
||||
}
|
||||
|
||||
export function isTextureAsset(asset: Asset): asset is TextureAsset {
|
||||
return asset.type === 'texture';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
|
||||
/** All asset types. Scenes and textures are assets too, but cannot be placed in scenes. */
|
||||
export const ASSET_TYPES = [
|
||||
'scene',
|
||||
'camera',
|
||||
@@ -7,6 +7,7 @@ export const ASSET_TYPES = [
|
||||
'behavior',
|
||||
'collider',
|
||||
'container',
|
||||
'texture',
|
||||
] as const;
|
||||
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
@@ -20,13 +21,18 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
behavior: 'Behavior',
|
||||
collider: 'Collider',
|
||||
container: 'Container',
|
||||
texture: 'Texture',
|
||||
};
|
||||
|
||||
/** Types that can be placed into a scene (everything except scenes themselves). */
|
||||
export type PlaceableAssetType = Exclude<AssetType, 'scene'>;
|
||||
/**
|
||||
* Types that can be placed into a scene. A texture is not one of them: it is picture data,
|
||||
* not something with a position – a sprite wears it (see the `texture` property). Dropping a
|
||||
* texture into the scene therefore creates a sprite, it does not place the texture itself.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture'>;
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
return type !== 'scene';
|
||||
return type !== 'scene' && type !== 'texture';
|
||||
}
|
||||
|
||||
export function isAssetType(value: unknown): value is AssetType {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
@@ -63,3 +64,40 @@ export function objectsWhere(
|
||||
export function findUsages(project: Project, assetId: string): SceneObjectRef[] {
|
||||
return objectsWhere(project, (o) => o.assetId === assetId);
|
||||
}
|
||||
|
||||
/** One place where an `asset`-kind property points at an asset. */
|
||||
export type PropertyUsage =
|
||||
| { on: 'asset'; asset: Asset; key: string }
|
||||
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
|
||||
|
||||
/**
|
||||
* Every property that references `assetId`, on assets and on the overrides of placed objects.
|
||||
*
|
||||
* This is the other half of "where is this asset used": `findUsages` finds assets that were
|
||||
* *placed*, which is meaningless for a texture – a texture is never placed, it is worn by
|
||||
* sprites through their `texture` property. Deleting one therefore has to count both, or it
|
||||
* reports "0 uses" for a picture that ten sprites are showing.
|
||||
*
|
||||
* A reference is deliberately left dangling rather than cleared when its asset goes: a
|
||||
* dangling one resolves to "no picture" everywhere it is read, and it comes back by itself
|
||||
* when the delete is undone.
|
||||
*/
|
||||
export function findPropertyUsages(project: Project, assetId: string): PropertyUsage[] {
|
||||
if (!assetId) return [];
|
||||
const result: PropertyUsage[] = [];
|
||||
const assetKeys = new Map<string, string[]>();
|
||||
for (const asset of project.assets) {
|
||||
const keys = schemaForAsset(asset)
|
||||
.filter((def) => def.kind === 'asset')
|
||||
.map((def) => def.key);
|
||||
assetKeys.set(asset.id, keys);
|
||||
for (const key of keys) if (asset.props[key] === assetId) result.push({ on: 'asset', asset, key });
|
||||
}
|
||||
for (const holder of objectHolders(project)) {
|
||||
for (const object of holder.objects) {
|
||||
for (const key of assetKeys.get(object.assetId) ?? [])
|
||||
if (object.overrides[key] === assetId) result.push({ on: 'object', holder, object, key });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { findAsset, type Project } from './Project';
|
||||
|
||||
/**
|
||||
* A stored image file name: `{sha256}.{png|jpg|webp|gif}`. Mirrors `TextureStore::isValidName`
|
||||
* on the server – the name is derived from the bytes there, so anything else in a document
|
||||
* was hand-written and must not be turned into a request.
|
||||
*/
|
||||
const FILE_PATTERN = /^[a-f0-9]{64}\.(png|jpg|webp|gif)$/;
|
||||
|
||||
export function isTextureFile(name: string): boolean {
|
||||
return FILE_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** A picture ready to be drawn: which file, and whether to smooth it when scaled. */
|
||||
export interface TextureRef {
|
||||
file: string;
|
||||
/** False for pixel art, where interpolation is the difference between crisp and mush. */
|
||||
smooth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture a bag of properties points at, or null when it points at nothing usable –
|
||||
* no reference, a dangling one (the texture was deleted), or one whose upload never
|
||||
* finished. Callers draw their plain colour in that case, which is what a sprite looked
|
||||
* like before textures existed.
|
||||
*/
|
||||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||||
const id = String(props.texture ?? '');
|
||||
if (!id) return null;
|
||||
const asset = findAsset(project, id);
|
||||
if (asset?.type !== 'texture' || !isTextureFile(asset.file)) return null;
|
||||
return { file: asset.file, smooth: String(asset.props.filter ?? 'linear') !== 'nearest' };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,16 @@ export type PropertyDef =
|
||||
/** One of a fixed set of string values, shown as a dropdown. */
|
||||
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
|
||||
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||||
| { key: string; label: string; kind: 'key'; default: string };
|
||||
| { key: string; label: string; kind: 'key'; default: string }
|
||||
/**
|
||||
* A reference to another asset, stored as its id (`''` = none) and picked from a
|
||||
* searchable dropdown. `assetTypes` limits what the picker offers; without it every
|
||||
* placeable asset is offered, which is what graph nodes acting on an object want.
|
||||
*
|
||||
* The id is not validated on load – a reference can dangle, exactly like a graph node's
|
||||
* asset parameter, and the places that read one have to cope with that.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] };
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
@@ -50,7 +59,31 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
sprite: [
|
||||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xe0553f },
|
||||
/*
|
||||
* The colour of the sprite: the fill of the box while it has no picture, and a multiplier
|
||||
* on the picture once it has one. White therefore means "no tint" – which is why it is the
|
||||
* default. A coloured default would silently stain every texture the moment it is applied,
|
||||
* and the first thing anyone would do is set it back to white.
|
||||
*
|
||||
* Tinting is worth keeping rather than suppressing for textured sprites: it is how one
|
||||
* white coin picture becomes ten differently coloured coins, per instance, for free.
|
||||
*/
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||||
{ key: 'texture', label: 'Texture', kind: 'asset', default: '', assetTypes: ['texture'] },
|
||||
],
|
||||
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property:
|
||||
// only how it is sampled is worth deciding, and that decision is the whole schema.
|
||||
texture: [
|
||||
{
|
||||
key: 'filter',
|
||||
label: 'Filter',
|
||||
kind: 'enum',
|
||||
default: 'linear',
|
||||
options: [
|
||||
{ value: 'linear', label: 'Smooth' },
|
||||
{ value: 'nearest', label: 'Pixel art' },
|
||||
],
|
||||
},
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
|
||||
behavior: [],
|
||||
@@ -91,11 +124,15 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Type schema with the common properties in front – the effective schema of an asset type. */
|
||||
/**
|
||||
* Type schema with the common properties in front – the effective schema of an asset type.
|
||||
* Only placeable types get them: `enabled` switches off a placed object, and a scene or a
|
||||
* texture is never placed, so the switch would have nothing to act on.
|
||||
*/
|
||||
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
|
||||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||||
type,
|
||||
type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]],
|
||||
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
|
||||
]),
|
||||
) as Record<AssetType, PropertySchema>;
|
||||
|
||||
@@ -153,8 +190,11 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
}
|
||||
case 'boolean':
|
||||
return Boolean(value);
|
||||
// An asset reference is an id like the other two are plain strings; whether it still
|
||||
// resolves is not decidable from a single property definition.
|
||||
case 'text':
|
||||
case 'key':
|
||||
case 'asset':
|
||||
return String(value);
|
||||
case 'enum': {
|
||||
const s = String(value);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
|
||||
/** Shown for an `asset` property that references nothing. */
|
||||
export const NO_ASSET_LABEL = 'None';
|
||||
|
||||
/**
|
||||
* How an `asset` reference reads wherever it is displayed: the referenced asset's name, "None"
|
||||
* for the empty id, and `<id>?` when the id no longer resolves.
|
||||
*
|
||||
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
|
||||
* only trace left of what was meant, and blanking it would make a deleted asset look like a
|
||||
* field that was never filled in. `project` may be null while a view has no project yet.
|
||||
*/
|
||||
export function assetRefLabel(project: Project | null, id: string): string {
|
||||
if (id === '') return NO_ASSET_LABEL;
|
||||
return (project ? findAsset(project, id) : undefined)?.name ?? `${id}?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The assets an `asset` property offers, in menu order. `assetTypes` restricts them to those
|
||||
* types; without it every *placeable* asset is offered, which is what a graph node acting on
|
||||
* an object means by "asset". Sorted by name (then type) so the order does not depend on when
|
||||
* the assets happened to be created.
|
||||
*/
|
||||
export function assetRefChoices(project: Project, assetTypes?: readonly AssetType[]): Asset[] {
|
||||
const allowed = (asset: Asset): boolean =>
|
||||
assetTypes ? assetTypes.includes(asset.type) : isPlaceableType(asset.type);
|
||||
return project.assets
|
||||
.filter(allowed)
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
|
||||
}
|
||||
|
||||
/** Menu entry for one candidate – the type disambiguates assets that share a name. */
|
||||
export function assetChoiceLabel(asset: Asset): string {
|
||||
return `${asset.name} (${asset.type})`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Project } from '../model/Project';
|
||||
import { statusMessage } from './http';
|
||||
import {
|
||||
type LoadedProject,
|
||||
type ProjectStore,
|
||||
@@ -62,14 +63,20 @@ export class HttpProjectStore implements ProjectStore {
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
|
||||
// Not every failure comes from our API: nginx rejects an oversized body itself and
|
||||
// answers with an HTML page. Such a response is explained by its status, and saying
|
||||
// "invalid JSON" instead of "too large" would send the user looking in the wrong place.
|
||||
// A *successful* response that is not JSON stays a defect and keeps its old message.
|
||||
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
|
||||
data = null;
|
||||
}
|
||||
if (response.status === 409) {
|
||||
const rev = (data as { revision?: number } | null)?.revision ?? -1;
|
||||
throw new StaleRevisionError(rev);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = (data as { error?: string } | null)?.error ?? response.statusText;
|
||||
const message =
|
||||
(data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText);
|
||||
throw new Error(`API ${method} ${route}: ${message} (${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { statusMessage } from './http';
|
||||
import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore';
|
||||
|
||||
/**
|
||||
* Talks to the texture routes of `api/index.php`. Routing goes through the `r` query parameter
|
||||
* (`?r=textures/{file}`) for the same reason as `HttpProjectStore`: nginx does not pass
|
||||
* PATH_INFO to PHP.
|
||||
*/
|
||||
export class HttpTextureStore implements TextureStore {
|
||||
constructor(private readonly endpoint: string) {}
|
||||
|
||||
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture> {
|
||||
return this.request<UploadedTexture>('POST', 'textures', { bytes, type });
|
||||
}
|
||||
|
||||
async list(): Promise<StoredTexture[]> {
|
||||
return this.request<StoredTexture[]>('GET', 'textures');
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `textures/${encodeURIComponent(file)}`);
|
||||
}
|
||||
|
||||
/** One round trip; every failure becomes an `Error` carrying the server's message. */
|
||||
private async request<T>(
|
||||
method: string,
|
||||
route: string,
|
||||
payload?: { bytes: Blob | ArrayBuffer; type: string },
|
||||
): Promise<T> {
|
||||
const url = `${this.endpoint}?r=${encodeURIComponent(route)}`;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||
body: payload?.bytes,
|
||||
});
|
||||
if (response.status === 204) return undefined as T;
|
||||
const text = await response.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// An upload is exactly the request nginx refuses before PHP sees it, and its answer is
|
||||
// an HTML page. Explain such a failure from the status; a successful non-JSON body is a defect.
|
||||
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
|
||||
data = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText);
|
||||
throw new Error(`API ${method} ${route}: ${message} (${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** What the server made of an uploaded image; the payload a texture asset is built from. */
|
||||
export interface UploadedTexture {
|
||||
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** One stored image as the server lists it. */
|
||||
export interface StoredTexture {
|
||||
file: string;
|
||||
bytes: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload boundary for images, the counterpart of `ProjectStore`. The editor only talks to this
|
||||
* interface. An upload is raw bytes rather than a multipart form: name, size and format are
|
||||
* derived from the image itself on the server, so there is nothing else to send.
|
||||
*/
|
||||
export interface TextureStore {
|
||||
/**
|
||||
* Stores an image and returns its file name and pixel size. `type` only fills the
|
||||
* Content-Type header – the server trusts the bytes, not the label – and uploading the same
|
||||
* picture twice yields the same file, because the name is the hash of its content.
|
||||
*/
|
||||
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture>;
|
||||
list(): Promise<StoredTexture[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The bit of HTTP handling both stores share. It exists because a failed response does not
|
||||
* always come from our API: nginx caps request bodies at 1 MB and answers a larger one with
|
||||
* its own HTML page, so there is no `{error}` object to read the reason from.
|
||||
*/
|
||||
|
||||
/** Reasons for the statuses that can arrive without an API body of their own. */
|
||||
const STATUS_MESSAGES: Record<number, string> = {
|
||||
400: 'Bad request',
|
||||
403: 'Forbidden',
|
||||
404: 'Not found',
|
||||
413: 'Request too large',
|
||||
500: 'Server error',
|
||||
502: 'Server unavailable',
|
||||
503: 'Server unavailable',
|
||||
504: 'Server timed out',
|
||||
};
|
||||
|
||||
/** What to tell the user about a failed response whose body is not our JSON – the status is all there is. */
|
||||
export function statusMessage(status: number, statusText: string): string {
|
||||
return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { isAssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import {
|
||||
coerceValue,
|
||||
@@ -62,6 +63,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
variables: variables.get(id) ?? [],
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a) });
|
||||
} else {
|
||||
assets.push({ id, type, name, props });
|
||||
}
|
||||
@@ -181,6 +184,18 @@ function normalizeGraph(raw: unknown): Graph {
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture of a texture asset. A file name that is not the server's content-addressed form
|
||||
* is dropped rather than kept: it can only have been hand-written, and a request built from it
|
||||
* would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour.
|
||||
*/
|
||||
function normalizeTexture(raw: Record<string, unknown>): { file: string; width: number; height: number } {
|
||||
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
|
||||
const size = (value: unknown): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||||
return { file, width: size(raw.width), height: size(raw.height) };
|
||||
}
|
||||
|
||||
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||||
const r = asRecord(raw);
|
||||
const bag: PropertyBag = {};
|
||||
|
||||
Reference in new issue
Block a user