import assert from "node:assert/strict"; import test from "node:test"; import { Mesh } from "../src/Mesh"; import { Node } from "../src/Node"; import { ArcRotateCamera } from "../src/camera/ArcRotateCamera"; import { PointLight } from "../src/lights/PointLight"; import { PBRMaterial } from "../src/materials/PBRMaterial"; import type { Texture } from "../src/materials/Texture"; import { createScene, testCore, triangle } from "./scene-helpers"; function close(actual: ArrayLike, expected: ArrayLike) { assert.equal(actual.length, expected.length); for (let index = 0; index < actual.length; index++) assert.ok( Math.abs(actual[index] - expected[index]) < 1e-5, `${actual[index]} != ${expected[index]} at ${index}`, ); } test("PBRMaterial packs factors, extensions, texture pointers, and UV transforms", async () => { const scene = await createScene(); const textures = Array.from( { length: 13 }, (_, id) => ({ id, ready: Promise.resolve() }) as unknown as Texture, ); const material = new PBRMaterial(scene, { baseColor: [0.1, 0.2, 0.3, 0.4], metallic: 0.5, roughness: 0.6, emissive: [0.7, 0.8, 0.9], normalScale: 0.75, alphaCutoff: 0.25, flags: 6, baseColorTexture: textures[0], metallicRoughnessTexture: textures[1], normalTexture: textures[2], emissiveTexture: textures[3], occlusionTexture: textures[4], transmissionTexture: textures[5], thicknessTexture: textures[6], clearcoatTexture: textures[7], iridescenceTexture: textures[8], iridescenceThicknessTexture: textures[9], anisotropyTexture: textures[10], specularTexture: textures[11], specularColorTexture: textures[12], transmission: 0.8, ior: 1.6, thickness: 0.4, attenuationDistance: 3, attenuationColor: [0.1, 0.2, 0.3], emissiveStrength: 4, clearcoat: 0.5, clearcoatRoughness: 0.6, specular: 0.7, occlusionStrength: 0.8, sheenColor: [0.9, 0.1, 0.2], sheenRoughness: 0.3, iridescence: 0.4, iridescenceIor: 1.4, iridescenceThicknessMinimum: 120, iridescenceThicknessMaximum: 380, anisotropy: 0.5, anisotropyRotation: 0.6, specularExtensionColor: [0.7, 0.8, 0.9], textureInfos: [ { offset: [0.1, 0.2], scale: [0.3, 0.4], rotation: 0.5, texCoord: 1, }, ], }); await material.ready; close( scene.array("materials").row(material.id), [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.75, 0.25, 6], ); assert.deepEqual( Array.from(scene.array("materialTextures").row(material.id)), [...Array.from({ length: 13 }, (_, index) => index + 1), 0, 0, 0], ); close( scene.array("materialExtensions").row(material.id), [ 0.8, 1.6, 0.4, 3, 0.1, 0.2, 0.3, 4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2, 0.3, 0.4, 1.4, 120, 380, 0.5, 0.6, 0, 0, 0.7, 0.8, 0.9, 0, ], ); const transforms = scene.array("materialTextureInfo").row(material.id); close(transforms.subarray(0, 8), [ 0.1, 0.2, 0.3, 0.4, Math.sin(0.5), Math.cos(0.5), 1, 0, ]); close(transforms.subarray(8, 16), [0, 0, 1, 1, 0, 1, 0, 0]); }); test("hot material setters write shared rows without rebuilding the graph", async () => { const scene = await createScene(); const material = new PBRMaterial(scene); await material.ready; const core = testCore(scene); const compiled = core.graphs.length; core.array("signals").row(0).fill(0); material.baseColor = [0.2, 0.3, 0.4, 0.5]; material.metallic = 0.6; material.roughness = 0.7; material.emissive = [0.8, 0.9, 1]; close( scene.array("materials").row(material.id), [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1, 0.5, 0], ); assert.equal(core.array("signals").row(0)[5], 1); assert.equal(core.graphs.length, compiled); }); test("Mesh packs geometry and bounds, then clones with copy-on-write", async () => { const scene = await createScene(); const firstMaterial = new PBRMaterial(scene); const secondMaterial = new PBRMaterial(scene, { baseColor: [1, 0, 0, 1] }); await Promise.all([firstMaterial.ready, secondMaterial.ready]); const mesh = new Mesh(scene, { material: firstMaterial, vertexData: triangle, }); await mesh.ready; assert.equal(mesh.vertexCount, 3); assert.equal(mesh.indexCount, 3); close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions); close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]); const clone = mesh.clone({ position: [2, 0, 0] }); await clone.ready; assert.equal(clone.geometryId, mesh.geometryId); await clone.setVertexData("positions", [0, 0, 0, 2, 0, 0, 0, 3, 0]); assert.notEqual(clone.geometryId, mesh.geometryId); close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions); close( scene.geometryData(clone.geometryId, "positions")!, [0, 0, 0, 2, 0, 0, 0, 3, 0], ); clone.material = secondMaterial; await clone.setMaterialForFaces(firstMaterial, 0); assert.equal(clone.materialId, secondMaterial.id); assert.equal(clone.faceMaterials.get(0), firstMaterial.id); assert.equal( scene.array(`mesh.${clone.id}.faceMaterials`).row(0)[0], firstMaterial.id + 1, ); }); test("Node, ArcRotateCamera, and light handles update their shared rows", async () => { const scene = await createScene(); const target = new Node(scene, { position: [1, 2, 3], scale: [2, 3, 4] }); await target.ready; target.translate([1, -1, 2]).rotateZ(Math.PI); close(target.position, [2, 1, 5]); close(target.scale, [2, 3, 4]); close(target.rotor, [0, 0, 1, 0]); const child = new Node(scene, { parent: target }); await child.ready; assert.equal(child.parentId, target.id); child.enabled = false; assert.equal(scene.array("nodes").row(child.id)[0], 0); const camera = new ArcRotateCamera(scene, { target, alpha: 0, beta: Math.PI / 2, radius: 5, }); await camera.ready; close(camera.position, [2, 1, 10]); assert.equal(scene.array("cameraMatrices").row(camera.cameraId)[19], 1); camera.radius = 3; close(camera.position, [2, 1, 8]); const light = new PointLight(scene, { position: [4, 5, 6], color: [0.2, 0.4, 0.6], intensity: 7, range: 8, radius: 2, }); await light.ready; close( scene.array("pointLights").row(light.id), [0.2, 0.4, 0.6, 7, 8, 1, 2, 0], ); light.intensity = 9; light.range = 10; light.radius = 3; close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]); assert.equal(scene.array("pointLights").row(light.id)[6], 3); }); test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => { const listeners = new Map void>>(); const element = { clientWidth: 400, clientHeight: 200, addEventListener(type: string, listener: (event: any) => void) { const registered = listeners.get(type) ?? new Set(); registered.add(listener); listeners.set(type, registered); }, removeEventListener(type: string, listener: (event: any) => void) { listeners.get(type)?.delete(listener); }, setPointerCapture() {}, } as unknown as HTMLElement; const dispatch = (type: string, event: Record) => { for (const listener of listeners.get(type) ?? []) listener({ preventDefault() {}, ...event }); }; const scene = await createScene(); const camera = new ArcRotateCamera(scene, { alpha: 0, beta: Math.PI / 2, radius: 10, fov: Math.PI / 2, aspect: 2, controls: { element }, }); await camera.ready; dispatch("pointerdown", { button: 2, clientX: 100, clientY: 100, pointerId: 1, }); dispatch("pointermove", { clientX: 120, clientY: 110 }); close(camera.position, [-2, 1, 10]); camera.detachControls(); });