import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, sceneRegistry } from '@pascal-app/core' import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' afterEach(() => { sceneRegistry.clear() }) function nodeMaterial(overrides: Record = {}) { // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial: // the exporter keys off `isNodeMaterial` and reads plain PBR props. return { isNodeMaterial: true, name: 'painted', color: new THREE.Color('#cc3300'), roughness: 0.3, metalness: 0.7, transparent: false, opacity: 1, side: THREE.FrontSide, alphaTest: 0, depthWrite: true, depthTest: true, vertexColors: false, toneMapped: true, ...overrides, } as unknown as THREE.Material } function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh { const geometry = new THREE.BoxGeometry(1, 1, 1) return new THREE.Mesh(geometry, material) } describe('prepareSceneForExport', () => { test('converts NodeMaterials to classic glTF-standard materials', () => { const root = new THREE.Group() root.name = 'scene-renderer' const mesh = meshWithNodeMaterial(nodeMaterial()) root.add(mesh) const { scene } = prepareSceneForExport(root, {}) const exported = scene.children[0] as THREE.Mesh const material = exported.material as THREE.MeshStandardMaterial expect(material.isMeshStandardMaterial).toBe(true) expect(material.roughness).toBeCloseTo(0.3) expect(material.metalness).toBeCloseTo(0.7) expect(material.color.getHexString()).toBe('cc3300') }) test('shared NodeMaterial instances convert to a single shared material', () => { const root = new THREE.Group() const shared = nodeMaterial() root.add(meshWithNodeMaterial(shared), meshWithNodeMaterial(shared)) const { scene } = prepareSceneForExport(root, {}) const meshes = scene.children as THREE.Mesh[] expect(meshes[0]!.material).toBe(meshes[1]!.material) }) test('strips editor overlays that live off the scene layer', () => { const root = new THREE.Group() const realMesh = meshWithNodeMaterial(nodeMaterial()) const overlay = meshWithNodeMaterial(nodeMaterial()) overlay.layers.set(1) // OVERLAY_LAYER / EDITOR_LAYER — off scene layer 0 root.add(realMesh, overlay) const { scene } = prepareSceneForExport(root, {}) const meshes: THREE.Mesh[] = [] scene.traverse((o) => { if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh) }) expect(meshes).toHaveLength(1) }) test('neutralises an invisible hitbox root but keeps its visible children', () => { // Door/window roots are selection hitboxes: a box geometry with an invisible // material (object stays visible). Left intact it would plug the wall opening. const root = new THREE.Group() const hitbox = new THREE.Mesh( new THREE.BoxGeometry(1, 2, 0.2), new THREE.MeshBasicMaterial({ visible: false }), ) const leaf = meshWithNodeMaterial(nodeMaterial()) hitbox.add(leaf) root.add(hitbox) const doorId = 'door_hitbox' sceneRegistry.nodes.set(doorId, hitbox) const nodes: Record = { [doorId]: { object: 'node', id: doorId, type: 'door' } as unknown as AnyNode, } const { scene } = prepareSceneForExport(root, nodes) const exported = scene.getObjectByProperty('name', doorId) as THREE.Mesh expect(exported).toBeDefined() // Geometry emptied -> GLTFExporter emits a plain node, no solid block. expect(exported.geometry.getAttribute('position')).toBeUndefined() // The visible leaf survives as a child. const visibleChildren = exported.children.filter((c) => (c as THREE.Mesh).isMesh) expect(visibleChildren).toHaveLength(1) }) test('stamps identity from the scene registry and strips other userData', () => { const root = new THREE.Group() const doorGroup = new THREE.Group() const leaf = new THREE.Group() leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } leaf.add(meshWithNodeMaterial(nodeMaterial())) doorGroup.add(leaf) root.add(doorGroup) const doorId = 'door_test' sceneRegistry.nodes.set(doorId, doorGroup) const nodes: Record = { [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Front door', } as unknown as AnyNode, } const { scene } = prepareSceneForExport(root, nodes) const exportedDoor = scene.getObjectByProperty('name', doorId) expect(exportedDoor).toBeDefined() expect(exportedDoor?.userData).toEqual({ pascalId: doorId, kind: 'door', label: 'Front door', openable: true, clips: ['Front door: open'], }) // The swing-leaf marker must not survive into glTF extras. let leafMarkerSurvived = false scene.traverse((object) => { if (object.userData.pascalSwingLeaf) leafMarkerSurvived = true }) expect(leafMarkerSurvived).toBe(false) }) test('does not flag a door/window openable when no open clip bakes', () => { // A cased opening (no swing leaf) / fixed window (no operable sash) builds // no movable part, so no clip bakes and the node must not claim openable. const root = new THREE.Group() const openingGroup = new THREE.Group() openingGroup.add(meshWithNodeMaterial(nodeMaterial())) root.add(openingGroup) const openingId = 'door_opening' sceneRegistry.nodes.set(openingId, openingGroup) const nodes: Record = { [openingId]: { object: 'node', id: openingId, type: 'door', name: 'Cased opening', } as unknown as AnyNode, } const { scene, animations } = prepareSceneForExport(root, nodes) expect(animations).toHaveLength(0) const exported = scene.getObjectByProperty('name', openingId) expect(exported?.userData).toEqual({ pascalId: openingId, kind: 'door', label: 'Cased opening', }) expect(exported?.userData.openable).toBeUndefined() expect(exported?.userData.clips).toBeUndefined() }) test('keeps the zone identity node with its polygon and strips the fill mesh', () => { const root = new THREE.Group() const zoneGroup = new THREE.Group() const fill = meshWithNodeMaterial(nodeMaterial()) fill.layers.set(2) // ZONE_LAYER zoneGroup.add(fill) zoneGroup.visible = false // the editor often hides zones at export time root.add(zoneGroup) const zoneId = 'zone_living' const polygon: [number, number][] = [ [0, 0], [4, 0], [4, 3], ] sceneRegistry.nodes.set(zoneId, zoneGroup) const nodes: Record = { [zoneId]: { object: 'node', id: zoneId, type: 'zone', name: 'Living Room', polygon, color: '#ff0000', } as unknown as AnyNode, } const { scene } = prepareSceneForExport(root, nodes) const exported = scene.getObjectByProperty('name', zoneId) expect(exported).toBeDefined() // Forced visible so GLTFExporter's onlyVisible keeps the metadata node. expect(exported?.visible).toBe(true) expect(exported?.userData).toEqual({ pascalId: zoneId, kind: 'zone', label: 'Living Room', polygon, color: '#ff0000', }) // The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead). let hasMesh = false exported?.traverse((o) => { if ((o as THREE.Mesh).isMesh) hasMesh = true }) expect(hasMesh).toBe(false) }) test('bakes a swing door into an open quaternion clip', () => { const root = new THREE.Group() const doorGroup = new THREE.Group() const leaf = new THREE.Group() leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } leaf.add(meshWithNodeMaterial(nodeMaterial())) doorGroup.add(leaf) root.add(doorGroup) const doorId = 'door_swing' sceneRegistry.nodes.set(doorId, doorGroup) const nodes: Record = { [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode, } const { scene, animations } = prepareSceneForExport(root, nodes) expect(animations).toHaveLength(1) const clip = animations[0]! expect(clip.name).toBe('Door: open') expect(clip.duration).toBe(1) // Playback intent carried in extras so consumers can play once and hold. expect(clip.userData).toEqual({ loop: false }) const track = clip.tracks[0]! expect(track).toBeInstanceOf(THREE.QuaternionKeyframeTrack) expect(track.name.endsWith('.quaternion')).toBe(true) expect(Array.from(track.times)).toEqual([0, 1]) // The track must target an object that exists in the exported tree. const targetUuid = track.name.replace('.quaternion', '') const target = scene.getObjectByProperty('uuid', targetUuid) expect(target).toBeDefined() // Rest pose is closed: the first keyframe is the identity rotation. const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4)) expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0) }) })