@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,441 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core'
3
+ import { buildDoorPreviewMesh } from '@pascal-app/viewer'
4
+ import * as THREE from 'three'
5
+ import { prepareSceneForExport } from './glb-export'
6
+
7
+ afterEach(() => {
8
+ sceneRegistry.clear()
9
+ })
10
+
11
+ function nodeMaterial(overrides: Record<string, unknown> = {}) {
12
+ // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial:
13
+ // the exporter keys off `isNodeMaterial` and reads plain PBR props.
14
+ return {
15
+ isNodeMaterial: true,
16
+ name: 'painted',
17
+ color: new THREE.Color('#cc3300'),
18
+ roughness: 0.3,
19
+ metalness: 0.7,
20
+ transparent: false,
21
+ opacity: 1,
22
+ side: THREE.FrontSide,
23
+ alphaTest: 0,
24
+ depthWrite: true,
25
+ depthTest: true,
26
+ vertexColors: false,
27
+ toneMapped: true,
28
+ ...overrides,
29
+ } as unknown as THREE.Material
30
+ }
31
+
32
+ function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
33
+ const geometry = new THREE.BoxGeometry(1, 1, 1)
34
+ return new THREE.Mesh(geometry, material)
35
+ }
36
+
37
+ describe('prepareSceneForExport', () => {
38
+ test('converts NodeMaterials to classic glTF-standard materials', () => {
39
+ const root = new THREE.Group()
40
+ root.name = 'scene-renderer'
41
+ const mesh = meshWithNodeMaterial(nodeMaterial())
42
+ root.add(mesh)
43
+
44
+ const { scene } = prepareSceneForExport(root, {})
45
+
46
+ const exported = scene.children[0] as THREE.Mesh
47
+ const material = exported.material as THREE.MeshStandardMaterial
48
+ expect(material.isMeshStandardMaterial).toBe(true)
49
+ expect(material.roughness).toBeCloseTo(0.3)
50
+ expect(material.metalness).toBeCloseTo(0.7)
51
+ expect(material.color.getHexString()).toBe('cc3300')
52
+ })
53
+
54
+ test('shared NodeMaterial instances convert to a single shared material', () => {
55
+ const root = new THREE.Group()
56
+ const shared = nodeMaterial()
57
+ root.add(meshWithNodeMaterial(shared), meshWithNodeMaterial(shared))
58
+
59
+ const { scene } = prepareSceneForExport(root, {})
60
+
61
+ const meshes = scene.children as THREE.Mesh[]
62
+ expect(meshes[0]!.material).toBe(meshes[1]!.material)
63
+ })
64
+
65
+ test('strips editor overlays that live off the scene layer', () => {
66
+ const root = new THREE.Group()
67
+ const realMesh = meshWithNodeMaterial(nodeMaterial())
68
+ const overlay = meshWithNodeMaterial(nodeMaterial())
69
+ overlay.layers.set(1) // OVERLAY_LAYER / EDITOR_LAYER — off scene layer 0
70
+ root.add(realMesh, overlay)
71
+
72
+ const { scene } = prepareSceneForExport(root, {})
73
+
74
+ const meshes: THREE.Mesh[] = []
75
+ scene.traverse((o) => {
76
+ if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh)
77
+ })
78
+ expect(meshes).toHaveLength(1)
79
+ })
80
+
81
+ test('neutralises an invisible hitbox root but keeps its visible children', () => {
82
+ // Door/window roots are selection hitboxes: a box geometry with an invisible
83
+ // material (object stays visible). Left intact it would plug the wall opening.
84
+ const root = new THREE.Group()
85
+ const hitbox = new THREE.Mesh(
86
+ new THREE.BoxGeometry(1, 2, 0.2),
87
+ new THREE.MeshBasicMaterial({ visible: false }),
88
+ )
89
+ const leaf = meshWithNodeMaterial(nodeMaterial())
90
+ hitbox.add(leaf)
91
+ root.add(hitbox)
92
+
93
+ const doorId = 'door_hitbox'
94
+ sceneRegistry.nodes.set(doorId, hitbox)
95
+ const nodes: Record<string, AnyNode> = {
96
+ [doorId]: { object: 'node', id: doorId, type: 'door' } as unknown as AnyNode,
97
+ }
98
+
99
+ const { scene } = prepareSceneForExport(root, nodes)
100
+
101
+ const exported = scene.getObjectByProperty('name', doorId) as THREE.Mesh
102
+ expect(exported).toBeDefined()
103
+ // Geometry emptied -> GLTFExporter emits a plain node, no solid block.
104
+ expect(exported.geometry.getAttribute('position')).toBeUndefined()
105
+ // The visible leaf survives as a child.
106
+ const visibleChildren = exported.children.filter((c) => (c as THREE.Mesh).isMesh)
107
+ expect(visibleChildren).toHaveLength(1)
108
+ })
109
+
110
+ test('stamps identity from the scene registry and strips other userData', () => {
111
+ const root = new THREE.Group()
112
+ const doorGroup = new THREE.Group()
113
+ const leaf = new THREE.Group()
114
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
115
+ leaf.add(meshWithNodeMaterial(nodeMaterial()))
116
+ doorGroup.add(leaf)
117
+ root.add(doorGroup)
118
+
119
+ const doorId = 'door_test'
120
+ sceneRegistry.nodes.set(doorId, doorGroup)
121
+ const nodes: Record<string, AnyNode> = {
122
+ [doorId]: {
123
+ object: 'node',
124
+ id: doorId,
125
+ type: 'door',
126
+ name: 'Front door',
127
+ } as unknown as AnyNode,
128
+ }
129
+
130
+ const { scene } = prepareSceneForExport(root, nodes)
131
+
132
+ const exportedDoor = scene.getObjectByProperty('name', doorId)
133
+ expect(exportedDoor).toBeDefined()
134
+ expect(exportedDoor?.userData).toEqual({
135
+ pascalId: doorId,
136
+ kind: 'door',
137
+ label: 'Front door',
138
+ openable: true,
139
+ clips: ['door_test: open'],
140
+ })
141
+
142
+ // The swing-leaf marker must not survive into glTF extras.
143
+ let leafMarkerSurvived = false
144
+ scene.traverse((object) => {
145
+ if (object.userData.pascalSwingLeaf) leafMarkerSurvived = true
146
+ })
147
+ expect(leafMarkerSurvived).toBe(false)
148
+ })
149
+
150
+ test('does not flag a door/window openable when no open clip bakes', () => {
151
+ // A cased opening (no swing leaf) / fixed window (no operable sash) builds
152
+ // no movable part, so no clip bakes and the node must not claim openable.
153
+ const root = new THREE.Group()
154
+ const openingGroup = new THREE.Group()
155
+ openingGroup.add(meshWithNodeMaterial(nodeMaterial()))
156
+ root.add(openingGroup)
157
+
158
+ const openingId = 'door_opening'
159
+ sceneRegistry.nodes.set(openingId, openingGroup)
160
+ const nodes: Record<string, AnyNode> = {
161
+ [openingId]: {
162
+ object: 'node',
163
+ id: openingId,
164
+ type: 'door',
165
+ name: 'Cased opening',
166
+ } as unknown as AnyNode,
167
+ }
168
+
169
+ const { scene, animations } = prepareSceneForExport(root, nodes)
170
+
171
+ expect(animations).toHaveLength(0)
172
+ const exported = scene.getObjectByProperty('name', openingId)
173
+ expect(exported?.userData).toEqual({
174
+ pascalId: openingId,
175
+ kind: 'door',
176
+ label: 'Cased opening',
177
+ })
178
+ expect(exported?.userData.openable).toBeUndefined()
179
+ expect(exported?.userData.clips).toBeUndefined()
180
+ })
181
+
182
+ test('keeps the zone identity node with its polygon and strips the fill mesh', () => {
183
+ const root = new THREE.Group()
184
+ const zoneGroup = new THREE.Group()
185
+ const fill = meshWithNodeMaterial(nodeMaterial())
186
+ fill.layers.set(2) // ZONE_LAYER
187
+ zoneGroup.add(fill)
188
+ zoneGroup.visible = false // the editor often hides zones at export time
189
+ root.add(zoneGroup)
190
+
191
+ const zoneId = 'zone_living'
192
+ const polygon: [number, number][] = [
193
+ [0, 0],
194
+ [4, 0],
195
+ [4, 3],
196
+ ]
197
+ sceneRegistry.nodes.set(zoneId, zoneGroup)
198
+ const nodes: Record<string, AnyNode> = {
199
+ [zoneId]: {
200
+ object: 'node',
201
+ id: zoneId,
202
+ type: 'zone',
203
+ name: 'Living Room',
204
+ polygon,
205
+ color: '#ff0000',
206
+ } as unknown as AnyNode,
207
+ }
208
+
209
+ const { scene } = prepareSceneForExport(root, nodes)
210
+
211
+ const exported = scene.getObjectByProperty('name', zoneId)
212
+ expect(exported).toBeDefined()
213
+ // Forced visible so GLTFExporter's onlyVisible keeps the metadata node.
214
+ expect(exported?.visible).toBe(true)
215
+ expect(exported?.userData).toEqual({
216
+ pascalId: zoneId,
217
+ kind: 'zone',
218
+ label: 'Living Room',
219
+ polygon,
220
+ color: '#ff0000',
221
+ })
222
+ // The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead).
223
+ let hasMesh = false
224
+ exported?.traverse((o) => {
225
+ if ((o as THREE.Mesh).isMesh) hasMesh = true
226
+ })
227
+ expect(hasMesh).toBe(false)
228
+ })
229
+
230
+ test('bakes a swing door into an open quaternion clip', () => {
231
+ const root = new THREE.Group()
232
+ const doorGroup = new THREE.Group()
233
+ const leaf = new THREE.Group()
234
+ leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
235
+ leaf.add(meshWithNodeMaterial(nodeMaterial()))
236
+ doorGroup.add(leaf)
237
+ root.add(doorGroup)
238
+
239
+ const doorId = 'door_swing'
240
+ sceneRegistry.nodes.set(doorId, doorGroup)
241
+ const nodes: Record<string, AnyNode> = {
242
+ [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode,
243
+ }
244
+
245
+ const { scene, animations } = prepareSceneForExport(root, nodes)
246
+
247
+ expect(animations).toHaveLength(1)
248
+ const clip = animations[0]!
249
+ expect(clip.name).toBe('door_swing: open')
250
+ expect(clip.duration).toBe(1)
251
+ // Playback intent carried in extras so consumers can play once and hold.
252
+ expect(clip.userData).toEqual({ loop: false })
253
+
254
+ const track = clip.tracks[0]!
255
+ expect(track).toBeInstanceOf(THREE.QuaternionKeyframeTrack)
256
+ expect(track.name.endsWith('.quaternion')).toBe(true)
257
+ expect(Array.from(track.times)).toEqual([0, 1])
258
+
259
+ // The track must target an object that exists in the exported tree.
260
+ const targetUuid = track.name.replace('.quaternion', '')
261
+ const target = scene.getObjectByProperty('uuid', targetUuid)
262
+ expect(target).toBeDefined()
263
+
264
+ // Rest pose is closed: the first keyframe is the identity rotation.
265
+ const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4))
266
+ expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
267
+ })
268
+
269
+ test('bakes registry-owned open clips and stamps the node openable', () => {
270
+ const root = new THREE.Group()
271
+ const nodeGroup = new THREE.Group()
272
+ const movingPart = new THREE.Group()
273
+ movingPart.add(meshWithNodeMaterial(nodeMaterial()))
274
+ nodeGroup.add(movingPart)
275
+ root.add(nodeGroup)
276
+
277
+ const kind = `test-openable-${crypto.randomUUID()}`
278
+ const nodeId = 'registry_openable'
279
+ registerNode({
280
+ kind,
281
+ schemaVersion: 1,
282
+ category: 'fixtures',
283
+ defaults: () => ({}),
284
+ capabilities: {},
285
+ exportAnimation: ({ node, object }: { node: AnyNode; object: THREE.Object3D }) => {
286
+ const target = object.children[0]!
287
+ const clip = new THREE.AnimationClip(`${node.id}: open`, 1, [
288
+ new THREE.VectorKeyframeTrack(`${target.uuid}.position`, [0, 1], [0, 0, 0, 0, 0, 1]),
289
+ ])
290
+ clip.userData = { loop: false }
291
+ return clip
292
+ },
293
+ } as never)
294
+ sceneRegistry.nodes.set(nodeId, nodeGroup)
295
+
296
+ const { scene, animations } = prepareSceneForExport(root, {
297
+ [nodeId]: {
298
+ object: 'node',
299
+ id: nodeId,
300
+ type: kind,
301
+ name: 'Custom openable',
302
+ } as unknown as AnyNode,
303
+ })
304
+
305
+ expect(animations).toHaveLength(1)
306
+ expect(animations[0]!.name).toBe('registry_openable: open')
307
+ const exported = scene.getObjectByProperty('name', nodeId)
308
+ expect(exported?.userData).toMatchObject({
309
+ pascalId: nodeId,
310
+ kind,
311
+ label: 'Custom openable',
312
+ openable: true,
313
+ clips: ['registry_openable: open'],
314
+ })
315
+ })
316
+
317
+ test('bakes a sliding door into a sampled position clip', () => {
318
+ // Operation doors build their moving parts in a named group posed by
319
+ // `poseDoorMovingParts`; the exporter samples it into keyframes. The active
320
+ // panel group slides along x.
321
+ const root = new THREE.Group()
322
+ const doorGroup = new THREE.Group()
323
+ const activePanel = new THREE.Group()
324
+ activePanel.name = 'door-sliding-active'
325
+ activePanel.add(meshWithNodeMaterial(nodeMaterial()))
326
+ doorGroup.add(activePanel)
327
+ root.add(doorGroup)
328
+
329
+ const doorId = 'door_sliding'
330
+ sceneRegistry.nodes.set(doorId, doorGroup)
331
+ const nodes: Record<string, AnyNode> = {
332
+ [doorId]: {
333
+ object: 'node',
334
+ id: doorId,
335
+ type: 'door',
336
+ name: 'Slider',
337
+ doorType: 'sliding',
338
+ slideDirection: 'left',
339
+ width: 1,
340
+ height: 2.1,
341
+ frameThickness: 0.05,
342
+ } as unknown as AnyNode,
343
+ }
344
+
345
+ const { scene, animations } = prepareSceneForExport(root, nodes)
346
+
347
+ expect(animations).toHaveLength(1)
348
+ const clip = animations[0]!
349
+ expect(clip.name).toBe('door_sliding: open')
350
+ expect(clip.duration).toBe(1)
351
+ expect(clip.userData).toEqual({ loop: false })
352
+
353
+ const track = clip.tracks[0]!
354
+ expect(track).toBeInstanceOf(THREE.VectorKeyframeTrack)
355
+ expect(track.name.endsWith('.position')).toBe(true)
356
+ // 16 segments -> 17 keyframes, evenly spaced over the 1s clip.
357
+ expect(track.times.length).toBe(17)
358
+ expect(track.times[0]).toBeCloseTo(0)
359
+ expect(track.times[track.times.length - 1]!).toBeCloseTo(1)
360
+
361
+ // Rest pose is closed (first keyframe centred); the panel slides off-centre.
362
+ expect(track.values[0]!).toBeCloseTo(0)
363
+ expect(track.values[1]!).toBeCloseTo(0)
364
+ expect(track.values[2]!).toBeCloseTo(0)
365
+ const lastX = track.values[track.values.length - 3]!
366
+ expect(Math.abs(lastX)).toBeGreaterThan(0.1)
367
+
368
+ const target = scene.getObjectByProperty('uuid', track.name.replace('.position', ''))
369
+ expect(target).toBeDefined()
370
+
371
+ const exported = scene.getObjectByProperty('name', doorId)
372
+ expect(exported?.userData.openable).toBe(true)
373
+ expect(exported?.userData.clips).toEqual(['door_sliding: open'])
374
+ })
375
+
376
+ test('bakes a roll-up curtain into a sampled scale clip', () => {
377
+ // Roll-up geometry can't vanish in a glTF clip, so the bake scales the
378
+ // curtain group up into the lintel instead.
379
+ const root = new THREE.Group()
380
+ const doorGroup = new THREE.Group()
381
+ const curtain = new THREE.Group()
382
+ curtain.name = 'door-rollup-curtain'
383
+ curtain.add(meshWithNodeMaterial(nodeMaterial()))
384
+ doorGroup.add(curtain)
385
+ root.add(doorGroup)
386
+
387
+ const doorId = 'door_rollup'
388
+ sceneRegistry.nodes.set(doorId, doorGroup)
389
+ const nodes: Record<string, AnyNode> = {
390
+ [doorId]: {
391
+ object: 'node',
392
+ id: doorId,
393
+ type: 'door',
394
+ name: 'Roll-up',
395
+ doorType: 'garage-rollup',
396
+ width: 2.4,
397
+ height: 2.2,
398
+ frameThickness: 0.05,
399
+ } as unknown as AnyNode,
400
+ }
401
+
402
+ const { animations } = prepareSceneForExport(root, nodes)
403
+
404
+ expect(animations).toHaveLength(1)
405
+ const scaleTrack = animations[0]!.tracks.find((t) => t.name.endsWith('.scale'))
406
+ expect(scaleTrack).toBeInstanceOf(THREE.VectorKeyframeTrack)
407
+ // Rest pose is closed (full curtain, scale 1); it shrinks toward the header.
408
+ expect(Array.from(scaleTrack!.values).slice(0, 3)).toEqual([1, 1, 1])
409
+ const lastScaleY = scaleTrack!.values[scaleTrack!.values.length - 2]!
410
+ expect(lastScaleY).toBeLessThan(0.1)
411
+ })
412
+
413
+ // Regression: a folding door saved in an open state (|fold angle| > π/2) used
414
+ // to bake a 180°-flipped rest pose. The export clones + decomposes the door
415
+ // matrix, which re-derives a gimbal-flipped euler (x=z=π) for the wide Y
416
+ // rotation; the pose reset must zero the full euler triple, not just `.y`.
417
+ test('bakes an identity rest pose for an open folding door', () => {
418
+ const node = DoorNode.parse({
419
+ id: 'door_folding',
420
+ doorType: 'folding',
421
+ leafCount: 4,
422
+ operationState: 0.65,
423
+ })
424
+ const mesh = buildDoorPreviewMesh(node)
425
+ const root = new THREE.Group()
426
+ root.add(mesh)
427
+ sceneRegistry.nodes.set(node.id, mesh)
428
+
429
+ const { scene, animations } = prepareSceneForExport(root, {
430
+ [node.id]: node as unknown as AnyNode,
431
+ })
432
+
433
+ expect(animations).toHaveLength(1)
434
+ for (let index = 0; index < 4; index++) {
435
+ const panel = scene.getObjectByName(`door-fold-${index}`)
436
+ expect(panel).toBeDefined()
437
+ // Rest quaternion must be identity — no residual π on any axis.
438
+ expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4)
439
+ }
440
+ })
441
+ })