From e9b2a123add32ceee664f76c641beb3268facdfa Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:52:42 +0000 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=90=9B=20renderProjectAnimations=20?= =?UTF-8?q?=E3=81=AE=E7=8A=B6=E6=85=8B=E5=BE=A9=E5=85=83=E3=82=92=20try=20?= =?UTF-8?q?finally=20=E3=81=B8=E7=A7=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderer.ts | 54 ++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index b1fc75c5..00f48630 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -409,34 +409,42 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende 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() + let sceneAngleCorrected = false + + // 途中で例外が出ても bone interpolation / scene angle / 選択中 animation を必ず入口の状態へ戻す + try { + Timeline.pause() + // Save selected animation + if (Mode.selected.id === 'animate') { + selectedAnimation = Animator.selected + currentTime = Timeline.time + } - BONE_INTERPOLATION_ENABLED.set(true) + correctSceneAngle() + sceneAngleCorrected = true + for (const animation of project.animations) { + animations.push(renderAnimation(animation, rig)) + PROGRESS.set(PROGRESS.get() + 1) + await sleepForAnimationFrame() + } + } finally { + if (sceneAngleCorrected) restoreSceneAngle() + + BONE_INTERPOLATION_ENABLED.set(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() + } - // Restore selected animation - if (Mode.selected.id === 'animate' && selectedAnimation) { - selectedAnimation.select() - Timeline.setTime(currentTime) - Animator.preview() - } else if (Mode.selected.id === 'edit') { - Animator.showDefaultPose() + console.timeEnd('Rendering animations took') } - console.timeEnd('Rendering animations took') console.log('Animations:', animations) return animations } From 175cc55d682a08cb7911445e157f131af72c2704 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:56:44 +0000 Subject: [PATCH 02/13] =?UTF-8?q?=F0=9F=90=9B=20pre-post=20=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=AE=20keyframe=20=E5=8F=82=E7=85=A7=E3=82=92?= =?UTF-8?q?=E6=A0=BC=E5=AD=90=E3=81=B8=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=81=97=20export=20=E5=87=BA=E5=8A=9B=E3=82=92=E6=AD=A3?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderer.ts | 5 +- src/tests/keyframeGridSnap.test.ts | 85 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/tests/keyframeGridSnap.test.ts diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index 00f48630..ba991338 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -164,7 +164,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 +180,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) { diff --git a/src/tests/keyframeGridSnap.test.ts b/src/tests/keyframeGridSnap.test.ts new file mode 100644 index 00000000..5112f941 --- /dev/null +++ b/src/tests/keyframeGridSnap.test.ts @@ -0,0 +1,85 @@ +/** + * 「1 tick 前の keyframe」 引きの格子スナップ単体テスト。 純粋な算術なので Blockbench global 不要。 + * + * keyframeCache のキーは load 時に `roundToNth(kf.time, 20)` で格子へ正規化される + * (`src/mods/animation.ts:70`) 一方、 frame ループの `time` も毎ステップ + * `roundToNth(time + 0.05, 20)` で再スナップされる (`src/systems/animationRenderer.ts:331`)。 + * ところが引く側の `time - 0.05` だけは正規化を通っておらず、 減算結果が格子から外れて + * `Map.get` がヒットしない frame が出る (= pre-post interpolation の指定が出力から落ちる)。 + * + * 確認するのは : + * 1. frame ループが 0〜3 秒で 61 frame を生成すること + * 2. 修正前の式 `time - 0.05` では 20 frame で keyframe を引けないこと (= バグの規模) + * 3. 修正後の式 `roundToNth(time - 0.05, 20)` では取りこぼしが 0 件になること + */ +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:70` と同じ式で作る。 + * 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:331` と同じ式で 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) + }) +}) From 2ec1737d773054ff7874b695432af85caa3213b1 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:19:45 +0000 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=A8=20=E6=B1=8E=E7=94=A8=20render?= =?UTF-8?q?=20hook=20=E3=81=AE=20registry=20=E3=81=A8=E5=85=AC=E9=96=8B=20?= =?UTF-8?q?API=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 2 + src/systems/animationRenderHooks.ts | 237 +++++++++++++++ src/tests/animationRenderHooks.test.ts | 405 +++++++++++++++++++++++++ 3 files changed, 644 insertions(+) create mode 100644 src/systems/animationRenderHooks.ts create mode 100644 src/tests/animationRenderHooks.test.ts 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/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts new file mode 100644 index 00000000..dfc1d639 --- /dev/null +++ b/src/systems/animationRenderHooks.ts @@ -0,0 +1,237 @@ +/** + * 外部 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 の既存経路がそのまま使える + * - **呼び出し順** : + * `onBeginRendering` → (animation ごとに `onBeginAnimation` → frame ごとに `onPose` → `onEndAnimation`) + * → `onEndRendering`。 begin / pose 系は登録順、 end 系は逆順 + * - `onPose` が呼ばれるのは `updatePreview` が keyframe pose を scene に確定させた直後 + * + * この 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 { + dispatchSequential(participants, 'onBeginRendering', hooks => hooks.onBeginRendering?.()) + } 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 + dispatchSequential(participants, 'onBeginAnimation', hooks => hooks.onBeginAnimation?.(context)) +} + +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() +} + +/** begin / 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) + } + } +} + +/** + * 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/tests/animationRenderHooks.test.ts b/src/tests/animationRenderHooks.test.ts new file mode 100644 index 00000000..a474cae6 --- /dev/null +++ b/src/tests/animationRenderHooks.test.ts @@ -0,0 +1,405 @@ +/** + * 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 し、 上書きしない', () => { + const first = { onPose: () => {} } + registerRenderHooks('a', first) + expect(() => registerRenderHooks('a', { onPose: () => {} })).toThrow() + + // 上書きされていないことを、 元の hook が呼ばれることで確認する + const log: string[] = [] + unregisterRenderHooks('a') + registerRenderHooks('a', { onPose: () => log.push('first') }) + beginRenderingSession() + dispatchPose(makePoseContext()) + endRenderingSession() + expect(log).toEqual(['first']) + }) + + 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) + }) + + 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() + }) +}) From a8e9dda3bd1a12e245db2a42b70cafab264bf904 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:28:33 +0000 Subject: [PATCH 04/13] =?UTF-8?q?=E2=9C=A8=20render=20hook=20=E3=81=AE?= =?UTF-8?q?=E7=99=BA=E7=81=AB=E7=82=B9=E3=82=92=20export=20=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=81=B8=E6=8E=A5=E7=B6=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderHooks.ts | 7 ++ src/systems/animationRenderer.ts | 122 ++++++++++++++++++++++------ src/systems/rigRenderer.ts | 19 +++-- 3 files changed, 116 insertions(+), 32 deletions(-) diff --git a/src/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts index dfc1d639..2264571c 100644 --- a/src/systems/animationRenderHooks.ts +++ b/src/systems/animationRenderHooks.ts @@ -11,6 +11,13 @@ * `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` は参考値として扱う * * この module は Blockbench / THREE の global を実行時に参照しない (= 型は `import type` と ambient のみ)。 */ diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index ba991338..8eb508b7 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -11,6 +11,16 @@ 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, + type RenderAnimationContext, + shouldDispatchPose, + withRenderHooksSuppressed, +} from './animationRenderHooks' import type { AnyRenderedNode, IRenderedRig } from './rigRenderer' import { sleepForAnimationFrame } from './util' @@ -124,10 +134,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 +170,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) @@ -199,10 +219,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 }) @@ -229,7 +249,7 @@ export function getFrame( break } case 'null_object': - updatePreview(animation, time) + updatePreview(animation, time, frameIndex) case 'camera': case 'struct': { transform.matrix = getNodeMatrix(outlinerNode, 1) @@ -299,7 +319,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() @@ -310,6 +334,19 @@ 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() } @@ -329,12 +366,38 @@ 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 + } + }, + } + dispatchBeginAnimation(currentRenderContext) + + try { + 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++ + } + } finally { + dispatchEndAnimation() + currentRenderContext = undefined } rendered.duration = rendered.frames.length @@ -415,6 +478,7 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende // 途中で例外が出ても bone interpolation / scene angle / 選択中 animation を必ず入口の状態へ戻す try { + beginRenderingSession() Timeline.pause() // Save selected animation if (Mode.selected.id === 'animate') { @@ -430,20 +494,26 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende await sleepForAnimationFrame() } } finally { - if (sceneAngleCorrected) restoreSceneAngle() - - BONE_INTERPOLATION_ENABLED.set(true) + // session の終了は Animator.preview() (= display_animation_frame の発火) より前に済ませる。 + // hook が throw しても既存の状態復元は必ず走らせる + try { + endRenderingSession() + } finally { + if (sceneAngleCorrected) restoreSceneAngle() + + BONE_INTERPOLATION_ENABLED.set(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() + } - // Restore selected animation - if (Mode.selected.id === 'animate' && selectedAnimation) { - selectedAnimation.select() - Timeline.setTime(currentTime) - Animator.preview() - } else if (Mode.selected.id === 'edit') { - Animator.showDefaultPose() + console.timeEnd('Rendering animations took') } - - console.timeEnd('Rendering animations took') } console.log('Animations:', animations) 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 { From 2bb3aec341f92b4fab3c3100c6849352d819d73d Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:46:36 +0000 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=85=20render=20hook=20=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=81=AE=20CLI=20=E6=A4=9C=E8=A8=BC=20harness=20?= =?UTF-8?q?=E3=81=A8=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 1 + package.json | 1 + src/systems/animationRenderHooks.ts | 3 + src/tests/animationRenderExport.test.ts | 408 ++++++++++++++++++++++++ src/tests/fixtures/minimalRig.ts | 17 +- src/tests/fixtures/renderHarness.ts | 234 ++++++++++++++ 6 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 src/tests/animationRenderExport.test.ts create mode 100644 src/tests/fixtures/renderHarness.ts 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/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts index 2264571c..766fb073 100644 --- a/src/systems/animationRenderHooks.ts +++ b/src/systems/animationRenderHooks.ts @@ -7,6 +7,9 @@ * - **同期のみ**。 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 系は逆順 diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts new file mode 100644 index 00000000..b62f759f --- /dev/null +++ b/src/tests/animationRenderExport.test.ts @@ -0,0 +1,408 @@ +/** + * production の `renderProjectAnimations` を Blockbench 無しで実走させ、 render hook 経路が + * 実際に export 出力へ効くことを機械的に固定する。 + * + * 確認するのは : + * 1. hook 未登録時の出力が決定的であること (= baseline) + * 2. hook 登録で `node_transforms` と `hashAnimations` が変わること + * 3. unregister で baseline へ完全復帰すること + * 4. `onPose` の `frameIndex` が 0 から 1 ずつ進み、 同じ値で複数回呼ばれること + * 5. `frameTimeSeconds` が frame ループの `time` と全 frame で一致すること + * 6. hook が throw しても global 状態 (interpolation フラグ / scene angle) が復旧すること + * 7. `onPose` の中から `evaluateBasePose` を呼べて、 `Timeline.time` が戻ること + * 8. 1 と 2 の render 結果で、 生成される mcfunction が byte 単位で違うこと + * + * `animationRenderer.ts` は import 連鎖の **module 評価時**に Blockbench global を要求する + * (= `Dialog` / `BoneAnimator.prototype`)。 global を後から生やす方式では越えられないため、 + * 該当 module を `vi.mock` で差し替えている。 + */ +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 { + 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 { createRenderHarness, type RenderHarness } from './fixtures/renderHarness' + +/** hook が pose を書き換えるときに足す回転量 (rad)。 baseline と確実に差が出る値。 */ +const HOOK_ROTATION = 0.25 + +const HOOK_ID = 'synthetic-physics' + +/** 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) + }) + + afterEach(() => { + unregisterRenderHooks(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('2. hook を登録すると node_transforms と hash が変わる', async () => { + const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, { + onPose() { + // hook は scene の node pose を直接書き換える契約。 + // `getFrame` が読むのは matrixWorld なので、 書き換え後に再計算まで行う。 + harness.bone.mesh.rotation.x += HOOK_ROTATION + globals().Canvas.scene.updateMatrixWorld(true) + }, + }) + 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)) + + // baseline は X 回転ゼロ、 hook 後は X 回転が乗る。 + // `rot` は `eulerFromQuaternion` 由来で **度数法かつ x の符号反転済み** なうえ、 + // scene 側の 180 度補正と合成されるため、 rad の生値とは一致しない。 + // ここで見たいのは「X 軸成分が 0 から明確に離れたか」なので大きさで判定する。 + const baselineRot = baseline[0].frames[1].node_transforms[BONE_UUID].rot + const hookedRot = hooked[0].frames[1].node_transforms[BONE_UUID].rot + expect(baselineRot[0]).toBeCloseTo(0, 9) + expect(Math.abs(hookedRot[0])).toBeGreaterThan(10) + }) + + it('3. unregister すると baseline へ完全復帰する', async () => { + const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + registerRenderHooks(HOOK_ID, { + onPose() { + harness.bone.mesh.rotation.x += HOOK_ROTATION + globals().Canvas.scene.updateMatrixWorld(true) + }, + }) + 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('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) + }) + + afterEach(() => { + unregisterRenderHooks(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, { + onPose() { + harness.bone.mesh.rotation.x += HOOK_ROTATION + globals().Canvas.scene.updateMatrixWorld(true) + }, + }) + 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/fixtures/minimalRig.ts b/src/tests/fixtures/minimalRig.ts index 4214e151..e1fa9839 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。 * @@ -375,7 +388,7 @@ export function buildFixtureVariables(options: FixtureOptions = {}): Record 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), + } +} From f120905f7b42dfe5de0e355794524f21f21585b0 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:08:39 +0000 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=90=9B=20keyframe=20=E3=81=AE?= =?UTF-8?q?=E8=A1=9D=E7=AA=81=E5=9B=9E=E9=81=BF=E3=81=A7=E3=81=9A=E3=82=89?= =?UTF-8?q?=E3=81=99=E6=99=82=E5=88=BB=E3=82=92=E6=A0=BC=E5=AD=90=E3=81=B8?= =?UTF-8?q?=E8=BC=89=E3=81=9B=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mods/animation.ts | 5 +- src/tests/keyframeGridSnap.test.ts | 98 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) 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/tests/keyframeGridSnap.test.ts b/src/tests/keyframeGridSnap.test.ts index 5112f941..19c25e03 100644 --- a/src/tests/keyframeGridSnap.test.ts +++ b/src/tests/keyframeGridSnap.test.ts @@ -7,10 +7,14 @@ * ところが引く側の `time - 0.05` だけは正規化を通っておらず、 減算結果が格子から外れて * `Map.get` がヒットしない frame が出る (= pre-post interpolation の指定が出力から落ちる)。 * + * あわせて、 正規化ループ自身の衝突回避 (= 丸め先が直前の keyframe と重なったら 1 tick ずらす) + * も格子を外れうるので、 そちらも同じ観点で固定する。 + * * 確認するのは : * 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 の両方から引けること */ import { describe, expect, it } from 'vitest' import { roundToNth } from '../util/misc' @@ -83,3 +87,97 @@ describe('getFrame の 1 tick 前 keyframe 引き - 格子スナップ', () => { 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` も更新しない」 + * 挙動まで含めて写している。 + * + * @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('連鎖衝突は修正後のみ検出される', () => { + // 3 つとも 0.1 / 0.15 へ丸まる並び。 + 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]) + }) +}) From 6948c256c28e6f93b3ff219f4ecdc4f9cce28ac7 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:14:50 +0000 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=90=9B=20=E4=BE=8B=E5=A4=96?= =?UTF-8?q?=E7=B5=8C=E8=B7=AF=E3=81=A7=20hook=20=E3=81=AE=20unwind=20?= =?UTF-8?q?=E3=81=A8=E7=8A=B6=E6=85=8B=E5=BE=A9=E5=85=83=E3=82=92=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E3=81=93=E3=81=BC=E3=81=95=E3=81=AA=E3=81=84=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderHooks.ts | 61 +++++++++- src/systems/animationRenderer.ts | 103 +++++++++++----- src/tests/animationRenderHooks.test.ts | 155 +++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 35 deletions(-) diff --git a/src/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts index 766fb073..adc3e99c 100644 --- a/src/systems/animationRenderHooks.ts +++ b/src/systems/animationRenderHooks.ts @@ -113,7 +113,13 @@ export function beginRenderingSession() { const participants = Array.from(REGISTERED_HOOKS, ([id, hooks]) => ({ id, hooks })) sessionParticipants = participants try { - dispatchSequential(participants, 'onBeginRendering', hooks => hooks.onBeginRendering?.()) + dispatchSequentialWithUnwind( + participants, + 'onBeginRendering', + hooks => hooks.onBeginRendering?.(), + 'onEndRendering', + hooks => hooks.onEndRendering?.() + ) } catch (error) { // session を開けないまま active に残すと以降の export が全て塞がるため、 状態を巻き戻してから rethrow する sessionParticipants = undefined @@ -140,7 +146,13 @@ export function isRenderingSessionActive() { export function dispatchBeginAnimation(context: RenderAnimationContext) { const participants = getActiveParticipants() if (!participants) return - dispatchSequential(participants, 'onBeginAnimation', hooks => hooks.onBeginAnimation?.(context)) + dispatchSequentialWithUnwind( + participants, + 'onBeginAnimation', + hooks => hooks.onBeginAnimation?.(context), + 'onEndAnimation', + hooks => hooks.onEndAnimation?.() + ) } export function dispatchPose(context: RenderHookContext) { @@ -209,7 +221,7 @@ function reversed(participants: readonly IRenderHookParticipant[]): IRenderHookP return participants.slice().reverse() } -/** begin / pose 系 : 最初の例外で即 rethrow する (= 以降の hook を呼ばない)。 */ +/** pose 系 : 最初の例外で即 rethrow する (= 以降の hook を呼ばない)。 */ function dispatchSequential( participants: readonly IRenderHookParticipant[], phase: string, @@ -224,6 +236,49 @@ function dispatchSequential( } } +/** + * 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` に落とす。 diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index 8eb508b7..94bf03a2 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -17,6 +17,7 @@ import { dispatchEndAnimation, dispatchPose, endRenderingSession, + hasRenderHooks, type RenderAnimationContext, shouldDispatchPose, withRenderHooksSuppressed, @@ -383,20 +384,26 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { } }, } - dispatchBeginAnimation(currentRenderContext) - try { - 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++ + // 部分失敗時の onEndAnimation は registry 側の unwind が送るので、 + // この呼び出しは内側の try (= dispatchEndAnimation を伴う方) には含めない + dispatchBeginAnimation(currentRenderContext) + + try { + 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++ + } + } finally { + dispatchEndAnimation() } } finally { - dispatchEndAnimation() + // dispatchEndAnimation の成否に関わらず context は必ず捨てる currentRenderContext = undefined } @@ -476,45 +483,77 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende const animations: IRenderedAnimation[] = [] let sceneAngleCorrected = false + let bodyError: unknown + let bodyFailed = false + let cleanupError: unknown + let cleanupFailed = false + + /** + * cleanup の 1 段。 throw しても残りの段を止めず、 最初の例外だけ保持して以降は `console.warn` + * に落とす (= 1 つの復元失敗が他の復元を巻き添えにしないため)。 + */ + function runCleanupStep(step: () => void) { + try { + step() + } catch (error) { + if (cleanupFailed) console.warn(error) + else { + cleanupFailed = true + cleanupError = error + } + } + } + // 途中で例外が出ても bone interpolation / scene angle / 選択中 animation を必ず入口の状態へ戻す try { - beginRenderingSession() 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() - correctSceneAngle() + // correctSceneAngle が 2 行の途中で throw しても復元を試みられるよう、 先にフラグを立てる sceneAngleCorrected = true + correctSceneAngle() for (const animation of project.animations) { animations.push(renderAnimation(animation, rig)) PROGRESS.set(PROGRESS.get() + 1) await sleepForAnimationFrame() } - } finally { - // session の終了は Animator.preview() (= display_animation_frame の発火) より前に済ませる。 - // hook が throw しても既存の状態復元は必ず走らせる - try { - endRenderingSession() - } finally { - if (sceneAngleCorrected) restoreSceneAngle() - - BONE_INTERPOLATION_ENABLED.set(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() - } + } catch (error) { + bodyFailed = true + bodyError = error + } - console.timeEnd('Rendering animations took') + // session の終了は Animator.preview() (= display_animation_frame の発火) より前に済ませる + runCleanupStep(() => endRenderingSession()) + runCleanupStep(() => { + if (sceneAngleCorrected) restoreSceneAngle() + }) + runCleanupStep(() => BONE_INTERPOLATION_ENABLED.set(true)) + runCleanupStep(() => { + // Restore selected animation + if (Mode.selected.id === 'animate' && selectedAnimation) { + selectedAnimation.select() + Timeline.setTime(currentTime) + Animator.preview() + } else if (Mode.selected.id === 'edit') { + Animator.showDefaultPose() } + }) + runCleanupStep(() => console.timeEnd('Rendering animations took')) + + // 元の例外を cleanup の例外で上書きしない + if (bodyFailed) { + if (cleanupFailed) console.warn(cleanupError) + throw bodyError } + if (cleanupFailed) throw cleanupError console.log('Animations:', animations) return animations diff --git a/src/tests/animationRenderHooks.test.ts b/src/tests/animationRenderHooks.test.ts index a474cae6..52843227 100644 --- a/src/tests/animationRenderHooks.test.ts +++ b/src/tests/animationRenderHooks.test.ts @@ -344,6 +344,161 @@ describe('animationRenderHooks - 例外の包装', () => { 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[] = [] From d2089b602c52e6f11656eaef6acb5de32d22a83d Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:24:08 +0000 Subject: [PATCH 08/13] =?UTF-8?q?=E2=9C=85=20hook=20=E6=9C=AA=E7=99=BB?= =?UTF-8?q?=E9=8C=B2=E6=99=82=E3=81=AE=E5=87=BA=E5=8A=9B=E3=82=92=E5=B0=8E?= =?UTF-8?q?=E5=85=A5=E5=89=8D=20golden=20=E3=81=A8=E6=AF=94=E8=BC=83?= =?UTF-8?q?=E3=81=97=20hook=20=E9=81=A9=E7=94=A8=E5=80=A4=E3=82=92?= =?UTF-8?q?=E6=9C=9F=E5=BE=85=E5=80=A4=E3=81=A7=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/animationRenderExport.test.ts | 143 +++++++++++---- src/tests/animationRenderHooks.test.ts | 26 ++- src/tests/fixtures/renderBaselineGolden.json | 181 +++++++++++++++++++ src/tests/fixtures/renderHarness.ts | 40 ++++ src/tests/keyframeGridSnap.test.ts | 10 +- 5 files changed, 350 insertions(+), 50 deletions(-) create mode 100644 src/tests/fixtures/renderBaselineGolden.json diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index b62f759f..7d57a34c 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -3,18 +3,39 @@ * 実際に export 出力へ効くことを機械的に固定する。 * * 確認するのは : - * 1. hook 未登録時の出力が決定的であること (= baseline) - * 2. hook 登録で `node_transforms` と `hashAnimations` が変わること - * 3. unregister で baseline へ完全復帰すること - * 4. `onPose` の `frameIndex` が 0 から 1 ずつ進み、 同じ値で複数回呼ばれること - * 5. `frameTimeSeconds` が frame ループの `time` と全 frame で一致すること - * 6. hook が throw しても global 状態 (interpolation フラグ / scene angle) が復旧すること - * 7. `onPose` の中から `evaluateBasePose` を呼べて、 `Timeline.time` が戻ること - * 8. 1 と 2 の render 結果で、 生成される mcfunction が byte 単位で違うこと + * 1. hook 未登録時の出力が決定的であること (= baseline) + * 1b. hook 未登録時の出力が **hook 導入前の実装と一致すること** (= golden 比較、 下記) + * 2. hook 登録で `node_transforms` と `hashAnimations` が変わること + * 2b. 変化後の値が期待値と一致すること (= 「変わった」 だけでなく 「正しく 1 回分だけ変わった」) + * 2c. 冪等でない hook を入れると 2b が落ちること (= 2b が二重適用を検出できることの裏取り) + * 3. unregister で baseline へ完全復帰すること + * 4. `onPose` の `frameIndex` が 0 から 1 ずつ進み、 同じ値で複数回呼ばれること + * 5. `frameTimeSeconds` が frame ループの `time` と全 frame で一致すること + * 6. hook が throw しても global 状態 (interpolation フラグ / scene angle) が復旧すること + * 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 を 「回帰ガード」 ではなく 「受け入れ条件の証明」 にしている。 + * 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -125,13 +146,43 @@ import { buildFixtureVariables, compileFixture, } from './fixtures/minimalRig' -import { createRenderHarness, type RenderHarness } from './fixtures/renderHarness' +import GOLDEN from './fixtures/renderBaselineGolden.json' +import { + createRenderHarness, + type RenderHarness, + serializeAnimations, +} from './fixtures/renderHarness' -/** hook が pose を書き換えるときに足す回転量 (rad)。 baseline と確実に差が出る値。 */ -const HOOK_ROTATION = 0.25 +/** + * 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' +/** + * 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 @@ -176,45 +227,68 @@ describe('renderProjectAnimations - hook 経路の実走', () => { expect(hashAnimations(first)).toBe(hashAnimations(second)) }) + it('1b. hook 未登録の出力は main (= a886b10e) の golden と一致する', async () => { + const animations = await render(createRenderHarness({ boneUuid: BONE_UUID })) + + expect(serializeAnimations(animations)).toEqual(GOLDEN.animations) + expect(hashAnimations(animations)).toBe(GOLDEN.hash) + }) + it('2. hook を登録すると node_transforms と hash が変わる', async () => { const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) const harness = createRenderHarness({ boneUuid: BONE_UUID }) - registerRenderHooks(HOOK_ID, { - onPose() { - // hook は scene の node pose を直接書き換える契約。 - // `getFrame` が読むのは matrixWorld なので、 書き換え後に再計算まで行う。 - harness.bone.mesh.rotation.x += HOOK_ROTATION - globals().Canvas.scene.updateMatrixWorld(true) - }, - }) + 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)) - - // baseline は X 回転ゼロ、 hook 後は X 回転が乗る。 - // `rot` は `eulerFromQuaternion` 由来で **度数法かつ x の符号反転済み** なうえ、 - // scene 側の 180 度補正と合成されるため、 rad の生値とは一致しない。 - // ここで見たいのは「X 軸成分が 0 から明確に離れたか」なので大きさで判定する。 - const baselineRot = baseline[0].frames[1].node_transforms[BONE_UUID].rot - const hookedRot = hooked[0].frames[1].node_transforms[BONE_UUID].rot - expect(baselineRot[0]).toBeCloseTo(0, 9) - expect(Math.abs(hookedRot[0])).toBeGreaterThan(10) }) - it('3. unregister すると baseline へ完全復帰する', async () => { - const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) + 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() { - harness.bone.mesh.rotation.x += HOOK_ROTATION + 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) @@ -343,12 +417,7 @@ describe('renderProjectAnimations - datapack までの byte 差分', () => { const baseline = await render(createRenderHarness({ boneUuid: BONE_UUID })) const harness = createRenderHarness({ boneUuid: BONE_UUID }) - registerRenderHooks(HOOK_ID, { - onPose() { - harness.bone.mesh.rotation.x += HOOK_ROTATION - globals().Canvas.scene.updateMatrixWorld(true) - }, - }) + registerRenderHooks(HOOK_ID, idempotentOffsetHook(harness)) const hooked = await render(harness) unregisterRenderHooks(HOOK_ID) diff --git a/src/tests/animationRenderHooks.test.ts b/src/tests/animationRenderHooks.test.ts index 52843227..32593c74 100644 --- a/src/tests/animationRenderHooks.test.ts +++ b/src/tests/animationRenderHooks.test.ts @@ -80,19 +80,27 @@ describe('animationRenderHooks - registry', () => { expect(hasRenderHooks()).toBe(false) }) - it('重複 id の登録は throw し、 上書きしない', () => { - const first = { onPose: () => {} } - registerRenderHooks('a', first) - expect(() => registerRenderHooks('a', { onPose: () => {} })).toThrow() - - // 上書きされていないことを、 元の hook が呼ばれることで確認する + it('重複 id の登録は throw し、 最初の hooks を保持する (= 上書きしない)', () => { const log: string[] = [] - unregisterRenderHooks('a') - registerRenderHooks('a', { onPose: () => log.push('first') }) + 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']) + + 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 しない (= 冪等)', () => { diff --git a/src/tests/fixtures/renderBaselineGolden.json b/src/tests/fixtures/renderBaselineGolden.json new file mode 100644 index 00000000..526f0c88 --- /dev/null +++ b/src/tests/fixtures/renderBaselineGolden.json @@ -0,0 +1,181 @@ +{ + "hash": "be401c44af6132f95070fa446894aaccf71370204b1c30fb890ab1f74521ddad", + "animations": [ + { + "name": "test_animation", + "storage_name": "test_animation", + "uuid": "animation-test_animation", + "loop_delay": 0, + "duration": 11, + "loop_mode": "once", + "modified_nodes": ["fixture-bone"], + "frames": [ + { + "time": 0, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0, 0], + "rot": [0, 0, 0], + "scale": [1, 1, 1], + "head_rot": [0, 0], + "matrix": [ + -1, 0, -1.2246467991473532e-16, 0, 0, 1, 0, 0, + 1.2246467991473532e-16, 0, -1, 0, 0, 0, 0, 1 + ] + } + } + }, + { + "time": 0.05, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.05, 0], + "rot": [0, 357.1352110243459, 0], + "scale": [1, 1, 1], + "head_rot": [0, 357.1352110243459], + "matrix": [ + -0.9987502603949663, 0, 0.049979169270678206, 0, 0, 1, 0, 0, + -0.049979169270678206, 0, -0.9987502603949663, 0, 0, 0.05, 0, 1 + ] + } + } + }, + { + "time": 0.1, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.1, 0], + "rot": [0, 354.2704220486918, 0], + "scale": [1, 1, 1], + "head_rot": [0, 354.2704220486918], + "matrix": [ + -0.9950041652780258, 0, 0.09983341664682803, 0, 0, 1, 0, 0, + -0.09983341664682803, 0, -0.9950041652780258, 0, 0, 0.1, 0, 1 + ] + } + } + }, + { + "time": 0.15, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.15, 0], + "rot": [0, 351.4056330730376, 0], + "scale": [0.9999999999999999, 1, 0.9999999999999999], + "head_rot": [0, 351.4056330730376], + "matrix": [ + -0.9887710779360422, 0, 0.1494381324735991, 0, 0, 1, 0, 0, + -0.1494381324735991, 0, -0.9887710779360422, 0, 0, 0.15, 0, 1 + ] + } + } + }, + { + "time": 0.2, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.2, 0], + "rot": [0, 348.54084409738357, 0], + "scale": [1, 1, 1], + "head_rot": [0, 348.54084409738357], + "matrix": [ + -0.9800665778412416, 0, 0.1986693307950611, 0, 0, 1, 0, 0, + -0.1986693307950611, 0, -0.9800665778412416, 0, 0, 0.2, 0, 1 + ] + } + } + }, + { + "time": 0.25, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.25, 0], + "rot": [0, 345.6760551217294, 0], + "scale": [0.9999999999999999, 1, 0.9999999999999999], + "head_rot": [0, 345.6760551217294], + "matrix": [ + -0.9689124217106447, 0, 0.24740395925452283, 0, 0, 1, 0, 0, + -0.24740395925452283, 0, -0.9689124217106447, 0, 0, 0.25, 0, 1 + ] + } + } + }, + { + "time": 0.3, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.3, 0], + "rot": [0, 342.81126614607535, 0], + "scale": [0.9999999999999999, 1, 0.9999999999999999], + "head_rot": [0, 342.81126614607535], + "matrix": [ + -0.955336489125606, 0, 0.29552020666133944, 0, 0, 1, 0, 0, + -0.29552020666133944, 0, -0.955336489125606, 0, 0, 0.3, 0, 1 + ] + } + } + }, + { + "time": 0.35, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.35, 0], + "rot": [0, 339.9464771704212, 0], + "scale": [0.9999999999999999, 1, 0.9999999999999999], + "head_rot": [0, 339.9464771704212], + "matrix": [ + -0.9393727128473789, 0, 0.34289780745545123, 0, 0, 1, 0, 0, + -0.34289780745545123, 0, -0.9393727128473789, 0, 0, 0.35, 0, 1 + ] + } + } + }, + { + "time": 0.4, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.4, 0], + "rot": [0, 337.0816881947671, 0], + "scale": [1, 1, 1], + "head_rot": [0, 337.0816881947671], + "matrix": [ + -0.9210609940028851, 0, 0.38941834230865036, 0, 0, 1, 0, 0, + -0.38941834230865036, 0, -0.9210609940028851, 0, 0, 0.4, 0, 1 + ] + } + } + }, + { + "time": 0.45, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.45, 0], + "rot": [0, 334.216899219113, 0], + "scale": [0.9999999999999999, 1, 0.9999999999999999], + "head_rot": [0, 334.216899219113], + "matrix": [ + -0.9004471023526769, 0, 0.4349655341112301, 0, 0, 1, 0, 0, + -0.4349655341112301, 0, -0.9004471023526769, 0, 0, 0.45, 0, 1 + ] + } + } + }, + { + "time": 0.5, + "node_transforms": { + "fixture-bone": { + "pos": [0, 0.5, 0], + "rot": [0, 331.35211024345887, 0], + "scale": [1, 1, 1], + "head_rot": [0, 331.35211024345887], + "matrix": [ + -0.8775825618903729, 0, 0.4794255386042029, 0, 0, 1, 0, 0, + -0.4794255386042029, 0, -0.8775825618903729, 0, 0, 0.5, 0, 1 + ] + } + } + } + ] + } + ] +} diff --git a/src/tests/fixtures/renderHarness.ts b/src/tests/fixtures/renderHarness.ts index fe0c9bcf..b9fa644a 100644 --- a/src/tests/fixtures/renderHarness.ts +++ b/src/tests/fixtures/renderHarness.ts @@ -17,6 +17,7 @@ */ import * as THREE from 'three' +import type { IRenderedAnimation } from '../../systems/animationRenderer' import type { AnyRenderedNode, IRenderedRig } from '../../systems/rigRenderer' /** frame ループの刻み幅 (= 1 tick)。 */ @@ -65,6 +66,45 @@ export class HarnessBone { } } +/** + * `renderProjectAnimations` の戻り値を golden 比較用の安定した形へ落とす。 + * + * `THREE.Matrix4` / `Vector3` / `Quaternion` をそのまま JSON 化すると three の内部表現 + * (= `_x` 等) に依存するため、 数値配列だけを取り出す。 丸めはしない (= bit 単位で比較する)。 + * + * **この関数は golden の生成側 (= main worktree) と比較側の両方で使うので、 + * 変更すると既存 golden と一致しなくなる**。 + */ +export function serializeAnimations(animations: readonly IRenderedAnimation[]) { + return animations.map(animation => ({ + name: animation.name, + storage_name: animation.storage_name, + uuid: animation.uuid, + loop_delay: animation.loop_delay, + duration: animation.duration, + loop_mode: animation.loop_mode, + modified_nodes: Object.keys(animation.modified_nodes).sort(), + frames: animation.frames.map(frame => ({ + time: frame.time, + 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), + ...(transform.interpolation + ? { interpolation: transform.interpolation } + : {}), + }, + ]) + ), + })), + })) +} + export interface HarnessOptions { /** * bone の uuid。 render 結果を `compileFixture` へ流す場合は、 fixture rig 側の bone uuid diff --git a/src/tests/keyframeGridSnap.test.ts b/src/tests/keyframeGridSnap.test.ts index 19c25e03..c7e4f964 100644 --- a/src/tests/keyframeGridSnap.test.ts +++ b/src/tests/keyframeGridSnap.test.ts @@ -2,8 +2,9 @@ * 「1 tick 前の keyframe」 引きの格子スナップ単体テスト。 純粋な算術なので Blockbench global 不要。 * * keyframeCache のキーは load 時に `roundToNth(kf.time, 20)` で格子へ正規化される - * (`src/mods/animation.ts:70`) 一方、 frame ループの `time` も毎ステップ - * `roundToNth(time + 0.05, 20)` で再スナップされる (`src/systems/animationRenderer.ts:331`)。 + * (= `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 の指定が出力から落ちる)。 * @@ -27,7 +28,8 @@ const SNAPPING = 20 const LENGTH = 3 /** - * keyframe 側のキー集合を `src/mods/animation.ts:70` と同じ式で作る。 + * keyframe 側のキー集合を `src/mods/animation.ts` の正規化式 (`roundToNth(kf.time, + * DEFAULT_SNAPPING_VALUE)`) と同じ形で作る。 * 0〜3 秒の全 tick 位置に keyframe が置かれている状況を想定する。 */ function buildKeyframeKeys(): Set { @@ -39,7 +41,7 @@ function buildKeyframeKeys(): Set { } /** - * `src/systems/animationRenderer.ts:331` と同じ式で frame ループを回し、 + * `src/systems/animationRenderer.ts` の `renderAnimation` の frame ループと同じ式で回し、 * 各 frame の `time` を列挙する。 */ function collectFrameTimes(): number[] { From 413e81050c24c547d365bc85b13dfb510a2b917b Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:54:24 +0000 Subject: [PATCH 09/13] =?UTF-8?q?=E2=9C=85=20=E6=AD=A3=E8=A6=8F=E5=8C=96?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E4=B8=BB=E5=BC=B5=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E6=85=8B=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E6=9C=AA?= =?UTF-8?q?=E8=A7=A3=E6=B1=BA=E3=81=AE=E6=AC=A0=E9=99=A5=E3=82=92=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/keyframeGridSnap.test.ts | 69 +++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/src/tests/keyframeGridSnap.test.ts b/src/tests/keyframeGridSnap.test.ts index c7e4f964..d504aada 100644 --- a/src/tests/keyframeGridSnap.test.ts +++ b/src/tests/keyframeGridSnap.test.ts @@ -9,13 +9,16 @@ * `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' @@ -97,7 +100,16 @@ describe('getFrame の 1 tick 前 keyframe 引き - 格子スナップ', () => { * 重く 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 と重なったときのずらし方 */ @@ -171,15 +183,60 @@ describe('mods/animation.ts の衝突回避 - 格子スナップ', () => { expect(keyframes.get(roundToNth(0.2 - TICK, SNAPPING))).toBeUndefined() }) - it('連鎖衝突は修正後のみ検出される', () => { - // 3 つとも 0.1 / 0.15 へ丸まる並び。 + 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 と衝突判定されず、 + // 修正前は 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) + }) +}) From f86c3db0d83a0cbee9074c40958a9fae21235cf1 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:54:24 +0000 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=90=9B=20renderAnimation=20?= =?UTF-8?q?=E3=81=AE=E4=BE=8B=E5=A4=96=E5=84=AA=E5=85=88=E3=81=A8=20sessio?= =?UTF-8?q?n=20=E6=89=80=E6=9C=89=E6=A8=A9=E3=82=92=E6=AD=A3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderer.ts | 156 +++++++++++++++--------- src/tests/animationRenderExport.test.ts | 96 +++++++++++++++ 2 files changed, 194 insertions(+), 58 deletions(-) diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index 94bf03a2..792feb72 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -351,6 +351,44 @@ export function updatePreview(animation: _Animation, time: number, frameIndex: n 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, @@ -384,28 +422,37 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { } }, } + const bodyError = createErrorSlot() + const cleanupError = createErrorSlot() + // dispatchBeginAnimation が部分失敗したときの onEndAnimation は registry 側の unwind が + // 送るため、 この flag で cleanup 側の dispatchEndAnimation と二重にならないようにする + let animationBegun = false try { - // 部分失敗時の onEndAnimation は registry 側の unwind が送るので、 - // この呼び出しは内側の try (= dispatchEndAnimation を伴う方) には含めない dispatchBeginAnimation(currentRenderContext) - - try { - 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++ - } - } finally { - dispatchEndAnimation() + 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++ } - } finally { + } 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( @@ -483,26 +530,11 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende const animations: IRenderedAnimation[] = [] let sceneAngleCorrected = false - let bodyError: unknown - let bodyFailed = false - let cleanupError: unknown - let cleanupFailed = false - - /** - * cleanup の 1 段。 throw しても残りの段を止めず、 最初の例外だけ保持して以降は `console.warn` - * に落とす (= 1 つの復元失敗が他の復元を巻き添えにしないため)。 - */ - function runCleanupStep(step: () => void) { - try { - step() - } catch (error) { - if (cleanupFailed) console.warn(error) - else { - cleanupFailed = true - cleanupError = error - } - } - } + const bodyError = createErrorSlot() + const cleanupError = createErrorSlot() + // この呼び出しが session を開いたかどうか。 開いた側だけが閉じる + // (= 並行に走ったもう 1 本の cleanup が、 こちらの session を終わらせないため) + let sessionStarted = false // 途中で例外が出ても bone interpolation / scene angle / 選択中 animation を必ず入口の状態へ戻す try { @@ -515,7 +547,10 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende // 退避より後に session を開く (= hook の onBeginRendering が選択状態を書き換えても、 // 書き換え後の状態を「元の状態」として保存しないため)。 // hook が 1 つも無いときは session 自体を張らない (= 従来の挙動と完全に同一にするため) - if (hasRenderHooks()) beginRenderingSession() + if (hasRenderHooks()) { + beginRenderingSession() + sessionStarted = true + } // correctSceneAngle が 2 行の途中で throw しても復元を試みられるよう、 先にフラグを立てる sceneAngleCorrected = true @@ -526,34 +561,39 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende await sleepForAnimationFrame() } } catch (error) { - bodyFailed = true - bodyError = error + bodyError.failed = true + bodyError.error = error } + // 選択状態の復元先は 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(() => endRenderingSession()) - runCleanupStep(() => { + runCleanupStep(cleanupError, () => { + if (sessionStarted) endRenderingSession() + }) + runCleanupStep(cleanupError, () => { if (sceneAngleCorrected) restoreSceneAngle() }) - runCleanupStep(() => BONE_INTERPOLATION_ENABLED.set(true)) - runCleanupStep(() => { - // Restore selected animation - if (Mode.selected.id === 'animate' && selectedAnimation) { - selectedAnimation.select() - Timeline.setTime(currentTime) - Animator.preview() - } else if (Mode.selected.id === 'edit') { - Animator.showDefaultPose() - } + 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(() => console.timeEnd('Rendering animations took')) + runCleanupStep(cleanupError, () => { + if (restoreDefaultPose) Animator.showDefaultPose() + }) + runCleanupStep(cleanupError, () => console.timeEnd('Rendering animations took')) // 元の例外を cleanup の例外で上書きしない - if (bodyFailed) { - if (cleanupFailed) console.warn(cleanupError) - throw bodyError - } - if (cleanupFailed) throw cleanupError + throwPreferringBody(bodyError, cleanupError) console.log('Animations:', animations) return animations diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index 7d57a34c..eeb40baa 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -12,6 +12,9 @@ * 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 で終わらせないこと * 7. `onPose` の中から `evaluateBasePose` を呼べて、 `Timeline.time` が戻ること * 8. 1 と 2 の render 結果で、 生成される mcfunction が byte 単位で違うこと * @@ -134,6 +137,9 @@ import { renderProjectAnimations, } from '../systems/animationRenderer' import { + beginRenderingSession, + endRenderingSession, + isRenderingSessionActive, RenderHookError, registerRenderHooks, type RenderHookContext, @@ -380,6 +386,96 @@ describe('renderProjectAnimations - hook 経路の実走', () => { 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('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 }> = [] From 1a3fbc55cf590f73e0f924089b36cc679c79711a Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:17:32 +0000 Subject: [PATCH 11/13] =?UTF-8?q?=E2=9C=85=20golden=20=E3=81=AE=E6=AF=94?= =?UTF-8?q?=E8=BC=83=E5=AF=BE=E8=B1=A1=E3=82=92=E5=85=A8=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E3=81=B8=E5=BA=83?= =?UTF-8?q?=E3=81=92=E9=83=A8=E5=88=86=E5=A4=B1=E6=95=97=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E7=B5=8C=E8=B7=AF=E3=81=A7=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/animationRenderExport.test.ts | 42 +++++ src/tests/fixtures/renderBaselineGolden.json | 155 +++++++++++++++++-- src/tests/fixtures/renderHarness.ts | 43 ++++- 3 files changed, 223 insertions(+), 17 deletions(-) diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index eeb40baa..2fd5be6e 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -15,6 +15,7 @@ * 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 単位で違うこと * @@ -171,6 +172,8 @@ const 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' /** * 1 frame につき 1 回分だけ平行移動を足す hook。 @@ -217,10 +220,12 @@ describe('renderProjectAnimations - hook 経路の実走', () => { // 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() }) @@ -454,6 +459,41 @@ describe('renderProjectAnimations - hook 経路の実走', () => { 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() {} }) @@ -501,10 +541,12 @@ 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() }) diff --git a/src/tests/fixtures/renderBaselineGolden.json b/src/tests/fixtures/renderBaselineGolden.json index 526f0c88..8938251e 100644 --- a/src/tests/fixtures/renderBaselineGolden.json +++ b/src/tests/fixtures/renderBaselineGolden.json @@ -8,10 +8,15 @@ "loop_delay": 0, "duration": 11, "loop_mode": "once", + "tsb_priority": null, "modified_nodes": ["fixture-bone"], "frames": [ { "time": 0, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0, 0], @@ -21,12 +26,24 @@ "matrix": [ -1, 0, -1.2246467991473532e-16, 0, 0, 1, 0, 0, 1.2246467991473532e-16, 0, -1, 0, 0, 0, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0, 0], + "left_rotation": [0, 1, 0, 6.123233995736766e-17], + "scale": [1, 1, 1] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.05, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.05, 0], @@ -36,12 +53,24 @@ "matrix": [ -0.9987502603949663, 0, 0.049979169270678206, 0, 0, 1, 0, 0, -0.049979169270678206, 0, -0.9987502603949663, 0, 0, 0.05, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.05, 0], + "left_rotation": [0, 0.9996875162757026, 0, -0.02499739591471227], + "scale": [1, 1, 1] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.1, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.1, 0], @@ -51,12 +80,24 @@ "matrix": [ -0.9950041652780258, 0, 0.09983341664682803, 0, 0, 1, 0, 0, -0.09983341664682803, 0, -0.9950041652780258, 0, 0, 0.1, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.1, 0], + "left_rotation": [0, 0.9987502603949663, 0, -0.04997916927067827], + "scale": [1, 1, 1] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.15, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.15, 0], @@ -66,12 +107,24 @@ "matrix": [ -0.9887710779360422, 0, 0.1494381324735991, 0, 0, 1, 0, 0, -0.1494381324735991, 0, -0.9887710779360422, 0, 0, 0.15, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.15, 0], + "left_rotation": [0, 0.9971888181122075, 0, -0.0749297072727423], + "scale": [0.9999999999999999, 1, 0.9999999999999999] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.2, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.2, 0], @@ -81,12 +134,24 @@ "matrix": [ -0.9800665778412416, 0, 0.1986693307950611, 0, 0, 1, 0, 0, -0.1986693307950611, 0, -0.9800665778412416, 0, 0, 0.2, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.2, 0], + "left_rotation": [0, 0.9950041652780257, 0, -0.0998334166468281], + "scale": [1, 1, 1] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.25, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.25, 0], @@ -96,12 +161,24 @@ "matrix": [ -0.9689124217106447, 0, 0.24740395925452283, 0, 0, 1, 0, 0, -0.24740395925452283, 0, -0.9689124217106447, 0, 0, 0.25, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.25, 0], + "left_rotation": [0, 0.9921976672293291, 0, -0.12467473338522765], + "scale": [0.9999999999999999, 1, 0.9999999999999999] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.3, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.3, 0], @@ -111,12 +188,24 @@ "matrix": [ -0.955336489125606, 0, 0.29552020666133944, 0, 0, 1, 0, 0, -0.29552020666133944, 0, -0.955336489125606, 0, 0, 0.3, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.3, 0], + "left_rotation": [0, 0.9887710779360424, 0, -0.14943813247359916], + "scale": [0.9999999999999999, 1, 0.9999999999999999] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.35, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.35, 0], @@ -126,12 +215,24 @@ "matrix": [ -0.9393727128473789, 0, 0.34289780745545123, 0, 0, 1, 0, 0, -0.34289780745545123, 0, -0.9393727128473789, 0, 0, 0.35, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.35, 0], + "left_rotation": [0, 0.9847265389049334, 0, -0.17410813759359592], + "scale": [0.9999999999999999, 1, 0.9999999999999999] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.4, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.4, 0], @@ -141,12 +242,24 @@ "matrix": [ -0.9210609940028851, 0, 0.38941834230865036, 0, 0, 1, 0, 0, -0.38941834230865036, 0, -0.9210609940028851, 0, 0, 0.4, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.4, 0], + "left_rotation": [0, 0.9800665778412416, 0, -0.19866933079506116], + "scale": [1, 1, 1] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.45, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.45, 0], @@ -156,12 +269,24 @@ "matrix": [ -0.9004471023526769, 0, 0.4349655341112301, 0, 0, 1, 0, 0, -0.4349655341112301, 0, -0.9004471023526769, 0, 0, 0.45, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.45, 0], + "left_rotation": [0, 0.9747941070689433, 0, -0.22310636213174545], + "scale": [0.9999999999999999, 1, 0.9999999999999999] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } }, { "time": 0.5, + "variants": null, + "variants_execute_condition": null, + "function": null, + "function_execute_condition": null, "node_transforms": { "fixture-bone": { "pos": [0, 0.5, 0], @@ -171,7 +296,15 @@ "matrix": [ -0.8775825618903729, 0, 0.4794255386042029, 0, 0, 1, 0, 0, -0.4794255386042029, 0, -0.8775825618903729, 0, 0, 0.5, 0, 1 - ] + ], + "decomposed": { + "translation": [0, 0.5, 0], + "left_rotation": [0, 0.9689124217106448, 0, -0.24740395925452285], + "scale": [1, 1, 1] + }, + "interpolation": null, + "function": null, + "function_execute_condition": null } } } diff --git a/src/tests/fixtures/renderHarness.ts b/src/tests/fixtures/renderHarness.ts index b9fa644a..c0e2ca58 100644 --- a/src/tests/fixtures/renderHarness.ts +++ b/src/tests/fixtures/renderHarness.ts @@ -69,11 +69,19 @@ export class HarnessBone { /** * `renderProjectAnimations` の戻り値を golden 比較用の安定した形へ落とす。 * - * `THREE.Matrix4` / `Vector3` / `Quaternion` をそのまま JSON 化すると three の内部表現 - * (= `_x` 等) に依存するため、 数値配列だけを取り出す。 丸めはしない (= bit 単位で比較する)。 + * `IRenderedAnimation` / `IRenderedFrame` / `INodeTransform` の **export に効くフィールドを + * 網羅する**。 `THREE.Matrix4` / `Vector3` / `Quaternion` をそのまま JSON 化すると three の + * 内部表現 (= `_x` 等) に依存するため、 数値配列へ落とす。 丸めはしない (= bit 単位で比較する)。 + * + * `modified_nodes` だけは node 全体だと巨大になるので uuid の一覧 (ソート済み) にしている。 + * `hashAnimations` が mix しているのも `Object.keys(modified_nodes)` なので粒度が揃う。 + * + * optional フィールドは `undefined` ではなく `null` に正規化する + * (= JSON 化で key ごと消えて比較が緩くなるのを防ぐため)。 * * **この関数は golden の生成側 (= main worktree) と比較側の両方で使うので、 - * 変更すると既存 golden と一致しなくなる**。 + * 変更すると既存 golden と一致しなくなる** (= 再生成手順は `animationRenderExport.test.ts` の + * 冒頭 JSDoc)。 */ export function serializeAnimations(animations: readonly IRenderedAnimation[]) { return animations.map(animation => ({ @@ -83,9 +91,14 @@ export function serializeAnimations(animations: readonly IRenderedAnimation[]) { 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, @@ -95,9 +108,27 @@ export function serializeAnimations(animations: readonly IRenderedAnimation[]) { scale: transform.scale, head_rot: transform.head_rot, matrix: Array.from(transform.matrix.elements), - ...(transform.interpolation - ? { interpolation: transform.interpolation } - : {}), + 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, }, ]) ), From 51ce941cae0adc33f4f6e8afb076c6d02aa864fd Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:45:59 +0000 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=90=9B=20interpolation=20=E3=83=95?= =?UTF-8?q?=E3=83=A9=E3=82=B0=E3=81=AE=E5=BE=A9=E5=85=83=E6=BC=8F=E3=82=8C?= =?UTF-8?q?=E3=82=92=E5=A1=9E=E3=81=8E=20hook=20API=20=E3=81=AE=20hash=20?= =?UTF-8?q?=E5=88=B6=E7=B4=84=E3=82=92=E6=98=8E=E8=A8=98=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderHooks.ts | 11 +++++++++++ src/systems/animationRenderer.ts | 18 +++++++++++------- src/tests/fixtures/minimalRig.ts | 7 ++++++- src/tests/fixtures/renderHarness.ts | 22 +++++++++++++++------- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts index adc3e99c..b82300e9 100644 --- a/src/systems/animationRenderHooks.ts +++ b/src/systems/animationRenderHooks.ts @@ -21,6 +21,17 @@ * 同じ `frameIndex` に対しては何度呼ばれても結果が変わらない (= 冪等) 実装が要る * - `timeSeconds` は `frameTimeSeconds` と一致しないことがある (= pre-post の side sample では * `frameTimeSeconds + 0.001`)。時刻の正本は `frameIndex` であり、 `timeSeconds` は参考値として扱う + * - **hook が加える変化は `pos` / `rot` / `scale` のいずれかに現れる形にすること**。 + * `hashAnimations` が node transform から mix するのは `pos` / `rot` / `scale` と + * `interpolation` / `function` 系だけで、 `matrix` も `decomposed` も mix しない。 その `pos` / + * `rot` / `scale` は `THREE.Matrix4.decompose` で得た T・R・S であり、 **shear と right rotation を + * 表現できない**。 一方 datapack compiler は matrix 全体を使う (= TSB 経路は `matrix.elements` を + * SVD 分解する `decomposeTsb`、 純正経路は `matrixToNbtFloatArray(transform.matrix)` で 16 要素を + * そのまま書き出す)。 したがって **shear や right rotation だけを動かす変換は、 mcfunction の中身が + * 変わっても hash が一致しうる** (= 非一様 scale と回転を組み合わせた場合に起きる)。 その場合 AJ の + * reload-skip 判定が 「変更なし」 と誤判定して、 新しい datapack が反映されないことがある。 + * これは hook 側の落ち度ではなく **AJ の既存 hash の制約**だが、 hash の入力を変えると既存 blueprint の + * hash が全て変わって datapack の全再生成が要るため、 本 PR では変更していない * * この module は Blockbench / THREE の global を実行時に参照しない (= 型は `import type` と ambient のみ)。 */ diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index 792feb72..a761b39c 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -518,12 +518,8 @@ 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 @@ -536,8 +532,16 @@ export async function renderProjectAnimations(project: ModelProject, rig: IRende // (= 並行に走ったもう 1 本の cleanup が、 こちらの session を終わらせないため) let sessionStarted = false - // 途中で例外が出ても bone interpolation / scene angle / 選択中 animation を必ず入口の状態へ戻す + // 途中で例外が出ても 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') { diff --git a/src/tests/fixtures/minimalRig.ts b/src/tests/fixtures/minimalRig.ts index e1fa9839..f52472b2 100644 --- a/src/tests/fixtures/minimalRig.ts +++ b/src/tests/fixtures/minimalRig.ts @@ -114,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] diff --git a/src/tests/fixtures/renderHarness.ts b/src/tests/fixtures/renderHarness.ts index c0e2ca58..58784829 100644 --- a/src/tests/fixtures/renderHarness.ts +++ b/src/tests/fixtures/renderHarness.ts @@ -11,9 +11,13 @@ * global を要求するため、 global を後から生やす方式では越えられない)。 mock の実体は * `animationRenderExport.test.ts` を参照。 * - * three は Blockbench が runtime 供給するもの (= 本 repo の直接依存ではない) だが、 + * production では three を Blockbench が runtime 供給する (= plugin は bundle に含めない) が、 * `matrixWorld` の階層計算を本物で回さないと出力が検証できないため、 harness では実物を - * `globalThis.THREE` に載せる。 + * `globalThis.THREE` に載せる。 そのため three は **テスト用の devDependency として明示的に + * 宣言してある** (= `package.json` の `three: ^0.134.0`。 Blockbench 5.1.4 は r129 相当だが、 + * `wintersky` が `^0.134.0` を要求しており、 別 version を足すと three が 2 コピー同居するため + * 0.134 に揃えた。 harness が使う `Object3D` / `Matrix4` / `Vector3` / `Quaternion` / `Euler` に + * r129 → r134 の破壊的変更は無い)。 */ import * as THREE from 'three' @@ -69,12 +73,16 @@ export class HarnessBone { /** * `renderProjectAnimations` の戻り値を golden 比較用の安定した形へ落とす。 * - * `IRenderedAnimation` / `IRenderedFrame` / `INodeTransform` の **export に効くフィールドを - * 網羅する**。 `THREE.Matrix4` / `Vector3` / `Quaternion` をそのまま JSON 化すると three の - * 内部表現 (= `_x` 等) に依存するため、 数値配列へ落とす。 丸めはしない (= bit 単位で比較する)。 + * **含むもの** : `IRenderedAnimation` の全スカラーフィールド (= `name` / `storage_name` / `uuid` / + * `loop_delay` / `duration` / `loop_mode` / `tsb_priority`) と、 `IRenderedFrame` / + * `INodeTransform` の全フィールド (= `matrix` の 16 要素と `decomposed` を含む)。 + * `THREE.Matrix4` / `Vector3` / `Quaternion` はそのまま JSON 化すると three の内部表現 + * (= `_x` 等) に依存するため数値配列へ落とす。 丸めはしない (= bit 単位で比較する)。 * - * `modified_nodes` だけは node 全体だと巨大になるので uuid の一覧 (ソート済み) にしている。 - * `hashAnimations` が mix しているのも `Object.keys(modified_nodes)` なので粒度が揃う。 + * **含まないもの** : `modified_nodes` の node 本体。 uuid の一覧 (ソート済み) へ縮約しており、 + * node の `type` / `storage_name` / `default_transform` 等は比較対象に入っていない。 + * `hashAnimations` が mix しているのも `Object.keys(modified_nodes)` なので、 それと粒度を + * 合わせた形 (= node 本体の中身は rig 側の責務で、 animation の render 結果ではないため)。 * * optional フィールドは `undefined` ではなく `null` に正規化する * (= JSON 化で key ごと消えて比較が緩くなるのを防ぐため)。 From 889655ac83dc224eda5541e44bc1a23d3a13f92e Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:59:13 +0000 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=90=9B=20hashAnimations=20=E3=81=AB?= =?UTF-8?q?=20matrix=20=E3=82=92=E6=B7=B7=E3=81=9C=20shear=20=E3=81=AE?= =?UTF-8?q?=E5=A4=89=E5=8C=96=E3=82=92=20reload=20=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=81=B8=E8=BC=89=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderHooks.ts | 18 +-- src/systems/animationRenderer.ts | 6 + src/systems/exporter.ts | 4 +- src/tests/animationRenderExport.test.ts | 128 ++++++++++++++++++- src/tests/fixtures/renderBaselineGolden.json | 3 +- 5 files changed, 145 insertions(+), 14 deletions(-) diff --git a/src/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts index b82300e9..38cb8a62 100644 --- a/src/systems/animationRenderHooks.ts +++ b/src/systems/animationRenderHooks.ts @@ -21,17 +21,13 @@ * 同じ `frameIndex` に対しては何度呼ばれても結果が変わらない (= 冪等) 実装が要る * - `timeSeconds` は `frameTimeSeconds` と一致しないことがある (= pre-post の side sample では * `frameTimeSeconds + 0.001`)。時刻の正本は `frameIndex` であり、 `timeSeconds` は参考値として扱う - * - **hook が加える変化は `pos` / `rot` / `scale` のいずれかに現れる形にすること**。 - * `hashAnimations` が node transform から mix するのは `pos` / `rot` / `scale` と - * `interpolation` / `function` 系だけで、 `matrix` も `decomposed` も mix しない。 その `pos` / - * `rot` / `scale` は `THREE.Matrix4.decompose` で得た T・R・S であり、 **shear と right rotation を - * 表現できない**。 一方 datapack compiler は matrix 全体を使う (= TSB 経路は `matrix.elements` を - * SVD 分解する `decomposeTsb`、 純正経路は `matrixToNbtFloatArray(transform.matrix)` で 16 要素を - * そのまま書き出す)。 したがって **shear や right rotation だけを動かす変換は、 mcfunction の中身が - * 変わっても hash が一致しうる** (= 非一様 scale と回転を組み合わせた場合に起きる)。 その場合 AJ の - * reload-skip 判定が 「変更なし」 と誤判定して、 新しい datapack が反映されないことがある。 - * これは hook 側の落ち度ではなく **AJ の既存 hash の制約**だが、 hash の入力を変えると既存 blueprint の - * hash が全て変わって datapack の全再生成が要るため、 本 PR では変更していない + * - **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 のみ)。 */ diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index a761b39c..2c3a6338 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -475,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(';')) 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/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index 2fd5be6e..7c52cc74 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -8,6 +8,7 @@ * 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 で一致すること @@ -27,6 +28,10 @@ * * 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 も * 作り直しになるので、 そのときは同じ手順を踏むこと。 現ブランチの出力で上書きしてはいけない。 * @@ -41,6 +46,8 @@ * 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`) を @@ -175,6 +182,73 @@ 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。 * @@ -241,8 +315,10 @@ describe('renderProjectAnimations - hook 経路の実走', () => { 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) - expect(hashAnimations(animations)).toBe(GOLDEN.hash) }) it('2. hook を登録すると node_transforms と hash が変わる', async () => { @@ -257,6 +333,56 @@ describe('renderProjectAnimations - hook 経路の実走', () => { 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)) diff --git a/src/tests/fixtures/renderBaselineGolden.json b/src/tests/fixtures/renderBaselineGolden.json index 8938251e..d43f161a 100644 --- a/src/tests/fixtures/renderBaselineGolden.json +++ b/src/tests/fixtures/renderBaselineGolden.json @@ -1,5 +1,6 @@ { - "hash": "be401c44af6132f95070fa446894aaccf71370204b1c30fb890ab1f74521ddad", + "_note": "hash 値は main (a886b10e) 由来だが、 本 PR で hashAnimations に matrix.elements を混ぜたため現行実装の出力とは一致しない。 比較には使わないこと (= 受け入れ条件の証明は animations の深比較が担う)。", + "main_hash_legacy_algorithm": "be401c44af6132f95070fa446894aaccf71370204b1c30fb890ab1f74521ddad", "animations": [ { "name": "test_animation",