import { vi } from "vitest"; import type { ToolcraftControlSchema } from "../schema/types"; import { TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS } from "./model-import-limit-values"; import type { ToolcraftModelTopologyDiagnostic, ToolcraftModelWorkerAnalysisSummary, } from "./model-import-types"; import { createToolcraftModelSourceAssetHandler } from "./model-source-asset-handler"; import type { ToolcraftModelWorkerClient, ToolcraftModelWorkerClientObservers, } from "./worker/model-import-worker-client"; import type { ToolcraftModelDecodeAnalyzeWorkerResult, ToolcraftModelWorkerGeometryTerminalResponse, } from "./worker/model-import-worker-protocol"; export const SHA_A = `sha256:${"a".repeat(64)}`; export const SHA_B = `sha256:${"b".repeat(64)}`; export const SHA_C = `sha256:${"c".repeat(64)}`; export const encoder = new TextEncoder(); export function modelControl( modelFormats: readonly ("glb" | "gltf" | "obj")[] = ["glb", "gltf"], ): ToolcraftControlSchema { return { assetKind: "model", modelFormats, modelLimits: TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, multiple: false, target: "source.model", topologyProfile: "realtime-mesh", type: "fileDrop", }; } export function file( name: string, bytes: BufferSource, type: string, ): File { return new File([bytes], name, { type }); } export function glbFile(name = "scene.glb"): File { return file( name, new Uint8Array([0x67, 0x6c, 0x54, 0x46]), "model/gltf-binary", ); } export function gltfBundle(): readonly File[] { return [ file( "scene.gltf", encoder.encode( JSON.stringify({ asset: { version: "2.0" }, buffers: [{ byteLength: 4, uri: "geometry.bin" }], }), ), "model/gltf+json", ), file( "geometry.bin", new Uint8Array([1, 2, 3, 4]), "application/octet-stream", ), ]; } function topologyDiagnostic( severity: ToolcraftModelTopologyDiagnostic["severity"], ): ToolcraftModelTopologyDiagnostic { return { affectedCount: 1, code: severity === "fatal" ? "no-renderable-triangles" : "boundary-edges", explanation: `${severity} diagnostic`, severity, }; } function analysis( outcome: ToolcraftModelWorkerAnalysisSummary["outcome"], ): ToolcraftModelWorkerAnalysisSummary { const severity = outcome === "clean" ? "info" : outcome; return { analyzerVersion: "toolcraft-topology-v1", diagnostics: [topologyDiagnostic(severity)], limits: TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, outcome, profile: "realtime-mesh", statistics: { boundaryEdgeCount: 2, componentCount: 1, decodedBytes: 256, edgeCount: 3, estimatedPeakWorkerBytes: 512, estimatedRepairBytes: outcome === "repairable" ? 128 : 0, nodeCount: 1, nonManifoldEdgeCount: 0, nonManifoldVertexCount: 0, primitiveCount: 1, triangleCount: 1, unusedVertexCount: 0, vertexCount: 3, }, }; } function decodeResult( outcome: ToolcraftModelWorkerAnalysisSummary["outcome"], ): ToolcraftModelDecodeAnalyzeWorkerResult { return { analysis: analysis(outcome), appearanceResources: [], canonicalDocument: new Uint8Array([9, 8, 7]).buffer, canonicalDocumentDigest: SHA_B, operation: "decode-and-analyze", ...(outcome === "repairable" ? { repairPlanEnvelope: { bytes: new Uint8Array([6, 5, 4]).buffer, envelopeDigest: SHA_C, planDigest: SHA_A, version: 1 as const, }, } : {}), receiptDigest: SHA_A, }; } export function workerClient( outcome: ToolcraftModelWorkerAnalysisSummary["outcome"], ): ToolcraftModelWorkerClient & { extractModelPackage: ReturnType; importModel: ReturnType; } { const result = decodeResult(outcome); const importModel = vi.fn( async ( request: { jobId: string }, observers: ToolcraftModelWorkerClientObservers = {}, ): Promise => { observers.onDraft?.({ draft: { appearanceResources: [], canonicalDocument: new Uint8Array([1, 2, 3]).buffer, canonicalDocumentDigest: SHA_A, receiptDigest: SHA_B, }, generation: 1, jobId: request.jobId, kind: "draft", }); return { generation: 1, jobId: request.jobId, kind: "result", result, }; }, ); const extractModelPackage = vi.fn(); return { cancel: vi.fn(), dispose: vi.fn(), extractModelPackage, importModel, repair: vi.fn(), reset: vi.fn(), }; } export async function prepare( outcome: ToolcraftModelWorkerAnalysisSummary["outcome"], files: readonly File[] = [glbFile()], options: Readonly<{ canvasFrame?: | { kind: "finite"; size: { height: number; unit: "px"; width: number }; } | { kind: "infinite" }; position?: { x: number; y: number }; }> = {}, ) { const worker = workerClient(outcome); const handler = createToolcraftModelSourceAssetHandler({ workerClient: worker, }); const control = modelControl(); const batch = { files, origin: options.position ? ("canvas" as const) : ("panel" as const), ...(options.position ? { position: options.position } : {}), target: control.target, }; const claim = handler.match(batch, control); if (!claim) throw new Error("Expected model claim"); const plan = handler.plan(batch, control, claim); const reportOperation = vi.fn(); const stageResource = vi.fn( async ( _ref: string, _bytes: Uint8Array, _options: unknown, ) => undefined, ); const prepared = await handler.prepare({ batch, canvasFrame: options.canvasFrame ?? { kind: "finite", size: { height: 512, unit: "px", width: 512 }, }, claim, control, jobId: "model-job-1", plan: { ...plan, sourceTarget: control.target }, reportOperation, signal: new AbortController().signal, stageResource, }); return { prepared, reportOperation, stageResource, worker }; }