@pascal-app/viewer 0.8.0 → 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 (284) hide show
  1. package/dist/components/error-boundary.d.ts +6 -0
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +6 -0
  4. package/dist/components/renderers/node-renderer.d.ts +6 -2
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +41 -22
  7. package/dist/components/renderers/parametric-node-renderer.d.ts +5 -0
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -0
  9. package/dist/components/renderers/parametric-node-renderer.js +37 -0
  10. package/dist/components/renderers/scene-renderer.d.ts +1 -1
  11. package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
  12. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  13. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  14. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  15. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  16. package/dist/components/viewer/frame-limiter.js +39 -4
  17. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  18. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  19. package/dist/components/viewer/glb-interactive.js +394 -0
  20. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  21. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  23. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  24. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  25. package/dist/components/viewer/glb-replace-instances.js +61 -0
  26. package/dist/components/viewer/glb-scene.d.ts +54 -0
  27. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  28. package/dist/components/viewer/glb-scene.js +920 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
  30. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  31. package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
  32. package/dist/components/viewer/index.d.ts +56 -3
  33. package/dist/components/viewer/index.d.ts.map +1 -1
  34. package/dist/components/viewer/index.js +280 -65
  35. package/dist/components/viewer/lights.d.ts +1 -1
  36. package/dist/components/viewer/lights.d.ts.map +1 -1
  37. package/dist/components/viewer/lights.js +201 -43
  38. package/dist/components/viewer/perf-monitor.d.ts +1 -1
  39. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  40. package/dist/components/viewer/perf-monitor.js +115 -12
  41. package/dist/components/viewer/post-processing.d.ts +7 -1
  42. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  43. package/dist/components/viewer/post-processing.js +395 -63
  44. package/dist/components/viewer/registered-systems.d.ts +12 -0
  45. package/dist/components/viewer/registered-systems.d.ts.map +1 -0
  46. package/dist/components/viewer/registered-systems.js +49 -0
  47. package/dist/components/viewer/scene-bvh.d.ts +18 -0
  48. package/dist/components/viewer/scene-bvh.d.ts.map +1 -0
  49. package/dist/components/viewer/scene-bvh.js +97 -0
  50. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  51. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  52. package/dist/components/viewer/scene-bvh.test.js +13 -0
  53. package/dist/components/viewer/scene-environment.d.ts +2 -0
  54. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  55. package/dist/components/viewer/scene-environment.js +79 -0
  56. package/dist/components/viewer/selection-manager.d.ts +1 -1
  57. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  58. package/dist/components/viewer/selection-manager.js +37 -14
  59. package/dist/components/viewer/viewer-camera.d.ts +1 -1
  60. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  61. package/dist/components/viewer/walkthrough-controls.d.ts +2 -1
  62. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  63. package/dist/components/viewer/walkthrough-controls.js +20 -0
  64. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  65. package/dist/hooks/use-gltf-ktx2.js +3 -22
  66. package/dist/hooks/use-node-events.d.ts +6 -74
  67. package/dist/hooks/use-node-events.d.ts.map +1 -1
  68. package/dist/hooks/use-node-events.js +28 -8
  69. package/dist/index.d.ts +48 -5
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +82 -4
  72. package/dist/lib/backdrop.d.ts +33 -0
  73. package/dist/lib/backdrop.d.ts.map +1 -0
  74. package/dist/lib/backdrop.js +92 -0
  75. package/dist/lib/box-uv.d.ts +13 -0
  76. package/dist/lib/box-uv.d.ts.map +1 -0
  77. package/dist/lib/box-uv.js +32 -0
  78. package/dist/lib/csg-utils.d.ts +10 -0
  79. package/dist/lib/csg-utils.d.ts.map +1 -0
  80. package/dist/lib/csg-utils.js +93 -0
  81. package/dist/lib/csg-utils.test.d.ts +2 -0
  82. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  83. package/dist/lib/csg-utils.test.js +45 -0
  84. package/dist/lib/drawable-geometry.d.ts +21 -0
  85. package/dist/lib/drawable-geometry.d.ts.map +1 -0
  86. package/dist/lib/drawable-geometry.js +22 -0
  87. package/dist/lib/edge-style.d.ts +4 -0
  88. package/dist/lib/edge-style.d.ts.map +1 -0
  89. package/dist/lib/edge-style.js +31 -0
  90. package/dist/lib/gpu-perf.d.ts +4 -0
  91. package/dist/lib/gpu-perf.d.ts.map +1 -0
  92. package/dist/lib/gpu-perf.js +23 -0
  93. package/dist/lib/ink-edges.d.ts +9 -0
  94. package/dist/lib/ink-edges.d.ts.map +1 -0
  95. package/dist/lib/ink-edges.js +49 -0
  96. package/dist/lib/isolation.d.ts +39 -0
  97. package/dist/lib/isolation.d.ts.map +1 -0
  98. package/dist/lib/isolation.js +103 -0
  99. package/dist/lib/ktx2-loader.d.ts +43 -0
  100. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  101. package/dist/lib/ktx2-loader.js +136 -0
  102. package/dist/lib/layers.d.ts +28 -0
  103. package/dist/lib/layers.d.ts.map +1 -1
  104. package/dist/lib/layers.js +28 -0
  105. package/dist/lib/materials.d.ts +38 -15
  106. package/dist/lib/materials.d.ts.map +1 -1
  107. package/dist/lib/materials.js +364 -72
  108. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  109. package/dist/lib/merged-outline-node.js +7 -0
  110. package/dist/lib/polygon-union.d.ts +4 -0
  111. package/dist/lib/polygon-union.d.ts.map +1 -0
  112. package/dist/lib/polygon-union.js +289 -0
  113. package/dist/lib/polygon-union.test.d.ts +2 -0
  114. package/dist/lib/polygon-union.test.d.ts.map +1 -0
  115. package/dist/lib/polygon-union.test.js +105 -0
  116. package/dist/lib/scene-themes.d.ts +30 -0
  117. package/dist/lib/scene-themes.d.ts.map +1 -0
  118. package/dist/lib/scene-themes.js +202 -0
  119. package/dist/lib/shadow-only.d.ts +4 -0
  120. package/dist/lib/shadow-only.d.ts.map +1 -0
  121. package/dist/lib/shadow-only.js +36 -0
  122. package/dist/lib/tsl-compat.d.ts +12 -0
  123. package/dist/lib/tsl-compat.d.ts.map +1 -0
  124. package/dist/lib/tsl-compat.js +13 -0
  125. package/dist/store/use-viewer.d.ts +92 -12
  126. package/dist/store/use-viewer.d.ts.map +1 -1
  127. package/dist/store/use-viewer.js +201 -6
  128. package/dist/store/use-viewer.test.d.ts +2 -0
  129. package/dist/store/use-viewer.test.d.ts.map +1 -0
  130. package/dist/store/use-viewer.test.js +42 -0
  131. package/dist/systems/ceiling/ceiling-system.d.ts +6 -2
  132. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  133. package/dist/systems/ceiling/ceiling-system.js +66 -13
  134. package/dist/systems/door/door-animation.test.d.ts +2 -0
  135. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  136. package/dist/systems/door/door-animation.test.js +51 -0
  137. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  138. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  139. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  140. package/dist/systems/door/door-system.d.ts +24 -0
  141. package/dist/systems/door/door-system.d.ts.map +1 -1
  142. package/dist/systems/door/door-system.js +864 -180
  143. package/dist/systems/elevator/elevator-interaction-system.d.ts +2 -0
  144. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -0
  145. package/dist/systems/elevator/elevator-interaction-system.js +62 -0
  146. package/dist/systems/fence/fence-system.d.ts +20 -0
  147. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  148. package/dist/systems/fence/fence-system.js +229 -57
  149. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +20 -0
  150. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -0
  151. package/dist/systems/floor-elevation/floor-elevation-system.js +78 -0
  152. package/dist/systems/geometry/geometry-system.d.ts +41 -0
  153. package/dist/systems/geometry/geometry-system.d.ts.map +1 -0
  154. package/dist/systems/geometry/geometry-system.js +348 -0
  155. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  156. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  157. package/dist/systems/geometry/geometry-system.test.js +30 -0
  158. package/dist/systems/interactive/control-widget.d.ts +10 -0
  159. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  160. package/dist/systems/interactive/control-widget.js +35 -0
  161. package/dist/systems/interactive/interactive-system.d.ts +1 -1
  162. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  163. package/dist/systems/interactive/interactive-system.js +55 -56
  164. package/dist/systems/item/item-system.d.ts +7 -0
  165. package/dist/systems/item/item-system.d.ts.map +1 -1
  166. package/dist/systems/item/item-system.js +28 -17
  167. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  168. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  169. package/dist/systems/item-light/item-light-system.js +18 -5
  170. package/dist/systems/level/level-system.d.ts.map +1 -1
  171. package/dist/systems/level/level-system.js +28 -4
  172. package/dist/systems/level/level-utils.d.ts +0 -3
  173. package/dist/systems/level/level-utils.d.ts.map +1 -1
  174. package/dist/systems/level/level-utils.js +2 -41
  175. package/dist/systems/roof/roof-materials.d.ts +3 -2
  176. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  177. package/dist/systems/roof/roof-materials.js +59 -14
  178. package/dist/systems/roof/roof-system.d.ts +46 -6
  179. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  180. package/dist/systems/roof/roof-system.js +1365 -170
  181. package/dist/systems/slab/slab-system.d.ts +5 -3
  182. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  183. package/dist/systems/slab/slab-system.js +204 -68
  184. package/dist/systems/slab/slab-system.test.d.ts +2 -0
  185. package/dist/systems/slab/slab-system.test.d.ts.map +1 -0
  186. package/dist/systems/slab/slab-system.test.js +63 -0
  187. package/dist/systems/stair/stair-materials.d.ts +4 -3
  188. package/dist/systems/stair/stair-materials.d.ts.map +1 -1
  189. package/dist/systems/stair/stair-materials.js +30 -15
  190. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  191. package/dist/systems/stair/stair-system.js +55 -63
  192. package/dist/systems/surface-hole-geometry.d.ts +3 -0
  193. package/dist/systems/surface-hole-geometry.d.ts.map +1 -0
  194. package/dist/systems/surface-hole-geometry.js +4 -0
  195. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  196. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  197. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  198. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  199. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  200. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  201. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  202. package/dist/systems/wall/wall-cutout.js +62 -17
  203. package/dist/systems/wall/wall-materials.d.ts +12 -7
  204. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  205. package/dist/systems/wall/wall-materials.js +279 -46
  206. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  207. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  208. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  209. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  210. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  211. package/dist/systems/wall/wall-support-extension.test.js +48 -0
  212. package/dist/systems/wall/wall-system.d.ts +2 -2
  213. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  214. package/dist/systems/wall/wall-system.js +493 -193
  215. package/dist/systems/window/window-animation-system.d.ts +12 -0
  216. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  217. package/dist/systems/window/window-animation-system.js +16 -5
  218. package/dist/systems/window/window-system.d.ts +7 -0
  219. package/dist/systems/window/window-system.d.ts.map +1 -1
  220. package/dist/systems/window/window-system.js +308 -18
  221. package/package.json +5 -6
  222. package/dist/components/renderers/building/building-renderer.d.ts +0 -5
  223. package/dist/components/renderers/building/building-renderer.d.ts.map +0 -1
  224. package/dist/components/renderers/building/building-renderer.js +0 -11
  225. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts +0 -5
  226. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +0 -1
  227. package/dist/components/renderers/ceiling/ceiling-renderer.js +0 -58
  228. package/dist/components/renderers/column/column-renderer.d.ts +0 -5
  229. package/dist/components/renderers/column/column-renderer.d.ts.map +0 -1
  230. package/dist/components/renderers/column/column-renderer.js +0 -597
  231. package/dist/components/renderers/door/door-renderer.d.ts +0 -5
  232. package/dist/components/renderers/door/door-renderer.d.ts.map +0 -1
  233. package/dist/components/renderers/door/door-renderer.js +0 -16
  234. package/dist/components/renderers/fence/fence-renderer.d.ts +0 -5
  235. package/dist/components/renderers/fence/fence-renderer.d.ts.map +0 -1
  236. package/dist/components/renderers/fence/fence-renderer.js +0 -15
  237. package/dist/components/renderers/guide/guide-renderer.d.ts +0 -5
  238. package/dist/components/renderers/guide/guide-renderer.d.ts.map +0 -1
  239. package/dist/components/renderers/guide/guide-renderer.js +0 -38
  240. package/dist/components/renderers/item/item-renderer.d.ts +0 -5
  241. package/dist/components/renderers/item/item-renderer.d.ts.map +0 -1
  242. package/dist/components/renderers/item/item-renderer.js +0 -172
  243. package/dist/components/renderers/level/level-renderer.d.ts +0 -5
  244. package/dist/components/renderers/level/level-renderer.d.ts.map +0 -1
  245. package/dist/components/renderers/level/level-renderer.js +0 -11
  246. package/dist/components/renderers/roof/roof-materials.d.ts +0 -4
  247. package/dist/components/renderers/roof/roof-materials.d.ts.map +0 -1
  248. package/dist/components/renderers/roof/roof-materials.js +0 -16
  249. package/dist/components/renderers/roof/roof-renderer.d.ts +0 -5
  250. package/dist/components/renderers/roof/roof-renderer.d.ts.map +0 -1
  251. package/dist/components/renderers/roof/roof-renderer.js +0 -32
  252. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +0 -5
  253. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +0 -1
  254. package/dist/components/renderers/roof-segment/roof-segment-renderer.js +0 -40
  255. package/dist/components/renderers/scan/scan-renderer.d.ts +0 -5
  256. package/dist/components/renderers/scan/scan-renderer.d.ts.map +0 -1
  257. package/dist/components/renderers/scan/scan-renderer.js +0 -54
  258. package/dist/components/renderers/site/site-renderer.d.ts +0 -5
  259. package/dist/components/renderers/site/site-renderer.d.ts.map +0 -1
  260. package/dist/components/renderers/site/site-renderer.js +0 -106
  261. package/dist/components/renderers/slab/slab-renderer.d.ts +0 -5
  262. package/dist/components/renderers/slab/slab-renderer.d.ts.map +0 -1
  263. package/dist/components/renderers/slab/slab-renderer.js +0 -58
  264. package/dist/components/renderers/spawn/spawn-renderer.d.ts +0 -5
  265. package/dist/components/renderers/spawn/spawn-renderer.d.ts.map +0 -1
  266. package/dist/components/renderers/spawn/spawn-renderer.js +0 -32
  267. package/dist/components/renderers/stair/stair-renderer.d.ts +0 -5
  268. package/dist/components/renderers/stair/stair-renderer.d.ts.map +0 -1
  269. package/dist/components/renderers/stair/stair-renderer.js +0 -603
  270. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts +0 -5
  271. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts.map +0 -1
  272. package/dist/components/renderers/stair-segment/stair-segment-renderer.js +0 -51
  273. package/dist/components/renderers/wall/wall-renderer.d.ts +0 -5
  274. package/dist/components/renderers/wall/wall-renderer.d.ts.map +0 -1
  275. package/dist/components/renderers/wall/wall-renderer.js +0 -16
  276. package/dist/components/renderers/window/window-renderer.d.ts +0 -5
  277. package/dist/components/renderers/window/window-renderer.d.ts.map +0 -1
  278. package/dist/components/renderers/window/window-renderer.js +0 -21
  279. package/dist/components/renderers/zone/zone-renderer.d.ts +0 -5
  280. package/dist/components/renderers/zone/zone-renderer.d.ts.map +0 -1
  281. package/dist/components/renderers/zone/zone-renderer.js +0 -181
  282. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  283. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  284. package/dist/components/viewer/ground-occluder.js +0 -79
