import { describe, expect, it, vi } from "vitest"; import 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, type ToolcraftThreeModelRenderResource, } from "./three-model-render-adapter"; import { asset, createModelRenderContext, createRenderer, } from "./three-model-render-adapter.test-support"; describe("standard Three model render adapter", () => { it("renders previews, updates opacity, and exposes geometry hit testing", async () => { const canonicalDocument = createValidModelDocument({ bounds: createModelBounds([-1, -1, 0], [1, 1, 0]), primitives: [ createModelPrimitive({ bounds: createModelBounds([-1, -1, 0], [1, 1, 0]), positions: new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]), }), ], }); const renderer = createRenderer(); vi.spyOn(renderer.domElement, "getBoundingClientRect").mockReturnValue({ bottom: 200, height: 200, left: 0, right: 300, toJSON: () => ({}), top: 0, width: 300, x: 0, y: 0, }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer as unknown as WebGLRenderer, }); const preview = createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "analyzing", purpose: "preview", viewport: { height: 200, width: 300 }, }); const releaseLease = vi.fn(); const context = createModelRenderContext(canonicalDocument, releaseLease); const resource = await adapter.create(preview, context); const host = document.createElement("div"); adapter.renderPreview(resource, { height: 200, host, pixelRatio: 2, selected: true, width: 300, }); expect(host.firstElementChild).toBe(renderer.domElement); expect(renderer.setPixelRatio).toHaveBeenCalledWith(2); expect(renderer.setSize).toHaveBeenCalledWith(300, 200, false); expect(renderer.render).toHaveBeenCalled(); expect(adapter.hitTest(resource, { clientX: 150, clientY: 100 })).toBe(true); const committed = createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "clean", purpose: "preview", viewport: { height: 200, width: 300 }, }); await adapter.update(resource, committed, context); expect(context.acquirePresentation).toHaveBeenCalledTimes(1); expect(resource.materials[0]!.opacity).toBe(1); expect(resource.materials[0]!.transparent).toBe(false); expect(renderer.domElement.style.opacity).toBe("1"); adapter.dispose(resource); adapter.dispose(resource); expect(releaseLease).toHaveBeenCalledTimes(1); }); it("stops geometry hit testing after the first visible mesh intersection", async () => { const first = createModelPrimitiveV2({ id: "first", materialId: "first-material", }); const second = createModelPrimitiveV2({ id: "second", materialId: "second-material", }); const canonicalDocument = createValidModelDocumentV2({ materials: [ createModelMaterial({ id: "first-material" }), createModelMaterial({ id: "second-material" }), ], nodes: [createModelNode({ primitiveIds: [first.id, second.id] })], primitives: [first, second], textures: [], }); const renderer = createRenderer(); vi.spyOn(renderer.domElement, "getBoundingClientRect").mockReturnValue({ bottom: 200, height: 200, left: 0, right: 300, toJSON: () => ({}), top: 0, width: 300, x: 0, y: 0, }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer 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), ); const meshes = resource.modelRoot.children[0]!.children; const firstRaycast = vi.spyOn(meshes[0]!, "raycast"); const secondRaycast = vi.spyOn(meshes[1]!, "raycast"); expect(adapter.hitTest(resource, { clientX: 150, clientY: 100 })).toBe(true); expect(firstRaycast).toHaveBeenCalledTimes(1); expect(secondRaycast).not.toHaveBeenCalled(); }); it("keeps the material shader configuration stable across processing phases", async () => { const canonicalDocument = createValidModelDocument(); const renderer = createRenderer(); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer as unknown as WebGLRenderer, }); const analyzing = createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "analyzing", purpose: "preview", viewport: { height: 200, width: 300 }, }); const context = createModelRenderContext(canonicalDocument); const resource = await adapter.create(analyzing, context); const material = resource.materials[0]!; const shaderVersion = material.version; for (const phase of ["clean", "repairing", "fixed"] as const) { await adapter.update( resource, createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase, purpose: "preview", viewport: { height: 200, width: 300 }, }), context, ); } expect(material.transparent).toBe(false); expect(material.opacity).toBe(1); expect(material.version).toBe(shaderVersion); expect(renderer.domElement.style.opacity).toBe("1"); adapter.dispose(resource); }); it("replaces canonical geometry without replacing the WebGL renderer", async () => { const firstDocument = createValidModelDocument(); const secondDocument = createValidModelDocument({ primitives: [ createModelPrimitive({ id: "replacement-a" }), createModelPrimitive({ id: "replacement-b" }), ], nodes: [ createModelNode({ id: "replacement-node", primitiveIds: ["replacement-a", "replacement-b"], }), ], rootNodeIds: ["replacement-node"], }); const renderer = createRenderer(); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer as unknown as WebGLRenderer, }); const releaseFirstLease = vi.fn(); const releaseSecondLease = vi.fn(); const firstContext = createModelRenderContext(firstDocument, releaseFirstLease); const secondContext = createModelRenderContext(secondDocument, releaseSecondLease); const resource = await adapter.create( createToolcraftModelPresentation(asset, { bounds: firstDocument.bounds, phase: "repairing", purpose: "preview", viewport: { height: 200, width: 300 }, }), firstContext, ); const originalGeometryDispose = vi.spyOn(resource.geometries[0]!, "dispose"); await adapter.update( resource, createToolcraftModelPresentation( { ...asset, activeDocumentRef: "document:replacement" }, { bounds: secondDocument.bounds, canonicalDocumentRef: "document:replacement", phase: "fixed", purpose: "preview", viewport: { height: 200, width: 300 }, }, ), secondContext, ); expect(firstContext.acquirePresentation).toHaveBeenCalledTimes(1); expect(secondContext.acquirePresentation).toHaveBeenCalledTimes(1); expect(resource.renderer).toBe(renderer); expect(resource.geometries).toHaveLength(1); expect(resource.geometries[0]!.getIndex()?.count).toBe(3); expect([...resource.nodeObjects.keys()]).toEqual(["replacement-node"]); expect(resource.presentation.canonicalDocumentRef).toBe("document:replacement"); expect(originalGeometryDispose).toHaveBeenCalledTimes(1); expect(releaseFirstLease).toHaveBeenCalledTimes(1); adapter.renderPreview(resource, { height: 200, host: document.createElement("div"), pixelRatio: 1, selected: false, width: 300, }); expect(releaseFirstLease).toHaveBeenCalledTimes(1); expect(releaseSecondLease).not.toHaveBeenCalled(); expect(renderer.dispose).not.toHaveBeenCalled(); adapter.dispose(resource); adapter.dispose(resource); expect(releaseFirstLease).toHaveBeenCalledTimes(1); expect(releaseSecondLease).toHaveBeenCalledTimes(1); }); it("renders full-opacity exports and retains one renderer until final adapter disposal", async () => { const canonicalDocument = createValidModelDocument(); const renderer = createRenderer(); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer as unknown as WebGLRenderer, }); const presentation = createToolcraftModelPresentation(asset, { bounds: canonicalDocument.bounds, phase: "repairing", purpose: "export", viewport: { height: 1080, width: 1920 }, }); const releaseLease = vi.fn(); const resource: ToolcraftThreeModelRenderResource = await adapter.create( presentation, createModelRenderContext(canonicalDocument, releaseLease), ); const geometryDispose = vi.spyOn(resource.geometries[0]!, "dispose"); const materialDispose = vi.spyOn(resource.materials[0]!, "dispose"); adapter.renderExport(resource, { height: 1080, pixelRatio: 1, width: 1920, }); expect(resource.materials[0]!.opacity).toBe(1); expect(renderer.domElement.style.opacity).toBe("1"); expect(renderer.render).toHaveBeenCalled(); adapter.dispose(resource); adapter.dispose(resource); expect(geometryDispose).toHaveBeenCalledTimes(1); expect(materialDispose).toHaveBeenCalledTimes(1); expect(renderer.renderLists!.dispose).not.toHaveBeenCalled(); expect(renderer.dispose).not.toHaveBeenCalled(); adapter.disposePreparedPreview?.(); expect(renderer.renderLists!.dispose).toHaveBeenCalledTimes(1); expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(releaseLease).toHaveBeenCalledTimes(1); }); });