A door/window only carries extras.openable + extras.clips when an open animation actually bakes. Cased openings (no leaf) and fixed windows (no operable sash) are no longer mislabelled openable, so the GLB never claims a part opens when nothing moves. /viewer is unaffected (it already required openable && clips). Adds a regression test for the no-clip case. (Export barrels reordered by the formatter.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
268 lines
9.0 KiB
TypeScript
268 lines
9.0 KiB
TypeScript
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<string, unknown> = {}) {
|
|
// 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<string, AnyNode> = {
|
|
[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<string, AnyNode> = {
|
|
[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<string, AnyNode> = {
|
|
[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<string, AnyNode> = {
|
|
[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<string, AnyNode> = {
|
|
[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)
|
|
})
|
|
})
|