import { describe, expect, it, vi } from "vitest"; 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"; 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", }; } describe("model export compositor", () => { it("renders visible models with the shared orientation into the target canvas", async () => { const schema = defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [ { controls: { model: { assetKind: "model", defaultValue: null, label: "Model", target: "media.model", type: "fileDrop", }, orientation: { defaultValue: { position: [0, 0, 5], up: [0, 1, 0] }, keyframeable: false, label: false, target: "view.orbit", type: "orientationGizmo", }, }, title: "Model", }, ], title: "Controls", }, }, }); 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 target = { getContext: vi.fn(() => ({ drawImage })), height: 2160, width: 3840, } as unknown as HTMLCanvasElement; const source = {} as HTMLCanvasElement; const renderExport = vi.fn( async (_request, context) => context.onRendered?.(source), ); const host: ToolcraftModelRenderHost = { acquirePresentation: vi.fn(), dispose: vi.fn(), getPreparationStatus: vi.fn(() => "ready" as const), hitTest: vi.fn(), prepare: vi.fn(async () => undefined), release: vi.fn(), renderExport, renderPreview: vi.fn(), subscribePreparation: vi.fn(() => () => undefined), }; await expect( renderToolcraftModelsToCanvas(host, state, target), ).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: 1080, pixelRatio: 2, width: 1920 }), ); expect(drawImage).toHaveBeenCalledWith(source, 0, 0, 3840, 2160); }); it("is a no-op when no model render host is available", async () => { const state = createToolcraftState( defineToolcraft({ canvas: { enabled: true }, panels: {} }), ); const canvas = {} as HTMLCanvasElement; await expect(renderToolcraftModelsToCanvas(null, state, canvas)).resolves.toBe(0); }); it("renders model frames relative to an infinite scene crop", async () => { const schema = defineToolcraft({ canvas: { enabled: true }, panels: {} }); 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 target = { getContext: vi.fn(() => ({ drawImage })), height: 200, width: 400, } as unknown as HTMLCanvasElement; const source = {} as HTMLCanvasElement; const renderExport = vi.fn( async (_request, context) => context.onRendered?.(source), ); const host = { acquirePresentation: vi.fn(), dispose: vi.fn(), getPreparationStatus: vi.fn(() => "ready" as const), hitTest: vi.fn(), prepare: vi.fn(async () => undefined), release: vi.fn(), renderExport, renderPreview: vi.fn(), subscribePreparation: vi.fn(() => () => undefined), } satisfies ToolcraftModelRenderHost; await expect( renderToolcraftModelsToCanvas(host, state, target, { canvasFrame: { kind: "infinite" }, exportFrame: { height: 100, width: 200, x: -100, y: -50 }, }), ).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, 150, 80, 100, 40); await expect( renderToolcraftModelsToCanvas(host, state, target, { canvasFrame: { kind: "infinite" }, exportFrame: { height: 100, width: 200, x: -100, y: -50 }, suppressedTargets: ["media.model"], }), ).resolves.toBe(0); }); });