From 0bbc45277e57887443e488fb45bc8ea6ba52590b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 15:25:14 +0200 Subject: [PATCH 1/2] Support animation blends in scene documents and editor --- AGENTS.md | 2 +- apps/web/src/editor/README.md | 9 ++ apps/web/src/editor/inspector.ts | 124 ++++++++++++++---- apps/web/src/editor/script.ts | 75 ++++++++--- packages/minerender/reference/concepts.md | 14 ++ .../minerender/src/scene/SceneDocument.ts | 17 ++- .../src/scene/SceneDocumentLoader.ts | 16 ++- .../minerender/test/SceneDocument.test.ts | 112 +++++++++++++++- .../test/SceneDocumentCodec.test.ts | 8 +- 9 files changed, 325 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a207615..e96c07d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ The private Yarn workspace root contains the public library and its consumers. U **Node rendering** (`src/env/node/NodeRenderer.ts`): The Node entry exports `NodeRenderer.create({ width, height, ...options })` for an owned native WebGL 2 context through optional `gl@9.0.0-rc.10` (experimental WebGL 2). The factory disables antialiasing and rejects an explicit `true`; `render.pixelRatio` increases image resolution. `new Renderer(options, surface)` and `new NodeRenderer(options, surface)` accept a caller-owned `RendererSurface` outside the deep-merged options: canvas, context, positive integer logical dimensions, and an optional native resize callback receiving physical dimensions. Alpha-enabled contexts injected into `NodeRenderer` require `premultipliedAlpha: true`. Node renderers disable stats, controls, and automatic window resizing, and reject `start()`/`toImage()`; use `renderOnce()` or `renderToBuffer()`. See [platform setup](./packages/minerender/reference/platforms.md). -**Scene documents** (`src/scene/`): Versioned `SceneDocument` JSON is validated before any asset requests. `SceneDocumentLoader` loads each object without instancing inside its own nested `DocumentObjectScene` under a transform `Group`. A document attaches to its parent only after every object loads successfully. Item definitions and GUI item layers accept an optional `context` (`SceneItemContext`: components, properties, count, item references by item ID, and `displayContext` on definitions) that the loader passes to `Models.getMerged`. Async `encodeSceneDocument` / `decodeSceneDocument` validate and convert portable links using `v1.` plus zlib-wrapped deflate in unpadded base64url; compressed data and UTF-8 JSON are each limited to 1 MiB. +**Scene documents** (`src/scene/`): Versioned `SceneDocument` JSON is validated before any asset requests. `SceneDocumentLoader` loads each object without instancing inside its own nested `DocumentObjectScene` under a transform `Group`. A document attaches to its parent only after every object loads successfully. Entity animations accept either one `name` or a nonempty `clips` list of names and optional nonnegative weights, with shared loop, speed, time, and pause settings. The editor adjusts blend weights without restarting playback, and scene JSON and embeds preserve the blend. Item definitions and GUI item layers accept an optional `context` (`SceneItemContext`: components, properties, count, item references by item ID, and `displayContext` on definitions) that the loader passes to `Models.getMerged`. Async `encodeSceneDocument` / `decodeSceneDocument` validate and convert portable links using `v1.` plus zlib-wrapped deflate in unpadded base64url; compressed data and UTF-8 JSON are each limited to 1 MiB. **Embeds** (`apps/embed/`): One `/embed/` page accepts exactly one shorthand (`skin`, `block`, `item`, `entity`, `model`), encoded `#scene`, or HTTPS `src`. URL camera/version values override document values; missing cameras fit the scene. Documents use the shared scene schema and a 1 MiB payload limit. Drawing buffers are limited to 4,096 pixels per side and 4,194,304 pixels in total independently of iframe size. Fragment changes replace the scene and discard stale loads. Off-screen embeds release their renderer and WebGL context, then recreate them with the saved camera and entity clip playback time when visible; hidden tabs pause rendering. Context loss shows a recovery message and **Retry** button; native context restoration redraws the scene. The editor's **Copy embed** encodes the document through the shared codec, preserving camera, background, and aspect ratio; `EMBED_URL` overrides its build-time destination. Host-page messaging and legacy query aliases are unsupported. V1 embed routes remain unchanged. diff --git a/apps/web/src/editor/README.md b/apps/web/src/editor/README.md index e0ea9494..42dfb5cb 100644 --- a/apps/web/src/editor/README.md +++ b/apps/web/src/editor/README.md @@ -60,6 +60,15 @@ use another host or a local embed server; the default is `https://beta.minerender.org/embed/`. See the [embed workspace README](../../../embed/README.md) for local development and deployment commands. +In an entity's **Animation** controls, select **Add clip** to combine clips, then +adjust each **Weight**. Weight 0 removes that clip's offset; weight 1 applies it +fully. Weights add together without normalization, and the numeric field also +accepts values above 1. Weight changes keep playback at its current time. **Loop**, +**Speed**, **Time (seconds)**, and the playback buttons control all selected clips. +Until you change **Loop**, each clip uses its own loop setting. Setting a time or +scrubbing pauses at that frame. JSON saves and copied embeds retain the selected +clips, weights, and shared playback time, including while paused. + Use **GUI layers** to add, edit, reorder, or remove texture, item, and text layers, or to insert the layers of a chest background, boss bar, book page, or tooltip. The controls expose positions, sizes, and each layer's content, including an item layer's diff --git a/apps/web/src/editor/inspector.ts b/apps/web/src/editor/inspector.ts index f130208a..a9eef9bd 100644 --- a/apps/web/src/editor/inspector.ts +++ b/apps/web/src/editor/inspector.ts @@ -7,7 +7,7 @@ interface InspectorContext { onError: (error: unknown) => void; getAnimationTime?: () => number; onSkinPoseChange?: (pose: SceneSkinDefinition["pose"]) => void; - onAnimationChange?: (animation: SceneEntityDefinition["animation"]) => void | Promise; + onAnimationChange?: (animation: SceneEntityDefinition["animation"], seek?: boolean) => void | Promise; } let nextListId = 0; @@ -325,47 +325,119 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD animationStatus.textContent = entries.length ? "" : "This entity has no available animation clips."; if (!entries.length) return; const drawn = entity.layers ?? ["main", ...passes.filter(pass => pass.when === undefined || entity.when?.includes(pass.when)).map(pass => pass.layer)]; - const emitAnimation = () => { - if (context.onAnimationChange) submit(() => context.onAnimationChange!(structuredClone(entity.animation))); + const emitAnimation = (seek = false) => { + if (context.onAnimationChange) submit(() => context.onAnimationChange!(structuredClone(entity.animation), seek)); else emit(); }; - const picker = select(animation, "Clip", [["", "None"], ...entries.map(([name, clip]) => [name, `${name} (${clip.layer ?? "main"})`] as [string, string])], entity.animation?.name ?? "", name => { - entity.animation = name ? { name, loop: clips![name].loop, speed: 1, time: 0 } : undefined; + const selectedClips = (): { name: string; weight?: number }[] => entity.animation?.clips + ?? (entity.animation?.name ? [{ name: entity.animation.name }] : []); + const keepTime = () => { + if (entity.animation) entity.animation.time = context.getAnimationTime?.() ?? entity.animation.time ?? 0; + }; + const setClips = (selected: { name: string; weight?: number }[]) => { + keepTime(); + const { loop, speed, time, paused } = entity.animation ?? { speed: 1, time: 0 }; + entity.animation = selected.length ? { clips: selected, loop, speed, time, paused } : undefined; emitAnimation(); + }; + const choices = entries.map(([name, clip]) => [name, `${name} (${clip.layer ?? "main"})`] as [string, string]); + const enableLayers = (picker: HTMLSelectElement) => { + for (const entry of Array.from(picker.options)) { + const clip = clips?.[entry.value]; + if (clip && !drawn.includes(clip.layer ?? "main")) { + entry.disabled = true; + entry.title = `Enable the ${clip.layer ?? "main"} layer to play this clip.`; + } + } + }; + note(animation, "Clip offsets add together. Weight 0 removes an offset; weight 1 applies it fully. Weights are not normalized."); + const selected = document.createElement("div"); + animation.append(selected); + const add = document.createElement("div"); + add.className = "editor-row"; + animation.append(add); + const available = entries.find(([, clip]) => drawn.includes(clip.layer ?? "main"))?.[0]; + const picker = select(add, "Add clip", choices, available ?? "", () => {}); + enableLayers(picker); + const addClip = button(add, "Add clip", () => { + setClips([...selectedClips(), { name: picker.value, weight: 1 }]); + renderClips(); renderPlayback(false); + selected.querySelectorAll("select").item(selectedClips().length - 1)?.focus(); }); - for (const entry of Array.from(picker.options)) { - const clip = clips?.[entry.value]; - if (clip && !drawn.includes(clip.layer ?? "main")) { - entry.disabled = true; - entry.title = `Enable the ${clip.layer ?? "main"} layer to play this clip.`; - } - } + picker.disabled = addClip.disabled = !available; + if (!available) note(animation, "Enable a clip's entity layer to add an animation."); const playback = document.createElement("div"); animation.append(playback); + function renderClips() { + selected.replaceChildren(); + if (!selectedClips().length) note(selected, "Add a clip to start playback."); + selectedClips().forEach((selection, index) => { + const group = document.createElement("fieldset"); + group.className = "editor-section"; + selected.append(group); + const clip = select(group, `Clip ${index + 1}`, choices, selection.name, name => { + setClips(selectedClips().map((value, i) => i === index ? { ...value, name } : value)); + renderPlayback(false); + }); + enableLayers(clip); + const row = document.createElement("div"); + row.className = "editor-row"; + group.append(row); + const range = document.createElement("input"); + range.type = "range"; + range.min = "0"; + range.max = String(Math.max(1, Math.ceil(selection.weight ?? 1))); + range.step = "0.01"; + range.value = String(selection.weight ?? 1); + range.setAttribute("aria-label", `Clip ${index + 1} weight slider`); + field(row, "Weight", range); + const changeWeight = (weight: number) => { + keepTime(); + if (entity.animation?.clips) entity.animation.clips[index].weight = weight; + else if (entity.animation) { + const { name, ...settings } = entity.animation; + entity.animation = { ...settings, clips: [{ name: name!, weight }] }; + } + range.max = String(Math.max(1, Math.ceil(weight))); + range.value = value.value = String(weight); + emitAnimation(); + }; + const value = number(row, "Weight value", selection.weight ?? 1, 0, undefined, "any", changeWeight); + value.setAttribute("aria-label", `Clip ${index + 1} weight value`); + range.addEventListener("input", () => changeWeight(range.valueAsNumber)); + button(group, `Remove clip ${index + 1}`, () => { + setClips(selectedClips().filter((_, i) => i !== index)); + renderClips(); + renderPlayback(false); + picker.focus(); + }); + }); + } function renderPlayback(liveTime = true) { playback.replaceChildren(); if (!entity.animation) return; - const clip = clips?.[entity.animation.name]; - const keepTime = () => { entity.animation!.time = context.getAnimationTime?.() ?? entity.animation!.time ?? 0; }; - const displayTime = (time: number) => clip?.length ? (entity.animation!.loop ?? clip.loop) ? time % clip.length : Math.min(time, clip.length) : time; - check(playback, "Loop", entity.animation.loop ?? clip?.loop ?? false, loop => { keepTime(); entity.animation!.loop = loop; emitAnimation(); }); + const activeClips = selectedClips().map(selection => clips?.[selection.name]).filter(clip => clip !== undefined); + const duration = Math.max(0, ...activeClips.map(clip => clip.length)); + const loop = check(playback, "Loop", entity.animation.loop ?? activeClips.every(clip => clip.loop), loop => { keepTime(); entity.animation!.loop = loop; emitAnimation(); }); + loop.indeterminate = entity.animation.loop === undefined && activeClips.some(clip => clip.loop) && !activeClips.every(clip => clip.loop); + if (entity.animation.loop === undefined) note(playback, "Clips use their own loop settings until you change Loop."); number(playback, "Speed", entity.animation.speed ?? 1, 0, undefined, 0.1, speed => { keepTime(); entity.animation!.speed = speed; emitAnimation(); }); - const time = displayTime((liveTime ? context.getAnimationTime?.() : undefined) ?? entity.animation.time ?? 0); + const time = (liveTime ? context.getAnimationTime?.() : undefined) ?? entity.animation.time ?? 0; const seek = (time: number) => { entity.animation!.time = time; entity.animation!.paused = true; syncPlayback(time); - emitAnimation(); + emitAnimation(true); }; - const timeInput = number(playback, "Time (seconds)", time, 0, clip?.length, "any", seek); + const timeInput = number(playback, "Time (seconds)", time, 0, undefined, "any", seek); const scrub = document.createElement("input"); scrub.type = "range"; scrub.min = "0"; - scrub.max = String(clip?.length ?? 0); + scrub.max = String(duration); scrub.step = "0.01"; scrub.value = String(time); - scrub.disabled = !clip?.length; + scrub.disabled = !duration; scrub.addEventListener("input", () => seek(scrub.valueAsNumber)); field(playback, "Scrub animation", scrub); const row = document.createElement("div"); @@ -374,7 +446,7 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD const play = button(row, entity.animation.paused ? "Play" : "Pause", () => { keepTime(); entity.animation!.paused = !entity.animation!.paused; - syncPlayback(displayTime(entity.animation!.time!)); + syncPlayback(entity.animation!.time!); emitAnimation(); }); function syncPlayback(time: number) { @@ -382,10 +454,11 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD scrub.value = String(time); play.textContent = entity.animation!.paused ? "Play" : "Pause"; } - button(row, "Restart", () => { entity.animation!.time = 0; entity.animation!.paused = false; syncPlayback(0); emitAnimation(); }); - button(row, "Stop", () => { delete entity.animation; picker.value = ""; emitAnimation(); renderPlayback(); }); - if (clip) note(playback, `Clip duration: ${clip.length.toFixed(2)} seconds. Set time to pause at a frame.`); + button(row, "Restart", () => { entity.animation!.time = 0; entity.animation!.paused = false; syncPlayback(0); emitAnimation(true); }); + button(row, "Stop", () => { delete entity.animation; emitAnimation(); renderClips(); renderPlayback(); }); + note(playback, `${activeClips.length === 1 ? "Clip duration" : "Longest clip"}: ${duration.toFixed(2)} seconds. Set time to pause at a frame.`); } + renderClips(); renderPlayback(); }).catch(error => { if (active) { animationStatus.textContent = "Could not load animation clips."; fail(error, animation); } @@ -614,6 +687,7 @@ function check(parent: HTMLElement, title: string, value: boolean, change: (valu input.addEventListener("change", () => change(input.checked)); label.append(input, document.createTextNode(title)); parent.append(label); + return input; } function number(parent: HTMLElement, title: string, value: number, min: number | undefined, max: number | undefined, step: number | "any", change: (value: number) => void) { diff --git a/apps/web/src/editor/script.ts b/apps/web/src/editor/script.ts index 1244b106..d2b1795f 100644 --- a/apps/web/src/editor/script.ts +++ b/apps/web/src/editor/script.ts @@ -1,6 +1,7 @@ import { AssetKey, AssetLoader, Entities, Renderer, SceneDocumentLoader, SceneExporter, encodeSceneDocument, isEntityObject, - type LoadedSceneObject, type SceneDocument, type SceneObjectDefinition, type SceneSkinDefinition, type SceneEntityDefinition + type EntityAnimation, type EntityObject, type LoadedSceneObject, type SceneDocument, type SceneObjectDefinition, + type SceneSkinDefinition, type SceneEntityDefinition } from "minerender"; import { Box3, Box3Helper, Color, GridHelper, Group, Raycaster, Vector2, Vector3 } from "three"; import { TransformControls } from "three/examples/jsm/controls/TransformControls.js"; @@ -29,6 +30,22 @@ function equalJson(left: unknown, right: unknown): boolean { && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && equalJson(a[key], b[key])); } +function animationClips(animation: NonNullable): { name: string; weight: number }[] { + return animation.name !== undefined ? [{ name: animation.name, weight: 1 }] + : animation.clips.map(clip => ({ name: clip.name, weight: clip.weight ?? 1 })); +} + +function playEntityAnimation(object: EntityObject, asset: string, animation: NonNullable, + clips: Record | undefined): void { + const selected = animationClips(animation); + const resolved = selected.map(({ name }) => { + const clip = clips?.[name]; + if (!clip) throw new Error(`Entity ${asset} has no animation "${name}"`); + return clip; + }); + object.playAnimations(resolved, { ...animation, weights: selected.map(clip => clip.weight) }); +} + function report(message: string, error = false): void { status.textContent = message; status.classList.toggle("error", error); @@ -88,13 +105,20 @@ async function start(): Promise { const selected = () => sceneDocument.objects.find(object => object.id === selectedId); const currentObject = () => selectedId ? objects.get(selectedId) : undefined; - const snapshot = (): SceneDocument => ({ - ...clone(sceneDocument), - camera: { + const snapshot = (): SceneDocument => { + const next = clone(sceneDocument); + for (const definition of next.objects) { + const object = objects.get(definition.id)?.object; + if (definition.type === "entity" && definition.animation && object && isEntityObject(object)) { + definition.animation.time = object.animationTime; + } + } + next.camera = { position: renderer.camera.position.toArray() as [number, number, number], target: renderer.orbitControls!.target.toArray() as [number, number, number] - } - }); + }; + return next; + }; const historySnapshot = (): string => { const { camera, ...definition } = sceneDocument; return JSON.stringify(definition); @@ -105,7 +129,7 @@ async function start(): Promise { element("redo").disabled = busy || loadingObjects.size > 0 || historyIndex >= history.length - 1; element("duplicate").disabled = busy || !selected(); element("delete").disabled = busy || !selected(); - for (const id of ["new-scene", "import-scene", "restore-scene", "apply-version", "export-scene", "copy-embed"]) element(id).disabled = busy; + for (const id of ["new-scene", "import-scene", "restore-scene", "apply-version", "save-scene", "export-scene", "copy-embed"]) element(id).disabled = busy; for (const id of ["export-format", "video-duration", "video-fps", "show-grid", "transparent"]) element(id).disabled = busy; element("add-form").querySelector("button[type=submit]")!.disabled = busy; } @@ -428,20 +452,35 @@ async function start(): Promise { return update; } - function applyAnimation(animation: SceneEntityDefinition["animation"]): Promise { + function applyAnimation(animation: SceneEntityDefinition["animation"], seek = false): Promise { const initial = selected(); if (busy || disposed || initial?.type !== "entity") return Promise.resolve(); const token = Symbol(); animationRequests.set(initial.id, token); return trackUpdate((async () => { - const clips = animation ? await Entities.getAnimations(AssetKey.parse("entities", initial.asset)) : undefined; + const before = objects.get(initial.id); + const sameClips = animation && before?.definition.type === "entity" && before.definition.animation + && equalJson(animationClips(animation).map(clip => clip.name), animationClips(before.definition.animation).map(clip => clip.name)); + const clips = animation && !sameClips ? await Entities.getAnimations(AssetKey.parse("entities", initial.asset)) : undefined; const definition = sceneDocument.objects.find(object => object.id === initial.id), loaded = objects.get(initial.id); if (disposed || animationRequests.get(initial.id) !== token || definition?.type !== "entity" || definition.asset !== initial.asset || loaded?.definition.type !== "entity" || !isEntityObject(loaded.object)) return; if (animation) { - const clip = clips?.[animation.name]; - if (!clip) throw new Error(`Entity ${definition.asset} has no animation "${animation.name}"`); - loaded.object.playAnimation(clip, animation); + const previous = loaded.definition.animation; + const selected = animationClips(animation); + if (sameClips && loaded === before) { + if (previous?.loop === animation.loop && (previous?.speed ?? 1) === (animation.speed ?? 1)) { + loaded.object.setAnimationWeights(selected.map(clip => clip.weight)); + if (seek) loaded.object.setAnimationTime(animation.time ?? 0); + } else { + loaded.object.playAnimations(loaded.object.activeAnimations, { + ...animation, time: animation.time ?? loaded.object.animationTime, weights: selected.map(clip => clip.weight) + }); + } + } else { + if (!seek && previous) animation = { ...animation, time: loaded.object.animationTime }; + playEntityAnimation(loaded.object, definition.asset, animation, clips); + } definition.animation = clone(animation); loaded.definition.animation = definition.animation; } else { @@ -489,12 +528,14 @@ async function start(): Promise { next.animation = current.animation; if (isEntityObject(staged.object)) { if (next.animation) { - const clip = clips?.[next.animation.name]; - if (!clip) throw new Error(`Entity ${next.asset} has no animation "${next.animation.name}"`); - staged.object.playAnimation(clip, next.animation); + playEntityAnimation(staged.object, next.asset, next.animation, clips); } else staged.object.stopAnimation(); } } + if (next.type === "entity" && current.type === "entity" && next.asset === current.asset && next.animation + && equalJson(next.animation, current.animation) && isEntityObject(old.object) && isEntityObject(staged.object)) { + staged.object.setAnimationTime(old.object.animationTime); + } Object.assign(staged.definition, next); staged.root.name = next.name ?? next.id; staged.root.position.fromArray(next.position ?? [0, 0, 0]); @@ -756,7 +797,9 @@ async function start(): Promise { element("new-scene").addEventListener("click", () => { void run("Creating empty scene…", async () => { await replaceDocument(emptyDocument()); report("Empty scene ready. Use Undo to restore the previous scene."); remember(); }); }); - element("save-scene").addEventListener("click", () => { download(snapshot(), "scene.json"); report("Scene JSON downloaded."); }); + element("save-scene").addEventListener("click", () => { void run("Saving scene…", () => { + download(snapshot(), "scene.json"); report("Scene JSON downloaded."); + }); }); element("import-scene").addEventListener("click", () => element("file-input").click()); element("file-input").addEventListener("change", event => { const input = event.target as HTMLInputElement; diff --git a/packages/minerender/reference/concepts.md b/packages/minerender/reference/concepts.md index a315d505..471d6370 100644 --- a/packages/minerender/reference/concepts.md +++ b/packages/minerender/reference/concepts.md @@ -89,6 +89,20 @@ Each update adds the weighted position, Euler-angle, and scale offsets to the ba A clip targets its `layer`, which defaults to `main`, including repeated draws of that geometry. The main-layer blend also poses selected layers without clips of their own. An explicit layer clip takes precedence, including when its weight is zero. Call `unsubscribe()` when playback ends; `entity.stopAnimation()` restores the caller's pose from before playback. Automatic gameplay-state selection, part visibility changes, and animated renderer transforms remain unsupported. +Scene documents store a blend in an entity's `animation.clips` list. For example, a camel's animation settings can combine walking and idle offsets: + +```json +{ + "clips": [{ "name": "walk", "weight": 0.5 }, { "name": "idle" }], + "loop": true, + "speed": 1, + "time": 0, + "paused": false +} +``` + +Each omitted weight defaults to `1`. Loop, speed, elapsed time, and pause apply to the blend; without a loop override, each clip uses its own loop flag. Existing `animation: { "name": "walk" }` documents still load. Supply either `name` or a nonempty `clips` list. Every named clip must exist and target a selected entity layer, or document loading rejects before the document attaches to the scene. The editor's JSON and copied embeds preserve these settings. + ## Item state `Models.getMerged(key, context)` evaluates an item definition with the supplied [ItemModelContext](/api/index/interfaces/ItemModelContext): data `components`, item-model `properties`, a stack `count`, a `displayContext`, and `itemReferences` for nodes such as `minecraft:bundle/selected_item`. The same state selects models, special renderers, and automatic tints. diff --git a/packages/minerender/src/scene/SceneDocument.ts b/packages/minerender/src/scene/SceneDocument.ts index 4c26048b..d10f764b 100644 --- a/packages/minerender/src/scene/SceneDocument.ts +++ b/packages/minerender/src/scene/SceneDocument.ts @@ -68,7 +68,9 @@ export interface SceneEntityDefinition extends SceneObjectDefinitionBase { when?: string[]; textures?: Record; options?: { flip?: boolean; wireframe?: boolean; tints?: Record }; - animation?: { name: string; loop?: boolean; speed?: number; time?: number; paused?: boolean }; + /** Plays one named clip or blends clips with weights defaulting to 1, without normalization. */ + animation?: ({ name: string; clips?: never } | { name?: never; clips: { name: string; weight?: number }[] }) + & { loop?: boolean; speed?: number; time?: number; paused?: boolean }; } export type SceneGuiLayer = (Omit & { texture: string }) @@ -182,6 +184,17 @@ const itemContext = (withDisplayContext: boolean): Validator => (value, path) => }); }; +function entityAnimation(value: unknown, path: string): void { + const animation = object(value, path); + if (["name", "clips"].filter(key => animation[key] !== undefined).length !== 1) { + fail(path, "expected exactly one of name or clips"); + } + fields(value, path, { name: text, clips: (value, path) => { + list((value, path) => { fields(value, path, { name: text, weight: nonnegative }, ["name"]); })(value, path); + if (!(value as unknown[]).length) fail(path, "expected at least one clip"); + }, loop: boolean, speed: nonnegative, time: nonnegative, paused: boolean }); +} + function guiLayer(value: unknown, path: string): void { const layer = object(value, path); if (["texture", "item", "text"].filter(key => layer[key] !== undefined).length !== 1) { @@ -239,7 +252,7 @@ function validateObject(value: unknown, path: string): void { fields(value, path, { ...common, asset, texture: asset, layers: list(text), when: list(text), textures: dictionary(asset), options: (value, path) => { fields(value, path, { ...renderOptions, flip: boolean, tints: dictionary((value, path) => { typeof value === "string" ? text(value, path) : color(value, path); }) }); }, - animation: (value, path) => { fields(value, path, { name: text, loop: boolean, speed: nonnegative, time: nonnegative, paused: boolean }, ["name"]); } + animation: entityAnimation }, [...required, "asset"]); if (Array.isArray(input.layers) && !input.layers.length) fail(`${path}.layers`, "expected at least one layer"); break; diff --git a/packages/minerender/src/scene/SceneDocumentLoader.ts b/packages/minerender/src/scene/SceneDocumentLoader.ts index 396e9691..7d614e25 100644 --- a/packages/minerender/src/scene/SceneDocumentLoader.ts +++ b/packages/minerender/src/scene/SceneDocumentLoader.ts @@ -193,10 +193,20 @@ export class SceneDocumentLoader { const entity = await content.addEntity(model, { ...definition.options, instanceMeshes: false }) as EntityObject; object = entity; if (definition.animation) { + const animation = definition.animation; const animations = await Entities.getAnimations(key); - const clip = animations?.[definition.animation.name]; - if (!clip) throw new Error(`Entity ${definition.asset} has no animation "${definition.animation.name}"`); - entity.playAnimation(clip, definition.animation); + if (animation.name !== undefined) { + const clip = animations?.[animation.name]; + if (!clip) throw new Error(`Entity ${definition.asset} has no animation "${animation.name}"`); + entity.playAnimation(clip, animation); + } else { + const clips = animation.clips.map(({ name }) => { + const clip = animations?.[name]; + if (!clip) throw new Error(`Entity ${definition.asset} has no animation "${name}"`); + return clip; + }); + entity.playAnimations(clips, { ...animation, weights: animation.clips.map(clip => clip.weight ?? 1) }); + } } break; } diff --git a/packages/minerender/test/SceneDocument.test.ts b/packages/minerender/test/SceneDocument.test.ts index 62df5d4e..d0a8d5f0 100644 --- a/packages/minerender/test/SceneDocument.test.ts +++ b/packages/minerender/test/SceneDocument.test.ts @@ -9,6 +9,8 @@ import { Entities } from "../src/assets/Entities"; import { ItemModelContext, Models } from "../src/assets/Models"; import { Caching } from "../src/cache/Caching"; import { EntityObject } from "../src/entity/scene/EntityObject"; +import type { EntityAnimation } from "../src/entity/EntityAnimation"; +import type { EntityModel } from "../src/entity/EntityModel"; import { Materials } from "../src/Materials"; import { BlockObject } from "../src/model/block/scene/BlockObject"; import { ModelObject } from "../src/model/scene/ModelObject"; @@ -47,6 +49,9 @@ test("scene documents round-trip portable options and copy the caller's data", t { id: "bundle", type: "item", asset: "minecraft:bundle", context: { displayContext: "none", count: 3, properties: { "minecraft:bundle/has_selected_item": true }, components: { "minecraft:bundle_contents": [{ id: "minecraft:apple", count: 1 }] }, itemReferences: { "minecraft:bundle/selected_item": "minecraft:apple" } } }, { id: "entity", type: "entity", asset: "minecraft:bat", layers: ["main"], animation: { name: "flying", paused: true, speed: 2 } }, + { id: "blend", type: "entity", asset: "minecraft:bat", animation: { + clips: [{ name: "flying", weight: 0.25 }, { name: "looking" }, { name: "flying", weight: 0 }], loop: false, time: 1, speed: 2, paused: true + } }, { id: "gui", type: "gui", layers: [{ text: [{ text: "Hello", color: 0xff0000 }], position: [0, 9] }, { item: "minecraft:item/diamond" }, { item: "minecraft:leather_helmet", context: { components: { "minecraft:dyed_color": 0x3f76e4 } } }] } ]); @@ -56,6 +61,29 @@ test("scene documents round-trip portable options and copy the caller's data", t const copy = SceneDocumentLoader.parse(input); copy.objects[1].position![0] = 64; t.is(input.objects[1].position![0], 16); + if (copy.objects[5].type === "entity") copy.objects[5].animation!.clips![0].weight = 2; + if (input.objects[5].type === "entity") t.is(input.objects[5].animation!.clips![0].weight, 0.25); +}); + +test("scene animations require one named clip or a nonempty blend with finite nonnegative weights", t => { + const invalid: [unknown, RegExp][] = [ + [{}, /animation: expected exactly one/], + [{ name: "walk", clips: [{ name: "walk" }] }, /animation: expected exactly one/], + [{ clips: [] }, /animation.clips: expected at least one clip/], + [{ clips: "walk" }, /animation.clips: expected an array/], + [{ clips: [{}] }, /animation.clips\[0\].name: required/], + [{ clips: [{ name: " " }] }, /animation.clips\[0\].name/], + [{ clips: [{ name: "walk", speed: 2 }] }, /animation.clips\[0\].speed: unsupported/], + [{ clips: [{ name: "walk" }], time: -1 }, /animation.time/], + [{ clips: [{ name: "walk" }], loop: 1 }, /animation.loop/], + [{ clips: [{ name: "walk" }], paused: "true" }, /animation.paused/] + ]; + for (const weight of [-1, NaN, Infinity, -Infinity, "1", null]) { + invalid.push([{ clips: [{ name: "walk", weight }] }, /animation.clips\[0\].weight/]); + } + for (const [animation, message] of invalid) { + t.throws(() => SceneDocumentLoader.parse({ ...document(), objects: [{ id: "bad", type: "entity", asset: "pig", animation }] }), { message }); + } }); test("scene validation reports the malformed field before requesting assets", t => { @@ -267,17 +295,26 @@ test.serial("item definitions and GUI item layers pass their item state with ite t.is(layer.context!.itemReferences!["minecraft:bundle/selected_item"].toNamespacedString(), "minecraft:item/apple"); }); -test.serial("entity animations load by name, start at saved time, and respect pause and disposal", async t => { +function entityFixture(t: ExecutionContext) { const material = new MeshBasicMaterial(); stub(t, Materials, "getImage", () => material); Caching.clear(); t.teardown(() => { material.dispose(); Caching.clear(); }); const key = new AssetKey("minecraft", "fixture"); - stub(t, Entities, "getEntity", async () => ({ id: "minecraft:fixture", key, + const model: EntityModel = { id: "minecraft:fixture", key, layer: { texture: [16, 16], root: { pose: { offset: [0, 0, 0], rotation: [0, 0, 0] }, cubes: [], children: {} } } - })); - stub(t, EntityObject.prototype, "init", async function () { this["createMeshes"](); }); + }; + const created: EntityObject[] = [], disposed: EntityObject[] = []; + const originalDispose = EntityObject.prototype.dispose; + stub(t, Entities, "getEntity", async () => model); + stub(t, EntityObject.prototype, "init", async function () { created.push(this); this["createMeshes"](); }); + stub(t, EntityObject.prototype, "dispose", function () { disposed.push(this); originalDispose.call(this); }); stub(t, Entities, "getAnimations", async () => ({ walk: { length: 4, loop: true, bones: {} } })); + return { model, created, disposed }; +} + +test.serial("entity animations load by name, start at saved time, and respect pause and disposal", async t => { + entityFixture(t); const scene = new MineRenderScene(); const loaded = await SceneDocumentLoader.loadObject(scene, { id: "animated", type: "entity", asset: "minecraft:fixture", animation: { name: "walk", time: 1, speed: 2, paused: true } @@ -297,6 +334,73 @@ test.serial("entity animations load by name, start at saved time, and respect pa t.deepEqual(entity.children, []); }); +test.serial("entity blends retain clip order and additive weights across layers with a shared paused clock", async t => { + const { model } = entityFixture(t); + model.layers = Object.fromEntries(["main", "main#2", "wool", "wool#2", "overlay"].map(name => [name, { key: model.key, layer: model.layer }])); + const walk: EntityAnimation = { length: 2, loop: true, bones: { root: { position: [ + { time: 0, value: [0, 0, 0], interpolation: "linear" }, { time: 2, value: [4, 0, 0], interpolation: "linear" } + ] } } }; + const look: EntityAnimation = { length: 4, loop: false, bones: { root: { rotation: [ + { time: 0, value: [0, 0, 0], interpolation: "linear" }, { time: 4, value: [0, 0, 4], interpolation: "linear" } + ] } } }; + const wool: EntityAnimation = { length: 4, loop: false, layer: "wool", bones: { root: { position: [ + { time: 0, value: [0, 0, 0], interpolation: "linear" }, { time: 4, value: [0, 8, 0], interpolation: "linear" } + ] } } }; + stub(t, Entities, "getAnimations", async () => ({ walk, look, wool })); + const scene = new MineRenderScene(); + const loaded = await SceneDocumentLoader.load(scene, document([{ id: "blended", type: "entity", asset: "minecraft:fixture", animation: { + clips: [{ name: "walk", weight: 0.25 }, { name: "look" }, { name: "walk", weight: 0.5 }, { name: "look", weight: 0 }, { name: "wool", weight: 2 }], + loop: true, time: 1, speed: 2, paused: true + } }])); + t.teardown(() => loaded.dispose()); + const entity = loaded.objects[0].object as EntityObject; + t.deepEqual(entity.activeAnimations, [walk, look, walk, look, wool]); + t.deepEqual(entity.animationWeights, [0.25, 1, 0.5, 0, 2]); + t.is(entity.getGroupByName("root", "main")!.position.x, 1.5); + loaded.advanceAnimations(0.25); + t.is(entity.animationTime, 1); + if (loaded.objects[0].definition.type === "entity") loaded.objects[0].definition.animation!.paused = false; + scene.dirty = false; + loaded.advanceAnimations(0.25); + t.is(entity.animationTime, 1.5); + t.true(scene.dirty); + loaded.advanceAnimations(1.5); + t.is(entity.animationTime, 4.5); + for (const layer of ["main", "main#2", "overlay"]) { + const root = entity.getGroupByName("root", layer)!; + t.deepEqual(root.position.toArray(), [0.75, 0, 0]); + t.is(root.rotation.z, 0.5); + } + for (const layer of ["wool", "wool#2"]) t.deepEqual(entity.getGroupByName("root", layer)!.position.toArray(), [0, 2, 0]); + loaded.dispose(); + loaded.advanceAnimations(1); + t.deepEqual(entity.activeAnimations, []); + t.deepEqual(entity.children, []); + t.deepEqual(scene.children, []); +}); + +test.serial("a missing blend clip rejects the whole document and disposes staged objects before starting partial playback", async t => { + const models = modelFixture(t); + const entities = entityFixture(t); + const played: EntityObject[] = []; + const originalPlay = EntityObject.prototype.playAnimations; + stub(t, EntityObject.prototype, "playAnimations", function (clips, options) { played.push(this); originalPlay.call(this, clips, options); }); + const scene = new MineRenderScene(); + const existing = new Group(); + scene.add(existing); + await t.throwsAsync(SceneDocumentLoader.load(scene, document([ + { id: "model", type: "model", asset: "minecraft:block/stone" }, + { id: "valid", type: "entity", asset: "minecraft:fixture", animation: { clips: [{ name: "walk" }] } }, + { id: "missing", type: "entity", asset: "minecraft:fixture", animation: { clips: [{ name: "walk" }, { name: "absent", weight: 0 }] } } + ])), { message: /Scene object "missing": Entity minecraft:fixture has no animation "absent"/ }); + t.deepEqual(played, [entities.created[0]]); + t.is(entities.created.length, 2); + t.is(entities.disposed.length, 2); + t.true(entities.created.every(entity => entities.disposed.includes(entity) && entity.parent === null && entity.children.length === 0 && !entity.animation)); + t.deepEqual(models.disposed, models.created); + t.deepEqual(scene.children, [existing]); +}); + test.serial("embedded skin and cape textures stay portable and base parts hide independently of overlays", async t => { const material = new MeshBasicMaterial(); let materialDisposed = false; diff --git a/packages/minerender/test/SceneDocumentCodec.test.ts b/packages/minerender/test/SceneDocumentCodec.test.ts index 8007fab6..d1628e7e 100644 --- a/packages/minerender/test/SceneDocumentCodec.test.ts +++ b/packages/minerender/test/SceneDocumentCodec.test.ts @@ -6,7 +6,13 @@ import { decodeSceneDocument, encodeSceneDocument, SCENE_DOCUMENT_MAX_BYTES } fr const scene: SceneDocument = { format: "minerender-scene", version: 1, minecraftVersion: "1.21.11", camera: { position: [60, 40, 60], target: [0, 16, 0] }, - objects: [{ id: "sign", type: "gui", name: "Grüße 🌍", layers: [{ text: "こんにちは", position: [0, 9] }] }] + objects: [ + { id: "sign", type: "gui", name: "Grüße 🌍", layers: [{ text: "こんにちは", position: [0, 9] }] }, + { id: "single", type: "entity", asset: "minecraft:bat", animation: { name: "flying", time: 0.5 } }, + { id: "blend", type: "entity", asset: "minecraft:bat", animation: { + clips: [{ name: "flying", weight: 0.25 }, { name: "looking" }, { name: "flying", weight: 0 }], loop: true, speed: 2, time: 1, paused: true + } } + ] }; function encoded(value: string | Uint8Array): string { From 92a49b81699471c70961bcef945b7b982a31de1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 17:00:19 +0200 Subject: [PATCH 2/2] codex round 1: reject inherited and duplicate clip names, bound API clips and weights, editor clip fixes --- AGENTS.md | 2 +- apps/api/src/request.ts | 7 +++++ apps/api/test/server.test.mjs | 2 ++ apps/web/src/editor/README.md | 11 ++------ apps/web/src/editor/inspector.ts | 28 +++++++++++++------ apps/web/src/editor/script.ts | 8 +++--- packages/minerender/reference/concepts.md | 7 ++--- .../minerender/src/scene/SceneDocument.ts | 5 ++++ .../src/scene/SceneDocumentLoader.ts | 4 +-- .../minerender/test/SceneDocument.test.ts | 25 ++++++++++++----- .../test/SceneDocumentCodec.test.ts | 2 +- 11 files changed, 64 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e96c07d0..e2e532d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ The private Yarn workspace root contains the public library and its consumers. U **Node rendering** (`src/env/node/NodeRenderer.ts`): The Node entry exports `NodeRenderer.create({ width, height, ...options })` for an owned native WebGL 2 context through optional `gl@9.0.0-rc.10` (experimental WebGL 2). The factory disables antialiasing and rejects an explicit `true`; `render.pixelRatio` increases image resolution. `new Renderer(options, surface)` and `new NodeRenderer(options, surface)` accept a caller-owned `RendererSurface` outside the deep-merged options: canvas, context, positive integer logical dimensions, and an optional native resize callback receiving physical dimensions. Alpha-enabled contexts injected into `NodeRenderer` require `premultipliedAlpha: true`. Node renderers disable stats, controls, and automatic window resizing, and reject `start()`/`toImage()`; use `renderOnce()` or `renderToBuffer()`. See [platform setup](./packages/minerender/reference/platforms.md). -**Scene documents** (`src/scene/`): Versioned `SceneDocument` JSON is validated before any asset requests. `SceneDocumentLoader` loads each object without instancing inside its own nested `DocumentObjectScene` under a transform `Group`. A document attaches to its parent only after every object loads successfully. Entity animations accept either one `name` or a nonempty `clips` list of names and optional nonnegative weights, with shared loop, speed, time, and pause settings. The editor adjusts blend weights without restarting playback, and scene JSON and embeds preserve the blend. Item definitions and GUI item layers accept an optional `context` (`SceneItemContext`: components, properties, count, item references by item ID, and `displayContext` on definitions) that the loader passes to `Models.getMerged`. Async `encodeSceneDocument` / `decodeSceneDocument` validate and convert portable links using `v1.` plus zlib-wrapped deflate in unpadded base64url; compressed data and UTF-8 JSON are each limited to 1 MiB. +**Scene documents** (`src/scene/`): Versioned `SceneDocument` JSON is validated before any asset requests. `SceneDocumentLoader` loads each object without instancing inside its own nested `DocumentObjectScene` under a transform `Group`. A document attaches to its parent only after every object loads successfully. Entity animations accept either one `name` or a nonempty `clips` list of names and optional nonnegative weights, with shared loop, speed, time, and pause settings. Item definitions and GUI item layers accept an optional `context` (`SceneItemContext`: components, properties, count, item references by item ID, and `displayContext` on definitions) that the loader passes to `Models.getMerged`. Async `encodeSceneDocument` / `decodeSceneDocument` validate and convert portable links using `v1.` plus zlib-wrapped deflate in unpadded base64url; compressed data and UTF-8 JSON are each limited to 1 MiB. **Embeds** (`apps/embed/`): One `/embed/` page accepts exactly one shorthand (`skin`, `block`, `item`, `entity`, `model`), encoded `#scene`, or HTTPS `src`. URL camera/version values override document values; missing cameras fit the scene. Documents use the shared scene schema and a 1 MiB payload limit. Drawing buffers are limited to 4,096 pixels per side and 4,194,304 pixels in total independently of iframe size. Fragment changes replace the scene and discard stale loads. Off-screen embeds release their renderer and WebGL context, then recreate them with the saved camera and entity clip playback time when visible; hidden tabs pause rendering. Context loss shows a recovery message and **Retry** button; native context restoration redraws the scene. The editor's **Copy embed** encodes the document through the shared codec, preserving camera, background, and aspect ratio; `EMBED_URL` overrides its build-time destination. Host-page messaging and legacy query aliases are unsupported. V1 embed routes remain unchanged. diff --git a/apps/api/src/request.ts b/apps/api/src/request.ts index 8a1d35dc..8b701641 100644 --- a/apps/api/src/request.ts +++ b/apps/api/src/request.ts @@ -2,6 +2,9 @@ import { parseSceneDocument } from "minerender/node"; import type { SceneDocument } from "minerender/node"; import { ApiError } from "./problem.js"; +const MAX_ANIMATION_CLIPS = 32; +const MAX_ANIMATION_WEIGHT = 1024; + export interface RenderRequest { scene: SceneDocument & { minecraftVersion: string }; output: { @@ -83,6 +86,10 @@ export function parseRenderRequest(value: unknown): RenderRequest { } } if (definition.type === "entity" && definition.animation) { + if ((definition.animation.clips?.length ?? 0) > MAX_ANIMATION_CLIPS) { + throw new ApiError(422, `${path}.animation.clips: at most ${MAX_ANIMATION_CLIPS} clips are supported`); + } + bounded(definition.animation.clips?.map(clip => clip.weight ?? 1), MAX_ANIMATION_WEIGHT, `${path}.animation.clips.weight`); bounded([definition.animation.time ?? 0, definition.animation.speed ?? 1], 1_000_000, `${path}.animation`); } if (definition.type !== "gui") continue; diff --git a/apps/api/test/server.test.mjs b/apps/api/test/server.test.mjs index e0e95bea..709b2a10 100644 --- a/apps/api/test/server.test.mjs +++ b/apps/api/test/server.test.mjs @@ -124,6 +124,8 @@ test("request errors reject before rendering and methods advertise their allowed { scene: { ...scene().scene, objects: Array.from({ length: 33 }, (_, i) => ({ id: String(i), type: "skin" })) } }, { ...scene(), output: { width: 0 } }, { ...scene(), output: { height: 2049 } }, + { scene: { ...scene().scene, objects: [{ id: "entity", type: "entity", asset: "pig", animation: { clips: Array.from({ length: 33 }, (_, index) => ({ name: `clip${index}` })) } }] } }, + { scene: { ...scene().scene, objects: [{ id: "entity", type: "entity", asset: "pig", animation: { clips: [{ name: "walk", weight: 1025 }] } }] } }, { ...scene(), output: { width: 1.5 } }, { ...scene(), output: { format: "jpeg" } }, { ...scene(), output: { background: 0x1000000 } }, diff --git a/apps/web/src/editor/README.md b/apps/web/src/editor/README.md index 42dfb5cb..bba524a9 100644 --- a/apps/web/src/editor/README.md +++ b/apps/web/src/editor/README.md @@ -60,14 +60,9 @@ use another host or a local embed server; the default is `https://beta.minerender.org/embed/`. See the [embed workspace README](../../../embed/README.md) for local development and deployment commands. -In an entity's **Animation** controls, select **Add clip** to combine clips, then -adjust each **Weight**. Weight 0 removes that clip's offset; weight 1 applies it -fully. Weights add together without normalization, and the numeric field also -accepts values above 1. Weight changes keep playback at its current time. **Loop**, -**Speed**, **Time (seconds)**, and the playback buttons control all selected clips. -Until you change **Loop**, each clip uses its own loop setting. Setting a time or -scrubbing pauses at that frame. JSON saves and copied embeds retain the selected -clips, weights, and shared playback time, including while paused. +In an entity's **Animation** controls, use **Clip to add** and **Add clip** to combine +clips, then adjust each **Weight** without restarting playback. Weights add without +normalization. Until you change **Loop**, each clip uses its own loop setting. Use **GUI layers** to add, edit, reorder, or remove texture, item, and text layers, or to insert the layers of a chest background, boss bar, book page, or tooltip. The diff --git a/apps/web/src/editor/inspector.ts b/apps/web/src/editor/inspector.ts index a9eef9bd..3bdfa64c 100644 --- a/apps/web/src/editor/inspector.ts +++ b/apps/web/src/editor/inspector.ts @@ -350,14 +350,12 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD } } }; - note(animation, "Clip offsets add together. Weight 0 removes an offset; weight 1 applies it fully. Weights are not normalized."); const selected = document.createElement("div"); animation.append(selected); const add = document.createElement("div"); add.className = "editor-row"; animation.append(add); - const available = entries.find(([, clip]) => drawn.includes(clip.layer ?? "main"))?.[0]; - const picker = select(add, "Add clip", choices, available ?? "", () => {}); + const picker = select(add, "Clip to add", choices, "", () => {}); enableLayers(picker); const addClip = button(add, "Add clip", () => { setClips([...selectedClips(), { name: picker.value, weight: 1 }]); @@ -365,12 +363,18 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD renderPlayback(false); selected.querySelectorAll("select").item(selectedClips().length - 1)?.focus(); }); - picker.disabled = addClip.disabled = !available; - if (!available) note(animation, "Enable a clip's entity layer to add an animation."); + if (!entries.some(([, clip]) => drawn.includes(clip.layer ?? "main"))) note(animation, "Enable a clip's entity layer to add an animation."); const playback = document.createElement("div"); animation.append(playback); function renderClips() { selected.replaceChildren(); + for (const option of Array.from(picker.options)) { + option.disabled = !drawn.includes(clips![option.value].layer ?? "main") + || selectedClips().some(clip => clip.name === option.value); + } + const available = Array.from(picker.options).find(option => !option.disabled); + picker.value = available?.value ?? ""; + picker.disabled = addClip.disabled = !available; if (!selectedClips().length) note(selected, "Add a clip to start playback."); selectedClips().forEach((selection, index) => { const group = document.createElement("fieldset"); @@ -378,9 +382,13 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD selected.append(group); const clip = select(group, `Clip ${index + 1}`, choices, selection.name, name => { setClips(selectedClips().map((value, i) => i === index ? { ...value, name } : value)); + renderClips(); renderPlayback(false); }); enableLayers(clip); + for (const option of Array.from(clip.options)) { + if (selectedClips().some((value, i) => i !== index && value.name === option.value)) option.disabled = true; + } const row = document.createElement("div"); row.className = "editor-row"; group.append(row); @@ -421,9 +429,10 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD const duration = Math.max(0, ...activeClips.map(clip => clip.length)); const loop = check(playback, "Loop", entity.animation.loop ?? activeClips.every(clip => clip.loop), loop => { keepTime(); entity.animation!.loop = loop; emitAnimation(); }); loop.indeterminate = entity.animation.loop === undefined && activeClips.some(clip => clip.loop) && !activeClips.every(clip => clip.loop); - if (entity.animation.loop === undefined) note(playback, "Clips use their own loop settings until you change Loop."); number(playback, "Speed", entity.animation.speed ?? 1, 0, undefined, 0.1, speed => { keepTime(); entity.animation!.speed = speed; emitAnimation(); }); - const time = (liveTime ? context.getAnimationTime?.() : undefined) ?? entity.animation.time ?? 0; + const displayTime = (time: number) => activeClips.length === 1 && (entity.animation?.loop ?? activeClips[0].loop) && duration > 0 + ? time % duration : time; + const time = displayTime((liveTime ? context.getAnimationTime?.() : undefined) ?? entity.animation.time ?? 0); const seek = (time: number) => { entity.animation!.time = time; entity.animation!.paused = true; @@ -436,7 +445,7 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD scrub.min = "0"; scrub.max = String(duration); scrub.step = "0.01"; - scrub.value = String(time); + scrub.value = String(Math.min(duration, time)); scrub.disabled = !duration; scrub.addEventListener("input", () => seek(scrub.valueAsNumber)); field(playback, "Scrub animation", scrub); @@ -450,8 +459,9 @@ export function renderInspector(container: HTMLElement, definition: SceneObjectD emitAnimation(); }); function syncPlayback(time: number) { + time = displayTime(time); timeInput.value = String(Number(time.toFixed(3))); - scrub.value = String(time); + scrub.value = String(Math.min(duration, time)); play.textContent = entity.animation!.paused ? "Play" : "Pause"; } button(row, "Restart", () => { entity.animation!.time = 0; entity.animation!.paused = false; syncPlayback(0); emitAnimation(true); }); diff --git a/apps/web/src/editor/script.ts b/apps/web/src/editor/script.ts index d2b1795f..02d06ba8 100644 --- a/apps/web/src/editor/script.ts +++ b/apps/web/src/editor/script.ts @@ -39,7 +39,7 @@ function playEntityAnimation(object: EntityObject, asset: string, animation: Non clips: Record | undefined): void { const selected = animationClips(animation); const resolved = selected.map(({ name }) => { - const clip = clips?.[name]; + const clip = clips && Object.prototype.hasOwnProperty.call(clips, name) ? clips[name] : undefined; if (!clip) throw new Error(`Entity ${asset} has no animation "${name}"`); return clip; }); @@ -468,7 +468,7 @@ async function start(): Promise { if (animation) { const previous = loaded.definition.animation; const selected = animationClips(animation); - if (sameClips && loaded === before) { + if (sameClips) { if (previous?.loop === animation.loop && (previous?.speed ?? 1) === (animation.speed ?? 1)) { loaded.object.setAnimationWeights(selected.map(clip => clip.weight)); if (seek) loaded.object.setAnimationTime(animation.time ?? 0); @@ -478,8 +478,8 @@ async function start(): Promise { }); } } else { - if (!seek && previous) animation = { ...animation, time: loaded.object.animationTime }; - playEntityAnimation(loaded.object, definition.asset, animation, clips); + playEntityAnimation(loaded.object, definition.asset, + !seek && previous ? { ...animation, time: loaded.object.animationTime } : animation, clips); } definition.animation = clone(animation); loaded.definition.animation = definition.animation; diff --git a/packages/minerender/reference/concepts.md b/packages/minerender/reference/concepts.md index 471d6370..6b894c55 100644 --- a/packages/minerender/reference/concepts.md +++ b/packages/minerender/reference/concepts.md @@ -94,14 +94,11 @@ Scene documents store a blend in an entity's `animation.clips` list. For example ```json { "clips": [{ "name": "walk", "weight": 0.5 }, { "name": "idle" }], - "loop": true, - "speed": 1, - "time": 0, - "paused": false + "loop": true } ``` -Each omitted weight defaults to `1`. Loop, speed, elapsed time, and pause apply to the blend; without a loop override, each clip uses its own loop flag. Existing `animation: { "name": "walk" }` documents still load. Supply either `name` or a nonempty `clips` list. Every named clip must exist and target a selected entity layer, or document loading rejects before the document attaches to the scene. The editor's JSON and copied embeds preserve these settings. +`name` plays one clip. Supply either `name` or a nonempty `clips` list. Every named clip must exist and target a selected entity layer, or document loading rejects before the document attaches to the scene. ## Item state diff --git a/packages/minerender/src/scene/SceneDocument.ts b/packages/minerender/src/scene/SceneDocument.ts index d10f764b..e9a61f1c 100644 --- a/packages/minerender/src/scene/SceneDocument.ts +++ b/packages/minerender/src/scene/SceneDocument.ts @@ -192,6 +192,11 @@ function entityAnimation(value: unknown, path: string): void { fields(value, path, { name: text, clips: (value, path) => { list((value, path) => { fields(value, path, { name: text, weight: nonnegative }, ["name"]); })(value, path); if (!(value as unknown[]).length) fail(path, "expected at least one clip"); + const names = new Set(); + for (const [index, clip] of (value as { name: string }[]).entries()) { + if (names.has(clip.name)) fail(`${path}[${index}].name`, `duplicate clip "${clip.name}"`); + names.add(clip.name); + } }, loop: boolean, speed: nonnegative, time: nonnegative, paused: boolean }); } diff --git a/packages/minerender/src/scene/SceneDocumentLoader.ts b/packages/minerender/src/scene/SceneDocumentLoader.ts index 7d614e25..09bf1df0 100644 --- a/packages/minerender/src/scene/SceneDocumentLoader.ts +++ b/packages/minerender/src/scene/SceneDocumentLoader.ts @@ -196,12 +196,12 @@ export class SceneDocumentLoader { const animation = definition.animation; const animations = await Entities.getAnimations(key); if (animation.name !== undefined) { - const clip = animations?.[animation.name]; + const clip = animations && Object.prototype.hasOwnProperty.call(animations, animation.name) ? animations[animation.name] : undefined; if (!clip) throw new Error(`Entity ${definition.asset} has no animation "${animation.name}"`); entity.playAnimation(clip, animation); } else { const clips = animation.clips.map(({ name }) => { - const clip = animations?.[name]; + const clip = animations && Object.prototype.hasOwnProperty.call(animations, name) ? animations[name] : undefined; if (!clip) throw new Error(`Entity ${definition.asset} has no animation "${name}"`); return clip; }); diff --git a/packages/minerender/test/SceneDocument.test.ts b/packages/minerender/test/SceneDocument.test.ts index d0a8d5f0..41559b74 100644 --- a/packages/minerender/test/SceneDocument.test.ts +++ b/packages/minerender/test/SceneDocument.test.ts @@ -15,7 +15,7 @@ import { Materials } from "../src/Materials"; import { BlockObject } from "../src/model/block/scene/BlockObject"; import { ModelObject } from "../src/model/scene/ModelObject"; import { MineRenderScene } from "../src/renderer/MineRenderScene"; -import { SceneDocument, SceneObjectDefinition, SceneSkinDefinition } from "../src/scene/SceneDocument"; +import { SceneDocument, SceneEntityDefinition, SceneObjectDefinition, SceneSkinDefinition } from "../src/scene/SceneDocument"; import { SceneDocumentLoader } from "../src/scene/SceneDocumentLoader"; import { SkinTextures } from "../src/skin/SkinTextures"; import { GuiObject } from "../src/gui/scene/GuiObject"; @@ -50,7 +50,7 @@ test("scene documents round-trip portable options and copy the caller's data", t components: { "minecraft:bundle_contents": [{ id: "minecraft:apple", count: 1 }] }, itemReferences: { "minecraft:bundle/selected_item": "minecraft:apple" } } }, { id: "entity", type: "entity", asset: "minecraft:bat", layers: ["main"], animation: { name: "flying", paused: true, speed: 2 } }, { id: "blend", type: "entity", asset: "minecraft:bat", animation: { - clips: [{ name: "flying", weight: 0.25 }, { name: "looking" }, { name: "flying", weight: 0 }], loop: false, time: 1, speed: 2, paused: true + clips: [{ name: "flying", weight: 0.25 }, { name: "looking" }], loop: false, time: 1, speed: 2, paused: true } }, { id: "gui", type: "gui", layers: [{ text: [{ text: "Hello", color: 0xff0000 }], position: [0, 9] }, { item: "minecraft:item/diamond" }, { item: "minecraft:leather_helmet", context: { components: { "minecraft:dyed_color": 0x3f76e4 } } }] } @@ -61,14 +61,13 @@ test("scene documents round-trip portable options and copy the caller's data", t const copy = SceneDocumentLoader.parse(input); copy.objects[1].position![0] = 64; t.is(input.objects[1].position![0], 16); - if (copy.objects[5].type === "entity") copy.objects[5].animation!.clips![0].weight = 2; - if (input.objects[5].type === "entity") t.is(input.objects[5].animation!.clips![0].weight, 0.25); }); test("scene animations require one named clip or a nonempty blend with finite nonnegative weights", t => { const invalid: [unknown, RegExp][] = [ [{}, /animation: expected exactly one/], [{ name: "walk", clips: [{ name: "walk" }] }, /animation: expected exactly one/], + [{ clips: [{ name: "walk" }, { name: "walk", weight: 0 }] }, /animation.clips\[1\].name: duplicate clip "walk"/], [{ clips: [] }, /animation.clips: expected at least one clip/], [{ clips: "walk" }, /animation.clips: expected an array/], [{ clips: [{}] }, /animation.clips\[0\].name: required/], @@ -349,13 +348,13 @@ test.serial("entity blends retain clip order and additive weights across layers stub(t, Entities, "getAnimations", async () => ({ walk, look, wool })); const scene = new MineRenderScene(); const loaded = await SceneDocumentLoader.load(scene, document([{ id: "blended", type: "entity", asset: "minecraft:fixture", animation: { - clips: [{ name: "walk", weight: 0.25 }, { name: "look" }, { name: "walk", weight: 0.5 }, { name: "look", weight: 0 }, { name: "wool", weight: 2 }], + clips: [{ name: "walk", weight: 0.75 }, { name: "look" }, { name: "wool", weight: 2 }], loop: true, time: 1, speed: 2, paused: true } }])); t.teardown(() => loaded.dispose()); const entity = loaded.objects[0].object as EntityObject; - t.deepEqual(entity.activeAnimations, [walk, look, walk, look, wool]); - t.deepEqual(entity.animationWeights, [0.25, 1, 0.5, 0, 2]); + t.deepEqual(entity.activeAnimations, [walk, look, wool]); + t.deepEqual(entity.animationWeights, [0.75, 1, 2]); t.is(entity.getGroupByName("root", "main")!.position.x, 1.5); loaded.advanceAnimations(0.25); t.is(entity.animationTime, 1); @@ -379,6 +378,18 @@ test.serial("entity blends retain clip order and additive weights across layers t.deepEqual(scene.children, []); }); +test.serial("scene animations reject inherited clip names", async t => { + entityFixture(t); + for (const [animation, name] of [ + [{ clips: [{ name: "walk" }, { name: "constructor", weight: 0 }] }, "constructor"], + [{ name: "toString" }, "toString"] + ] as [NonNullable, string][]) { + await t.throwsAsync(SceneDocumentLoader.loadObject(new MineRenderScene(), { + id: "inherited", type: "entity", asset: "minecraft:fixture", animation + }), { message: `Scene object "inherited": Entity minecraft:fixture has no animation "${name}"` }); + } +}); + test.serial("a missing blend clip rejects the whole document and disposes staged objects before starting partial playback", async t => { const models = modelFixture(t); const entities = entityFixture(t); diff --git a/packages/minerender/test/SceneDocumentCodec.test.ts b/packages/minerender/test/SceneDocumentCodec.test.ts index d1628e7e..491cdb81 100644 --- a/packages/minerender/test/SceneDocumentCodec.test.ts +++ b/packages/minerender/test/SceneDocumentCodec.test.ts @@ -10,7 +10,7 @@ const scene: SceneDocument = { { id: "sign", type: "gui", name: "Grüße 🌍", layers: [{ text: "こんにちは", position: [0, 9] }] }, { id: "single", type: "entity", asset: "minecraft:bat", animation: { name: "flying", time: 0.5 } }, { id: "blend", type: "entity", asset: "minecraft:bat", animation: { - clips: [{ name: "flying", weight: 0.25 }, { name: "looking" }, { name: "flying", weight: 0 }], loop: true, speed: 2, time: 1, paused: true + clips: [{ name: "flying", weight: 0.25 }, { name: "looking" }], loop: true, speed: 2, time: 1, paused: true } } ] };