import { describe, expect, it, vi } from "vitest"; import { model3dModule } from "../../modules/built-ins/model-3d/model-3d-module"; import { spatialViewModule } from "../../modules/built-ins/spatial-view/spatial-view-module"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { createToolcraftState } from "../../state/create-template-state"; import type { ToolcraftModelAsset } from "../../state/types"; import type { ToolcraftModelRenderHost } from "./model-render-binding"; import { renderToolcraftModelsToCanvas } from "./model-export"; import { renderToolcraftModelsInWorldContext } from "./model-export-world-context"; function modelAsset(id: string, layerId: string): ToolcraftModelAsset { const analysis = { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean" as const, triangles: 1, vertices: 3, }; return { activeDocumentRef: `toolcraft:model-document:sha256:${"1".repeat(64)}`, analysis, assetKind: "model", fileName: `${id}.glb`, id, layerId, lifecycle: "clean", mimeType: "model/gltf-binary", originalAnalysis: analysis, originalDocumentRef: `toolcraft:model-document:sha256:${"1".repeat(64)}`, position: { x: 0, y: 0 }, size: { height: 512, unit: "px", width: 512 }, sourceBundleDigest: `sha256:${"2".repeat(64)}`, sourceBundleRef: `toolcraft:model-source-bundle:sha256:${"2".repeat(64)}`, sourceTarget: "media.model", topologyProfile: "realtime-mesh", }; } function createHost( renderExport: ToolcraftModelRenderHost["renderExport"], ): ToolcraftModelRenderHost { return { acquirePresentation: vi.fn(), dispose: vi.fn(), getPreparationStatus: vi.fn(() => "ready" as const), hitTest: vi.fn(), prepare: vi.fn(async () => undefined), release: vi.fn(), retain: vi.fn(() => () => undefined), renderExport, renderPreview: vi.fn(), subscribePreparation: vi.fn(() => () => undefined), }; } describe("model export compositor", () => { it.each(["callback", "settlement"] as const)( "rejects cancellation at the world renderer %s boundary without drawing", async (phase) => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { mediaAssets: [modelAsset("visible", "layer")] }, ); const canvas = document.createElement("canvas"); const drawImage = vi.fn(); const context = { drawImage } as unknown as CanvasRenderingContext2D; const controller = new AbortController(); const host = createHost(async (_request, renderContext) => { expect(renderContext.signal).toBe(controller.signal); controller.abort(new Error("cancel world draw")); if (phase === "callback") await renderContext.onRendered?.(canvas); }); await expect( renderToolcraftModelsInWorldContext({ canvas, context, exportFrame: { height: 100, width: 100, x: 0, y: 0 }, host, signal: controller.signal, state, }), ).rejects.toBe(controller.signal.reason); expect(drawImage).not.toHaveBeenCalled(); }, ); it("renders visible models with canonical world frames and shared orientation", async () => { const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: { controls: { sections: [ { id: "test-section-1", controls: { model: { applicability: { mode: "always" as const }, assetKind: "model", defaultValue: null, label: "Model", target: "media.model", type: "fileDrop", }, orientation: { applicability: { mode: "always" as const }, defaultValue: { position: [0, 0, 5], up: [0, 1, 0] }, keyframeable: false, label: false, target: "view.orbit", type: "orientationGizmo", }, }, title: "Model", }, ], title: "Controls", }, }, }, modules: [model3dModule(), spatialViewModule()], }); const visible = modelAsset("visible", "layer-1"); const hidden = modelAsset("hidden", "layer-2"); const state = createToolcraftState(schema, { mediaAssets: [visible, hidden], values: { "view.orbit": { position: [2, 3, 4], up: [0, 1, 0] }, }, }); state.layers = state.layers.map((layer) => layer.id === hidden.layerId ? { ...layer, visible: false } : layer, ); const drawImage = vi.fn(); const context = { drawImage, restore: vi.fn(), save: vi.fn(), setTransform: vi.fn(), }; const target = { getContext: vi.fn(() => context), height: 2160, width: 3839, } as unknown as HTMLCanvasElement; const source = {} as HTMLCanvasElement; const renderExport = vi.fn( async (_request, context) => context.onRendered?.(source), ); const host = createHost(renderExport); await expect( renderToolcraftModelsToCanvas(host, state, target, { signal: new AbortController().signal, }), ).resolves.toBe(1); expect(renderExport).toHaveBeenCalledWith( expect.objectContaining({ asset: visible, orientation: { position: [2, 3, 4], up: [0, 1, 0] }, phase: "final", target: "media.model", }), expect.objectContaining({ height: 512, pixelRatio: 3839 / 1920, width: 512, }), ); expect(context.save).toHaveBeenCalledOnce(); expect(context.setTransform).toHaveBeenCalledWith( 3839 / 1920, 0, 0, 2, 3839 / 2, 1080, ); expect(drawImage).toHaveBeenCalledWith(source, -256, -256, 512, 512); expect(context.restore).toHaveBeenCalledOnce(); }); it("is a no-op when no model render host is available", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [], }), ); const canvas = {} as HTMLCanvasElement; await expect( renderToolcraftModelsToCanvas(null, state, canvas, { signal: new AbortController().signal, }), ).resolves.toBe(0); }); it("restores the public canvas context after async render failure", async () => { const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { mediaAssets: [modelAsset("broken", "model-layer")] }, ); const context = { drawImage: vi.fn(), restore: vi.fn(), save: vi.fn(), setTransform: vi.fn(), }; const canvas = { getContext: vi.fn(() => context), height: 1080, width: 1920, } as unknown as HTMLCanvasElement; const failure = new Error("model export failed"); const host = createHost( vi.fn(async () => { throw failure; }), ); await expect( renderToolcraftModelsToCanvas(host, state, canvas, { signal: new AbortController().signal, }), ).rejects.toBe(failure); expect(context.save).toHaveBeenCalledOnce(); expect(context.restore).toHaveBeenCalledOnce(); }); it("renders world destinations without replacing the ambient transform", async () => { const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }); const visible = modelAsset("visible", "layer-1"); visible.position = { x: 0, y: 0 }; visible.size = { height: 20, unit: "px", width: 50 }; const state = createToolcraftState(schema, { canvas: { mode: "infinite" }, mediaAssets: [visible], }); const drawImage = vi.fn(); const context = { drawImage, setTransform: vi.fn(), } as unknown as CanvasRenderingContext2D; const target = { getContext: vi.fn(() => context), height: 200, width: 400, } as unknown as HTMLCanvasElement; const source = {} as HTMLCanvasElement; const renderExport = vi.fn( async (_request, context) => context.onRendered?.(source), ); const host = createHost(renderExport); await expect( renderToolcraftModelsInWorldContext({ canvas: target, context, exportFrame: { height: 100, width: 200, x: -100, y: -50 }, host, signal: new AbortController().signal, state, }), ).resolves.toBe(1); expect(renderExport).toHaveBeenCalledWith( expect.objectContaining({ viewport: { height: 20, width: 50 } }), expect.objectContaining({ height: 20, pixelRatio: 2, width: 50 }), ); expect(drawImage).toHaveBeenCalledWith(source, -25, -10, 50, 20); expect(context.setTransform).not.toHaveBeenCalled(); await expect( renderToolcraftModelsInWorldContext({ canvas: target, context, exportFrame: { height: 100, width: 200, x: -100, y: -50 }, host, signal: new AbortController().signal, state, suppressedTargets: ["media.model"], }), ).resolves.toBe(0); }); });