diff --git a/bun.lock b/bun.lock index ed4cbd80..ae95efbc 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,7 @@ "svelte-patching-tools": "^1.0.14", "svelte-preprocess": "^6.0.3", "svelte-preprocess-esbuild": "^3.0.1", + "three": "^0.134.0", "typescript": "^6.0.2", "typescript-eslint": "^8.45.0", "vitest": "^3.2.4", diff --git a/package.json b/package.json index 6403f123..0c0a20e0 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "svelte-patching-tools": "^1.0.14", "svelte-preprocess": "^6.0.3", "svelte-preprocess-esbuild": "^3.0.1", + "three": "^0.134.0", "typescript": "^6.0.2", "typescript-eslint": "^8.45.0", "vitest": "^3.2.4" diff --git a/src/index.ts b/src/index.ts index 3110184c..c3911894 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ import { VanillaItemDisplay } from './outliner/vanillaItemDisplay' import { checkForIncompatabilities } from './popups/incompatability/incompatability' import { openInstallPopup } from './popups/installed/installed' import './prism/mcfunctionPrism' +import { RENDER_HOOKS_API } from './systems/animationRenderHooks' import { cleanupExportedFiles } from './systems/cleaner' import TELLRAW from './systems/datapackCompiler/tellraw' import { exportProject } from './systems/exporter' @@ -112,6 +113,7 @@ const AnimatedJavaApi = { }, TELLRAW, getBlockState, + renderHooks: RENDER_HOOKS_API, } window.AnimatedJava = AnimatedJavaApi diff --git a/src/mods/animation.ts b/src/mods/animation.ts index 8fc85779..98246c0e 100644 --- a/src/mods/animation.ts +++ b/src/mods/animation.ts @@ -69,7 +69,10 @@ registerPropertyOverridePatch({ for (const kf of animator.keyframes) { let rounded = roundToNth(kf.time, DEFAULT_SNAPPING_VALUE) if (rounded === kf.time) continue - if (rounded === lastTime) rounded += 0.05 + // ずらした結果も格子へ載せ直す (= 素の加算だと 0.1 + 0.05 が + // 0.15000000000000002 になり、 frame ループ側の時刻から引けなくなる) + if (rounded === lastTime) + rounded = roundToNth(rounded + 0.05, DEFAULT_SNAPPING_VALUE) kf.time = rounded lastTime = rounded } diff --git a/src/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts new file mode 100644 index 00000000..38cb8a62 --- /dev/null +++ b/src/systems/animationRenderHooks.ts @@ -0,0 +1,309 @@ +/** + * 外部 plugin (= 物理シミュレーション等) が AJ の datapack export に自分の計算結果を載せるための + * 汎用 pose pipeline hook。 AJ 側には物理固有のロジックを一切持たせず、 registry と dispatch だけを提供する。 + * + * 契約 : + * - **hook が 1 つも登録されていないときの AJ の出力は従来と完全に同一** (= 全 dispatch が no-op) + * - **同期のみ**。 hook が Promise を返しても await しない + * - **matrix を返さない**。 hook は Blockbench の scene 上の node pose を直接書き換える。 + * これにより後段の `getFrame` / 差分省略 / `hashAnimations` / datapack compiler の既存経路がそのまま使える + * - **pose を書き換えた hook は自分で `Canvas.scene.updateMatrixWorld(true)` を呼ぶこと**。 + * `onPose` は AJ 側の `updateMatrixWorld` より後に発火するため、 呼ばないと後段の `getFrame` が読む + * `matrixWorld` に変更が反映されない + * - **呼び出し順** : + * `onBeginRendering` → (animation ごとに `onBeginAnimation` → frame ごとに `onPose` → `onEndAnimation`) + * → `onEndRendering`。 begin / pose 系は登録順、 end 系は逆順 + * - `onPose` が呼ばれるのは `updatePreview` が keyframe pose を scene に確定させた直後 + * - **`onPose` は同じ `frameIndex` で 1 frame につき複数回呼ばれる**。AJ は 1 frame の中で + * `updatePreview` を複数回走らせるため (= IK を成立させるための二度呼び / pre-post 判定の side sample と + * その巻き戻し / null_object ごとの再評価)。回数は blueprint の構成で変わるので、 **hook 側は回数を数えず + * `frameIndex` の変化だけを見て「進める」 か 「同じ状態を再適用する」 かを決めること**。 + * 同じ `frameIndex` に対しては何度呼ばれても結果が変わらない (= 冪等) 実装が要る + * - `timeSeconds` は `frameTimeSeconds` と一致しないことがある (= pre-post の side sample では + * `frameTimeSeconds + 0.001`)。時刻の正本は `frameIndex` であり、 `timeSeconds` は参考値として扱う + * - **hook が加える変化は matrix に現れていればよい** (= `pos` / `rot` / `scale` に出る必要はない)。 + * `hashAnimations` は node transform の `matrix.elements` 16 要素をそのまま mix するため、 + * shear や right rotation だけを動かす変換も reload-skip 判定に反映される。 + * `pos` / `rot` / `scale` は `THREE.Matrix4.decompose` の出力で shear と right rotation を + * 表現できないので、 **派生値だけでは datapack compiler が見る情報 (= TSB 経路の `decomposeTsb` + * による SVD、 純正経路の 16 要素そのまま) を覆えない**。 hash が matrix を mix しているのは + * その差を埋めるため + * + * この module は Blockbench / THREE の global を実行時に参照しない (= 型は `import type` と ambient のみ)。 + */ +import type { IRenderedRig } from './rigRenderer' + +/** animation 単位のコンテキスト。 */ +export interface RenderAnimationContext { + animation: _Animation + rig: IRenderedRig + excludedNodeUuids: ReadonlySet + /** 指定時刻の keyframe pose を scene へ再評価する。 呼び出し側が閉包として詰める。 */ + evaluateBasePose(timeSeconds: number): void +} + +/** frame 単位のコンテキスト (= `RenderAnimationContext` に時刻情報を足したもの)。 */ +export interface RenderHookContext extends RenderAnimationContext { + /** frame ループの整数。 side sample でも変わらない。 */ + frameIndex: number + /** `frameIndex / 20`。 */ + frameTimeSeconds: number + /** `updatePreview` の実引数 (= side sample では `frameTimeSeconds + 0.001`)。 */ + timeSeconds: number +} + +export interface RenderHooks { + onBeginRendering?(): void + onBeginAnimation?(context: RenderAnimationContext): void + onPose?(context: RenderHookContext): void + onEndAnimation?(): void + onEndRendering?(): void +} + +/** hook の callback が throw したときの wrapper。 どの plugin のどの段で落ちたかを保持する。 */ +export class RenderHookError extends Error { + readonly hookId: string + readonly phase: string + + constructor(hookId: string, phase: string, cause: unknown) { + super(`Render hook '${hookId}' threw during '${phase}'`, { cause }) + this.name = 'RenderHookError' + this.hookId = hookId + this.phase = phase + } +} + +interface IRenderHookParticipant { + id: string + hooks: RenderHooks +} + +/** 登録順 (= `Map` の挿入順) を保つ registry。 */ +const REGISTERED_HOOKS = new Map() + +/** + * 進行中の session の参加者スナップショット。 `undefined` は session が非 active であることを表す。 + * session 中の register / unregister で参加者が変わらないようにするためスナップショットを持つ + * (= `onBeginAnimation` を受けていない hook が `onPose` を受ける事態を防ぐ)。 + */ +let sessionParticipants: IRenderHookParticipant[] | undefined + +/** suppression の入れ子カウンタ。 boolean にすると内側の抜けで外側の抑制が解けるため。 */ +let suppressionDepth = 0 + +// --- registry --------------------------------------------------------------- + +export function registerRenderHooks(id: string, hooks: RenderHooks) { + if (!id) { + throw new Error('Render hook id must be a non-empty string.') + } + if (REGISTERED_HOOKS.has(id)) { + throw new Error(`Render hooks with id '${id}' are already registered.`) + } + REGISTERED_HOOKS.set(id, hooks) +} + +export function unregisterRenderHooks(id: string) { + REGISTERED_HOOKS.delete(id) +} + +export function hasRenderHooks() { + return REGISTERED_HOOKS.size > 0 +} + +// --- session ---------------------------------------------------------------- + +export function beginRenderingSession() { + if (sessionParticipants) { + throw new Error('A render hook session is already active.') + } + const participants = Array.from(REGISTERED_HOOKS, ([id, hooks]) => ({ id, hooks })) + sessionParticipants = participants + try { + dispatchSequentialWithUnwind( + participants, + 'onBeginRendering', + hooks => hooks.onBeginRendering?.(), + 'onEndRendering', + hooks => hooks.onEndRendering?.() + ) + } catch (error) { + // session を開けないまま active に残すと以降の export が全て塞がるため、 状態を巻き戻してから rethrow する + sessionParticipants = undefined + throw error + } +} + +export function endRenderingSession() { + const participants = sessionParticipants + if (!participants) return + try { + dispatchCleanup(reversed(participants), 'onEndRendering', hooks => hooks.onEndRendering?.()) + } finally { + sessionParticipants = undefined + } +} + +export function isRenderingSessionActive() { + return sessionParticipants !== undefined +} + +// --- dispatch --------------------------------------------------------------- + +export function dispatchBeginAnimation(context: RenderAnimationContext) { + const participants = getActiveParticipants() + if (!participants) return + dispatchSequentialWithUnwind( + participants, + 'onBeginAnimation', + hooks => hooks.onBeginAnimation?.(context), + 'onEndAnimation', + hooks => hooks.onEndAnimation?.() + ) +} + +export function dispatchPose(context: RenderHookContext) { + const participants = getActiveParticipants() + if (!participants) return + dispatchSequential(participants, 'onPose', hooks => hooks.onPose?.(context)) +} + +export function dispatchEndAnimation() { + const participants = getActiveParticipants() + if (!participants) return + dispatchCleanup(reversed(participants), 'onEndAnimation', hooks => hooks.onEndAnimation?.()) +} + +/** 呼び出し側が context の組み立てコストを避けるための事前判定。 */ +export function shouldDispatchPose() { + const participants = getActiveParticipants() + return participants !== undefined && participants.length > 0 +} + +// --- suppression ------------------------------------------------------------ + +/** `fn` の実行中だけ全 dispatch を抑制する。 入れ子で呼んでよい。 */ +export function withRenderHooksSuppressed(fn: () => T): T { + suppressionDepth++ + try { + return fn() + } finally { + suppressionDepth-- + } +} + +export function areRenderHooksSuppressed() { + return suppressionDepth > 0 +} + +// --- 公開 API --------------------------------------------------------------- + +/** 外部 plugin 向けの公開 API。 `version` は互換性確認用。 */ +export const RENDER_HOOKS_API = { + version: 1, + register: registerRenderHooks, + unregister: unregisterRenderHooks, +} + +/** + * registry と session / suppression の状態を全消しする。 **テスト専用** (= production から呼ばない)。 + * vitest の各 case を独立した状態から始めるためだけに存在する。 + */ +export function resetRenderHooksForTesting() { + REGISTERED_HOOKS.clear() + sessionParticipants = undefined + suppressionDepth = 0 +} + +// --- internal --------------------------------------------------------------- + +/** dispatch 対象の参加者。 session 非 active または suppression 中は `undefined`。 */ +function getActiveParticipants(): IRenderHookParticipant[] | undefined { + if (!sessionParticipants) return undefined + if (suppressionDepth > 0) return undefined + return sessionParticipants +} + +function reversed(participants: readonly IRenderHookParticipant[]): IRenderHookParticipant[] { + return participants.slice().reverse() +} + +/** pose 系 : 最初の例外で即 rethrow する (= 以降の hook を呼ばない)。 */ +function dispatchSequential( + participants: readonly IRenderHookParticipant[], + phase: string, + invoke: (hooks: RenderHooks) => void +) { + for (const participant of participants) { + try { + invoke(participant.hooks) + } catch (error) { + throw new RenderHookError(participant.id, phase, error) + } + } +} + +/** + * begin 系 : 最初の例外で即 rethrow する (= 以降の hook を呼ばない)。 加えて、 **その時点で + * 成功済みの participant を逆順に unwind** し、 対になる cleanup を必ず届ける + * (= 途中で失敗したとき、 begin だけ受け取って end を受け取らない hook が出るのを防ぐ)。 + */ +function dispatchSequentialWithUnwind( + participants: readonly IRenderHookParticipant[], + phase: string, + invoke: (hooks: RenderHooks) => void, + unwindPhase: string, + unwind: (hooks: RenderHooks) => void +) { + const started: IRenderHookParticipant[] = [] + for (const participant of participants) { + try { + invoke(participant.hooks) + } catch (error) { + const failure = new RenderHookError(participant.id, phase, error) + unwindStarted(started, unwindPhase, unwind) + throw failure + } + started.push(participant) + } +} + +/** + * unwind : 成功済みの participant へ逆順で cleanup を送る。 + * ここでの例外は `console.warn` に落とす (= unwind の引き金になった元の例外を優先するため)。 + */ +function unwindStarted( + started: readonly IRenderHookParticipant[], + phase: string, + invoke: (hooks: RenderHooks) => void +) { + for (const participant of reversed(started)) { + try { + invoke(participant.hooks) + } catch (error) { + console.warn(new RenderHookError(participant.id, phase, error)) + } + } +} + +/** + * end 系 : 全件を実行してから最初の例外を throw する (= 1 つ目の失敗が残りの cleanup を飛ばさない)。 + * 2 件目以降の例外は `console.warn` に落とす。 + */ +function dispatchCleanup( + participants: readonly IRenderHookParticipant[], + phase: string, + invoke: (hooks: RenderHooks) => void +) { + let firstError: RenderHookError | undefined + for (const participant of participants) { + try { + invoke(participant.hooks) + } catch (error) { + const wrapped = new RenderHookError(participant.id, phase, error) + if (firstError) console.warn(wrapped) + else firstError = wrapped + } + } + if (firstError) throw firstError +} diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index b1fc75c5..2c3a6338 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -11,6 +11,17 @@ import { VanillaBlockDisplay } from '../outliner/vanillaBlockDisplay' import { VanillaItemDisplay } from '../outliner/vanillaItemDisplay' import { sanitizeStorageKey } from '../util/minecraftUtil' import { eulerFromQuaternion, roundToNth, scrubUndefined } from '../util/misc' +import { + beginRenderingSession, + dispatchBeginAnimation, + dispatchEndAnimation, + dispatchPose, + endRenderingSession, + hasRenderHooks, + type RenderAnimationContext, + shouldDispatchPose, + withRenderHooksSuppressed, +} from './animationRenderHooks' import type { AnyRenderedNode, IRenderedRig } from './rigRenderer' import { sleepForAnimationFrame } from './util' @@ -124,10 +135,22 @@ let lastFrameCache = new Map() let keyframeCache = new Map>() let excludedNodesCache = new Set() let nodeCache = new Map() +/** + * hook context の animation 単位部分。 frame ごとに作り直さないよう `renderAnimation` が 1 回だけ組み立て、 + * `updatePreview` の wrapper が読む。 session 非 active なら dispatch されないので stale でも害はない。 + */ +let currentRenderContext: RenderAnimationContext | undefined + +/** animation の除外ノード uuid 集合を作る。 `getFrame` の cache と hook context の両方から使う。 */ +function collectExcludedNodeUuids(animation: _Animation): Set { + return new Set(animation.excluded_nodes ? animation.excluded_nodes.map(b => b.value) : []) +} + export function getFrame( animation: _Animation, nodeMap: IRenderedRig['nodes'], - time = 0 + time = 0, + frameIndex: number ): IRenderedFrame { const frame: IRenderedFrame = { time, @@ -148,9 +171,7 @@ export function getFrame( : new Map() keyframeCache.set(uuid, keyframeMap) } - excludedNodesCache = new Set( - animation.excluded_nodes ? animation.excluded_nodes.map(b => b.value) : [] - ) + excludedNodesCache = collectExcludedNodeUuids(animation) nodeCache = new Map() for (const node of getAnimatableNodes()) { nodeCache.set(node.uuid, node) @@ -164,7 +185,8 @@ export function getFrame( const keyframes = keyframeCache.get(uuid) if (!keyframes) continue const keyframe = keyframes.get(time) - const prevKeyframe = keyframes.get(time - 0.05) + // keyframeCache のキーは格子に正規化済みなので、引く側も再スナップしないと浮動小数誤差で外れる + const prevKeyframe = keyframes.get(roundToNth(time - 0.05, 20)) const lastFrame = lastFrameCache.get(uuid) const transform = {} as INodeTransform @@ -179,7 +201,7 @@ export function getFrame( if (node.parent && node.parent !== 'root') { const parentKeyframes = keyframeCache.get(node.parent) const parentKeyframe = parentKeyframes?.get(time) - const prevParentKeyframe = parentKeyframes?.get(time - 0.05) + const prevParentKeyframe = parentKeyframes?.get(roundToNth(time - 0.05, 20)) if (parentKeyframe?.interpolation === 'step') { transform.interpolation = 'step' } else if (prevParentKeyframe?.data_points.length === 2) { @@ -198,10 +220,10 @@ export function getFrame( transform.interpolation = 'step' } else if (prevKeyframe?.data_points.length === 2) { transform.interpolation = 'pre-post' - updatePreview(animation, time + 0.001) + updatePreview(animation, time + 0.001, frameIndex) const postMatrix = getNodeMatrix(outlinerNode, node.base_scale) transform.matrix = postMatrix - updatePreview(animation, time) + updatePreview(animation, time, frameIndex) } lastFrameCache.set(uuid, { matrix: transform.matrix, keyframe }) @@ -228,7 +250,7 @@ export function getFrame( break } case 'null_object': - updatePreview(animation, time) + updatePreview(animation, time, frameIndex) case 'camera': case 'struct': { transform.matrix = getNodeMatrix(outlinerNode, 1) @@ -298,7 +320,11 @@ function getFunctionKeyframe( return {} } -export function updatePreview(animation: _Animation, time: number) { +/** + * keyframe pose を scene へ確定させるだけの素の評価。 hook を一切呼ばない。 + * effects の表示は含まない (= hook が pose を書き換える前に effects に読ませないため wrapper 側に置く)。 + */ +function updatePreviewBase(animation: _Animation, time: number) { Timeline.time = time Animator.showDefaultPose(true) const nodes: OutlinerNode[] = getAnimatableNodes() @@ -309,9 +335,60 @@ export function updatePreview(animation: _Animation, time: number) { } Animator.resetLastValues() Canvas.scene.updateMatrixWorld(true) +} + +export function updatePreview(animation: _Animation, time: number, frameIndex: number) { + updatePreviewBase(animation, time) + // hook は scene の node pose を直接書き換えるので、 pose 確定後・ effects が読む前に挟む + if (shouldDispatchPose() && currentRenderContext) { + dispatchPose({ + ...currentRenderContext, + frameIndex, + frameTimeSeconds: frameIndex / 20, + timeSeconds: time, + }) + } if (animation.effects) animation.effects.displayFrame() } +/** 例外を 1 件だけ保持する箱。 2 件目以降は `console.warn` へ落とす。 */ +interface IErrorSlot { + failed: boolean + error?: unknown +} + +function createErrorSlot(): IErrorSlot { + return { failed: false } +} + +/** + * cleanup の 1 段。 throw しても後続の段を止めず、 例外は `slot` に集める + * (= 1 つの復元失敗が他の復元を巻き添えにしないため)。 + */ +function runCleanupStep(slot: IErrorSlot, step: () => void) { + try { + step() + } catch (error) { + if (slot.failed) console.warn(error) + else { + slot.failed = true + slot.error = error + } + } +} + +/** + * 本体と cleanup の例外を、 **本体優先**で送出する。 + * 本体が throw していたら cleanup 側の例外は `console.warn` に落とす (= 元の例外を上書きしない)。 + */ +function throwPreferringBody(body: IErrorSlot, cleanup: IErrorSlot) { + if (body.failed) { + if (cleanup.failed) console.warn(cleanup.error) + throw body.error + } + if (cleanup.failed) throw cleanup.error +} + function renderAnimation(animation: _Animation, rig: IRenderedRig) { const rendered = { name: animation.name, @@ -328,13 +405,54 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { const includedNodes = new Set() - for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20)) { - updatePreview(animation, time) - updatePreview(animation, time) // IK doesn't work unless I call this twice for some reason... - const frame: IRenderedFrame = getFrame(animation, rig.nodes, time) - Object.keys(frame.node_transforms).forEach(n => includedNodes.add(n)) - rendered.frames.push(frame) + currentRenderContext = { + animation, + rig, + excludedNodeUuids: collectExcludedNodeUuids(animation), + evaluateBasePose(timeSeconds: number) { + const previousTime = Timeline.time + try { + // この閉包は onPose の中から呼ばれうる (= 再入経路) ため、 防御として抑制下で回す + withRenderHooksSuppressed(() => { + updatePreviewBase(animation, timeSeconds) + updatePreviewBase(animation, timeSeconds) // IK doesn't work unless I call this twice for some reason... + }) + } finally { + Timeline.time = previousTime + } + }, } + const bodyError = createErrorSlot() + const cleanupError = createErrorSlot() + // dispatchBeginAnimation が部分失敗したときの onEndAnimation は registry 側の unwind が + // 送るため、 この flag で cleanup 側の dispatchEndAnimation と二重にならないようにする + let animationBegun = false + try { + dispatchBeginAnimation(currentRenderContext) + animationBegun = true + + let frameIndex = 0 + for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20)) { + updatePreview(animation, time, frameIndex) + updatePreview(animation, time, frameIndex) // IK doesn't work unless I call this twice for some reason... + const frame: IRenderedFrame = getFrame(animation, rig.nodes, time, frameIndex) + Object.keys(frame.node_transforms).forEach(n => includedNodes.add(n)) + rendered.frames.push(frame) + frameIndex++ + } + } catch (error) { + bodyError.failed = true + bodyError.error = error + } + + runCleanupStep(cleanupError, () => { + if (animationBegun) dispatchEndAnimation() + }) + runCleanupStep(cleanupError, () => { + // dispatchEndAnimation の成否に関わらず context は必ず捨てる + currentRenderContext = undefined + }) + throwPreferringBody(bodyError, cleanupError) rendered.duration = rendered.frames.length rendered.modified_nodes = Object.fromEntries( @@ -357,6 +475,12 @@ export function hashAnimations(animations: IRenderedAnimation[]) { hash.update(';' + frame.time.toString()) for (const [uuid, node] of Object.entries(frame.node_transforms)) { hash.update(';' + uuid) + // matrix は pos / rot / scale の上位互換 (= それらは Matrix4.decompose の出力で、 + // shear と right rotation を表現できない)。 一方 datapack compiler は matrix 全体を + // 使う (= TSB 経路は decomposeTsb の SVD、 純正経路は 16 要素をそのまま書き出す) ため、 + // 派生値だけを mix すると 「出力は変わったのに hash は同じ」 = reload-skip の誤判定が起きる。 + // 情報量が最大なので他のどの派生値よりも先に混ぜる。 + hash.update(';' + node.matrix.elements.join(';')) hash.update(';' + node.pos.join(';')) hash.update(';' + node.rot.join(';')) hash.update(';' + node.scale.join(';')) @@ -400,43 +524,87 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende excludedNodesCache = new Set() nodeCache = new Map() - BONE_INTERPOLATION_ENABLED.set(false) - - PROGRESS_DESCRIPTION.set('Rendering Animations...') - PROGRESS.set(0) - MAX_PROGRESS.set(project.animations.length) - + // console.time は保護区間の外に置く。 cleanup の console.timeEnd が無条件に走るため、 + // ここを try の中にすると time 未実行のまま timeEnd が呼ばれる経路ができる console.time('Rendering animations took') let selectedAnimation: _Animation | undefined let currentTime = 0 - Timeline.pause() - // Save selected animation - if (Mode.selected.id === 'animate') { - selectedAnimation = Animator.selected - currentTime = Timeline.time - } - - correctSceneAngle() const animations: IRenderedAnimation[] = [] - for (const animation of project.animations) { - animations.push(renderAnimation(animation, rig)) - PROGRESS.set(PROGRESS.get() + 1) - await sleepForAnimationFrame() - } - restoreSceneAngle() - - BONE_INTERPOLATION_ENABLED.set(true) + let sceneAngleCorrected = false + + const bodyError = createErrorSlot() + const cleanupError = createErrorSlot() + // この呼び出しが session を開いたかどうか。 開いた側だけが閉じる + // (= 並行に走ったもう 1 本の cleanup が、 こちらの session を終わらせないため) + let sessionStarted = false + + // 途中で例外が出ても bone interpolation / scene angle / 選択中 animation を必ず入口の状態へ戻す。 + // interpolation フラグを倒すのは try に入ってから (= 直後の PROGRESS 系 subscriber が throw しても + // false のまま取り残されないようにするため) + try { + BONE_INTERPOLATION_ENABLED.set(false) + + PROGRESS_DESCRIPTION.set('Rendering Animations...') + PROGRESS.set(0) + MAX_PROGRESS.set(project.animations.length) + + Timeline.pause() + // Save selected animation + if (Mode.selected.id === 'animate') { + selectedAnimation = Animator.selected + currentTime = Timeline.time + } + // 退避より後に session を開く (= hook の onBeginRendering が選択状態を書き換えても、 + // 書き換え後の状態を「元の状態」として保存しないため)。 + // hook が 1 つも無いときは session 自体を張らない (= 従来の挙動と完全に同一にするため) + if (hasRenderHooks()) { + beginRenderingSession() + sessionStarted = true + } - // Restore selected animation - if (Mode.selected.id === 'animate' && selectedAnimation) { - selectedAnimation.select() - Timeline.setTime(currentTime) - Animator.preview() - } else if (Mode.selected.id === 'edit') { - Animator.showDefaultPose() + // correctSceneAngle が 2 行の途中で throw しても復元を試みられるよう、 先にフラグを立てる + sceneAngleCorrected = true + correctSceneAngle() + for (const animation of project.animations) { + animations.push(renderAnimation(animation, rig)) + PROGRESS.set(PROGRESS.get() + 1) + await sleepForAnimationFrame() + } + } catch (error) { + bodyError.failed = true + bodyError.error = error } - console.timeEnd('Rendering animations took') + // 選択状態の復元先は cleanup 開始時点の Mode で 1 回だけ判定し、 各 step で使い回す + const animationToRestore = Mode.selected.id === 'animate' ? selectedAnimation : undefined + const restoreDefaultPose = !animationToRestore && Mode.selected.id === 'edit' + + // session の終了は Animator.preview() (= display_animation_frame の発火) より前に済ませる + runCleanupStep(cleanupError, () => { + if (sessionStarted) endRenderingSession() + }) + runCleanupStep(cleanupError, () => { + if (sceneAngleCorrected) restoreSceneAngle() + }) + runCleanupStep(cleanupError, () => BONE_INTERPOLATION_ENABLED.set(true)) + // Restore selected animation (= 1 つが throw しても残りが走るよう操作ごとに分ける) + runCleanupStep(cleanupError, () => { + if (animationToRestore) animationToRestore.select() + }) + runCleanupStep(cleanupError, () => { + if (animationToRestore) Timeline.setTime(currentTime) + }) + runCleanupStep(cleanupError, () => { + if (animationToRestore) Animator.preview() + }) + runCleanupStep(cleanupError, () => { + if (restoreDefaultPose) Animator.showDefaultPose() + }) + runCleanupStep(cleanupError, () => console.timeEnd('Rendering animations took')) + + // 元の例外を cleanup の例外で上書きしない + throwPreferringBody(bodyError, cleanupError) + console.log('Animations:', animations) return animations } diff --git a/src/systems/exporter.ts b/src/systems/exporter.ts index b69d2d7b..715fe1fe 100644 --- a/src/systems/exporter.ts +++ b/src/systems/exporter.ts @@ -130,11 +130,13 @@ async function actuallyExportProject({ PROGRESS_DESCRIPTION.set('Hashing Rendered Objects...') const rigHash = hashRig(rig) let animationHash = hashAnimations(animations) - // TSB Optimized Export : hashAnimations は量子化前の raw float (pos/rot/scale) で + // TSB Optimized Export : hashAnimations は量子化前の raw float (matrix / pos / rot / scale) で // 計算するため、 `tsb_quantization_digits_default` を変えても hash 不変 = // on_load の reload-skip 判定 (= 同一 hash で init_queue スキップ) で新桁数の // anim cell が load されない。 桁数を hash 入力に混ぜることで桁数変更も hash 動的に // 反応 → init_queue 走行 → 新 cell load。 + // (量子化桁数は render 結果のどのフィールドにも現れないので、 hash に matrix を混ぜた後も + // この補正は依然として必要) if (aj.tsb_optimized_export) { const crypto = require('crypto') animationHash = crypto diff --git a/src/systems/rigRenderer.ts b/src/systems/rigRenderer.ts index 5e0a0577..3e303435 100644 --- a/src/systems/rigRenderer.ts +++ b/src/systems/rigRenderer.ts @@ -24,6 +24,7 @@ import { restoreSceneAngle, updatePreview, } from './animationRenderer' +import { withRenderHooksSuppressed } from './animationRenderHooks' import { IntentionalExportError } from './errors' import type { TintSource } from './minecraft/itemDefinitions' @@ -803,12 +804,18 @@ function renderVariant(variant: Variant, rig: IRenderedRig): IRenderedVariant { function getDefaultTransforms(rig: IRenderedRig) { // @ts-expect-error - Broken BB types const anim = new Blockbench.Animation() - correctSceneAngle() - updatePreview(anim, 0) - updatePreview(anim, 0) // IK doesn't work unless I call this twice for some reason... - const transforms = getFrame(anim, rig.nodes, 0).node_transforms - restoreSceneAngle() - return transforms + // 使い捨ての空 Animation を評価するだけの経路なので、 外部 hook は絶対に発火させない + return withRenderHooksSuppressed(() => { + try { + correctSceneAngle() + updatePreview(anim, 0, 0) + updatePreview(anim, 0, 0) // IK doesn't work unless I call this twice for some reason... + const transforms = getFrame(anim, rig.nodes, 0, 0).node_transforms + return transforms + } finally { + restoreSceneAngle() + } + }) } export function renderRig(modelExportFolder: string, textureExportFolder: string): IRenderedRig { diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts new file mode 100644 index 00000000..7c52cc74 --- /dev/null +++ b/src/tests/animationRenderExport.test.ts @@ -0,0 +1,741 @@ +/** + * production の `renderProjectAnimations` を Blockbench 無しで実走させ、 render hook 経路が + * 実際に export 出力へ効くことを機械的に固定する。 + * + * 確認するのは : + * 1. hook 未登録時の出力が決定的であること (= baseline) + * 1b. hook 未登録時の出力が **hook 導入前の実装と一致すること** (= golden 比較、 下記) + * 2. hook 登録で `node_transforms` と `hashAnimations` が変わること + * 2b. 変化後の値が期待値と一致すること (= 「変わった」 だけでなく 「正しく 1 回分だけ変わった」) + * 2c. 冪等でない hook を入れると 2b が落ちること (= 2b が二重適用を検出できることの裏取り) + * 2d. shear だけを加えた場合も hash が変わること (= 旧式の hash では検出できなかった経路) + * 3. unregister で baseline へ完全復帰すること + * 4. `onPose` の `frameIndex` が 0 から 1 ずつ進み、 同じ値で複数回呼ばれること + * 5. `frameTimeSeconds` が frame ループの `time` と全 frame で一致すること + * 6. hook が throw しても global 状態 (interpolation フラグ / scene angle) が復旧すること + * 6b. 本体と `onEndAnimation` が両方 throw したとき、 本体側の例外が伝播すること + * 6c. cleanup の 1 段が throw しても、 残りの段が走ること + * 6d. 自分が開いていない session を cleanup で終わらせないこと + * 6e. `onBeginAnimation` の部分失敗で、 成功済み hook の `onEndAnimation` が 1 回だけ走ること + * 7. `onPose` の中から `evaluateBasePose` を呼べて、 `Timeline.time` が戻ること + * 8. 1 と 2 の render 結果で、 生成される mcfunction が byte 単位で違うこと + * + * `animationRenderer.ts` は import 連鎖の **module 評価時**に Blockbench global を要求する + * (= `Dialog` / `BoneAnimator.prototype`)。 global を後から生やす方式では越えられないため、 + * 該当 module を `vi.mock` で差し替えている。 + * + * ## golden (`fixtures/renderBaselineGolden.json`) の再生成手順 + * + * golden は **hook 導入前の commit (`a886b10e`) の実装が出した値**であり、 現ブランチのコードから + * 作ったものではない。 これが 1b を 「回帰ガード」 ではなく 「受け入れ条件の証明」 にしている。 + * + * ただし **hash 値だけは比較に使わない**。 本 PR で `hashAnimations` に `matrix.elements` を + * 混ぜたため、 golden の `main_hash_legacy_algorithm` は現行実装の出力と一致しない (= 意図した変更)。 + * 比較対象は `animations` の深比較のみで、 hash の決定性は `1.`、 変化への追従は `2.` / `2d.` が見る。 + * harness の fixture 構成 (= bone 1 個 / keyframe 無し / length 0.5) を変えると golden も + * 作り直しになるので、 そのときは同じ手順を踏むこと。 現ブランチの出力で上書きしてはいけない。 + * + * 1. `git worktree add --detach a886b10e` + * 2. worktree へ repo の `node_modules` を symlink し、 現行の `renderHarness.ts` をコピーする + * 3. worktree 内に使い捨ての test を置き、 `renderProjectAnimations` を実走させて + * `serializeAnimations` + `hashAnimations` の結果を JSON へ書き出す + * 4. 出力を本 file の golden へ移し、 `prettier --write` をかける (= 整形後も内容は bit 一致する) + * 5. `git worktree remove --force ` + * + * harness にアダプタは要らない。 `renderProjectAnimations` と `hashAnimations` の signature は + * hook 導入の前後で変わっておらず、 harness は `updatePreview` / `getFrame` を直接呼ばないため。 + * mock 一式も、 `animationRenderHooks` を除けばそのまま通る。 + */ +import { createHash } from 'node:crypto' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// exportProgress は svelte-patching-tools/blockbench (= `class SvelteDialog extends Dialog`) を +// 芋づるで引き、 module 評価時に `Dialog` global を要求する。 render 経路が使うのは +// observable 3 本の get / set だけなので、 最小の store で差し替える。 +vi.mock('../dialogs/exportProgress/exportProgress', () => { + function observable(initial: T) { + let value = initial + return { + get: () => value, + set: (next: T) => { + value = next + }, + } + } + return { + PROGRESS: observable(0), + MAX_PROGRESS: observable(1), + PROGRESS_DESCRIPTION: observable(''), + } +}) + +// boneAnimatorMod は top-level で `registerPropertyOverridePatch` を実行し、 module 評価時に +// `BoneAnimator.prototype` を要求する。 render 経路が使うのはフラグの set だけ。 +vi.mock('../mods/boneAnimatorMod', () => { + let enabled = true + return { + BONE_INTERPOLATION_ENABLED: { + get: () => enabled, + set: (next: boolean) => { + enabled = next + }, + }, + } +}) + +// outliner 4 種は Blockbench の OutlinerElement 継承ツリーを module 評価時に要求する。 +// render 経路が触るのは `.all` (= getAnimatableNodes) と `instanceof` (= getNodeMatrix の +// TextDisplay 判定) だけなので、 static `all` を持つ空 class で足りる。 +vi.mock('../outliner/interaction', () => ({ + Interaction: class Interaction { + static all: unknown[] = [] + }, +})) +vi.mock('../outliner/textDisplay', () => ({ + TextDisplay: class TextDisplay { + static all: unknown[] = [] + }, +})) +vi.mock('../outliner/vanillaBlockDisplay', () => ({ + VanillaBlockDisplay: class VanillaBlockDisplay { + static all: unknown[] = [] + }, +})) +vi.mock('../outliner/vanillaItemDisplay', () => ({ + VanillaItemDisplay: class VanillaItemDisplay { + static all: unknown[] = [] + }, +})) + +// minecraftUtil は constants.getFsModule / systems/minecraft/* を芋づるで引く。 +// 使うのは sanitizeStorageKey (= animationRenderer) と toSmallCaps (= tellraw) だけなので、 +// production と同一実装をそのまま写す。 +vi.mock('../util/minecraftUtil', () => ({ + sanitizeStorageKey: (str: string) => str.toLowerCase().replace(/[^a-z0-9_]+/g, '_'), + toSmallCaps: (str: string) => str, + parseResourceLocation(resourceLocation: string) { + let [namespace, ...parts] = resourceLocation.split(':') + if (parts.length === 0) { + parts = [namespace] + namespace = 'minecraft' + } + return { namespace, path: parts.join('') } + }, +})) + +// systems/util.ts (= sleepForAnimationFrame の提供元) が引く formats/blueprint は +// svelte component / svg asset / Blockbench API を芋づるで引く。 忠実な最小コピーで差し替える。 +vi.mock('../formats/blueprint', () => ({ + projectTargetVersionIsAtLeast(version: string): boolean { + if (!Project?.animated_java) return false + return !compareVersions(version, Project.animated_java.target_minecraft_version) + }, +})) + +// tellraw.ts の `import { type IRenderedVariant } from '../rigRenderer'` は verbatimModuleSyntax の +// 下で side-effect import として残り、 型しか使っていないのに実体 (= constants → util/lang の +// LANGUAGES 仮想モジュール) がロードされる。 実行時に参照される値は無いので空モジュールで足りる。 +vi.mock('../systems/rigRenderer', () => ({})) + +import { BONE_INTERPOLATION_ENABLED } from '../mods/boneAnimatorMod' +import { + hashAnimations, + type IRenderedAnimation, + renderProjectAnimations, +} from '../systems/animationRenderer' +import { + beginRenderingSession, + endRenderingSession, + isRenderingSessionActive, + RenderHookError, + registerRenderHooks, + type RenderHookContext, + unregisterRenderHooks, +} from '../systems/animationRenderHooks' +import { createAnimationStorageTsb } from '../systems/datapackCompiler/createAnimationStorageTsb' +import { + BLUEPRINT_ID, + BONE_UUID, + buildFixtureVariables, + compileFixture, +} from './fixtures/minimalRig' +import GOLDEN from './fixtures/renderBaselineGolden.json' +import { + createRenderHarness, + type RenderHarness, + serializeAnimations, +} from './fixtures/renderHarness' + +/** + * hook が pose に足す Y 方向の平行移動 (= Blockbench 単位)。 + * + * 回転ではなく平行移動にしているのは、 期待値を書き下せるようにするため。 + * scene の 180 度補正は Y 軸まわりなので **Y 成分に影響しない**うえ、 `getNodeMatrix` は + * 位置を 1/16 するだけなので、 出力の `pos[1]` は `time + HOOK_OFFSET_BB / 16` になる。 + */ +const HOOK_OFFSET_BB = 16 +/** 出力座標系での hook の効き幅 (= `HOOK_OFFSET_BB / 16`)。 */ +const HOOK_OFFSET = HOOK_OFFSET_BB / 16 + +const HOOK_ID = 'synthetic-physics' +/** 部分失敗の検証で 2 つ目の hook として使う id。 */ +const SECOND_HOOK_ID = 'synthetic-physics-2' + +/** + * shear 検証で matrix に加える量。 **`THREE.Matrix4.decompose` から見えない大きさ**である必要がある。 + * + * 加え方は `m12 += Δ` / `m21 -= Δ` の反対称ペア。 harness の pose は Y 軸まわりの回転だけなので + * 全 frame で `m12 = m21 = 0` であり、 このペアは : + * + * - `decompose` の scale = 各列のノルム → `sqrt(1 + Δ²)`。 `Δ ≤ 1e-8` なら `1 + Δ²` が double で + * 1 に丸まるので **bit 単位で不変** + * - `setFromRotationMatrix` はこの姿勢 (= trace ≤ 0 かつ m22 > m33) で第 2 分岐に入り、 + * `m12` と `m21` を **和** `(m12 + m21)` の形でしか読まない → `(+Δ) + (-Δ) = 0` で **不変** + * - 一方 col0 と col1 の内積は `-2Δ` になる → 基底が直交でなくなる = **shear** + * + * `1e-7` まで上げると scale と quaternion が動いてしまい 「decompose から見えない」 が崩れる。 + */ +const SHEAR_DELTA = 1e-8 + +/** + * shear を当てる frame。 **基底が軸並行な frame でないと完全な不可視にはならない**ため 0 に固定する。 + * + * harness の pose は frame ごとに Y 軸まわり `time` rad の回転が入るので、 frame 0 以外では + * col0 のノルムが `0.9999999999999999` になり `invSX !== invSY` となる。 すると `decompose` の + * 正規化で `Δ * invSY - Δ * invSX` が厳密な 0 にならず、 `rot` に 1e-25 度オーダーの残差が出て + * 旧式 hash の文字列が変わってしまう (= 「旧式は検出できない」 の証明が成立しなくなる)。 + * frame 0 は基底が厳密に軸並行 (= 180 度 Y 回転) なので残差が完全に消える。 + * + * 裏を返すと、 **旧式 hash が shear を拾えるかどうかは浮動小数の残差次第**であって、 + * shear そのものを見ているわけではない。 + */ +const SHEAR_FRAME_INDEX = 0 + +/** + * 本 PR で `matrix.elements` を混ぜる前の `hashAnimations` を再現したもの。 + * 「旧式では検出できなかった」 ことを示すためだけに使う (= production には存在しない)。 + */ +function legacyHashAnimations(animations: IRenderedAnimation[]) { + const hash = createHash('sha256') + for (const animation of animations) { + hash.update('anim;' + animation.name) + hash.update(';' + animation.duration.toString()) + hash.update(';' + animation.loop_mode) + hash.update(';' + (animation.tsb_priority ?? 'low')) + hash.update(';' + Object.keys(animation.modified_nodes).join(';')) + for (const frame of animation.frames) { + hash.update(';' + frame.time.toString()) + for (const [uuid, node] of Object.entries(frame.node_transforms)) { + hash.update(';' + uuid) + hash.update(';' + node.pos.join(';')) + hash.update(';' + node.rot.join(';')) + hash.update(';' + node.scale.join(';')) + node.interpolation && hash.update(';' + node.interpolation) + if (node.function) hash.update(';' + node.function) + if (node.function_execute_condition) + hash.update(';' + node.function_execute_condition) + } + if (frame.variants) { + hash.update(';' + frame.variants) + if (frame.variants_execute_condition) + hash.update(';' + frame.variants_execute_condition) + } + if (frame.function) hash.update(';' + frame.function) + if (frame.function_execute_condition) + hash.update(';' + frame.function_execute_condition) + } + } + return hash.digest('hex') +} + +/** + * 1 frame につき 1 回分だけ平行移動を足す hook。 + * + * `onPose` は 1 frame につき複数回呼ばれるが、 production は各 `updatePreview` の頭で + * pose を rest から組み直すため、 **絶対値ではなく加算でも結果は 1 回分に収まる** (= 冪等)。 + */ +function idempotentOffsetHook(harness: RenderHarness) { + return { + onPose() { + // hook は scene の node pose を直接書き換える契約。 + // `getFrame` が読むのは matrixWorld なので、 書き換え後に再計算まで行う。 + harness.bone.mesh.position.y += HOOK_OFFSET_BB + globals().Canvas.scene.updateMatrixWorld(true) + }, + } +} + +/** render 経路が触る global を型無しで読むための入口。 */ +function globals(): any { + return globalThis as any +} + +/** harness を組んで production の `renderProjectAnimations` を実走させる。 */ +async function render(harness: RenderHarness): Promise { + return await renderProjectAnimations(harness.project, harness.rig) +} + +/** 全 frame の bone transform を数値だけの形に落とす (= 比較しやすくするため)。 */ +function extractBoneTransforms(animations: IRenderedAnimation[]) { + return animations[0].frames.map(frame => { + const transform = frame.node_transforms[BONE_UUID] + return { + time: frame.time, + pos: transform?.pos, + rot: transform?.rot, + matrix: transform?.matrix.elements.slice(), + } + }) +} + +describe('renderProjectAnimations - hook 経路の実走', () => { + beforeEach(() => { + // production が毎 render で戻り値全体を console.log するため、 出力を抑える。 + vi.spyOn(console, 'log').mockImplementation(() => {}) + unregisterRenderHooks(HOOK_ID) + unregisterRenderHooks(SECOND_HOOK_ID) + }) + + afterEach(() => { + unregisterRenderHooks(HOOK_ID) + unregisterRenderHooks(SECOND_HOOK_ID) + vi.restoreAllMocks() + }) + + it('1. hook 未登録の出力は決定的 (= 2 回走らせて完全一致)', async () => { + const first = await render(createRenderHarness({ boneUuid: BONE_UUID })) + const second = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + expect(first[0].frames.length).toBeGreaterThan(1) + expect(extractBoneTransforms(first)).toEqual(extractBoneTransforms(second)) + expect(hashAnimations(first)).toBe(hashAnimations(second)) + }) + + it('1b. hook 未登録の出力は main (= a886b10e) の golden と一致する', async () => { + const animations = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + // transform の深比較が受け入れ条件 (= hook 未登録時の出力が導入前と一致する) の本体。 + // golden の hash 値は比較に使わない (= 本 PR で hashAnimations に matrix を混ぜたため + // main 由来の値とは一致しない)。 hash の決定性は `1.`、 変化への追従は `2.` が見ている。 + expect(serializeAnimations(animations)).toEqual(GOLDEN.animations) + }) + + it('2. hook を登録すると node_transforms と hash が変わる', async () => { + const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, idempotentOffsetHook(harness)) + const hooked = await render(harness) + + expect(hooked[0].frames.length).toBe(baseline[0].frames.length) + expect(extractBoneTransforms(hooked)).not.toEqual(extractBoneTransforms(baseline)) + expect(hashAnimations(hooked)).not.toBe(hashAnimations(baseline)) + }) + + it('2d. shear だけを加えると pos / rot / scale は不変でも hash が変わる', async () => { + const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, { + onPose(context: RenderHookContext) { + // frame 0 に限定する (= 下記のとおり、 基底が軸並行な frame でないと + // decompose の残差が完全には消えないため)。 + if (context.frameIndex !== SHEAR_FRAME_INDEX) return + // `matrixWorld` を直接いじる (= `mesh.matrix` 側だと次の updateMatrixWorld で + // TRS から再合成されて消える)。 そのためここでは updateMatrixWorld を呼ばない。 + const elements = harness.bone.mesh.matrixWorld.elements + elements[4] += SHEAR_DELTA // m12 (= col1.x) + elements[1] -= SHEAR_DELTA // m21 (= col0.y) + }, + }) + const sheared = await render(harness) + + // 基底が直交でなくなっている (= shear が乗っている)。 + const shearedMatrix = + sheared[0].frames[SHEAR_FRAME_INDEX].node_transforms[BONE_UUID].matrix.elements + const col0 = [shearedMatrix[0], shearedMatrix[1], shearedMatrix[2]] + const col1 = [shearedMatrix[4], shearedMatrix[5], shearedMatrix[6]] + const dot = col0[0] * col1[0] + col0[1] * col1[1] + col0[2] * col1[2] + expect(Math.abs(dot)).toBeCloseTo(2 * SHEAR_DELTA, 12) + + // pos / rot / scale は **bit 単位で** 不変。 + const baseFrames = baseline[0].frames + const shearedFrames = sheared[0].frames + expect(shearedFrames.length).toBe(baseFrames.length) + shearedFrames.forEach((frame, index) => { + const before = baseFrames[index].node_transforms[BONE_UUID] + const after = frame.node_transforms[BONE_UUID] + expect(after.pos).toEqual(before.pos) + expect(after.rot).toEqual(before.rot) + expect(after.scale).toEqual(before.scale) + }) + // matrix は shear を当てた frame だけが変わっている。 + const beforeMatrix = Array.from( + baseFrames[SHEAR_FRAME_INDEX].node_transforms[BONE_UUID].matrix.elements + ) + const afterMatrix = Array.from(shearedMatrix) + expect(afterMatrix).not.toEqual(beforeMatrix) + + // 旧式 (= matrix を mix しない) では変化を検出できなかった。 + expect(legacyHashAnimations(sheared)).toBe(legacyHashAnimations(baseline)) + // 現行実装は検出する。 + expect(hashAnimations(sheared)).not.toBe(hashAnimations(baseline)) + }) + + it('2b. hook 適用後の pos が期待値ちょうど (= 複数回呼ばれても 1 回分)', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, idempotentOffsetHook(harness)) + const hooked = await render(harness) + + const frames = hooked[0].frames + expect(frames.length).toBe(harness.expectedFrameTimes.length) + frames.forEach(frame => { + // baseline の pos は [0, time, 0] (= harness の applyPoseAtTime による)。 + // hook が 1 回分だけ効くなら [0, time + 1, 0] になる。 + const pos = frame.node_transforms[BONE_UUID].pos + expect(pos[0]).toBeCloseTo(0, 9) + expect(pos[1]).toBeCloseTo(frame.time + HOOK_OFFSET, 9) + expect(pos[2]).toBeCloseTo(0, 9) + }) + }) + + it('2c. 冪等でない hook なら 2b の期待値から外れる (= 二重適用を検出できる)', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + // dispatch のたびに効き幅が増える hook (= production 側の pose 再構築で吸収されない)。 + let drift = 0 + registerRenderHooks(HOOK_ID, { + onPose() { + drift += HOOK_OFFSET_BB + harness.bone.mesh.position.y += drift + globals().Canvas.scene.updateMatrixWorld(true) + }, + }) + const hooked = await render(harness) + + const offExpected = hooked[0].frames.filter(frame => { + const pos = frame.node_transforms[BONE_UUID].pos + return Math.abs(pos[1] - (frame.time + HOOK_OFFSET)) > 1e-6 + }) + expect(offExpected.length).toBeGreaterThan(0) + }) + + it('3. unregister すると baseline へ完全復帰する', async () => { + const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, idempotentOffsetHook(harness)) + const hooked = await render(harness) + expect(hashAnimations(hooked)).not.toBe(hashAnimations(baseline)) + + unregisterRenderHooks(HOOK_ID) + const restored = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + expect(extractBoneTransforms(restored)).toEqual(extractBoneTransforms(baseline)) + expect(hashAnimations(restored)).toBe(hashAnimations(baseline)) + }) + + it('4. onPose の frameIndex は 0 から 1 ずつ進み、 同じ値で複数回呼ばれる', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + const calls: Array<{ frameIndex: number; frameTimeSeconds: number; timeSeconds: number }> = + [] + registerRenderHooks(HOOK_ID, { + onPose(context: RenderHookContext) { + calls.push({ + frameIndex: context.frameIndex, + frameTimeSeconds: context.frameTimeSeconds, + timeSeconds: context.timeSeconds, + }) + }, + }) + const animations = await render(harness) + + const frameCount = animations[0].frames.length + expect(frameCount).toBe(harness.expectedFrameTimes.length) + + // 出現する frameIndex の集合が [0..N-1] で、 各値が 1 回以上出ること。 + const distinct = [...new Set(calls.map(call => call.frameIndex))] + expect(distinct).toEqual([...Array(frameCount).keys()]) + for (const index of distinct) { + expect(calls.filter(call => call.frameIndex === index).length).toBeGreaterThanOrEqual(1) + } + // 同じ frameIndex で複数回呼ばれる (= advance は frameIndex 単位で 1 回だけ)。 + expect(calls.length).toBeGreaterThan(frameCount) + // frameIndex は単調非減少 (= 戻らない)。 + for (let i = 1; i < calls.length; i++) { + expect(calls[i].frameIndex).toBeGreaterThanOrEqual(calls[i - 1].frameIndex) + } + // frameTimeSeconds は frameIndex / 20、 timeSeconds はそれ自身か side sample (+0.001)。 + for (const call of calls) { + expect(call.frameTimeSeconds).toBe(call.frameIndex / 20) + const delta = call.timeSeconds - call.frameTimeSeconds + expect(delta === 0 || Math.abs(delta - 0.001) < 1e-9).toBe(true) + } + }) + + it('5. frameTimeSeconds が frame ループの time と全 frame で一致する', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + const seenTimes = new Map() + registerRenderHooks(HOOK_ID, { + onPose(context: RenderHookContext) { + seenTimes.set(context.frameIndex, context.frameTimeSeconds) + }, + }) + const animations = await render(harness) + + const frames = animations[0].frames + expect(seenTimes.size).toBe(frames.length) + frames.forEach((frame, index) => { + expect(seenTimes.get(index)).toBe(frame.time) + expect(frame.time).toBe(harness.expectedFrameTimes[index]) + }) + }) + + it('6. hook が throw しても interpolation フラグと scene angle が復旧する', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + const cause = new Error('physics exploded') + registerRenderHooks(HOOK_ID, { + onPose() { + throw cause + }, + }) + + const caught = await render(harness).then( + () => undefined, + (error: unknown) => error + ) + + // (a)(b) reject し、 RenderHookError で hookId / phase / cause を保つ + expect(caught).toBeInstanceOf(RenderHookError) + expect((caught as RenderHookError).hookId).toBe(HOOK_ID) + expect((caught as RenderHookError).phase).toBe('onPose') + expect((caught as RenderHookError).cause).toBe(cause) + // (c) BONE_INTERPOLATION_ENABLED が true に戻っている + expect(BONE_INTERPOLATION_ENABLED.get()).toBe(true) + // (d) scene の 180 度回転が戻っている (= 単位 quaternion) + expect(harness.scene.quaternion.w).toBeCloseTo(1, 9) + expect(harness.scene.quaternion.y).toBeCloseTo(0, 9) + }) + + it('6b. onPose と onEndAnimation が両方 throw したら onPose 由来が伝播する', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + const poseCause = new Error('pose exploded') + const endCause = new Error('cleanup exploded') + registerRenderHooks(HOOK_ID, { + onPose() { + throw poseCause + }, + onEndAnimation() { + throw endCause + }, + }) + + const caught = await render(harness).then( + () => undefined, + (error: unknown) => error + ) + + // 本体 (= frame ループ内の onPose) 由来が優先される。 + expect(caught).toBeInstanceOf(RenderHookError) + expect((caught as RenderHookError).phase).toBe('onPose') + expect((caught as RenderHookError).cause).toBe(poseCause) + + // cleanup (= onEndAnimation) 由来は console.warn に落ちる。 + const warned = warn.mock.calls + .map(call => call[0]) + .filter( + (arg): arg is RenderHookError => + arg instanceof RenderHookError && arg.phase === 'onEndAnimation' + ) + expect(warned.length).toBe(1) + expect(warned[0].cause).toBe(endCause) + warn.mockRestore() + }) + + it('6c. 選択 animation の復元は select() が throw しても setTime / preview が走る', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + const animation = harness.project.animations[0] as unknown as { + select: () => void + } + // renderAnimation が冒頭で 1 回呼ぶので、 cleanup 側の 2 回目だけ throw させる。 + let selectCalls = 0 + const selectCause = new Error('select exploded') + animation.select = () => { + selectCalls++ + if (selectCalls >= 2) throw selectCause + } + let previewCalls = 0 + globals().Animator.preview = () => { + previewCalls++ + } + // cleanup の setTime がこの値へ戻すことを確認する (= frame ループ後の時刻と区別できる値)。 + globals().Timeline.time = 0.3 + + const caught = await render(harness).then( + () => undefined, + (error: unknown) => error + ) + + // 本体は正常終了しているので、 cleanup 側の例外がそのまま出る。 + expect(caught).toBe(selectCause) + expect(selectCalls).toBe(2) + // select() が throw しても後続の 2 step が走っている。 + expect(globals().Timeline.time).toBe(0.3) + expect(previewCalls).toBe(1) + }) + + it('6e. onBeginAnimation の部分失敗で、 成功済み hook の onEndAnimation が 1 回だけ走る', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + const cause = new Error('begin exploded') + let endAnimationCalls = 0 + registerRenderHooks(HOOK_ID, { + onBeginAnimation() {}, + onEndAnimation() { + endAnimationCalls++ + }, + }) + registerRenderHooks(SECOND_HOOK_ID, { + onBeginAnimation() { + throw cause + }, + }) + + const caught = await render(harness).then( + () => undefined, + (error: unknown) => error + ) + unregisterRenderHooks(SECOND_HOOK_ID) + + // registry 側の unwind が送る 1 回だけ (= cleanup の dispatchEndAnimation と二重にならない)。 + expect(endAnimationCalls).toBe(1) + // 伝播するのは失敗した hook 由来の例外。 + expect(caught).toBeInstanceOf(RenderHookError) + expect((caught as RenderHookError).hookId).toBe(SECOND_HOOK_ID) + expect((caught as RenderHookError).phase).toBe('onBeginAnimation') + expect((caught as RenderHookError).cause).toBe(cause) + // global 状態は復旧している。 + expect(BONE_INTERPOLATION_ENABLED.get()).toBe(true) + expect(harness.scene.quaternion.w).toBeCloseTo(1, 9) + expect(harness.scene.quaternion.y).toBeCloseTo(0, 9) + }) + + it('6d. 自分が開いていない session を cleanup で終わらせない', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, { onPose() {} }) + + // 別の render が既に session を開いている状況を作る (= 並行実行の再現)。 + beginRenderingSession() + expect(isRenderingSessionActive()).toBe(true) + + // 2 本目は beginRenderingSession が「既に active」で throw する。 + const caught = await render(harness).then( + () => undefined, + (error: unknown) => error + ) + expect(caught).toBeInstanceOf(Error) + + // 2 本目の cleanup は自分が開いた session ではないので閉じない。 + expect(isRenderingSessionActive()).toBe(true) + + endRenderingSession() + expect(isRenderingSessionActive()).toBe(false) + }) + + it('7. onPose の中から evaluateBasePose を呼べて Timeline.time が戻る', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + const observed: Array<{ before: number; after: number }> = [] + registerRenderHooks(HOOK_ID, { + onPose(context: RenderHookContext) { + const before = globals().Timeline.time as number + context.evaluateBasePose(0.1) + observed.push({ before, after: globals().Timeline.time as number }) + }, + }) + + const animations = await render(harness) + + expect(animations[0].frames.length).toBeGreaterThan(1) + expect(observed.length).toBeGreaterThan(0) + for (const entry of observed) { + expect(entry.after).toBe(entry.before) + } + }) +}) + +describe('renderProjectAnimations - datapack までの byte 差分', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + unregisterRenderHooks(HOOK_ID) + unregisterRenderHooks(SECOND_HOOK_ID) + }) + + afterEach(() => { + unregisterRenderHooks(HOOK_ID) + unregisterRenderHooks(SECOND_HOOK_ID) + vi.restoreAllMocks() + }) + + /** hook 無し / 有りの 2 種類の render 結果を作る。 */ + async function renderBaselineAndHooked() { + const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, idempotentOffsetHook(harness)) + const hooked = await render(harness) + unregisterRenderHooks(HOOK_ID) + + return { baseline, hooked } + } + + /** TSB 経路の animation storage を生成する (= frame data が実際に載るファイル群)。 */ + async function buildStorage(animations: IRenderedAnimation[]) { + const variables = buildFixtureVariables({ renderedAnimations: animations }) + const result = await createAnimationStorageTsb( + variables.rig as never, + animations as never, + { + blueprintId: BLUEPRINT_ID, + quantizationDigits: 5, + cellsPerTick: 1000, + maxLineBytes: 1_000_000, + loadDebugLog: false, + } + ) + const files = new Map() + for (const [path, file] of result.files) files.set(path, String(file.content)) + return files + } + + it('8. hook の有無で animation storage の mcfunction が byte 単位で変わる', async () => { + const { baseline, hooked } = await renderBaselineAndHooked() + + const baselineFiles = await buildStorage(baseline) + const hookedFiles = await buildStorage(hooked) + + // 生成されるファイル構成そのものは変わらない (= 変わるのは中身)。 + expect([...hookedFiles.keys()].sort()).toEqual([...baselineFiles.keys()].sort()) + + const changed = [...baselineFiles.keys()].filter( + path => baselineFiles.get(path) !== hookedFiles.get(path) + ) + expect(changed.length).toBeGreaterThan(0) + // 変わるのは bone の frame data (= expand で storage へ書き込む cell) を持つファイル。 + expect(changed.some(path => path.includes('/expand/'))).toBe(true) + }) + + it('8b. .mcb 側の scaffolding は pose 非依存 (= hook の有無で完全一致)', async () => { + const { baseline, hooked } = await renderBaselineAndHooked() + + const baselineFiles = await compileFixture({ renderedAnimations: baseline }) + const hookedFiles = await compileFixture({ renderedAnimations: hooked }) + + // frame data は `createAnimationStorageTsb` 側の cell ファイルに載るため、 + // `compileMcbProject` の生成物は frame 数 / 名前が同じなら byte 一致する。 + expect([...hookedFiles.keys()].sort()).toEqual([...baselineFiles.keys()].sort()) + const changed = [...baselineFiles.keys()].filter( + path => baselineFiles.get(path) !== hookedFiles.get(path) + ) + expect(changed).toEqual([]) + }) +}) diff --git a/src/tests/animationRenderHooks.test.ts b/src/tests/animationRenderHooks.test.ts new file mode 100644 index 00000000..32593c74 --- /dev/null +++ b/src/tests/animationRenderHooks.test.ts @@ -0,0 +1,568 @@ +/** + * animationRenderHooks の registry / session / dispatch / suppression 単体テスト。 + * 対象 module は Blockbench global を実行時に参照しないため、 BB 無しで走る。 + * + * 確認するのは : + * 1. registry の登録 / 解除と入力バリデーション + * 2. session の呼び出し順 (= begin / pose は登録順、 end は逆順) と参加者スナップショット + * 3. hook 未登録 / session 外 / suppression 中の dispatch が完全 no-op であること + * 4. hook が throw したときの `RenderHookError` 包装と、 end 系の全件実行 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + RenderHookError, + areRenderHooksSuppressed, + beginRenderingSession, + dispatchBeginAnimation, + dispatchEndAnimation, + dispatchPose, + endRenderingSession, + hasRenderHooks, + isRenderingSessionActive, + registerRenderHooks, + resetRenderHooksForTesting, + shouldDispatchPose, + unregisterRenderHooks, + withRenderHooksSuppressed, + type RenderAnimationContext, + type RenderHookContext, +} from '../systems/animationRenderHooks' + +// --- helpers ---------------------------------------------------------------- + +/** hook 側は context の中身を見ないので、 型を満たす最小の dummy を使う。 */ +function makeAnimationContext(): RenderAnimationContext { + return { + animation: {} as _Animation, + rig: {} as RenderAnimationContext['rig'], + excludedNodeUuids: new Set(), + evaluateBasePose: () => {}, + } +} + +function makePoseContext(frameIndex = 0): RenderHookContext { + return { + ...makeAnimationContext(), + frameIndex, + frameTimeSeconds: frameIndex / 20, + timeSeconds: frameIndex / 20, + } +} + +/** 呼び出し順を 1 本の配列に記録する probe を作る。 */ +function makeProbe(id: string, log: string[]) { + return { + onBeginRendering: () => log.push(`${id}:beginRendering`), + onBeginAnimation: () => log.push(`${id}:beginAnimation`), + onPose: () => log.push(`${id}:pose`), + onEndAnimation: () => log.push(`${id}:endAnimation`), + onEndRendering: () => log.push(`${id}:endRendering`), + } +} + +beforeEach(() => { + resetRenderHooksForTesting() +}) + +// --- registry --------------------------------------------------------------- + +describe('animationRenderHooks - registry', () => { + it('登録 / 解除に応じて hasRenderHooks が変化する', () => { + expect(hasRenderHooks()).toBe(false) + registerRenderHooks('a', {}) + expect(hasRenderHooks()).toBe(true) + unregisterRenderHooks('a') + expect(hasRenderHooks()).toBe(false) + }) + + it('空 id の登録は throw する', () => { + expect(() => registerRenderHooks('', {})).toThrow() + expect(hasRenderHooks()).toBe(false) + }) + + it('重複 id の登録は throw し、 最初の hooks を保持する (= 上書きしない)', () => { + const log: string[] = [] + registerRenderHooks('a', makeProbe('first', log)) + + expect(() => registerRenderHooks('a', makeProbe('second', log))).toThrow() + + // throw 後も registry には最初の hooks が残っている (= 解除 / 再登録を挟まずに確認する)。 + beginRenderingSession() + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext()) + dispatchEndAnimation() + endRenderingSession() + + expect(log).toEqual([ + 'first:beginRendering', + 'first:beginAnimation', + 'first:pose', + 'first:endAnimation', + 'first:endRendering', + ]) + expect(log.some(entry => entry.startsWith('second:'))).toBe(false) + }) + + it('未登録 id の解除は throw しない (= 冪等)', () => { + expect(() => unregisterRenderHooks('missing')).not.toThrow() + expect(() => unregisterRenderHooks('missing')).not.toThrow() + }) +}) + +// --- session / dispatch ----------------------------------------------------- + +describe('animationRenderHooks - session と dispatch', () => { + it('1 hook で begin → animation → pose → endAnimation → endRendering の順に呼ばれる', () => { + const log: string[] = [] + registerRenderHooks('a', makeProbe('a', log)) + + beginRenderingSession() + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext(0)) + dispatchPose(makePoseContext(1)) + dispatchEndAnimation() + endRenderingSession() + + expect(log).toEqual([ + 'a:beginRendering', + 'a:beginAnimation', + 'a:pose', + 'a:pose', + 'a:endAnimation', + 'a:endRendering', + ]) + }) + + it('複数 hook で begin / pose は登録順、 end は逆順になる', () => { + const log: string[] = [] + registerRenderHooks('a', makeProbe('a', log)) + registerRenderHooks('b', makeProbe('b', log)) + + beginRenderingSession() + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext()) + dispatchEndAnimation() + endRenderingSession() + + expect(log).toEqual([ + 'a:beginRendering', + 'b:beginRendering', + 'a:beginAnimation', + 'b:beginAnimation', + 'a:pose', + 'b:pose', + 'b:endAnimation', + 'a:endAnimation', + 'b:endRendering', + 'a:endRendering', + ]) + }) + + it('onPose だけを持つ hook でも他の callback 欠落で throw しない', () => { + const log: string[] = [] + registerRenderHooks('a', { onPose: () => log.push('pose') }) + + beginRenderingSession() + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext()) + dispatchEndAnimation() + endRenderingSession() + + expect(log).toEqual(['pose']) + }) + + it('hook が 1 つも無ければ全 dispatch が no-op', () => { + expect(() => { + beginRenderingSession() + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext()) + dispatchEndAnimation() + endRenderingSession() + }).not.toThrow() + expect(shouldDispatchPose()).toBe(false) + }) + + it('session 外の dispatch は no-op', () => { + const log: string[] = [] + registerRenderHooks('a', makeProbe('a', log)) + + expect(isRenderingSessionActive()).toBe(false) + expect(shouldDispatchPose()).toBe(false) + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext()) + dispatchEndAnimation() + expect(log).toEqual([]) + }) + + it('session 中の register はその session に参加しない (= スナップショット)', () => { + const log: string[] = [] + registerRenderHooks('a', makeProbe('a', log)) + + beginRenderingSession() + registerRenderHooks('late', makeProbe('late', log)) + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext()) + dispatchEndAnimation() + endRenderingSession() + + expect(log.some(entry => entry.startsWith('late:'))).toBe(false) + expect(hasRenderHooks()).toBe(true) + }) + + it('session 中の unregister でも参加者から外れない (= スナップショット)', () => { + const log: string[] = [] + registerRenderHooks('a', makeProbe('a', log)) + + beginRenderingSession() + unregisterRenderHooks('a') + dispatchPose(makePoseContext()) + endRenderingSession() + + expect(log).toEqual(['a:beginRendering', 'a:pose', 'a:endRendering']) + expect(hasRenderHooks()).toBe(false) + }) + + it('beginRenderingSession の二重呼び出しは throw、 endRenderingSession の二重呼び出しは throw しない', () => { + beginRenderingSession() + expect(isRenderingSessionActive()).toBe(true) + expect(() => beginRenderingSession()).toThrow() + + endRenderingSession() + expect(isRenderingSessionActive()).toBe(false) + expect(() => endRenderingSession()).not.toThrow() + }) + + it('shouldDispatchPose は session active かつ hook 登録済みのときだけ true', () => { + registerRenderHooks('a', makeProbe('a', [])) + expect(shouldDispatchPose()).toBe(false) + beginRenderingSession() + expect(shouldDispatchPose()).toBe(true) + endRenderingSession() + expect(shouldDispatchPose()).toBe(false) + }) +}) + +// --- suppression ------------------------------------------------------------ + +describe('animationRenderHooks - suppression', () => { + it('suppression 中は dispatch が呼ばれない', () => { + const log: string[] = [] + registerRenderHooks('a', makeProbe('a', log)) + beginRenderingSession() + + withRenderHooksSuppressed(() => { + expect(areRenderHooksSuppressed()).toBe(true) + expect(shouldDispatchPose()).toBe(false) + dispatchBeginAnimation(makeAnimationContext()) + dispatchPose(makePoseContext()) + dispatchEndAnimation() + }) + + expect(areRenderHooksSuppressed()).toBe(false) + dispatchPose(makePoseContext()) + endRenderingSession() + expect(log).toEqual(['a:beginRendering', 'a:pose', 'a:endRendering']) + }) + + it('fn の戻り値をそのまま返す', () => { + expect(withRenderHooksSuppressed(() => 42)).toBe(42) + }) + + it('fn が throw してもカウンタが戻る', () => { + expect(() => + withRenderHooksSuppressed(() => { + throw new Error('boom') + }) + ).toThrow('boom') + expect(areRenderHooksSuppressed()).toBe(false) + }) + + it('入れ子の内側を抜けても外側の抑制は続く', () => { + const log: string[] = [] + registerRenderHooks('a', { onPose: () => log.push('pose') }) + beginRenderingSession() + + withRenderHooksSuppressed(() => { + withRenderHooksSuppressed(() => { + expect(areRenderHooksSuppressed()).toBe(true) + }) + // 内側を抜けた直後でも抑制されたまま + expect(areRenderHooksSuppressed()).toBe(true) + dispatchPose(makePoseContext()) + }) + + expect(areRenderHooksSuppressed()).toBe(false) + endRenderingSession() + expect(log).toEqual([]) + }) +}) + +// --- 例外の扱い ------------------------------------------------------------- + +describe('animationRenderHooks - 例外の包装', () => { + it('onPose の例外は hookId / phase / cause を保った RenderHookError になる', () => { + const cause = new Error('physics exploded') + registerRenderHooks('spring-bone', { + onPose: () => { + throw cause + }, + }) + beginRenderingSession() + + let caught: unknown + try { + dispatchPose(makePoseContext()) + } catch (error) { + caught = error + } + endRenderingSession() + + expect(caught).toBeInstanceOf(RenderHookError) + const hookError = caught as RenderHookError + expect(hookError.hookId).toBe('spring-bone') + expect(hookError.phase).toBe('onPose') + expect(hookError.cause).toBe(cause) + expect(hookError.message).toContain('spring-bone') + expect(hookError.message).toContain('onPose') + }) + + it('onBeginAnimation の例外は以降の hook を呼ばずに即 rethrow する', () => { + const log: string[] = [] + registerRenderHooks('a', { + onBeginAnimation: () => { + log.push('a') + throw new Error('boom') + }, + }) + registerRenderHooks('b', { onBeginAnimation: () => log.push('b') }) + beginRenderingSession() + + expect(() => dispatchBeginAnimation(makeAnimationContext())).toThrow(RenderHookError) + expect(log).toEqual(['a']) + endRenderingSession() + }) + + it('onBeginRendering が throw したら session を active に残さない', () => { + registerRenderHooks('a', { + onBeginRendering: () => { + throw new Error('boom') + }, + }) + + expect(() => beginRenderingSession()).toThrow(RenderHookError) + expect(isRenderingSessionActive()).toBe(false) + }) +}) + +// --- 部分失敗の unwind ------------------------------------------------------ + +describe('animationRenderHooks - begin 系の部分失敗 unwind', () => { + it('onBeginRendering の途中失敗で、 成功済み hook に onEndRendering が届く', () => { + const log: string[] = [] + registerRenderHooks('a', { + onBeginRendering: () => log.push('a:begin'), + onEndRendering: () => log.push('a:end'), + }) + registerRenderHooks('b', { + onBeginRendering: () => { + log.push('b:begin') + throw new Error('boom') + }, + onEndRendering: () => log.push('b:end'), + }) + + expect(() => beginRenderingSession()).toThrow(RenderHookError) + + // a は begin を受け取ったので end も受け取る。 b は begin 自体が失敗したので end は来ない。 + expect(log).toEqual(['a:begin', 'b:begin', 'a:end']) + expect(isRenderingSessionActive()).toBe(false) + }) + + it('onBeginRendering の unwind でも元の例外 (= 失敗した hook 由来) が伝播する', () => { + const cause = new Error('boom') + registerRenderHooks('a', { onBeginRendering: () => {} }) + registerRenderHooks('b', { + onBeginRendering: () => { + throw cause + }, + }) + + let caught: unknown + try { + beginRenderingSession() + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(RenderHookError) + expect((caught as RenderHookError).hookId).toBe('b') + expect((caught as RenderHookError).phase).toBe('onBeginRendering') + expect((caught as RenderHookError).cause).toBe(cause) + }) + + it('unwind 中の onEndRendering が throw しても元の例外が優先される', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cause = new Error('boom') + registerRenderHooks('a', { + onBeginRendering: () => {}, + onEndRendering: () => { + throw new Error('unwind failed') + }, + }) + registerRenderHooks('b', { + onBeginRendering: () => { + throw cause + }, + }) + + let caught: unknown + try { + beginRenderingSession() + } catch (error) { + caught = error + } + + expect((caught as RenderHookError).hookId).toBe('b') + expect((caught as RenderHookError).cause).toBe(cause) + // unwind 側の失敗は warn に落ちる + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toBeInstanceOf(RenderHookError) + expect((warn.mock.calls[0][0] as RenderHookError).phase).toBe('onEndRendering') + expect(isRenderingSessionActive()).toBe(false) + warn.mockRestore() + }) + + it('onBeginAnimation の途中失敗で、 成功済み hook に onEndAnimation が届く', () => { + const log: string[] = [] + registerRenderHooks('a', { + onBeginAnimation: () => log.push('a:begin'), + onEndAnimation: () => log.push('a:end'), + }) + registerRenderHooks('b', { + onBeginAnimation: () => { + log.push('b:begin') + throw new Error('boom') + }, + onEndAnimation: () => log.push('b:end'), + }) + beginRenderingSession() + + expect(() => dispatchBeginAnimation(makeAnimationContext())).toThrow(RenderHookError) + + expect(log).toEqual(['a:begin', 'b:begin', 'a:end']) + endRenderingSession() + }) + + it('onBeginAnimation の unwind でも元の例外が伝播する', () => { + const cause = new Error('boom') + registerRenderHooks('a', { onBeginAnimation: () => {} }) + registerRenderHooks('b', { + onBeginAnimation: () => { + throw cause + }, + }) + beginRenderingSession() + + let caught: unknown + try { + dispatchBeginAnimation(makeAnimationContext()) + } catch (error) { + caught = error + } + endRenderingSession() + + expect(caught).toBeInstanceOf(RenderHookError) + expect((caught as RenderHookError).hookId).toBe('b') + expect((caught as RenderHookError).phase).toBe('onBeginAnimation') + expect((caught as RenderHookError).cause).toBe(cause) + }) + + it('unwind 中の onEndAnimation が throw しても元の例外が優先される', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cause = new Error('boom') + registerRenderHooks('a', { + onBeginAnimation: () => {}, + onEndAnimation: () => { + throw new Error('unwind failed') + }, + }) + registerRenderHooks('b', { + onBeginAnimation: () => { + throw cause + }, + }) + beginRenderingSession() + + let caught: unknown + try { + dispatchBeginAnimation(makeAnimationContext()) + } catch (error) { + caught = error + } + endRenderingSession() + + expect((caught as RenderHookError).hookId).toBe('b') + expect((caught as RenderHookError).cause).toBe(cause) + expect(warn).toHaveBeenCalledTimes(1) + expect((warn.mock.calls[0][0] as RenderHookError).phase).toBe('onEndAnimation') + warn.mockRestore() + }) + + it('onEndAnimation は 1 つ目が throw しても全件実行してから throw する', () => { + const log: string[] = [] + // end 系は逆順なので b → a の順に走る。 先に走る b を throw させる + registerRenderHooks('a', { onEndAnimation: () => log.push('a') }) + registerRenderHooks('b', { + onEndAnimation: () => { + log.push('b') + throw new Error('boom') + }, + }) + beginRenderingSession() + + let caught: unknown + try { + dispatchEndAnimation() + } catch (error) { + caught = error + } + endRenderingSession() + + expect(log).toEqual(['b', 'a']) + expect(caught).toBeInstanceOf(RenderHookError) + expect((caught as RenderHookError).hookId).toBe('b') + expect((caught as RenderHookError).phase).toBe('onEndAnimation') + }) + + it('onEndRendering は全件実行し、 2 件目以降の例外は console.warn に落ちる', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const log: string[] = [] + registerRenderHooks('a', { + onEndRendering: () => { + log.push('a') + throw new Error('boom-a') + }, + }) + registerRenderHooks('b', { + onEndRendering: () => { + log.push('b') + throw new Error('boom-b') + }, + }) + beginRenderingSession() + + let caught: unknown + try { + endRenderingSession() + } catch (error) { + caught = error + } + + expect(log).toEqual(['b', 'a']) + expect((caught as RenderHookError).hookId).toBe('b') + expect(warn).toHaveBeenCalledTimes(1) + // 例外が出ても session 状態は破棄されている + expect(isRenderingSessionActive()).toBe(false) + warn.mockRestore() + }) +}) diff --git a/src/tests/fixtures/minimalRig.ts b/src/tests/fixtures/minimalRig.ts index 4214e151..f52472b2 100644 --- a/src/tests/fixtures/minimalRig.ts +++ b/src/tests/fixtures/minimalRig.ts @@ -51,7 +51,12 @@ export const DISPLAY_ITEM = 'minecraft:stone' const BONE_TYPES = ['bone', 'text_display', 'item_display', 'block_display'] -const BONE_UUID = 'fixture-bone' +/** + * fixture rig の bone uuid。 外から組み上げた `IRenderedAnimation[]` を + * `FixtureOptions.renderedAnimations` で流し込む場合、 animation 側の node uuid を + * これに合わせないと `createAnimationStorageTsb` が bone を `modified_nodes` から引けない。 + */ +export const BONE_UUID = 'fixture-bone' const LOCATOR_UUID = 'fixture-locator' const CAMERA_UUID = 'fixture-camera' @@ -73,6 +78,14 @@ export interface FixtureOptions { /** 各 frame の variants 配列。variant keyframe の有無を作るために使う。 */ frameVariants?: Array }> + /** + * 組み上がった `IRenderedAnimation[]` を直接流し込む注入口。 + * + * 指定すると `animations` spec からの合成 (`buildAnimations`) をバイパスし、 これをそのまま + * `variables.animations` に載せる。 production の `renderProjectAnimations` の出力を + * datapack まで通すために使う。 + */ + renderedAnimations?: IRenderedAnimation[] /** * TSB 最適化経路を使うか。省略時 true。 * @@ -101,7 +114,12 @@ function compareVersionsImpl(versionA: string, versionB: string): boolean { /** * `THREE.Matrix4` の最小 stub。 `matrixToNbtFloatArray` が使う copy / transpose / toArray - * だけを持つ (= three は本 repo の依存に含まれず、 Blockbench が runtime で供給するため)。 + * だけを持つ。 + * + * three 自体は devDependency として入っている (= `renderHarness.ts` が実物を使う) が、 + * この fixture は matrix 演算を必要とせず identity を配るだけなので、 stub のまま据え置く。 + * `installGlobals` の `g.THREE ??=` は既に実物が載っていれば上書きしないので、 + * harness と同一プロセスで動いても衝突しない。 */ class Matrix4Stub { elements: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] @@ -375,7 +393,7 @@ export function buildFixtureVariables(options: FixtureOptions = {}): Record ({ + name: animation.name, + storage_name: animation.storage_name, + uuid: animation.uuid, + loop_delay: animation.loop_delay, + duration: animation.duration, + loop_mode: animation.loop_mode, + tsb_priority: animation.tsb_priority ?? null, + modified_nodes: Object.keys(animation.modified_nodes).sort(), + frames: animation.frames.map(frame => ({ + time: frame.time, + variants: frame.variants ?? null, + variants_execute_condition: frame.variants_execute_condition ?? null, + function: frame.function ?? null, + function_execute_condition: frame.function_execute_condition ?? null, + node_transforms: Object.fromEntries( + Object.entries(frame.node_transforms).map(([uuid, transform]) => [ + uuid, + { + pos: transform.pos, + rot: transform.rot, + scale: transform.scale, + head_rot: transform.head_rot, + matrix: Array.from(transform.matrix.elements), + decomposed: { + translation: [ + transform.decomposed.translation.x, + transform.decomposed.translation.y, + transform.decomposed.translation.z, + ], + left_rotation: [ + transform.decomposed.left_rotation.x, + transform.decomposed.left_rotation.y, + transform.decomposed.left_rotation.z, + transform.decomposed.left_rotation.w, + ], + scale: [ + transform.decomposed.scale.x, + transform.decomposed.scale.y, + transform.decomposed.scale.z, + ], + }, + interpolation: transform.interpolation ?? null, + function: transform.function ?? null, + function_execute_condition: transform.function_execute_condition ?? null, + }, + ]) + ), + })), + })) +} + +export interface HarnessOptions { + /** + * bone の uuid。 render 結果を `compileFixture` へ流す場合は、 fixture rig 側の bone uuid + * (= `minimalRig.ts` の `BONE_UUID`) と一致させる必要がある。 + */ + boneUuid?: string + /** animation 名。 */ + animationName?: string + /** animation 長 (秒)。 frame 数は `length / 0.05 + 1`。 */ + animationLength?: number +} + +/** `renderProjectAnimations` に渡す一式と、 assert 用の参照。 */ +export interface RenderHarness { + /** `renderProjectAnimations(project, rig)` の第 1 引数。 */ + project: ModelProject + /** 同第 2 引数。 */ + rig: IRenderedRig + /** 単一の bone。 hook から pose を書き換える対象。 */ + bone: HarnessBone + /** `Canvas.scene` の実体。 */ + scene: THREE.Scene + /** frame ループが生成する時刻の一覧 (= assert 用の期待値)。 */ + expectedFrameTimes: number[] +} + +/** + * `renderProjectAnimations` の実行時に触られる global を立てる。 + * + * 実際に落ちて必要だと分かったものだけを載せている : + * - `THREE` : `getNodeMatrix` / `correctSceneAngle` / `eulerFromQuaternion` + * - `Math.radToDeg` : `threeAxisRotationToTwoAxisRotation` (= Blockbench が Math に生やす拡張) + * - `requestAnimationFrame` : `sleepForAnimationFrame` (= browser global) + * - `Canvas` / `Timeline` / `Animator` / `Mode` / `Preview` : render ループ本体 + * - `NullObject` / `Group` / `Locator` / `OutlinerElement` : `getAnimatableNodes()` + * (= `Interaction` / `TextDisplay` / `Vanilla*Display` は import 経由なので test 側の mock が担当) + */ +export function installRenderGlobals(): void { + const g = globalThis as any + + // 既存 fixture (`minimalRig.ts`) の `g.THREE ??= { Matrix4: Matrix4Stub }` に負けないよう明示代入する。 + g.THREE = THREE + if (typeof (Math as any).radToDeg !== 'function') { + ;(Math as any).radToDeg = (radians: number) => THREE.MathUtils.radToDeg(radians) + } + if (typeof g.requestAnimationFrame !== 'function') { + g.requestAnimationFrame = (callback: (time: number) => void) => { + return setTimeout(() => callback(Date.now()), 0) as unknown as number + } + } + + g.Canvas = { scene: new THREE.Scene() } + g.Timeline = { + time: 0, + pause() {}, + setTime(time: number) { + g.Timeline.time = time + }, + } + g.Animator = { + selected: undefined as unknown, + showDefaultPose() { + for (const bone of g.Group.all as HarnessBone[]) bone.resetToRestPose() + }, + resetLastValues() {}, + preview() {}, + } + g.Mode = { selected: { id: 'animate' } } + g.Preview = { all: [] } + + // `getAnimatableNodes()` が読む global 群。 bone は Group.all に置く。 + g.NullObject = { all: [] } + g.Group = { all: [] as HarnessBone[] } + g.Locator = { all: [] } + g.OutlinerElement = { types: {} } +} + +/** frame ループ (`animationRenderer.ts` の `for (let time = 0; ...)`) と同じ時刻列。 */ +function buildExpectedFrameTimes(length: number): number[] { + const times: number[] = [] + for (let time = 0; time <= length; time = Math.round((time + TICK) * 20) / 20) { + times.push(time) + } + return times +} + +/** `IRenderedRig` の最小形。 render 経路が読むのは `nodes` だけ。 */ +function buildHarnessRig(bone: HarnessBone): IRenderedRig { + const nodes: Record = { + [bone.uuid]: { + type: 'bone', + name: bone.name, + storage_name: bone.name, + uuid: bone.uuid, + parent: 'root', + base_scale: 1, + bounding_box: null, + configs: { default: {}, variants: {} }, + }, + } + return { + nodes: nodes as Record, + variants: {}, + textures: {}, + model_export_folder: '', + texture_export_folder: '', + includes_custom_models: false, + } as unknown as IRenderedRig +} + +/** + * `_Animation` 相当の最小実装。 production が実際に呼ぶのは + * `select()` / `getBoneAnimator()` / `animators` / `effects` / `excluded_nodes` / `length` だけ。 + */ +function buildHarnessAnimation(bone: HarnessBone, name: string, length: number) { + const animator = { + // `getFrame` の keyframeCache は `animation.animators[uuid]` が truthy でないと + // 当該 node を丸ごと skip するため、 空でも animator 自体は必要。 + keyframes: [] as unknown[], + displayFrame() { + bone.applyPoseAtTime((globalThis as any).Timeline.time as number) + }, + } + return { + name, + uuid: `animation-${name}`, + length, + loop: 'once', + loop_delay: 0, + excluded_nodes: [] as Array<{ value: string }>, + effects: undefined, + animators: { [bone.uuid]: animator } as Record, + select() {}, + getBoneAnimator(node: { uuid: string }) { + return this.animators[node.uuid] + }, + } +} + +/** + * global を立て直したうえで、 単一 bone / 単一 animation の harness を組む。 + * 呼ぶたびに scene と node registry を作り直すので、 test 間で状態が漏れない。 + */ +export function createRenderHarness(options: HarnessOptions = {}): RenderHarness { + const boneUuid = options.boneUuid ?? 'harness-bone' + const animationName = options.animationName ?? 'test_animation' + const animationLength = options.animationLength ?? 0.5 + + installRenderGlobals() + const g = globalThis as any + + const bone = new HarnessBone(boneUuid, 'body') + const scene = g.Canvas.scene as THREE.Scene + scene.add(bone.mesh) + g.Group.all = [bone] + + const animation = buildHarnessAnimation(bone, animationName, animationLength) + g.Animator.selected = animation + + return { + project: { animations: [animation] } as unknown as ModelProject, + rig: buildHarnessRig(bone), + bone, + scene, + expectedFrameTimes: buildExpectedFrameTimes(animationLength), + } +} diff --git a/src/tests/keyframeGridSnap.test.ts b/src/tests/keyframeGridSnap.test.ts new file mode 100644 index 00000000..d504aada --- /dev/null +++ b/src/tests/keyframeGridSnap.test.ts @@ -0,0 +1,242 @@ +/** + * 「1 tick 前の keyframe」 引きの格子スナップ単体テスト。 純粋な算術なので Blockbench global 不要。 + * + * keyframeCache のキーは load 時に `roundToNth(kf.time, 20)` で格子へ正規化される + * (= `src/mods/animation.ts` の extend override 内の正規化ループ) 一方、 frame ループの `time` も + * 毎ステップ `roundToNth(time + 0.05, 20)` で再スナップされる + * (= `src/systems/animationRenderer.ts` の `renderAnimation` の frame ループ)。 + * ところが引く側の `time - 0.05` だけは正規化を通っておらず、 減算結果が格子から外れて + * `Map.get` がヒットしない frame が出る (= pre-post interpolation の指定が出力から落ちる)。 + * + * あわせて、 正規化ループ自身の衝突回避 (= 丸め先が直前の keyframe と重なったら 1 tick ずらす) + * も格子を外れうるので、 そちらも同じ観点で固定する。 **この衝突回避が保証するのは + * 「ずらした結果が格子に載る」ことだけで、 衝突の解消そのものは保証しない**。 解消できない + * ケースは末尾の「既知の未解決欠陥」 describe に現状のまま固定してある。 + * + * 確認するのは : + * 1. frame ループが 0〜3 秒で 61 frame を生成すること + * 2. 修正前の式 `time - 0.05` では 20 frame で keyframe を引けないこと (= バグの規模) + * 3. 修正後の式 `roundToNth(time - 0.05, 20)` では取りこぼしが 0 件になること + * 4. 衝突回避でずらした時刻が格子上に載り、 自 frame と 1 tick 後の frame の両方から引けること + * 5. 衝突が解消されない 2 パターン (= 3 件が同一格子点 / channel またぎ) の現状 + */ +import { describe, expect, it } from 'vitest' +import { roundToNth } from '../util/misc' + +/** frame ループ 1 ステップの刻み幅 (= 1 tick)。 */ +const TICK = 0.05 +/** keyframe の格子分解能 (= `DEFAULT_SNAPPING_VALUE`)。 */ +const SNAPPING = 20 +/** テスト対象の animation 長 (秒)。 */ +const LENGTH = 3 + +/** + * keyframe 側のキー集合を `src/mods/animation.ts` の正規化式 (`roundToNth(kf.time, + * DEFAULT_SNAPPING_VALUE)`) と同じ形で作る。 + * 0〜3 秒の全 tick 位置に keyframe が置かれている状況を想定する。 + */ +function buildKeyframeKeys(): Set { + const keys = new Set() + for (let k = 0; k <= LENGTH * SNAPPING; k++) { + keys.add(roundToNth(k / SNAPPING, SNAPPING)) + } + return keys +} + +/** + * `src/systems/animationRenderer.ts` の `renderAnimation` の frame ループと同じ式で回し、 + * 各 frame の `time` を列挙する。 + */ +function collectFrameTimes(): number[] { + const times: number[] = [] + for (let time = 0; time <= LENGTH; time = roundToNth(time + TICK, SNAPPING)) { + times.push(time) + } + return times +} + +/** + * 「1 tick 前」 を `lookup` で引いたときの取りこぼし件数を数える。 + * `time = 0` は前 frame 自体が存在しないので対象から除く。 + */ +function countMisses(keys: Set, lookup: (time: number) => number): number { + let misses = 0 + for (const time of collectFrameTimes()) { + if (time < TICK) continue + if (!keys.has(lookup(time))) misses++ + } + return misses +} + +describe('getFrame の 1 tick 前 keyframe 引き - 格子スナップ', () => { + it('frame ループは 0〜3 秒で 61 frame を生成する', () => { + expect(collectFrameTimes()).toHaveLength(61) + }) + + it('修正前の式 (time - 0.05) は 20 frame で keyframe を引けない', () => { + const keys = buildKeyframeKeys() + expect(countMisses(keys, time => time - TICK)).toBe(20) + }) + + it('修正後の式 (roundToNth(time - 0.05, 20)) は取りこぼしが 0 件', () => { + const keys = buildKeyframeKeys() + expect(countMisses(keys, time => roundToNth(time - TICK, SNAPPING))).toBe(0) + }) + + it('格子から外れる代表例 (t=0.15 / t=0.2) を再スナップで救えている', () => { + const keys = buildKeyframeKeys() + // 減算そのままだと 0.09999999999999999 / 0.15000000000000002 になり格子と一致しない + expect(keys.has(0.15 - TICK)).toBe(false) + expect(keys.has(0.2 - TICK)).toBe(false) + expect(roundToNth(0.15 - TICK, SNAPPING)).toBe(0.1) + expect(roundToNth(0.2 - TICK, SNAPPING)).toBe(0.15) + }) +}) + +// --- 衝突回避 (= mods/animation.ts の load 時正規化) --------------------------- + +/** + * `src/mods/animation.ts` の load 時正規化ループを写したもの (= 本体は Blockbench 結合が + * 重く vitest から import できないため、 式だけを 1:1 で再現する)。 + * + * production と同じく「格子に載っていた keyframe は素通しし、 `lastTime` も更新しない」 + * 挙動まで含めて写している。 判定は **直前に書き換えた 1 件との比較だけ**で、 それより前の + * 時刻は見ない。 + * + * **入力のモデルについて** : この helper が受け取る配列は「単一 channel 内の並び」を模したもので、 + * production が実際に舐める `animator.keyframes` 全体の並びではない。 `animator.keyframes` は + * Blockbench 側の getter (`js/animations/timeline_animators.js` の `get keyframes()`) で、 + * `rotation` / `position` / `scale` の各 channel 配列を **順に連結**して返す。 つまり実際の並びは + * channel の境目で時刻が 0 付近へ戻り、 channel 内の順序も sort されていない。 + * 正規化ループはこの連結列をそのまま 1 本の時系列として扱い、 `lastTime` を channel をまたいで + * 引き継ぐため、 別 channel の正当な同時刻 keyframe まで衝突扱いになる (= 下の「既知の未解決欠陥」)。 + * + * @param deconflict 丸め先が直前の keyframe と重なったときのずらし方 + */ +function normalizeKeyframeTimes( + times: readonly number[], + deconflict: (rounded: number) => number +): number[] { + const result: number[] = [] + let lastTime = -Infinity + for (const time of times) { + let rounded = roundToNth(time, SNAPPING) + if (rounded === time) { + result.push(time) + continue + } + if (rounded === lastTime) rounded = deconflict(rounded) + result.push(rounded) + lastTime = rounded + } + return result +} + +/** 修正前のずらし方 (= 素の加算)。 */ +const RAW_DECONFLICT = (rounded: number) => rounded + TICK +/** 修正後のずらし方 (= ずらした結果も格子へ載せ直す)。 */ +const SNAPPED_DECONFLICT = (rounded: number) => roundToNth(rounded + TICK, SNAPPING) + +/** + * 衝突する 2 つの keyframe。 どちらも `roundToNth(t, 20)` が 0.1 に落ちるため、 + * 2 つ目が衝突回避で 1 tick ずらされる。 + */ +const COLLIDING_TIMES = [0.11, 0.12] as const + +describe('mods/animation.ts の衝突回避 - 格子スナップ', () => { + it('修正前のずらし方は格子から外れた時刻を作る', () => { + const [first, second] = normalizeKeyframeTimes(COLLIDING_TIMES, RAW_DECONFLICT) + expect(first).toBe(0.1) + expect(second).toBe(0.15000000000000002) + expect(second).not.toBe(0.15) + expect(buildKeyframeKeys().has(second)).toBe(false) + }) + + it('修正後のずらし方は格子上の時刻を作る', () => { + const [first, second] = normalizeKeyframeTimes(COLLIDING_TIMES, SNAPPED_DECONFLICT) + expect(first).toBe(0.1) + expect(second).toBe(0.15) + expect(buildKeyframeKeys().has(second)).toBe(true) + }) + + it('衝突回避で作られた keyframe を自 frame と 1 tick 後の frame の両方から引ける', () => { + const normalized = normalizeKeyframeTimes(COLLIDING_TIMES, SNAPPED_DECONFLICT) + // getFrame の keyframeCache と同じく `kf.time` をそのままキーにする。 + const keyframes = new Map(normalized.map((time, index) => [time, index])) + + // 自 frame (= `keyframes.get(time)`) から引ける。 + expect(keyframes.get(0.15)).toBe(1) + // 1 tick 後の frame (= `keyframes.get(roundToNth(time - 0.05, 20))`) からも引ける。 + expect(keyframes.get(roundToNth(0.2 - TICK, SNAPPING))).toBe(1) + }) + + it('修正前は自 frame から引けず 1 tick 後からだけ引けるという不整合だった', () => { + const normalized = normalizeKeyframeTimes(COLLIDING_TIMES, RAW_DECONFLICT) + const keyframes = new Map(normalized.map((time, index) => [time, index])) + + // frame 0.15 は自分の keyframe を認識できない。 + expect(keyframes.get(0.15)).toBeUndefined() + // 一方で `0.2 - 0.05` は 0.15000000000000002 と同じ double なので偶然ヒットしていた。 + expect(0.2 - TICK).toBe(0.15000000000000002) + expect(keyframes.get(0.2 - TICK)).toBe(1) + // 再スナップを入れるとその偶然のヒットも消える (= 修正前の式との組み合わせでは全滅)。 + expect(keyframes.get(roundToNth(0.2 - TICK, SNAPPING))).toBeUndefined() + }) + + it('ずらし先が直前の値と一致する並びなら、 連鎖しても解決される', () => { + // 丸め先は [0.1, 0.1, 0.15]。 3 つ目の丸め先 0.15 が、 2 つ目のずらし先 0.15 と + // **たまたま一致する**ため、 直前 1 件との比較でも衝突として拾える。 + const chain = [0.11, 0.12, 0.13] as const + // 修正前は 2 つ目が 0.15000000000000002 になるため 3 つ目の 0.15 と一致せず、 + // 実質同時刻の keyframe が 2 つ残っていた。 + expect(normalizeKeyframeTimes(chain, RAW_DECONFLICT)).toEqual([ + 0.1, 0.15000000000000002, 0.15, + ]) + // 修正後はずらし先が格子に載るので一致し、 次の格子へ送られる。 + expect(normalizeKeyframeTimes(chain, SNAPPED_DECONFLICT)).toEqual([0.1, 0.15, 0.2]) + }) +}) + +// --- 既知の未解決欠陥 -------------------------------------------------------- + +/** + * 下記 2 件は **現時点で直っていない挙動を固定するテスト**。 + * + * 正規化ループが保証するのは「ずらした結果が格子に載る」ことだけで、 衝突の解消そのものは + * 保証しない。 判定が直前 1 件との一致比較に限られており、 かつ `lastTime` を channel を + * またいで引き継ぐため。 + * + * 根本修正は正規化を channel 単位へ作り替える再設計になるので、 **別途 board で管理している**。 + * ここでは現状を固定して、 挙動が変わったとき (= 直ったとき) に気付けるようにするのが目的 + * (= 落ちたらこの describe ごと書き換える)。 + */ +describe('mods/animation.ts の衝突回避 - 既知の未解決欠陥', () => { + it('3 つが同じ格子点へ丸まると重複が残る', () => { + // 3 つとも 0.15 へ丸まる並び。 + const chain = [0.126, 0.127, 0.128] as const + const normalized = normalizeKeyframeTimes(chain, SNAPPED_DECONFLICT) + + // 2 つ目は 0.15 → 0.2 へ送られるが、 3 つ目の丸め先 0.15 は直前の 0.2 と一致しないため + // 衝突と判定されず、 1 つ目と同時刻のまま残る。 + expect(normalized).toEqual([0.15, 0.2, 0.15]) + // keyframeCache は `new Map(keyframes.map(kf => [kf.time, kf]))` なので、 + // 同時刻の 2 件は 1 件に潰れる (= 片方が silent に消える)。 + expect(new Set(normalized).size).toBe(2) + expect(new Set(normalized).size).toBeLessThan(chain.length) + // commit 6 (= ずらし先の格子化) はこのケースの結果を何も変えていない。 + expect(normalizeKeyframeTimes(chain, RAW_DECONFLICT)).toEqual(normalized) + }) + + it('channel をまたいだ正当な同時刻 keyframe が 1 tick ずらされる', () => { + // `animator.keyframes` の連結を模した並び。 例えば rotation の末尾が 0.503、 + // position の先頭が 0.502 という、 どちらも 0.5 に載るべき組み合わせ。 + const acrossChannels = [0.503, 0.502] as const + const normalized = normalizeKeyframeTimes(acrossChannels, SNAPPED_DECONFLICT) + + // 別 channel なので本来はどちらも 0.5 で問題ないが、 `lastTime` が引き継がれるため + // 2 つ目が衝突扱いになり 1 tick 後ろへ送られる。 + expect(normalized).toEqual([0.5, 0.55]) + // commit 6 はこのケースの結果も変えていない (= ずらし先が元から格子上のため)。 + expect(normalizeKeyframeTimes(acrossChannels, RAW_DECONFLICT)).toEqual(normalized) + }) +})