import { describe, expect, it, vi } from "vitest"; import { Mesh, MeshStandardMaterial, type Scene, type WebGLRenderer } from "three"; import { createModelBounds, createModelMaterial, createModelNode, createModelPrimitive, createModelPrimitiveV2, createValidModelDocument, createValidModelDocumentV2, } from "../../model-import/canonical/model-document-test-support"; import { createToolcraftModelPresentation } from "./model-render-binding"; import { createToolcraftThreeModelRenderAdapter } from "./three-model-render-adapter"; import { asset, createModelRenderContext, createRenderer, } from "./three-model-render-adapter.test-support"; describe("standard Three model render adapter", () => { it("prewarms and reuses the first WebGL renderer for visible model output", async () => { const renderer = createRenderer(); const createRendererFactory = vi.fn(() => renderer as unknown as WebGLRenderer); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: createRendererFactory, }); await adapter.preparePreview?.({ height: 200, host: document.createElement("div"), pixelRatio: 1, target: "source.model", width: 300, }); expect(createRendererFactory).toHaveBeenCalledTimes(1); expect(renderer.render).toHaveBeenCalledTimes(2); const prewarmScene = renderer.render.mock.calls[0]?.[0] as Scene; const vertexColorModes: boolean[] = []; const prewarmMaterialDisposals: ReturnType[] = []; prewarmScene.traverse((object) => { if (object instanceof Mesh && object.material instanceof MeshStandardMaterial) { vertexColorModes.push(object.material.vertexColors === true); prewarmMaterialDisposals.push(vi.spyOn(object.material, "dispose")); } }); expect(vertexColorModes.sort()).toEqual([false, true]); const canonicalDocument = createValidModelDocument(); const resource = await adapter.create( createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "clean", purpose: "preview", viewport: { height: 200, width: 300 }, }), createModelRenderContext(canonicalDocument), ); adapter.renderPreview(resource, { height: 200, host: document.createElement("div"), pixelRatio: 1, selected: false, width: 300, }); expect(createRendererFactory).toHaveBeenCalledTimes(1); expect(resource.renderer).toBe(renderer); expect(renderer.render).toHaveBeenCalledTimes(3); expect(renderer.setSize).toHaveBeenCalledTimes(1); expect(prewarmMaterialDisposals.every((dispose) => dispose.mock.calls.length === 0)).toBe(true); adapter.dispose(resource); expect(renderer.dispose).not.toHaveBeenCalled(); expect(prewarmMaterialDisposals.every((dispose) => dispose.mock.calls.length === 0)).toBe(true); adapter.disposePreparedPreview?.(); expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(prewarmMaterialDisposals.every((dispose) => dispose.mock.calls.length === 1)).toBe(true); }); it("disposes an unused prewarmed renderer", async () => { const renderer = createRenderer(); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer as unknown as WebGLRenderer, }); await adapter.preparePreview?.({ height: 1080, host: document.createElement("div"), pixelRatio: 1, target: "source.model", width: 1920, }); adapter.disposePreparedPreview?.(); adapter.disposePreparedPreview?.(); expect(renderer.render).toHaveBeenCalledTimes(2); expect(renderer.renderLists!.dispose).toHaveBeenCalledTimes(1); expect(renderer.dispose).toHaveBeenCalledTimes(1); }); it("builds render resources while preserving canonical geometry and node matrices", async () => { const localMatrix = [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1] as const; const primitive = createModelPrimitive({ bounds: createModelBounds([-1, -1, 0], [1, 1, 0]), normals: undefined, positions: new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]), }); const canonicalDocument = createValidModelDocument({ bounds: createModelBounds([-1, -1, 0], [1, 1, 0]), nodes: [createModelNode({ localMatrix })], primitives: [primitive], }); const positionsBefore = [...primitive.positions]; const renderer = createRenderer(); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer as unknown as WebGLRenderer, }); const presentation = createToolcraftModelPresentation(asset, { bounds: createModelBounds([100, 100, 100], [200, 200, 200]), phase: "analyzing", purpose: "preview", viewport: { height: 200, width: 300 }, }); const resource = await adapter.create( presentation, createModelRenderContext(canonicalDocument), ); expect(resource.nodeObjects.get("node-1")!.matrix.toArray()).toEqual(localMatrix); expect(resource.presentation.displayFit.sourceBounds).toEqual(canonicalDocument.bounds); expect(resource.fitRoot.matrix.toArray()).toEqual(resource.presentation.displayFit.matrix); expect(resource.fitRoot.matrix.toArray()).not.toEqual(presentation.displayFit.matrix); expect(resource.geometries[0]!.getAttribute("position").array).not.toBe(primitive.positions); expect(resource.geometries[0]!.getAttribute("normal")).toBeDefined(); expect(primitive.positions).toEqual(new Float32Array(positionsBefore)); expect(primitive.normals).toBeUndefined(); expect(resource.materials[0]!.opacity).toBe(1); expect(renderer.domElement.style.opacity).toBe("0.4"); }); it("omits unreferenced vertices from the GPU projection without mutating the canonical model", async () => { const primitive = createModelPrimitive({ indices: new Uint32Array([0, 1, 2]), normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0]), positions: new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0, 100, 100, 100, 200, 200, 200]), }); const positionsBefore = primitive.positions.slice(); const normalsBefore = primitive.normals!.slice(); const canonicalDocument = createValidModelDocument({ primitives: [primitive], }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => createRenderer() as unknown as WebGLRenderer, }); const resource = await adapter.create( createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "repairable", purpose: "preview", viewport: { height: 200, width: 300 }, }), createModelRenderContext(canonicalDocument), ); expect(resource.geometries[0]!.getAttribute("position").count).toBe(3); expect(resource.geometries[0]!.getAttribute("normal").count).toBe(3); expect(resource.geometries[0]!.getIndex()?.count).toBe(3); expect(primitive.positions).toEqual(positionsBefore); expect(primitive.normals).toEqual(normalsBefore); }); it("batches same-node primitives that share one appearance without mutating canonical geometry", async () => { const first = createModelPrimitive({ id: "primitive-1" }); const second = createModelPrimitive({ id: "primitive-2", positions: new Float32Array([-2, -2, 0, -1, -2, 0, -2, -1, 0]), }); const firstPositions = first.positions.slice(); const secondPositions = second.positions.slice(); const canonicalDocument = createValidModelDocument({ nodes: [ createModelNode({ primitiveIds: [first.id, second.id], }), ], primitives: [first, second], }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => createRenderer() as unknown as WebGLRenderer, }); const resource = await adapter.create( createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "clean", purpose: "preview", viewport: { height: 200, width: 300 }, }), createModelRenderContext(canonicalDocument), ); expect(resource.geometries).toHaveLength(1); expect(resource.materials).toHaveLength(1); expect(resource.nodeObjects.get("node-1")?.children).toHaveLength(1); expect(resource.geometries[0]!.getAttribute("position").count).toBe(6); expect(resource.geometries[0]!.getIndex()?.count).toBe(6); expect(first.positions).toEqual(firstPositions); expect(second.positions).toEqual(secondPositions); }); it("deduplicates identical opaque projection geometry while keeping canonical primitives", async () => { const first = createModelPrimitive({ id: "primitive-1" }); const duplicate = createModelPrimitive({ id: "primitive-duplicate" }); const duplicatePositions = duplicate.positions.slice(); const canonicalDocument = createValidModelDocument({ nodes: [ createModelNode({ primitiveIds: [first.id, duplicate.id], }), ], primitives: [first, duplicate], }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => createRenderer() as unknown as WebGLRenderer, }); const resource = await adapter.create( createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "analyzing", purpose: "preview", viewport: { height: 200, width: 300 }, }), createModelRenderContext(canonicalDocument), ); expect(resource.geometries).toHaveLength(1); expect(resource.geometries[0]!.getAttribute("position").count).toBe(3); expect(resource.geometries[0]!.getIndex()?.count).toBe(3); expect(canonicalDocument.primitives).toHaveLength(2); expect(duplicate.positions).toEqual(duplicatePositions); }); it("retains duplicate geometry contributions for authored blend materials", async () => { const first = createModelPrimitiveV2({ id: "first", materialId: "blend" }); const second = createModelPrimitiveV2({ id: "second", materialId: "blend", }); const canonicalDocument = createValidModelDocumentV2({ materials: [ createModelMaterial({ alphaMode: "blend", id: "blend", opacity: 0.5, }), ], nodes: [createModelNode({ primitiveIds: [first.id, second.id] })], primitives: [first, second], textures: [], }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => createRenderer() as unknown as WebGLRenderer, }); const resource = await adapter.create( createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "clean", purpose: "preview", viewport: { height: 200, width: 300 }, }), createModelRenderContext(canonicalDocument), ); expect(resource.geometries).toHaveLength(1); expect(resource.geometries[0]!.getAttribute("position").count).toBe(6); expect(resource.geometries[0]!.getIndex()?.count).toBe(6); }); it("preserves per-primitive custom material semantics", async () => { const first = createModelPrimitiveV2({ id: "primitive-1", materialId: "material-1", }); const second = createModelPrimitiveV2({ id: "primitive-2", materialId: "material-2", }); const canonicalDocument = createValidModelDocumentV2({ materials: [ createModelMaterial({ alphaMode: "blend", id: "material-1", opacity: 0.5, }), createModelMaterial({ baseColor: [0.2, 0.3, 0.4], id: "material-2" }), ], nodes: [ createModelNode({ primitiveIds: [first.id, second.id], }), ], primitives: [first, second], textures: [], }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => createRenderer() as unknown as WebGLRenderer, }); const resource = await adapter.create( createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "clean", purpose: "preview", viewport: { height: 200, width: 300 }, }), createModelRenderContext(canonicalDocument), ); expect(resource.geometries).toHaveLength(2); expect(resource.materials).toHaveLength(2); expect(resource.materials[0]).not.toBe(resource.materials[1]); expect(resource.materials[0]!.depthWrite).toBe(false); expect(resource.materials.map((material) => material.name)).toEqual([ "material-1", "material-2", ]); expect(resource.nodeObjects.get("node-1")?.children).toHaveLength(2); }); it("builds only geometry-bearing branches while preserving their transform ancestry", async () => { const primitive = createModelPrimitive(); const canonicalDocument = createValidModelDocument({ nodes: [ createModelNode({ children: ["mesh-node"], id: "mesh-parent", name: "Mesh parent", primitiveIds: [], }), createModelNode({ id: "mesh-node", name: "Mesh node", primitiveIds: [primitive.id], }), ...Array.from({ length: 5_000 }, (_, index) => createModelNode({ id: `empty-${index}`, name: `Empty ${index}`, primitiveIds: [], }), ), ], primitives: [primitive], rootNodeIds: [ "mesh-parent", ...Array.from({ length: 5_000 }, (_, index) => `empty-${index}`), ], }); const renderer = createRenderer(); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer as unknown as WebGLRenderer, }); const presentation = createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "clean", purpose: "preview", viewport: { height: 200, width: 300 }, }); const resource = await adapter.create( presentation, createModelRenderContext(canonicalDocument), ); expect([...resource.nodeObjects.keys()]).toEqual(["mesh-parent", "mesh-node"]); expect(resource.modelRoot.children).toEqual([resource.nodeObjects.get("mesh-parent")]); expect(resource.nodeObjects.get("mesh-parent")?.children).toContain( resource.nodeObjects.get("mesh-node"), ); }); });