@@ -1,49 +1,227 @@
1
- import { clampDoorOperationState, getDoorRenderOpenAmount, sceneRegistry, useInteractive, useScene, } from '@pascal-app/core';
1
+ import { clampDoorOperationState, DEFAULT_WALL_THICKNESS, DoorNode as DoorNodeSchema, getDoorRenderOpenAmount, getEffectiveNode, getWallThickness, sceneRegistry, useInteractive, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
+ import { useEffect, useRef } from 'react';
3
4
  import * as THREE from 'three';
4
- import { baseMaterial, glassMaterial } from '../../lib/materials';
5
+ import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
6
+ import { createDefaultMaterial, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, resolveMaterialRef, } from '../../lib/materials';
7
+ import useViewer from '../../store/use-viewer';
8
+ import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry';
5
9
  // Invisible material for root mesh — used as selection hitbox only
6
10
  const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false });
7
- const revealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' });
11
+ const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' });
12
+ // Door hardware (handle / hinges / closer / panic bar) renders a catalog metal
13
+ // finish by default (chrome), separate from the door body. The flat material is
14
+ // only a fallback if the catalog ref ever fails to resolve.
15
+ const HARDWARE_DEFAULT_REF = 'library:metal-chrome';
16
+ // Door body defaults to a catalog colour (generic approach). Glass keeps the
17
+ // built-in FrontSide glass material — the catalog `preset-glass` is DoubleSide,
18
+ // which poisons the WebGPU MRT scene pass.
19
+ const PANEL_DEFAULT_REF = 'library:preset-softwhite';
20
+ const FRAME_DEFAULT_REF = 'library:preset-softwhite';
21
+ const GLASS_DEFAULT_REF = 'library:preset-glass';
22
+ const defaultHardwareMaterial = createDefaultMaterial('#3a3a3a', 0.4);
23
+ let baseMaterial = getBaseMaterial();
24
+ let frameMaterial = getBaseMaterial();
25
+ let revealMaterial = defaultRevealMaterial;
26
+ let glassMaterial = defaultGlassMaterial;
27
+ let hardwareMaterial = defaultHardwareMaterial;
28
+ let currentDoorSlot;
29
+ // Per-frame viewer state, captured so the per-node mesh builder (which runs
30
+ // outside React) can resolve each door's slot materials.
31
+ let currentShading = 'rendered';
32
+ let currentTextures = true;
33
+ let currentColorPreset = 'clay';
34
+ let currentSceneMaterials;
35
+ const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' });
36
+ const MAX_DOOR_REBUILDS_PER_FRAME = 16;
37
+ const DOOR_PROGRESSIVE_DIRTY_THRESHOLD = MAX_DOOR_REBUILDS_PER_FRAME;
38
+ const DOOR_PROGRESSIVE_TIME_BUDGET_MS = 8;
39
+ // Legacy/unparsed door nodes can miss schema-defaulted fields (segments,
40
+ // columnRatios, dividerThickness, …) and crash the geometry build. Re-apply the
41
+ // Zod defaults; if the node is structurally invalid (e.g. a segment missing a
42
+ // required field) drop the bad segments, then fall back to defaults entirely.
43
+ function normalizeDoorNodeForRender(node) {
44
+ const parsed = DoorNodeSchema.safeParse(node);
45
+ if (parsed.success)
46
+ return parsed.data;
47
+ const retry = DoorNodeSchema.safeParse({ ...node, segments: undefined });
48
+ if (retry.success)
49
+ return retry.data;
50
+ return { ...DOOR_RENDER_DEFAULTS, id: node.id, parentId: node.parentId };
51
+ }
8
52
  export const DoorSystem = () => {
9
53
  const dirtyNodes = useScene((state) => state.dirtyNodes);
10
54
  const clearDirty = useScene((state) => state.clearDirty);
55
+ const shading = useViewer((state) => state.shading);
56
+ const textures = useViewer((state) => state.textures);
57
+ const colorPreset = useViewer((state) => state.colorPreset);
58
+ const sceneMaterials = useScene((state) => state.materials);
59
+ const materialRevisionRef = useRef(null);
60
+ // Subscribe so an override-only update (no scene write) still re-runs
61
+ // the component, letting the gate below pick up the latest dirtyNodes
62
+ // set from the same render pass that received the override-publishing
63
+ // `markDirty` call. Mirrors WallSystem.
64
+ useLiveNodeOverrides((s) => s.overrides);
65
+ const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset);
66
+ baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial;
67
+ frameMaterial = textures ? getBaseMaterial(shading) : joineryMaterial;
68
+ revealMaterial = textures ? defaultRevealMaterial : joineryMaterial;
69
+ glassMaterial = textures ? defaultGlassMaterial : joineryMaterial;
70
+ hardwareMaterial = textures ? defaultHardwareMaterial : joineryMaterial;
71
+ useEffect(() => {
72
+ const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`;
73
+ if (materialRevisionRef.current === materialRevision)
74
+ return;
75
+ materialRevisionRef.current = materialRevision;
76
+ const nodes = useScene.getState().nodes;
77
+ for (const node of Object.values(nodes)) {
78
+ if (node?.type === 'door') {
79
+ useScene.getState().dirtyNodes.add(node.id);
80
+ }
81
+ }
82
+ });
83
+ // Editing a scene material a door slot references must rebuild that door
84
+ // (door meshes are built by this system, not <GeometrySystem>).
85
+ useEffect(() => {
86
+ void sceneMaterials;
87
+ const nodes = useScene.getState().nodes;
88
+ for (const node of Object.values(nodes)) {
89
+ if (node?.type !== 'door')
90
+ continue;
91
+ if (!nodeReferencesSceneMaterial(node))
92
+ continue;
93
+ useScene.getState().dirtyNodes.add(node.id);
94
+ }
95
+ }, [sceneMaterials]);
11
96
  useFrame(() => {
12
97
  if (dirtyNodes.size === 0)
13
98
  return;
99
+ const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset);
100
+ baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial;
101
+ frameMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial;
102
+ revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial;
103
+ glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial;
104
+ hardwareMaterial = textures ? defaultHardwareMaterial : frameJoineryMaterial;
105
+ currentShading = shading;
106
+ currentTextures = textures;
107
+ currentColorPreset = colorPreset;
108
+ currentSceneMaterials = sceneMaterials;
14
109
  const nodes = useScene.getState().nodes;
110
+ const dirtyDoorIds = [];
15
111
  dirtyNodes.forEach((id) => {
16
112
  const node = nodes[id];
17
- if (!node || node.type !== 'door')
113
+ if (node?.type !== 'door')
18
114
  return;
115
+ dirtyDoorIds.push(id);
116
+ });
117
+ const useProgressiveDoorRebuilds = dirtyDoorIds.length > DOOR_PROGRESSIVE_DIRTY_THRESHOLD;
118
+ const frameStartedAt = performance.now();
119
+ let rebuiltDoorsThisFrame = 0;
120
+ for (const id of dirtyDoorIds) {
121
+ if (useProgressiveDoorRebuilds) {
122
+ if (rebuiltDoorsThisFrame >= MAX_DOOR_REBUILDS_PER_FRAME) {
123
+ break;
124
+ }
125
+ if (rebuiltDoorsThisFrame > 0 &&
126
+ performance.now() - frameStartedAt >= DOOR_PROGRESSIVE_TIME_BUDGET_MS) {
127
+ break;
128
+ }
129
+ }
130
+ const node = nodes[id];
131
+ if (node?.type !== 'door')
132
+ continue;
19
133
  const mesh = sceneRegistry.nodes.get(id);
20
134
  if (!mesh)
21
- return; // Keep dirty until mesh mounts
22
- updateDoorMesh(node, mesh);
135
+ continue; // Keep dirty until mesh mounts
136
+ // Merge any live override (width / height / position) so the mesh
137
+ // rebuild reflects the in-flight drag without zustand churn. When
138
+ // no override is set this returns the scene node unchanged.
139
+ const effectiveNode = getEffectiveNode(node);
140
+ updateDoorMesh(effectiveNode, mesh);
23
141
  clearDirty(id);
142
+ rebuiltDoorsThisFrame += 1;
24
143
  // Rebuild the parent wall so its cutout reflects the updated door geometry
25
- if (node.parentId) {
26
- useScene.getState().dirtyNodes.add(node.parentId);
144
+ // Avoid triggering expensive wall CSG rebuilds while the door is being interactively moved/duplicated.
145
+ // The editor tools will request a final wall rebuild on commit.
146
+ const isTransient = !!node.metadata?.isTransient;
147
+ if (!isTransient && effectiveNode.parentId) {
148
+ useScene.getState().dirtyNodes.add(effectiveNode.parentId);
27
149
  }
28
- });
150
+ }
29
151
  }, 3);
30
152
  return null;
31
153
  };
154
+ function tagDoorSlot(mesh) {
155
+ mesh.userData.slotId = currentDoorSlot;
156
+ return mesh;
157
+ }
158
+ const NO_RAYCAST = () => { };
159
+ // An open door leaf swings perpendicular to the wall, so in a top-down view its
160
+ // flat panel blankets the room interior and wins the selection raycast over the
161
+ // slab/items beneath it. Drop the swung leaf out of the raycast so a click on
162
+ // the floor falls through to what's underneath; the door stays selectable via
163
+ // its proud invisible cutout proxy at the opening (see syncDoorCutout).
164
+ function disableSubtreeRaycast(object) {
165
+ object.traverse((child) => {
166
+ ;
167
+ child.raycast = NO_RAYCAST;
168
+ });
169
+ }
170
+ function nodeReferencesSceneMaterial(node) {
171
+ const slots = node.slots;
172
+ if (!slots)
173
+ return false;
174
+ for (const ref of Object.values(slots)) {
175
+ if (typeof ref === 'string' && ref.startsWith('scene:'))
176
+ return true;
177
+ }
178
+ return false;
179
+ }
180
+ function doorSlotDefault(slotId) {
181
+ if (!currentTextures)
182
+ return createSurfaceRoleMaterial('joinery', currentColorPreset);
183
+ if (slotId === 'glass') {
184
+ return (resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ??
185
+ defaultGlassMaterial);
186
+ }
187
+ if (slotId === 'hardware') {
188
+ return (resolveMaterialRef(HARDWARE_DEFAULT_REF, currentSceneMaterials, currentShading) ??
189
+ defaultHardwareMaterial);
190
+ }
191
+ if (slotId === 'frame') {
192
+ return (resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ??
193
+ getBaseMaterial(currentShading));
194
+ }
195
+ return (resolveMaterialRef(PANEL_DEFAULT_REF, currentSceneMaterials, currentShading) ??
196
+ getBaseMaterial(currentShading));
197
+ }
198
+ // Resolve a door's slot to a material: the `node.slots` override (colored mode
199
+ // only) → the body/glass/hardware default. Textures-off ignores overrides — the
200
+ // monochrome escape hatch.
201
+ function resolveDoorSlotMaterial(node, slotId) {
202
+ const fallback = doorSlotDefault(slotId);
203
+ if (!currentTextures)
204
+ return fallback;
205
+ const ref = node.slots?.[slotId];
206
+ if (!ref)
207
+ return fallback;
208
+ return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback;
209
+ }
32
210
  function addBox(parent, material, w, h, d, x, y, z) {
33
- const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
211
+ const geometry = new THREE.BoxGeometry(w, h, d);
212
+ applyWorldScaleBoxUVs(geometry, w, h, d);
213
+ const m = new THREE.Mesh(geometry, material);
34
214
  m.position.set(x, y, z);
215
+ tagDoorSlot(m);
35
216
  parent.add(m);
36
217
  }
37
218
  function addRotatedBox(parent, material, w, h, d, x, y, z, rotationY) {
38
- const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
219
+ const geometry = new THREE.BoxGeometry(w, h, d);
220
+ applyWorldScaleBoxUVs(geometry, w, h, d);
221
+ const m = new THREE.Mesh(geometry, material);
39
222
  m.position.set(x, y, z);
40
223
  m.rotation.y = rotationY;
41
- parent.add(m);
42
- }
43
- function addBoxWithRotation(parent, material, w, h, d, x, y, z, rotation) {
44
- const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
45
- m.position.set(x, y, z);
46
- m.rotation.set(rotation[0], rotation[1], rotation[2]);
224
+ tagDoorSlot(m);
47
225
  parent.add(m);
48
226
  }
49
227
  function addShape(parent, material, shape, depth) {
@@ -54,6 +232,18 @@ function addShape(parent, material, shape, depth) {
54
232
  });
55
233
  geometry.translate(0, 0, -depth / 2);
56
234
  const mesh = new THREE.Mesh(geometry, material);
235
+ tagDoorSlot(mesh);
236
+ parent.add(mesh);
237
+ }
238
+ function addShapeAt(parent, material, shape, depth, x, y, z) {
239
+ const geometry = new THREE.ExtrudeGeometry(shape, {
240
+ depth,
241
+ bevelEnabled: false,
242
+ curveSegments: 24,
243
+ });
244
+ geometry.translate(x, y, z - depth / 2);
245
+ const mesh = new THREE.Mesh(geometry, material);
246
+ tagDoorSlot(mesh);
57
247
  parent.add(mesh);
58
248
  }
59
249
  function getClampedArchHeight(width, height, archHeight) {
@@ -83,6 +273,32 @@ function getArchBoundaryY(x, halfWidth, springY, archHeight) {
83
273
  const t = Math.min(Math.abs(x) / halfWidth, 1);
84
274
  return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0));
85
275
  }
276
+ function getOneSidedArchBoundaryYAtX(x, left, right, top, archHeight, curvedSide) {
277
+ const width = right - left;
278
+ if (width <= 1e-6)
279
+ return top;
280
+ const clampedArchHeight = getClampedArchHeight(width, Number.MAX_SAFE_INTEGER, archHeight);
281
+ const springY = top - clampedArchHeight;
282
+ const distanceFromApex = curvedSide === 'left'
283
+ ? Math.max(0, Math.min((right - x) / width, 1))
284
+ : Math.max(0, Math.min((x - left) / width, 1));
285
+ return (springY + clampedArchHeight * Math.sqrt(Math.max(1 - distanceFromApex * distanceFromApex, 0)));
286
+ }
287
+ function getRoundedBoundaryYAtX(x, left, right, top, radii) {
288
+ if (radii.topLeft > 1e-6 && x < left + radii.topLeft) {
289
+ const centerX = left + radii.topLeft;
290
+ const centerY = top - radii.topLeft;
291
+ const dx = x - centerX;
292
+ return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0));
293
+ }
294
+ if (radii.topRight > 1e-6 && x > right - radii.topRight) {
295
+ const centerX = right - radii.topRight;
296
+ const centerY = top - radii.topRight;
297
+ const dx = x - centerX;
298
+ return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0));
299
+ }
300
+ return top;
301
+ }
86
302
  function createArchBandShape(width, outerSpringY, outerTopY, innerSpringY, innerTopY, insetX) {
87
303
  const halfWidth = width / 2;
88
304
  const innerHalfWidth = Math.max(halfWidth - insetX, 0);
@@ -215,6 +431,87 @@ function createRoundedLeafFrameShape(width, bottom, top, radii, insetX, insetY)
215
431
  outer.holes.push(shapeToReversedPath(createRoundedTopShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii)));
216
432
  return outer;
217
433
  }
434
+ function createRoundedClippedLeafFrameShape(left, right, bottom, top, fullLeft, fullRight, radii, insetX, insetY) {
435
+ const outerRadii = normalizeTopCornerRadii(radii, fullRight - fullLeft, top - bottom);
436
+ const outer = createTopClippedRectShape(left, right, bottom, top, (x) => getRoundedBoundaryYAtX(x, fullLeft, fullRight, top, {
437
+ topLeft: outerRadii.topLeft,
438
+ topRight: outerRadii.topRight,
439
+ }));
440
+ if (!outer)
441
+ return null;
442
+ const innerLeft = left + insetX;
443
+ const innerRight = right - insetX;
444
+ const innerBottom = bottom + insetY;
445
+ const innerTop = top - insetY;
446
+ if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01)
447
+ return outer;
448
+ const innerFullLeft = fullLeft + insetX;
449
+ const innerFullRight = fullRight - insetX;
450
+ const innerRadii = normalizeTopCornerRadii({
451
+ topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0),
452
+ topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0),
453
+ }, innerFullRight - innerFullLeft, innerTop - innerBottom);
454
+ const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => getRoundedBoundaryYAtX(x, innerFullLeft, innerFullRight, innerTop, {
455
+ topLeft: innerRadii.topLeft,
456
+ topRight: innerRadii.topRight,
457
+ }));
458
+ if (holeShape)
459
+ outer.holes.push(shapeToReversedPath(holeShape));
460
+ return outer;
461
+ }
462
+ function createArchedLeafFrameShape(width, bottom, top, archHeight, insetX, insetY) {
463
+ const halfWidth = width / 2;
464
+ const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight);
465
+ const innerLeft = -halfWidth + insetX;
466
+ const innerRight = halfWidth - insetX;
467
+ const innerBottom = bottom + insetY;
468
+ const innerTop = top - insetY;
469
+ if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01)
470
+ return outer;
471
+ const innerArchHeight = getClampedArchHeight(innerRight - innerLeft, innerTop - innerBottom, Math.max(archHeight - insetY, 0.01));
472
+ outer.holes.push(shapeToReversedPath(createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight)));
473
+ return outer;
474
+ }
475
+ function createArchedClippedLeafFrameShape(left, right, bottom, top, fullLeft, fullRight, archHeight, insetX, insetY) {
476
+ const fullCenterX = (fullLeft + fullRight) / 2;
477
+ const fullHalfWidth = (fullRight - fullLeft) / 2;
478
+ const springY = top - archHeight;
479
+ const outer = createTopClippedRectShape(left, right, bottom, top, (x) => getArchBoundaryY(x - fullCenterX, fullHalfWidth, springY, archHeight));
480
+ if (!outer)
481
+ return null;
482
+ const innerLeft = left + insetX;
483
+ const innerRight = right - insetX;
484
+ const innerBottom = bottom + insetY;
485
+ const innerTop = top - insetY;
486
+ if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01)
487
+ return outer;
488
+ const innerFullLeft = fullLeft + insetX;
489
+ const innerFullRight = fullRight - insetX;
490
+ const innerArchHeight = getClampedArchHeight(innerFullRight - innerFullLeft, innerTop - innerBottom, Math.max(archHeight - insetY, 0.01));
491
+ const innerFullCenterX = (innerFullLeft + innerFullRight) / 2;
492
+ const innerFullHalfWidth = (innerFullRight - innerFullLeft) / 2;
493
+ const innerSpringY = innerTop - innerArchHeight;
494
+ const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => getArchBoundaryY(x - innerFullCenterX, innerFullHalfWidth, innerSpringY, innerArchHeight));
495
+ if (holeShape)
496
+ outer.holes.push(shapeToReversedPath(holeShape));
497
+ return outer;
498
+ }
499
+ function createOneSidedArchLeafFrameShape(left, right, bottom, top, archHeight, insetX, insetY, curvedSide) {
500
+ const outer = createTopClippedRectShape(left, right, bottom, top, (x) => getOneSidedArchBoundaryYAtX(x, left, right, top, archHeight, curvedSide));
501
+ if (!outer)
502
+ return null;
503
+ const innerLeft = left + insetX;
504
+ const innerRight = right - insetX;
505
+ const innerBottom = bottom + insetY;
506
+ const innerTop = top - insetY;
507
+ if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01)
508
+ return outer;
509
+ const innerArchHeight = getClampedArchHeight(innerRight - innerLeft, innerTop - innerBottom, Math.max(archHeight - insetY, 0.01));
510
+ const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => getOneSidedArchBoundaryYAtX(x, innerLeft, innerRight, innerTop, innerArchHeight, curvedSide));
511
+ if (holeShape)
512
+ outer.holes.push(shapeToReversedPath(holeShape));
513
+ return outer;
514
+ }
218
515
  function createTopClippedRectShape(left, right, bottom, top, getBoundaryY) {
219
516
  const segments = 20;
220
517
  const points = [];
@@ -242,17 +539,19 @@ function disposeObject(object) {
242
539
  child.geometry.dispose();
243
540
  });
244
541
  }
245
- function addLeafSegmentContent({ addLeafBox, leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, segments, contentPadding, keepFrameWhenEmpty = false, }) {
542
+ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, segments, contentPadding, keepFrameWhenEmpty = false, renderPerimeterFrame = true, openingShape = 'rectangle', openingTopRadii, archHeight, archOuterSide, }) {
246
543
  const hasLeafContent = segments.some((seg) => seg.type !== 'empty');
247
544
  const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty;
248
545
  const cpX = contentPadding[0];
249
546
  const cpY = contentPadding[1];
250
- if (shouldRenderFrame && cpY > 0) {
547
+ if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) {
548
+ currentDoorSlot = 'panel';
251
549
  addLeafBox(baseMaterial, leafWidth, cpY, leafDepth, leafCenterX, leafCenterY + leafHeight / 2 - cpY / 2, 0);
252
550
  addLeafBox(baseMaterial, leafWidth, cpY, leafDepth, leafCenterX, leafCenterY - leafHeight / 2 + cpY / 2, 0);
253
551
  }
254
- if (shouldRenderFrame && cpX > 0) {
552
+ if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) {
255
553
  const innerH = leafHeight - 2 * cpY;
554
+ currentDoorSlot = 'panel';
256
555
  addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafCenterX - leafWidth / 2 + cpX / 2, leafCenterY, 0);
257
556
  addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafCenterX + leafWidth / 2 - cpX / 2, leafCenterY, 0);
258
557
  }
@@ -261,9 +560,32 @@ function addLeafSegmentContent({ addLeafBox, leafWidth, leafHeight, leafCenterX,
261
560
  const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0);
262
561
  const contentTop = leafCenterY + contentH / 2;
263
562
  let segY = contentTop;
563
+ const leafLeft = leafCenterX - leafWidth / 2;
564
+ const leafRight = leafCenterX + leafWidth / 2;
565
+ const leafTop = leafCenterY + leafHeight / 2;
566
+ const hasShapedTop = openingShape === 'rounded' || openingShape === 'arch';
567
+ const clampedLeafArchHeight = openingShape === 'arch' ? getClampedArchHeight(leafWidth, leafHeight, archHeight) : 0;
568
+ const leafSpringY = leafTop - clampedLeafArchHeight;
569
+ const topBoundaryAtX = (x) => {
570
+ if (openingShape === 'rounded' && openingTopRadii) {
571
+ return getRoundedBoundaryYAtX(x, leafLeft, leafRight, leafTop, {
572
+ topLeft: openingTopRadii.topLeft,
573
+ topRight: openingTopRadii.topRight,
574
+ });
575
+ }
576
+ if (openingShape === 'arch') {
577
+ if (archOuterSide) {
578
+ return getOneSidedArchBoundaryYAtX(x, leafLeft, leafRight, leafTop, clampedLeafArchHeight, archOuterSide);
579
+ }
580
+ return getArchBoundaryY(x - leafCenterX, leafWidth / 2, leafSpringY, clampedLeafArchHeight);
581
+ }
582
+ return leafTop;
583
+ };
264
584
  for (const seg of segments) {
265
585
  const segH = (seg.heightRatio / totalRatio) * contentH;
266
586
  const segCenterY = segY - segH / 2;
587
+ const segTop = segY;
588
+ const segBottom = segY - segH;
267
589
  const numCols = seg.columnRatios.length;
268
590
  const colSum = seg.columnRatios.reduce((a, b) => a + b, 0);
269
591
  const usableW = contentW - (numCols - 1) * seg.dividerThickness;
@@ -278,9 +600,20 @@ function addLeafSegmentContent({ addLeafBox, leafWidth, leafHeight, leafCenterX,
278
600
  }
279
601
  if (seg.type !== 'empty') {
280
602
  cx = leafCenterX - contentW / 2;
603
+ currentDoorSlot = 'panel';
281
604
  for (let c = 0; c < numCols - 1; c++) {
282
605
  cx += colWidths[c];
283
- addLeafBox(baseMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0);
606
+ const dividerLeft = cx;
607
+ const dividerRight = cx + seg.dividerThickness;
608
+ const dividerShape = hasShapedTop && addLeafShape
609
+ ? createTopClippedRectShape(dividerLeft, dividerRight, segBottom, segTop, topBoundaryAtX)
610
+ : null;
611
+ if (dividerShape && addLeafShape) {
612
+ addLeafShape(baseMaterial, dividerShape, leafDepth + 0.001);
613
+ }
614
+ else {
615
+ addLeafBox(baseMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0);
616
+ }
284
617
  cx += seg.dividerThickness;
285
618
  }
286
619
  }
@@ -288,32 +621,107 @@ function addLeafSegmentContent({ addLeafBox, leafWidth, leafHeight, leafCenterX,
288
621
  const colW = colWidths[c];
289
622
  const colX = colXCenters[c];
290
623
  if (seg.type === 'glass') {
624
+ currentDoorSlot = 'glass';
291
625
  const glassDepth = Math.max(0.004, leafDepth * 0.15);
292
- addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0);
626
+ const segmentLeft = colX - colW / 2;
627
+ const segmentRight = colX + colW / 2;
628
+ const glassShape = hasShapedTop && addLeafShape
629
+ ? createTopClippedRectShape(segmentLeft, segmentRight, segBottom, segTop, topBoundaryAtX)
630
+ : null;
631
+ if (glassShape && addLeafShape) {
632
+ addLeafShape(glassMaterial, glassShape, glassDepth);
633
+ }
634
+ else {
635
+ addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0);
636
+ }
293
637
  }
294
638
  else if (seg.type === 'panel') {
295
- addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0);
639
+ currentDoorSlot = 'panel';
640
+ const segmentLeft = colX - colW / 2;
641
+ const segmentRight = colX + colW / 2;
642
+ const outerPanelShape = hasShapedTop && addLeafShape
643
+ ? createTopClippedRectShape(segmentLeft, segmentRight, segBottom, segTop, topBoundaryAtX)
644
+ : null;
645
+ if (outerPanelShape && addLeafShape) {
646
+ addLeafShape(baseMaterial, outerPanelShape, leafDepth);
647
+ }
648
+ else {
649
+ addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0);
650
+ }
296
651
  const panelW = colW - 2 * seg.panelInset;
297
652
  const panelH = segH - 2 * seg.panelInset;
298
653
  if (panelW > 0.01 && panelH > 0.01) {
299
654
  const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth);
300
655
  const panelZ = leafDepth / 2 + effectiveDepth / 2;
301
- addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ);
656
+ const insetLeft = colX - panelW / 2;
657
+ const insetRight = colX + panelW / 2;
658
+ const insetTop = segTop - seg.panelInset;
659
+ const insetBottom = segBottom + seg.panelInset;
660
+ const innerPanelShape = hasShapedTop && addLeafShape
661
+ ? createTopClippedRectShape(insetLeft, insetRight, insetBottom, insetTop, topBoundaryAtX)
662
+ : null;
663
+ if (innerPanelShape && addLeafShape) {
664
+ addLeafShape(baseMaterial, innerPanelShape, effectiveDepth);
665
+ }
666
+ else {
667
+ addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ);
668
+ }
302
669
  }
303
670
  }
304
671
  }
305
672
  segY -= segH;
306
673
  }
307
674
  }
308
- function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, hingeX, hingeSide, swingRotation, segments, contentPadding, handle, handleBothSides = false, handleHeight, handleSide, doorCloser, panicBar, panicBarHeight, doorHeight, }) {
675
+ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, hingeX, hingeSide, swingRotation, openRotationY, segments, contentPadding, handle, handleBothSides = false, handleHeight, handleSide, doorCloser, panicBar, panicBarHeight, doorHeight, openingShape, openingTopRadii, archHeight, roundedBoundary, archedBoundary, archOuterSide, }) {
309
676
  const hasLeafContent = segments.some((seg) => seg.type !== 'empty');
310
677
  const leafGroup = new THREE.Group();
311
678
  leafGroup.position.set(hingeX, 0, 0);
312
679
  leafGroup.rotation.y = swingRotation;
680
+ // Marks this group as the swing leaf and records its fully-open angle so the
681
+ // GLB exporter can bake an open/close animation clip from a single pose. The
682
+ // exporter strips this marker before writing the file.
683
+ leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY };
313
684
  mesh.add(leafGroup);
314
685
  const addLeafBox = (material, w, h, d, x, y, z) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z);
686
+ const addLeafShape = (material, shape, depth) => addShapeAt(leafGroup, material, shape, depth, -hingeX, 0, 0);
687
+ const localLeafCenterX = leafCenterX - hingeX;
688
+ const leafBottom = leafCenterY - leafHeight / 2;
689
+ const leafTop = leafCenterY + leafHeight / 2;
690
+ const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch';
691
+ if (usesShapedLeafFrame && hasLeafContent) {
692
+ currentDoorSlot = 'panel';
693
+ if (openingShape === 'rounded') {
694
+ const roundedLeafShape = roundedBoundary
695
+ ? createRoundedClippedLeafFrameShape(leafCenterX - leafWidth / 2, leafCenterX + leafWidth / 2, leafBottom, leafTop, roundedBoundary.fullLeft, roundedBoundary.fullRight, roundedBoundary.radii, contentPadding[0], contentPadding[1])
696
+ : createRoundedLeafFrameShape(leafWidth, leafBottom, leafTop, openingTopRadii, contentPadding[0], contentPadding[1]);
697
+ if (roundedLeafShape) {
698
+ if (roundedBoundary) {
699
+ addLeafShape(baseMaterial, roundedLeafShape, leafDepth);
700
+ }
701
+ else {
702
+ addShapeAt(leafGroup, baseMaterial, roundedLeafShape, leafDepth, localLeafCenterX, 0, 0);
703
+ }
704
+ }
705
+ }
706
+ else if (openingShape === 'arch') {
707
+ const archedLeafShape = archOuterSide
708
+ ? createOneSidedArchLeafFrameShape(leafCenterX - leafWidth / 2, leafCenterX + leafWidth / 2, leafBottom, leafTop, archHeight, contentPadding[0], contentPadding[1], archOuterSide)
709
+ : archedBoundary
710
+ ? createArchedClippedLeafFrameShape(leafCenterX - leafWidth / 2, leafCenterX + leafWidth / 2, leafBottom, leafTop, archedBoundary.fullLeft, archedBoundary.fullRight, archedBoundary.archHeight, contentPadding[0], contentPadding[1])
711
+ : createArchedLeafFrameShape(leafWidth, leafBottom, leafTop, getClampedArchHeight(leafWidth, leafHeight, archHeight), contentPadding[0], contentPadding[1]);
712
+ if (archedLeafShape) {
713
+ if (archOuterSide || archedBoundary) {
714
+ addLeafShape(baseMaterial, archedLeafShape, leafDepth);
715
+ }
716
+ else {
717
+ addShapeAt(leafGroup, baseMaterial, archedLeafShape, leafDepth, localLeafCenterX, 0, 0);
718
+ }
719
+ }
720
+ }
721
+ }
315
722
  addLeafSegmentContent({
316
723
  addLeafBox,
724
+ addLeafShape,
317
725
  leafWidth,
318
726
  leafHeight,
319
727
  leafCenterX,
@@ -321,72 +729,224 @@ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, le
321
729
  leafDepth,
322
730
  segments,
323
731
  contentPadding,
732
+ renderPerimeterFrame: !usesShapedLeafFrame,
733
+ openingShape,
734
+ openingTopRadii,
735
+ archHeight,
736
+ archOuterSide,
324
737
  });
325
738
  if (hasLeafContent && handle) {
739
+ currentDoorSlot = 'hardware';
326
740
  const handleY = handleHeight - doorHeight / 2;
327
741
  const faceZ = leafDepth / 2;
328
742
  const handleX = handleSide === 'right'
329
743
  ? leafCenterX + leafWidth / 2 - 0.045
330
744
  : leafCenterX - leafWidth / 2 + 0.045;
331
- addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005);
332
- addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025);
745
+ addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005);
746
+ addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025);
333
747
  if (handleBothSides) {
334
- addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005);
335
- addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025);
748
+ addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005);
749
+ addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025);
336
750
  }
337
751
  }
338
752
  if (hasLeafContent && doorCloser) {
753
+ currentDoorSlot = 'hardware';
339
754
  const closerY = leafCenterY + leafHeight / 2 - 0.04;
340
- addLeafBox(baseMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03);
341
- addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafCenterX + leafWidth / 4, closerY + 0.025, leafDepth / 2 + 0.015);
755
+ addLeafBox(hardwareMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03);
756
+ addLeafBox(hardwareMaterial, 0.14, 0.015, 0.015, leafCenterX + leafWidth / 4, closerY + 0.025, leafDepth / 2 + 0.015);
342
757
  }
343
758
  if (hasLeafContent && panicBar) {
759
+ currentDoorSlot = 'hardware';
344
760
  const barY = panicBarHeight - doorHeight / 2;
345
- addLeafBox(baseMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03);
761
+ addLeafBox(hardwareMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03);
346
762
  }
347
763
  if (hasLeafContent) {
764
+ currentDoorSlot = 'hardware';
348
765
  const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012;
349
766
  const hingeH = 0.1;
350
767
  const hingeW = 0.024;
351
768
  const hingeD = leafDepth + 0.016;
352
- const leafBottom = leafCenterY - leafHeight / 2;
353
- const leafTop = leafCenterY + leafHeight / 2;
354
- addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0);
355
- addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0);
356
- addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0);
769
+ addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0);
770
+ addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0);
771
+ addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0);
772
+ }
773
+ // When the leaf is swung open it projects into the room and would otherwise
774
+ // win a top-down selection click over the floor beneath it. Drop only the
775
+ // swung leaf out of the raycast; a closed leaf stays in the wall plane and
776
+ // keeps its hit-eligibility (so paint-by-slot still works on it).
777
+ if (Math.abs(swingRotation) > 1e-3) {
778
+ disableSubtreeRaycast(leafGroup);
779
+ }
780
+ }
781
+ // Names of the re-poseable moving groups each operation-door builder emits. The
782
+ // builder fills them with geometry at the CLOSED pose; `poseDoorMovingParts`
783
+ // then transforms the group to a given open fraction. This is the same
784
+ // build-once + pose-at-t split windows use (`poseWindowMovingParts`), and it is
785
+ // the single source of truth for door kinematics: the live system poses the
786
+ // registered mesh, and the GLB exporter poses an export clone to sample the
787
+ // open/close keyframes for a baked animation clip.
788
+ const SLIDING_ACTIVE_PANEL_NAME = 'door-sliding-active';
789
+ const POCKET_LEAF_NAME = 'door-pocket-leaf';
790
+ const BARN_LEAF_NAME = 'door-barn-leaf';
791
+ const TILTUP_LEAF_NAME = 'door-tiltup-leaf';
792
+ // Folding panels form a hinged chain; each `<name><index>` group is parented to
793
+ // the previous one so a single per-joint rotation reproduces the accordion.
794
+ const FOLDING_PANEL_NAME = 'door-fold-';
795
+ // Sectional panels each ride the overhead curve independently (non-rigid as a
796
+ // set), so each `<name><index>` group is posed on its own.
797
+ const SECTIONAL_PANEL_NAME = 'door-sectional-';
798
+ const ROLLUP_CURTAIN_NAME = 'door-rollup-curtain';
799
+ /**
800
+ * Pose an operation door's moving parts (sliding/pocket/barn leaf, tilt-up and
801
+ * sectional panels, folding chain, roll-up curtain) at `value` (0 = closed,
802
+ * 1 = open) by transforming the named groups its builder emitted at the closed
803
+ * pose. Returns true when the door type has a pose path and its groups exist.
804
+ *
805
+ * Mirrors `poseWindowMovingParts`: the live door system calls it after a
806
+ * (re)build so `operationState` is reflected, and the GLB exporter calls it on a
807
+ * clone to sample keyframes. Swing doors are not handled here — their leaf group
808
+ * carries a `pascalSwingLeaf` marker the exporter reads directly.
809
+ *
810
+ * Roll-up is the one type whose live geometry changes (slats vanish onto a
811
+ * drum), which a glTF clip can't express; the baked approximation scales the
812
+ * curtain up into the lintel. The live roll-up animation keeps its full-detail
813
+ * rebuild and is intentionally NOT routed through this scale.
814
+ */
815
+ export function poseDoorMovingParts(node, mesh, value) {
816
+ if (!mesh)
817
+ return false;
818
+ const t = clampDoorOperationState(value);
819
+ const frameThickness = node.frameThickness;
820
+ const insideWidth = node.width - 2 * frameThickness;
821
+ const leafHeight = node.height - frameThickness;
822
+ const leafCenterY = -frameThickness / 2;
823
+ switch (node.doorType) {
824
+ case 'sliding': {
825
+ const group = mesh.getObjectByName(SLIDING_ACTIVE_PANEL_NAME);
826
+ if (!group)
827
+ return false;
828
+ const activeSign = node.slideDirection === 'left' ? 1 : -1;
829
+ group.position.x = -activeSign * insideWidth * 0.44 * t;
830
+ return true;
831
+ }
832
+ case 'pocket': {
833
+ const group = mesh.getObjectByName(POCKET_LEAF_NAME);
834
+ if (!group)
835
+ return false;
836
+ const slideSign = node.slideDirection === 'right' ? 1 : -1;
837
+ group.position.x = slideSign * insideWidth * t;
838
+ return true;
839
+ }
840
+ case 'barn': {
841
+ const group = mesh.getObjectByName(BARN_LEAF_NAME);
842
+ if (!group)
843
+ return false;
844
+ const slideSign = node.slideDirection === 'right' ? 1 : -1;
845
+ group.position.x = slideSign * insideWidth * t;
846
+ return true;
847
+ }
848
+ case 'garage-tiltup': {
849
+ const group = mesh.getObjectByName(TILTUP_LEAF_NAME);
850
+ if (!group)
851
+ return false;
852
+ // Rigid hinge: the closed leaf hangs from the lintel; opening rotates it
853
+ // about the top edge. `position` keeps the top edge tracking the hinge as
854
+ // the group rotates about its own origin (see the closed build below).
855
+ const angle = (Math.PI / 2) * t;
856
+ const hingeY = leafCenterY + leafHeight / 2;
857
+ group.rotation.set(-angle, 0, 0);
858
+ group.position.set(0, hingeY * (1 - Math.cos(angle)), Math.sin(angle) * (hingeY - leafHeight));
859
+ return true;
860
+ }
861
+ case 'folding': {
862
+ const panelCount = node.leafCount === 2 ? 2 : 4;
863
+ const foldAngle = Math.PI * 0.44 * t;
864
+ // Each panel group is parented to the previous, so its rotation is the
865
+ // joint angle (the change in absolute segment direction), not the absolute
866
+ // angle: alternating panels target ∓foldAngle, so joints fold by ±2·angle.
867
+ // The leading sign folds the leaves toward −z (matching the original rig).
868
+ let posed = false;
869
+ let prevDirection = 0;
870
+ for (let index = 0; index < panelCount; index++) {
871
+ const group = mesh.getObjectByName(`${FOLDING_PANEL_NAME}${index}`);
872
+ const direction = index % 2 === 0 ? -1 : 1;
873
+ if (group) {
874
+ posed = true;
875
+ // Set the full triple (not just `.y`): when the export clones and
876
+ // decomposes the door matrix, a |Y| > π/2 rotation re-derives into a
877
+ // gimbal-flipped euler (x=z=π). Assigning only `.y` would leave that
878
+ // π residue on x/z and bake a flipped rest pose.
879
+ group.rotation.set(0, (prevDirection - direction) * foldAngle, 0);
880
+ }
881
+ prevDirection = direction;
882
+ }
883
+ return posed;
884
+ }
885
+ case 'garage-sectional': {
886
+ const panelCount = Math.max(3, Math.min(12, Math.round(node.garagePanelCount)));
887
+ const panelHeight = leafHeight / panelCount;
888
+ const curveRadius = panelHeight * 0.58;
889
+ const curveLength = (Math.PI / 2) * curveRadius;
890
+ const overheadY = leafCenterY + leafHeight / 2 - panelHeight / 2;
891
+ const openAmount = getDoorRenderOpenAmount('garage-sectional', t);
892
+ const travel = openAmount * ((panelCount - 1) * panelHeight + curveLength + panelHeight * 0.65);
893
+ let posed = false;
894
+ for (let index = 0; index < panelCount; index++) {
895
+ const group = mesh.getObjectByName(`${SECTIONAL_PANEL_NAME}${index}`);
896
+ if (!group)
897
+ continue;
898
+ posed = true;
899
+ const orderFromTop = panelCount - 1 - index;
900
+ const pathPosition = travel - orderFromTop * panelHeight;
901
+ let y = overheadY + pathPosition;
902
+ let z = 0;
903
+ let rotationX = 0;
904
+ if (pathPosition > 0 && pathPosition <= curveLength) {
905
+ const theta = pathPosition / curveRadius;
906
+ rotationX = -theta;
907
+ y = overheadY + curveRadius * Math.sin(theta);
908
+ z = -curveRadius * (1 - Math.cos(theta));
909
+ }
910
+ else if (pathPosition > curveLength) {
911
+ rotationX = -Math.PI / 2;
912
+ y = overheadY + curveRadius;
913
+ z = -(curveRadius + pathPosition - curveLength);
914
+ }
915
+ group.position.set(0, y, z);
916
+ group.rotation.set(rotationX, 0, 0);
917
+ }
918
+ return posed;
919
+ }
920
+ case 'garage-rollup': {
921
+ const group = mesh.getObjectByName(ROLLUP_CURTAIN_NAME);
922
+ if (!group)
923
+ return false;
924
+ group.scale.y = Math.max(0.02, 1 - t);
925
+ return true;
926
+ }
927
+ default:
928
+ return false;
357
929
  }
358
930
  }
359
- function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, leafCount, doorHeight, handleHeight, segments, contentPadding, }) {
931
+ function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, leafCount, doorHeight, handleHeight, segments, contentPadding, }) {
360
932
  const panelCount = leafCount === 2 ? 2 : 4;
361
- const foldAmount = clampDoorOperationState(operationState);
362
933
  const panelLength = insideWidth / panelCount;
363
- const foldAngle = Math.PI * 0.44 * foldAmount;
364
- addBox(mesh, baseMaterial, insideWidth, Math.min(frameThickness * 0.5, 0.025), Math.max(frameDepth * 0.45, 0.035), 0, leafCenterY + leafHeight / 2 - 0.018, 0);
365
- const vertices = [{ x: -insideWidth / 2, z: 0 }];
934
+ currentDoorSlot = 'hardware';
935
+ addBox(mesh, hardwareMaterial, insideWidth, Math.min(frameThickness * 0.5, 0.025), Math.max(frameDepth * 0.45, 0.035), 0, leafCenterY + leafHeight / 2 - 0.018, 0);
936
+ // Build the panels as a hinged chain at the CLOSED pose: each panel group is
937
+ // parented to the previous one at that panel's end (`panelLength` along local
938
+ // x), starting from the left jamb. At rest every joint is straight, so the
939
+ // panels lie flat across the opening; `poseDoorMovingParts` folds the joints.
940
+ let parent = mesh;
366
941
  for (let index = 0; index < panelCount; index++) {
367
- const previous = vertices[index];
368
- const direction = index % 2 === 0 ? -1 : 1;
369
- const angle = direction * foldAngle;
370
- vertices.push({
371
- x: previous.x + panelLength * Math.cos(angle),
372
- z: previous.z + panelLength * Math.sin(angle),
373
- });
374
- }
375
- for (let index = 0; index < panelCount; index++) {
376
- const start = vertices[index];
377
- const end = vertices[index + 1];
378
- const dx = end.x - start.x;
379
- const dz = end.z - start.z;
380
- const centerX = (start.x + end.x) / 2;
381
- const centerZ = (start.z + end.z) / 2;
382
- const rotationY = Math.atan2(-dz, dx);
383
- const localX = {
384
- x: Math.cos(rotationY),
385
- z: -Math.sin(rotationY),
386
- };
387
- const addFoldingLeafBox = (material, w, h, d, x, y, z) => {
388
- addRotatedBox(mesh, material, w, h, d, centerX + localX.x * x + Math.sin(rotationY) * z, y, centerZ + localX.z * x + Math.cos(rotationY) * z, rotationY);
389
- };
942
+ const group = new THREE.Group();
943
+ group.name = `${FOLDING_PANEL_NAME}${index}`;
944
+ group.position.set(index === 0 ? -insideWidth / 2 : panelLength, 0, 0);
945
+ parent.add(group);
946
+ // Panel content runs from local x=0 to x=panelLength, so it is centred at
947
+ // panelLength/2 within the group (the segment helper centres on leafCenterX).
948
+ currentDoorSlot = undefined;
949
+ const addFoldingLeafBox = (material, w, h, d, x, y, z) => addBox(group, material, w, h, d, panelLength / 2 + x, y, z);
390
950
  addLeafSegmentContent({
391
951
  addLeafBox: addFoldingLeafBox,
392
952
  leafWidth: Math.max(0.08, panelLength),
@@ -398,79 +958,96 @@ function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
398
958
  contentPadding,
399
959
  keepFrameWhenEmpty: true,
400
960
  });
401
- for (const point of [start, end]) {
402
- addBox(mesh, revealMaterial, 0.018, leafHeight * 0.92, leafDepth + 0.016, point.x, leafCenterY, point.z);
961
+ // Reveal posts at the panel's hinge edges (local x=0 and x=panelLength).
962
+ currentDoorSlot = undefined;
963
+ for (const px of [0, panelLength]) {
964
+ addBox(group, revealMaterial, 0.018, leafHeight * 0.92, leafDepth + 0.016, px, leafCenterY, 0);
403
965
  }
966
+ parent = group;
404
967
  }
405
- const handlePoint = vertices[vertices.length - 1];
968
+ // Handle on the free end of the last panel (its local x=panelLength edge).
406
969
  const handleY = handleHeight - doorHeight / 2;
407
- addBox(mesh, baseMaterial, 0.035, 0.16, leafDepth + 0.035, handlePoint.x - 0.035, handleY, handlePoint.z + 0.045);
408
- addBox(mesh, baseMaterial, 0.035, 0.16, leafDepth + 0.035, handlePoint.x - 0.035, handleY, handlePoint.z - 0.045);
970
+ currentDoorSlot = 'hardware';
971
+ addBox(parent, hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, panelLength - 0.035, handleY, 0.045);
972
+ addBox(parent, hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, panelLength - 0.035, handleY, -0.045);
409
973
  }
410
- function addPocketDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
411
- const openAmount = clampDoorOperationState(operationState);
974
+ function addPocketDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
412
975
  const slideSign = slideDirection === 'right' ? 1 : -1;
413
976
  const leafWidth = insideWidth;
414
- const leafCenterX = slideSign * insideWidth * openAmount;
415
977
  const topY = leafCenterY + leafHeight / 2;
416
978
  const pocketCenterX = slideSign * insideWidth;
417
979
  const handleY = handleHeight - doorHeight / 2;
418
- const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055);
419
- addBox(mesh, baseMaterial, insideWidth * 2, Math.min(frameThickness * 0.45, 0.024), Math.max(frameDepth * 0.38, 0.03), slideSign * (insideWidth / 2), topY - 0.018, 0);
980
+ // Leaf built closed (centred in the opening); `poseDoorMovingParts` slides the
981
+ // group into the pocket. Handle rides inside the group at its closed offset.
982
+ const handleX = -slideSign * (leafWidth / 2 - 0.055);
983
+ currentDoorSlot = 'hardware';
984
+ addBox(mesh, hardwareMaterial, insideWidth * 2, Math.min(frameThickness * 0.45, 0.024), Math.max(frameDepth * 0.38, 0.03), slideSign * (insideWidth / 2), topY - 0.018, 0);
985
+ currentDoorSlot = undefined;
420
986
  addBox(mesh, revealMaterial, insideWidth * 0.9, 0.018, Math.max(frameDepth * 0.32, 0.026), pocketCenterX, topY - 0.055, 0);
421
987
  addBox(mesh, revealMaterial, 0.018, leafHeight * 0.94, leafDepth + 0.014, slideSign * insideWidth * 0.5, leafCenterY, 0);
422
- const addPocketLeafBox = (material, w, h, d, x, y, z) => addBox(mesh, material, w, h, d, x, y, z);
988
+ const leafGroup = new THREE.Group();
989
+ leafGroup.name = POCKET_LEAF_NAME;
990
+ mesh.add(leafGroup);
991
+ const addPocketLeafBox = (material, w, h, d, x, y, z) => addBox(leafGroup, material, w, h, d, x, y, z);
423
992
  addLeafSegmentContent({
424
993
  addLeafBox: addPocketLeafBox,
425
994
  leafWidth,
426
995
  leafHeight,
427
- leafCenterX,
996
+ leafCenterX: 0,
428
997
  leafCenterY,
429
998
  leafDepth,
430
999
  segments,
431
1000
  contentPadding,
432
1001
  });
433
- addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02);
434
- addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, -leafDepth / 2 - 0.02);
1002
+ currentDoorSlot = 'hardware';
1003
+ addBox(leafGroup, hardwareMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02);
1004
+ addBox(leafGroup, hardwareMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, -leafDepth / 2 - 0.02);
435
1005
  }
436
- function addBarnDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
437
- const openAmount = clampDoorOperationState(operationState);
1006
+ function addBarnDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
438
1007
  const slideSign = slideDirection === 'right' ? 1 : -1;
439
1008
  const leafWidth = insideWidth * 1.06;
440
- const leafCenterX = slideSign * insideWidth * openAmount;
441
1009
  const faceZ = frameDepth / 2 + leafDepth / 2 + 0.028;
442
1010
  const trackY = leafCenterY + leafHeight / 2 + Math.max(frameThickness * 0.55, 0.045);
443
1011
  const railLength = insideWidth * 2.25;
444
1012
  const railCenterX = slideSign * (insideWidth * 0.56);
445
1013
  const handleY = handleHeight - doorHeight / 2;
446
- const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075);
1014
+ // Leaf + wheels + handle ride the BARN_LEAF group, built closed (leaf centred
1015
+ // in the opening); `poseDoorMovingParts` slides the group along the rail. The
1016
+ // rail and end stops stay static on the mesh.
1017
+ const handleX = -slideSign * (leafWidth / 2 - 0.075);
447
1018
  const wheelY = trackY - 0.075;
448
- addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01);
449
- addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01);
450
- addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01);
451
- const addBarnLeafBox = (material, w, h, d, x, y, z) => addBox(mesh, material, w, h, d, x, y, faceZ + z);
1019
+ currentDoorSlot = 'hardware';
1020
+ addBox(mesh, hardwareMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01);
1021
+ addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01);
1022
+ addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01);
1023
+ const leafGroup = new THREE.Group();
1024
+ leafGroup.name = BARN_LEAF_NAME;
1025
+ mesh.add(leafGroup);
1026
+ const addBarnLeafBox = (material, w, h, d, x, y, z) => addBox(leafGroup, material, w, h, d, x, y, faceZ + z);
452
1027
  addLeafSegmentContent({
453
1028
  addLeafBox: addBarnLeafBox,
454
1029
  leafWidth,
455
1030
  leafHeight,
456
- leafCenterX,
1031
+ leafCenterX: 0,
457
1032
  leafCenterY,
458
1033
  leafDepth,
459
1034
  segments,
460
1035
  contentPadding,
461
1036
  keepFrameWhenEmpty: true,
462
1037
  });
463
- addRotatedBox(mesh, revealMaterial, 0.018, leafHeight * 0.86, 0.012, leafCenterX, leafCenterY, faceZ + leafDepth / 2 + 0.014, -0.52);
464
- addRotatedBox(mesh, revealMaterial, 0.018, leafHeight * 0.86, 0.012, leafCenterX, leafCenterY, faceZ + leafDepth / 2 + 0.014, 0.52);
1038
+ currentDoorSlot = undefined;
1039
+ addRotatedBox(leafGroup, revealMaterial, 0.018, leafHeight * 0.86, 0.012, 0, leafCenterY, faceZ + leafDepth / 2 + 0.014, -0.52);
1040
+ addRotatedBox(leafGroup, revealMaterial, 0.018, leafHeight * 0.86, 0.012, 0, leafCenterY, faceZ + leafDepth / 2 + 0.014, 0.52);
1041
+ currentDoorSlot = 'hardware';
465
1042
  for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) {
466
- addBox(mesh, revealMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022);
467
- addBox(mesh, revealMaterial, 0.026, 0.16, 0.026, leafCenterX + offset, wheelY - 0.075, faceZ + 0.022);
1043
+ addBox(leafGroup, hardwareMaterial, 0.085, 0.085, 0.035, offset, wheelY, faceZ + 0.022);
1044
+ addBox(leafGroup, hardwareMaterial, 0.026, 0.16, 0.026, offset, wheelY - 0.075, faceZ + 0.022);
468
1045
  }
469
- addBox(mesh, baseMaterial, 0.032, 0.22, leafDepth + 0.034, handleX, handleY, faceZ + leafDepth / 2 + 0.02);
470
- addBox(mesh, baseMaterial, 0.032, 0.22, leafDepth + 0.034, handleX, handleY, faceZ - leafDepth / 2 - 0.02);
1046
+ currentDoorSlot = 'hardware';
1047
+ addBox(leafGroup, hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, handleX, handleY, faceZ + leafDepth / 2 + 0.02);
1048
+ addBox(leafGroup, hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, handleX, handleY, faceZ - leafDepth / 2 - 0.02);
471
1049
  }
472
- function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
473
- const openAmount = clampDoorOperationState(operationState);
1050
+ function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
474
1051
  const activeOnRight = slideDirection === 'left';
475
1052
  const fixedSign = activeOnRight ? -1 : 1;
476
1053
  const activeSign = activeOnRight ? 1 : -1;
@@ -478,16 +1055,22 @@ function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
478
1055
  const panelHeight = leafHeight;
479
1056
  const closedActiveX = activeSign * insideWidth * 0.23;
480
1057
  const fixedX = fixedSign * insideWidth * 0.23;
481
- const activeX = closedActiveX - activeSign * insideWidth * 0.44 * openAmount;
482
1058
  const frontZ = leafDepth / 2 + 0.016;
483
1059
  const backZ = -leafDepth / 2 - 0.006;
484
1060
  const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02);
485
1061
  const handleY = handleHeight - doorHeight / 2;
486
- const handleX = activeX + activeSign * (panelWidth / 2 - 0.06);
487
- addBox(mesh, revealMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0);
488
- addBox(mesh, revealMaterial, insideWidth, 0.018, Math.max(frameDepth * 0.28, 0.022), 0, -leafHeight / 2 + 0.04, 0);
1062
+ // Active panel + handle ride the SLIDING_ACTIVE_PANEL group, built at the
1063
+ // closed position; `poseDoorMovingParts` slides the group behind the fixed
1064
+ // panel. The fixed panel and rails stay static on the mesh.
1065
+ const handleX = closedActiveX + activeSign * (panelWidth / 2 - 0.06);
1066
+ currentDoorSlot = 'hardware';
1067
+ addBox(mesh, hardwareMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0);
1068
+ addBox(mesh, hardwareMaterial, insideWidth, 0.018, Math.max(frameDepth * 0.28, 0.022), 0, -leafHeight / 2 + 0.04, 0);
489
1069
  const addFixedPanelBox = (material, w, h, d, x, y, z) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ);
490
- const addActivePanelBox = (material, w, h, d, x, y, z) => addBox(mesh, material, w, h, d, x + activeX, y, z + frontZ);
1070
+ const activePanelGroup = new THREE.Group();
1071
+ activePanelGroup.name = SLIDING_ACTIVE_PANEL_NAME;
1072
+ mesh.add(activePanelGroup);
1073
+ const addActivePanelBox = (material, w, h, d, x, y, z) => addBox(activePanelGroup, material, w, h, d, x + closedActiveX, y, z + frontZ);
491
1074
  addLeafSegmentContent({
492
1075
  addLeafBox: addFixedPanelBox,
493
1076
  leafWidth: panelWidth,
@@ -510,51 +1093,39 @@ function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
510
1093
  contentPadding,
511
1094
  keepFrameWhenEmpty: true,
512
1095
  });
513
- addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01);
514
- addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ - leafDepth / 2 - 0.01);
1096
+ currentDoorSlot = 'hardware';
1097
+ addBox(activePanelGroup, hardwareMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01);
1098
+ addBox(activePanelGroup, hardwareMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ - leafDepth / 2 - 0.01);
515
1099
  }
516
- function addGarageSectionalDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, garagePanelCount, }) {
517
- const openAmount = getDoorRenderOpenAmount('garage-sectional', operationState);
1100
+ function addGarageSectionalDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, garagePanelCount, }) {
518
1101
  const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount)));
519
1102
  const panelHeight = leafHeight / panelCount;
520
1103
  const panelGap = Math.min(0.012, panelHeight * 0.08);
521
1104
  const travelDepth = Math.max(leafHeight, 1.4);
522
- const curveRadius = panelHeight * 0.58;
523
- const curveLength = (Math.PI / 2) * curveRadius;
524
- const travel = openAmount * ((panelCount - 1) * panelHeight + curveLength + panelHeight * 0.65);
525
- const overheadY = leafCenterY + leafHeight / 2 - panelHeight / 2;
526
1105
  const railY = leafCenterY + leafHeight / 2 - 0.04;
527
1106
  const railZ = -travelDepth / 2;
528
- addBox(mesh, revealMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, -insideWidth / 2 + 0.035, railY, railZ);
529
- addBox(mesh, revealMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, insideWidth / 2 - 0.035, railY, railZ);
1107
+ currentDoorSlot = 'hardware';
1108
+ addBox(mesh, hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, -insideWidth / 2 + 0.035, railY, railZ);
1109
+ addBox(mesh, hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, insideWidth / 2 - 0.035, railY, railZ);
1110
+ // Each panel is built flat (centred at its group origin) and the trims sit on
1111
+ // the panel's front face in local space; `poseDoorMovingParts` rides each
1112
+ // group along the overhead curve. Panel order, geometry and trim placement
1113
+ // match the inline curve math (which the pose function reuses).
1114
+ const revealOffset = (panelHeight - panelGap) * 0.22;
1115
+ const trimDepth = 0.01;
1116
+ const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006;
530
1117
  for (let index = 0; index < panelCount; index++) {
531
- const orderFromTop = panelCount - 1 - index;
532
- const pathPosition = travel - orderFromTop * panelHeight;
533
- let y = overheadY + pathPosition;
534
- let z = 0;
535
- let rotationX = 0;
536
- if (pathPosition > 0 && pathPosition <= curveLength) {
537
- const theta = pathPosition / curveRadius;
538
- rotationX = -theta;
539
- y = overheadY + curveRadius * Math.sin(theta);
540
- z = -curveRadius * (1 - Math.cos(theta));
541
- }
542
- else if (pathPosition > curveLength) {
543
- rotationX = -Math.PI / 2;
544
- y = overheadY + curveRadius;
545
- z = -(curveRadius + pathPosition - curveLength);
546
- }
547
- const revealOffset = (panelHeight - panelGap) * 0.22;
548
- const trimDepth = 0.01;
549
- const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006;
550
- const addSectionalTrim = (localY) => {
551
- addBoxWithRotation(mesh, revealMaterial, insideWidth - 0.16, 0.012, trimDepth, 0, y + localY * Math.cos(rotationX) - trimFaceOffset * Math.sin(rotationX), z + localY * Math.sin(rotationX) + trimFaceOffset * Math.cos(rotationX), [rotationX, 0, 0]);
552
- };
553
- addBoxWithRotation(mesh, baseMaterial, insideWidth, Math.max(0.04, panelHeight - panelGap), leafDepth, 0, y, z, [rotationX, 0, 0]);
554
- addSectionalTrim(revealOffset);
555
- addSectionalTrim(-revealOffset);
556
- }
557
- addBox(mesh, revealMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0);
1118
+ const group = new THREE.Group();
1119
+ group.name = `${SECTIONAL_PANEL_NAME}${index}`;
1120
+ mesh.add(group);
1121
+ currentDoorSlot = 'panel';
1122
+ addBox(group, baseMaterial, insideWidth, Math.max(0.04, panelHeight - panelGap), leafDepth, 0, 0, 0);
1123
+ currentDoorSlot = undefined;
1124
+ addBox(group, revealMaterial, insideWidth - 0.16, 0.012, trimDepth, 0, revealOffset, trimFaceOffset);
1125
+ addBox(group, revealMaterial, insideWidth - 0.16, 0.012, trimDepth, 0, -revealOffset, trimFaceOffset);
1126
+ }
1127
+ currentDoorSlot = 'hardware';
1128
+ addBox(mesh, hardwareMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0);
558
1129
  }
559
1130
  function addGarageRollupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, }) {
560
1131
  const openAmount = clampDoorOperationState(operationState);
@@ -562,52 +1133,83 @@ function addGarageRollupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafD
562
1133
  const visibleHeight = leafHeight * (1 - openAmount);
563
1134
  const visibleSlatCount = Math.ceil(visibleHeight / slatHeight);
564
1135
  const topY = leafCenterY + leafHeight / 2;
565
- const curtainCenterY = topY - visibleHeight / 2;
566
1136
  const drumMaxRadius = Math.max(0.12, Math.min(0.22, leafHeight * 0.075));
567
1137
  const drumY = topY + drumMaxRadius * 0.12;
568
1138
  const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72;
569
- addBox(mesh, revealMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), -insideWidth / 2 + 0.03, leafCenterY, 0);
570
- addBox(mesh, revealMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), insideWidth / 2 - 0.03, leafCenterY, 0);
1139
+ currentDoorSlot = 'hardware';
1140
+ addBox(mesh, hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), -insideWidth / 2 + 0.03, leafCenterY, 0);
1141
+ addBox(mesh, hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), insideWidth / 2 - 0.03, leafCenterY, 0);
571
1142
  if (visibleHeight > 0.01) {
572
- addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0);
1143
+ // Wrap the visible curtain in a group pivoted at the lintel (topY). The live
1144
+ // door keeps rebuilding the full slat detail per `operationState` (this group
1145
+ // is just an organisational wrapper at scale 1 — world positions unchanged),
1146
+ // but it gives the GLB exporter a single node to scale up into the header as
1147
+ // an open clip, since the slats can't literally vanish in a glTF animation.
1148
+ const curtain = new THREE.Group();
1149
+ curtain.name = ROLLUP_CURTAIN_NAME;
1150
+ curtain.position.set(0, topY, 0);
1151
+ mesh.add(curtain);
1152
+ currentDoorSlot = 'panel';
1153
+ addBox(curtain, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, -visibleHeight / 2, 0);
1154
+ currentDoorSlot = undefined;
573
1155
  for (let index = 0; index < visibleSlatCount; index++) {
574
- const y = topY - Math.min(visibleHeight, index * slatHeight);
575
- addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012);
1156
+ const y = -Math.min(visibleHeight, index * slatHeight);
1157
+ addBox(curtain, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012);
576
1158
  }
577
- addBox(mesh, revealMaterial, insideWidth - 0.04, 0.028, leafDepth + 0.018, 0, topY - visibleHeight, leafDepth / 2 + 0.004);
1159
+ const bottomBarHeight = 0.028;
1160
+ addBox(curtain, revealMaterial, insideWidth - 0.04, bottomBarHeight, leafDepth + 0.018, 0, -visibleHeight + bottomBarHeight / 2, leafDepth / 2 + 0.004);
578
1161
  }
1162
+ currentDoorSlot = 'panel';
579
1163
  const drum = new THREE.Mesh(new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36), baseMaterial);
580
1164
  drum.position.set(0, drumY, drumZ);
581
1165
  drum.rotation.z = Math.PI / 2;
1166
+ tagDoorSlot(drum);
582
1167
  mesh.add(drum);
583
- addBox(mesh, revealMaterial, insideWidth + frameThickness, 0.026, Math.max(frameDepth * 0.52, 0.04), 0, topY + 0.02, 0);
1168
+ currentDoorSlot = 'hardware';
1169
+ addBox(mesh, hardwareMaterial, insideWidth + frameThickness, 0.026, Math.max(frameDepth * 0.52, 0.04), 0, topY + 0.02, 0);
584
1170
  }
585
- function addGarageTiltupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, }) {
586
- const openAmount = clampDoorOperationState(operationState);
587
- const angle = (Math.PI / 2) * openAmount;
1171
+ function addGarageTiltupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, }) {
588
1172
  const hingeY = leafCenterY + leafHeight / 2;
589
- const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2);
590
- const panelCenterZ = -Math.sin(angle) * (leafHeight / 2);
1173
+ // Leaf built closed (hanging from the lintel); `poseDoorMovingParts` rotates
1174
+ // the group about the top hinge to open it. Rails + top bar stay static.
1175
+ const panelCenterY = hingeY - leafHeight / 2;
591
1176
  const railLength = Math.max(leafHeight * 0.72, 1.2);
592
1177
  const railY = hingeY - frameThickness * 0.35;
593
1178
  const railZ = -railLength / 2;
594
- addBox(mesh, revealMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, -insideWidth / 2 + 0.04, railY, railZ);
595
- addBox(mesh, revealMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, insideWidth / 2 - 0.04, railY, railZ);
596
- addBoxWithRotation(mesh, baseMaterial, insideWidth, leafHeight, leafDepth, 0, panelCenterY, panelCenterZ, [-angle, 0, 0]);
1179
+ currentDoorSlot = 'hardware';
1180
+ addBox(mesh, hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, -insideWidth / 2 + 0.04, railY, railZ);
1181
+ addBox(mesh, hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, insideWidth / 2 - 0.04, railY, railZ);
1182
+ const leafGroup = new THREE.Group();
1183
+ leafGroup.name = TILTUP_LEAF_NAME;
1184
+ mesh.add(leafGroup);
1185
+ currentDoorSlot = 'panel';
1186
+ addBox(leafGroup, baseMaterial, insideWidth, leafHeight, leafDepth, 0, panelCenterY, 0);
597
1187
  const insetWidth = Math.max(0.1, insideWidth - 0.22);
598
1188
  const insetHeight = Math.max(0.1, leafHeight - 0.28);
599
1189
  const trimDepth = 0.012;
600
1190
  const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006;
601
1191
  const addTiltupTrim = (localX, localY, trimWidth, trimHeight) => {
602
- addBoxWithRotation(mesh, revealMaterial, trimWidth, trimHeight, trimDepth, localX, panelCenterY + localY * Math.cos(angle) + trimFaceOffset * Math.sin(angle), panelCenterZ - localY * Math.sin(angle) + trimFaceOffset * Math.cos(angle), [-angle, 0, 0]);
1192
+ currentDoorSlot = undefined;
1193
+ addBox(leafGroup, revealMaterial, trimWidth, trimHeight, trimDepth, localX, panelCenterY + localY, trimFaceOffset);
603
1194
  };
604
1195
  addTiltupTrim(0, insetHeight / 2, insetWidth, 0.018);
605
1196
  addTiltupTrim(0, -insetHeight / 2, insetWidth, 0.018);
606
1197
  addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight);
607
1198
  addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight);
608
- addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0);
1199
+ currentDoorSlot = 'hardware';
1200
+ addBox(mesh, hardwareMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0);
1201
+ }
1202
+ function getEffectiveOpeningShape(node) {
1203
+ return node.doorType === 'folding' ||
1204
+ node.doorType === 'pocket' ||
1205
+ node.doorType === 'barn' ||
1206
+ node.doorType === 'sliding'
1207
+ ? 'rectangle'
1208
+ : (node.openingShape ?? 'rectangle');
609
1209
  }
610
- function updateDoorMesh(node, mesh) {
1210
+ function updateDoorMesh(rawNode, mesh) {
1211
+ const node = normalizeDoorNodeForRender(rawNode);
1212
+ currentDoorSlot = undefined;
611
1213
  // Root mesh is an invisible hitbox; all visuals live in child meshes
612
1214
  mesh.geometry.dispose();
613
1215
  mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth);
@@ -622,7 +1224,15 @@ function updateDoorMesh(node, mesh) {
622
1224
  disposeObject(child);
623
1225
  mesh.remove(child);
624
1226
  }
625
- const { width, height, openingKind, openingShape, frameThickness, frameDepth, threshold, thresholdHeight, segments, handle, handleHeight, handleSide, doorCloser, panicBar, panicBarHeight, contentPadding, hingesSide, swingDirection, swingAngle: nodeSwingAngle = 0, doorType = 'hinged', operationState: nodeOperationState = 0, leafCount = 1, slideDirection = 'left', garagePanelCount = 4, } = node;
1227
+ // Point the builder-facing materials at this door's slot overrides for the
1228
+ // duration of its build (recomputed per node, so the next door resets cleanly
1229
+ // without a restore). Reveal keeps its own material.
1230
+ baseMaterial = resolveDoorSlotMaterial(node, 'panel');
1231
+ frameMaterial = resolveDoorSlotMaterial(node, 'frame');
1232
+ glassMaterial = resolveDoorSlotMaterial(node, 'glass');
1233
+ hardwareMaterial = resolveDoorSlotMaterial(node, 'hardware');
1234
+ const { width, height, openingKind, openingShape: rawOpeningShape, frameThickness, frameDepth, threshold, thresholdHeight, segments, handle, handleHeight, handleSide, doorCloser, panicBar, panicBarHeight, contentPadding, hingesSide, swingDirection, swingAngle: nodeSwingAngle = 0, doorType = 'hinged', operationState: nodeOperationState = 0, leafCount = 1, slideDirection = 'left', garagePanelCount = 4, } = node;
1235
+ const openingShape = getEffectiveOpeningShape(node) ?? rawOpeningShape;
626
1236
  const runtimeDoorState = useInteractive.getState().doors[node.id];
627
1237
  const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle;
628
1238
  const operationState = runtimeDoorState?.operationState ?? nodeOperationState;
@@ -637,6 +1247,7 @@ function updateDoorMesh(node, mesh) {
637
1247
  const leafCenterY = -frameThickness / 2;
638
1248
  const swingDirectionSign = swingDirection === 'inward' ? 1 : -1;
639
1249
  // ── Frame members ──
1250
+ currentDoorSlot = 'frame';
640
1251
  if (openingShape === 'arch') {
641
1252
  const frameBottom = -height / 2;
642
1253
  const frameTop = height / 2;
@@ -647,26 +1258,27 @@ function updateDoorMesh(node, mesh) {
647
1258
  const useShallowHeadBar = frameArchHeight <= frameThickness * 2;
648
1259
  const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY;
649
1260
  const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01);
650
- addBox(mesh, baseMaterial, frameThickness, postHeight, frameDepth, -width / 2 + frameThickness / 2, frameBottom + postHeight / 2, 0);
651
- addBox(mesh, baseMaterial, frameThickness, postHeight, frameDepth, width / 2 - frameThickness / 2, frameBottom + postHeight / 2, 0);
652
- addShape(mesh, baseMaterial, useShallowHeadBar
1261
+ addBox(mesh, frameMaterial, frameThickness, postHeight, frameDepth, -width / 2 + frameThickness / 2, frameBottom + postHeight / 2, 0);
1262
+ addBox(mesh, frameMaterial, frameThickness, postHeight, frameDepth, width / 2 - frameThickness / 2, frameBottom + postHeight / 2, 0);
1263
+ addShape(mesh, frameMaterial, useShallowHeadBar
653
1264
  ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop)
654
1265
  : createArchBandShape(width, frameSpringY, frameTop, frameInnerSpringY, frameInnerTopY, frameThickness), frameDepth);
655
1266
  }
656
1267
  else if (openingShape === 'rounded') {
657
- addShape(mesh, baseMaterial, createRoundedDoorFrameShape(width, height, frameThickness, getDoorTopRadii(node, width, height)), frameDepth);
1268
+ addShape(mesh, frameMaterial, createRoundedDoorFrameShape(width, height, frameThickness, getDoorTopRadii(node, width, height)), frameDepth);
658
1269
  }
659
1270
  else {
660
1271
  // Left post — full height
661
- addBox(mesh, baseMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
1272
+ addBox(mesh, frameMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
662
1273
  // Right post — full height
663
- addBox(mesh, baseMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0);
1274
+ addBox(mesh, frameMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0);
664
1275
  // Head (top bar) — full width
665
- addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1276
+ addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
666
1277
  }
667
1278
  // ── Threshold (inside the frame) ──
668
1279
  if (threshold) {
669
- addBox(mesh, baseMaterial, insideWidth, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0);
1280
+ currentDoorSlot = 'frame';
1281
+ addBox(mesh, frameMaterial, insideWidth, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0);
670
1282
  }
671
1283
  if (doorType === 'garage-sectional') {
672
1284
  addGarageSectionalDoor(mesh, {
@@ -676,7 +1288,6 @@ function updateDoorMesh(node, mesh) {
676
1288
  leafDepth,
677
1289
  frameThickness,
678
1290
  frameDepth,
679
- operationState,
680
1291
  garagePanelCount,
681
1292
  });
682
1293
  }
@@ -699,7 +1310,6 @@ function updateDoorMesh(node, mesh) {
699
1310
  leafDepth,
700
1311
  frameThickness,
701
1312
  frameDepth,
702
- operationState,
703
1313
  });
704
1314
  }
705
1315
  else if (doorType === 'folding') {
@@ -710,7 +1320,6 @@ function updateDoorMesh(node, mesh) {
710
1320
  leafDepth,
711
1321
  frameThickness,
712
1322
  frameDepth,
713
- operationState,
714
1323
  leafCount,
715
1324
  doorHeight: height,
716
1325
  handleHeight,
@@ -726,7 +1335,6 @@ function updateDoorMesh(node, mesh) {
726
1335
  leafDepth,
727
1336
  frameThickness,
728
1337
  frameDepth,
729
- operationState,
730
1338
  slideDirection,
731
1339
  doorHeight: height,
732
1340
  handleHeight,
@@ -742,7 +1350,6 @@ function updateDoorMesh(node, mesh) {
742
1350
  leafDepth,
743
1351
  frameThickness,
744
1352
  frameDepth,
745
- operationState,
746
1353
  slideDirection,
747
1354
  doorHeight: height,
748
1355
  handleHeight,
@@ -758,7 +1365,6 @@ function updateDoorMesh(node, mesh) {
758
1365
  leafDepth,
759
1366
  frameThickness,
760
1367
  frameDepth,
761
- operationState,
762
1368
  slideDirection,
763
1369
  doorHeight: height,
764
1370
  handleHeight,
@@ -768,6 +1374,14 @@ function updateDoorMesh(node, mesh) {
768
1374
  }
769
1375
  else if (doorType === 'double' || doorType === 'french') {
770
1376
  const doubleLeafW = insideWidth / 2;
1377
+ const fullLeafTopRadii = getDoorTopRadii(node, insideWidth, leafH);
1378
+ const roundedBoundary = openingShape === 'rounded'
1379
+ ? {
1380
+ fullLeft: -insideWidth / 2,
1381
+ fullRight: insideWidth / 2,
1382
+ radii: fullLeafTopRadii,
1383
+ }
1384
+ : undefined;
771
1385
  addDoorLeaf(mesh, {
772
1386
  leafWidth: doubleLeafW,
773
1387
  leafHeight: leafH,
@@ -777,6 +1391,7 @@ function updateDoorMesh(node, mesh) {
777
1391
  hingeX: -insideWidth / 2,
778
1392
  hingeSide: 'left',
779
1393
  swingRotation: -clampedSwingAngle * swingDirectionSign,
1394
+ openRotationY: (-Math.PI / 2) * swingDirectionSign,
780
1395
  segments,
781
1396
  contentPadding,
782
1397
  handle,
@@ -787,6 +1402,13 @@ function updateDoorMesh(node, mesh) {
787
1402
  panicBar,
788
1403
  panicBarHeight,
789
1404
  doorHeight: height,
1405
+ openingShape,
1406
+ openingTopRadii: openingShape === 'rounded'
1407
+ ? { topLeft: fullLeafTopRadii.topLeft, topRight: 0 }
1408
+ : fullLeafTopRadii,
1409
+ archHeight: node.archHeight ?? 0.45,
1410
+ roundedBoundary,
1411
+ archOuterSide: openingShape === 'arch' ? 'left' : undefined,
790
1412
  });
791
1413
  addDoorLeaf(mesh, {
792
1414
  leafWidth: doubleLeafW,
@@ -797,6 +1419,7 @@ function updateDoorMesh(node, mesh) {
797
1419
  hingeX: insideWidth / 2,
798
1420
  hingeSide: 'right',
799
1421
  swingRotation: clampedSwingAngle * swingDirectionSign,
1422
+ openRotationY: (Math.PI / 2) * swingDirectionSign,
800
1423
  segments,
801
1424
  contentPadding,
802
1425
  handle,
@@ -807,6 +1430,13 @@ function updateDoorMesh(node, mesh) {
807
1430
  panicBar,
808
1431
  panicBarHeight,
809
1432
  doorHeight: height,
1433
+ openingShape,
1434
+ openingTopRadii: openingShape === 'rounded'
1435
+ ? { topLeft: 0, topRight: fullLeafTopRadii.topRight }
1436
+ : fullLeafTopRadii,
1437
+ archHeight: node.archHeight ?? 0.45,
1438
+ roundedBoundary,
1439
+ archOuterSide: openingShape === 'arch' ? 'right' : undefined,
810
1440
  });
811
1441
  }
812
1442
  else {
@@ -821,6 +1451,7 @@ function updateDoorMesh(node, mesh) {
821
1451
  hingeX,
822
1452
  hingeSide: hingesSide,
823
1453
  swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
1454
+ openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
824
1455
  segments,
825
1456
  contentPadding,
826
1457
  handle,
@@ -831,37 +1462,90 @@ function updateDoorMesh(node, mesh) {
831
1462
  panicBar,
832
1463
  panicBarHeight,
833
1464
  doorHeight: height,
1465
+ openingShape,
1466
+ openingTopRadii: getDoorTopRadii(node, insideWidth, leafH),
1467
+ archHeight: node.archHeight ?? 0.45,
834
1468
  });
835
1469
  }
1470
+ // Operation doors build their moving parts at the closed pose inside named
1471
+ // groups; reflect the door's current `operationState` by posing them. Roll-up
1472
+ // is excluded: its live rebuild already renders the open state at full detail
1473
+ // (the named curtain group is only there for the GLB exporter's scale clip).
1474
+ if (doorType !== 'garage-rollup') {
1475
+ poseDoorMovingParts(node, mesh, operationState);
1476
+ }
836
1477
  syncDoorCutout(node, mesh);
1478
+ // Guard: some degenerate door configs can leave a child mesh with an
1479
+ // empty (0-vertex) geometry — e.g. a zero-area extruded leaf frame.
1480
+ // Submitting such a mesh trips a WebGPU error ("Vertex buffer slot 0
1481
+ // … was not set" on a Draw(0, …)). Hide any empty mesh so it is never
1482
+ // drawn (it would render nothing anyway).
1483
+ hideEmptyGeometryMeshes(mesh);
1484
+ }
1485
+ function hideEmptyGeometryMeshes(root) {
1486
+ root.traverse((obj) => {
1487
+ const child = obj;
1488
+ if (!child.isMesh || !child.geometry)
1489
+ return;
1490
+ const position = child.geometry.getAttribute('position');
1491
+ if (!position || position.count === 0)
1492
+ child.visible = false;
1493
+ });
837
1494
  }
838
1495
  function syncDoorCutout(node, mesh) {
839
- // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
1496
+ // ── Cutout: invisible raycast hit target for the whole opening ──
840
1497
  let cutout = mesh.getObjectByName('cutout');
841
1498
  if (!cutout) {
842
1499
  cutout = new THREE.Mesh();
843
1500
  cutout.name = 'cutout';
1501
+ // The cutout (invisible) is proud of the wall on both faces, so it wins the
1502
+ // scene raycast over the wall in front of the recessed door body — making it
1503
+ // the selection AND paint hit target for the whole opening. The paint
1504
+ // capability then re-raycasts the door's parts to find the slot. Its depth
1505
+ // is snug to the wall (not 1m) so it no longer blankets the room floor in a
1506
+ // top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
844
1507
  mesh.add(cutout);
845
1508
  }
846
1509
  cutout.geometry.dispose();
847
- if (node.openingShape === 'arch') {
1510
+ const depth = resolveOpeningCutoutProxyDepth(node);
1511
+ const openingShape = getEffectiveOpeningShape(node);
1512
+ if (openingShape === 'arch') {
848
1513
  cutout.geometry = new THREE.ExtrudeGeometry(createArchShape(-node.width / 2, node.width / 2, -node.height / 2, node.height / 2, getClampedArchHeight(node.width, node.height, node.archHeight)), {
849
- depth: 1,
1514
+ depth,
850
1515
  bevelEnabled: false,
851
1516
  curveSegments: 24,
852
1517
  });
853
- cutout.geometry.translate(0, 0, -0.5);
1518
+ cutout.geometry.translate(0, 0, -depth / 2);
854
1519
  }
855
- else if (node.openingShape === 'rounded') {
1520
+ else if (openingShape === 'rounded') {
856
1521
  cutout.geometry = new THREE.ExtrudeGeometry(createRoundedTopShape(-node.width / 2, node.width / 2, -node.height / 2, node.height / 2, getDoorTopRadii(node, node.width, node.height)), {
857
- depth: 1,
1522
+ depth,
858
1523
  bevelEnabled: false,
859
1524
  curveSegments: 24,
860
1525
  });
861
- cutout.geometry.translate(0, 0, -0.5);
1526
+ cutout.geometry.translate(0, 0, -depth / 2);
862
1527
  }
863
1528
  else {
864
- cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0);
1529
+ cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth);
865
1530
  }
866
1531
  cutout.visible = false;
867
1532
  }
1533
+ // Resolve the cutout proxy depth from the opening's parent wall thickness so
1534
+ // the proxy stays proud of both wall faces (front/back selection) without the
1535
+ // old 1m depth that blanketed the floor. Falls back to the default thickness
1536
+ // when the parent wall isn't a resolvable wall node.
1537
+ function resolveOpeningCutoutProxyDepth(node) {
1538
+ const parentId = node.parentId;
1539
+ const parent = parentId ? useScene.getState().nodes[parentId] : undefined;
1540
+ const wallThickness = parent?.type === 'wall' ? getWallThickness(parent) : DEFAULT_WALL_THICKNESS;
1541
+ return getOpeningCutoutProxyDepth(wallThickness);
1542
+ }
1543
+ /**
1544
+ * Build a fresh door mesh for preview/ghost rendering.
1545
+ * Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
1546
+ */
1547
+ export function buildDoorPreviewMesh(node) {
1548
+ const mesh = new THREE.Mesh();
1549
+ updateDoorMesh(node, mesh);
1550
+ return mesh;
1551
+ }