@pascal-app/viewer 0.9.1 → 1.0.0-beta.1

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 (206) hide show
  1. package/README.md +20 -5
  2. package/dist/components/error-boundary.d.ts +6 -0
  3. package/dist/components/error-boundary.d.ts.map +1 -1
  4. package/dist/components/error-boundary.js +6 -0
  5. package/dist/components/renderers/node-renderer.d.ts +5 -1
  6. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  7. package/dist/components/renderers/node-renderer.js +5 -2
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  9. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  10. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  11. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  12. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  13. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  14. package/dist/components/viewer/frame-limiter.js +59 -4
  15. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  16. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  17. package/dist/components/viewer/glb-interactive.js +394 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  19. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  20. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  22. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  23. package/dist/components/viewer/glb-replace-instances.js +61 -0
  24. package/dist/components/viewer/glb-scene.d.ts +54 -0
  25. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  26. package/dist/components/viewer/glb-scene.js +920 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts +22 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.js +445 -0
  30. package/dist/components/viewer/index.d.ts +19 -2
  31. package/dist/components/viewer/index.d.ts.map +1 -1
  32. package/dist/components/viewer/index.js +113 -12
  33. package/dist/components/viewer/lights.d.ts.map +1 -1
  34. package/dist/components/viewer/lights.js +36 -5
  35. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  36. package/dist/components/viewer/perf-monitor.js +17 -2
  37. package/dist/components/viewer/post-processing.d.ts +7 -1
  38. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  39. package/dist/components/viewer/post-processing.js +180 -24
  40. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  41. package/dist/components/viewer/registered-systems.js +6 -4
  42. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  43. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  44. package/dist/components/viewer/scene-bvh.js +3 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  46. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  47. package/dist/components/viewer/scene-bvh.test.js +13 -0
  48. package/dist/components/viewer/scene-environment.d.ts +2 -0
  49. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  50. package/dist/components/viewer/scene-environment.js +79 -0
  51. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  52. package/dist/components/viewer/selection-manager.js +23 -12
  53. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  54. package/dist/hooks/use-gltf-ktx2.js +2 -24
  55. package/dist/hooks/use-node-events.d.ts +1 -1
  56. package/dist/hooks/use-node-events.d.ts.map +1 -1
  57. package/dist/hooks/use-node-events.js +22 -14
  58. package/dist/index.d.ts +24 -10
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +29 -13
  61. package/dist/lib/backdrop.d.ts +33 -0
  62. package/dist/lib/backdrop.d.ts.map +1 -0
  63. package/dist/lib/backdrop.js +92 -0
  64. package/dist/lib/box-uv.d.ts +13 -0
  65. package/dist/lib/box-uv.d.ts.map +1 -0
  66. package/dist/lib/box-uv.js +32 -0
  67. package/dist/lib/csg-utils.d.ts +3 -9
  68. package/dist/lib/csg-utils.d.ts.map +1 -1
  69. package/dist/lib/csg-utils.js +65 -5
  70. package/dist/lib/csg-utils.test.d.ts +2 -0
  71. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  72. package/dist/lib/csg-utils.test.js +45 -0
  73. package/dist/lib/edge-style.d.ts +1 -0
  74. package/dist/lib/edge-style.d.ts.map +1 -1
  75. package/dist/lib/edge-style.js +26 -6
  76. package/dist/lib/hero-pose.d.ts +47 -0
  77. package/dist/lib/hero-pose.d.ts.map +1 -0
  78. package/dist/lib/hero-pose.js +227 -0
  79. package/dist/lib/ink-edges.d.ts +1 -1
  80. package/dist/lib/ink-edges.d.ts.map +1 -1
  81. package/dist/lib/ink-edges.js +13 -7
  82. package/dist/lib/ktx2-loader.d.ts +46 -0
  83. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  84. package/dist/lib/ktx2-loader.js +142 -0
  85. package/dist/lib/layers.d.ts +10 -0
  86. package/dist/lib/layers.d.ts.map +1 -1
  87. package/dist/lib/layers.js +10 -0
  88. package/dist/lib/materials.d.ts +16 -1
  89. package/dist/lib/materials.d.ts.map +1 -1
  90. package/dist/lib/materials.js +158 -34
  91. package/dist/lib/materials.test.d.ts +2 -0
  92. package/dist/lib/materials.test.d.ts.map +1 -0
  93. package/dist/lib/materials.test.js +25 -0
  94. package/dist/lib/merged-outline-node.test.d.ts +2 -0
  95. package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
  96. package/dist/lib/merged-outline-node.test.js +22 -0
  97. package/dist/lib/scene-themes.d.ts +1 -0
  98. package/dist/lib/scene-themes.d.ts.map +1 -1
  99. package/dist/lib/scene-themes.js +25 -16
  100. package/dist/lib/shadow-only.d.ts +4 -0
  101. package/dist/lib/shadow-only.d.ts.map +1 -0
  102. package/dist/lib/shadow-only.js +36 -0
  103. package/dist/lib/snapshot-pipeline.d.ts +42 -0
  104. package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
  105. package/dist/lib/snapshot-pipeline.js +254 -0
  106. package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
  107. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
  108. package/dist/lib/terrain-perimeter-fill.js +75 -0
  109. package/dist/lib/texture-node-guard.d.ts +13 -0
  110. package/dist/lib/texture-node-guard.d.ts.map +1 -0
  111. package/dist/lib/texture-node-guard.js +39 -0
  112. package/dist/lib/texture-reference.d.ts +31 -0
  113. package/dist/lib/texture-reference.d.ts.map +1 -0
  114. package/dist/lib/texture-reference.js +139 -0
  115. package/dist/lib/texture-reference.test.d.ts +2 -0
  116. package/dist/lib/texture-reference.test.d.ts.map +1 -0
  117. package/dist/lib/texture-reference.test.js +86 -0
  118. package/dist/lib/tsl-compat.d.ts +12 -0
  119. package/dist/lib/tsl-compat.d.ts.map +1 -0
  120. package/dist/lib/tsl-compat.js +13 -0
  121. package/dist/store/use-viewer.d.ts +73 -18
  122. package/dist/store/use-viewer.d.ts.map +1 -1
  123. package/dist/store/use-viewer.js +191 -4
  124. package/dist/store/use-viewer.test.d.ts +2 -0
  125. package/dist/store/use-viewer.test.d.ts.map +1 -0
  126. package/dist/store/use-viewer.test.js +61 -0
  127. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  128. package/dist/systems/ceiling/ceiling-system.js +12 -5
  129. package/dist/systems/door/door-animation.test.d.ts +2 -0
  130. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  131. package/dist/systems/door/door-animation.test.js +51 -0
  132. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  133. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  134. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  135. package/dist/systems/door/door-system.d.ts +24 -0
  136. package/dist/systems/door/door-system.d.ts.map +1 -1
  137. package/dist/systems/door/door-system.js +494 -161
  138. package/dist/systems/fence/fence-system.d.ts +17 -0
  139. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  140. package/dist/systems/fence/fence-system.js +221 -53
  141. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  142. package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
  143. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  144. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  145. package/dist/systems/geometry/geometry-system.js +95 -26
  146. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  147. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  148. package/dist/systems/geometry/geometry-system.test.js +30 -0
  149. package/dist/systems/interactive/control-widget.d.ts +10 -0
  150. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  151. package/dist/systems/interactive/control-widget.js +35 -0
  152. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  153. package/dist/systems/interactive/interactive-system.js +55 -56
  154. package/dist/systems/item/item-system.d.ts.map +1 -1
  155. package/dist/systems/item/item-system.js +20 -6
  156. package/dist/systems/level/level-system.d.ts.map +1 -1
  157. package/dist/systems/level/level-system.js +34 -9
  158. package/dist/systems/level/level-utils.d.ts +0 -3
  159. package/dist/systems/level/level-utils.d.ts.map +1 -1
  160. package/dist/systems/level/level-utils.js +7 -45
  161. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  162. package/dist/systems/roof/roof-materials.js +29 -8
  163. package/dist/systems/roof/roof-system.d.ts +21 -8
  164. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  165. package/dist/systems/roof/roof-system.js +1079 -221
  166. package/dist/systems/slab/slab-system.d.ts +7 -4
  167. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  168. package/dist/systems/slab/slab-system.js +31 -83
  169. package/dist/systems/slab/slab-system.test.js +54 -14
  170. package/dist/systems/stair/stair-system.js +7 -4
  171. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  172. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  173. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  174. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  175. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  176. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  177. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  178. package/dist/systems/wall/wall-cutout.js +49 -17
  179. package/dist/systems/wall/wall-materials.d.ts +11 -7
  180. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  181. package/dist/systems/wall/wall-materials.js +244 -35
  182. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  183. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  184. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  185. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  186. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  187. package/dist/systems/wall/wall-support-extension.test.js +116 -0
  188. package/dist/systems/wall/wall-system.d.ts +4 -8
  189. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  190. package/dist/systems/wall/wall-system.js +397 -177
  191. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
  192. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
  193. package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
  194. package/dist/systems/window/window-animation-system.d.ts +12 -0
  195. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  196. package/dist/systems/window/window-animation-system.js +16 -5
  197. package/dist/systems/window/window-system.d.ts +7 -0
  198. package/dist/systems/window/window-system.d.ts.map +1 -1
  199. package/dist/systems/window/window-system.js +240 -14
  200. package/package.json +4 -4
  201. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  202. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  203. package/dist/components/viewer/ground-occluder.js +0 -67
  204. package/dist/components/viewer/walkthrough-controls.d.ts +0 -2
  205. package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
  206. package/dist/components/viewer/walkthrough-controls.js +0 -131
@@ -1,15 +1,37 @@
1
- import { clampDoorOperationState, DoorNode as DoorNodeSchema, getDoorRenderOpenAmount, getEffectiveNode, sceneRegistry, useInteractive, useLiveNodeOverrides, 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
3
  import { useEffect, useRef } from 'react';
4
4
  import * as THREE from 'three';
5
- import { createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, } from '../../lib/materials';
5
+ import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
6
+ import { createDefaultMaterial, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, resolveMaterialRef, } from '../../lib/materials';
6
7
  import useViewer from '../../store/use-viewer';
8
+ import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry';
7
9
  // Invisible material for root mesh — used as selection hitbox only
8
10
  const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false });
9
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);
10
23
  let baseMaterial = getBaseMaterial();
24
+ let frameMaterial = getBaseMaterial();
11
25
  let revealMaterial = defaultRevealMaterial;
12
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;
13
35
  const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' });
14
36
  const MAX_DOOR_REBUILDS_PER_FRAME = 16;
15
37
  const DOOR_PROGRESSIVE_DIRTY_THRESHOLD = MAX_DOOR_REBUILDS_PER_FRAME;
@@ -33,6 +55,7 @@ export const DoorSystem = () => {
33
55
  const shading = useViewer((state) => state.shading);
34
56
  const textures = useViewer((state) => state.textures);
35
57
  const colorPreset = useViewer((state) => state.colorPreset);
58
+ const sceneMaterials = useScene((state) => state.materials);
36
59
  const materialRevisionRef = useRef(null);
37
60
  // Subscribe so an override-only update (no scene write) still re-runs
38
61
  // the component, letting the gate below pick up the latest dirtyNodes
@@ -41,8 +64,10 @@ export const DoorSystem = () => {
41
64
  useLiveNodeOverrides((s) => s.overrides);
42
65
  const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset);
43
66
  baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial;
67
+ frameMaterial = textures ? getBaseMaterial(shading) : joineryMaterial;
44
68
  revealMaterial = textures ? defaultRevealMaterial : joineryMaterial;
45
69
  glassMaterial = textures ? defaultGlassMaterial : joineryMaterial;
70
+ hardwareMaterial = textures ? defaultHardwareMaterial : joineryMaterial;
46
71
  useEffect(() => {
47
72
  const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`;
48
73
  if (materialRevisionRef.current === materialRevision)
@@ -55,18 +80,37 @@ export const DoorSystem = () => {
55
80
  }
56
81
  }
57
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]);
58
96
  useFrame(() => {
59
97
  if (dirtyNodes.size === 0)
60
98
  return;
61
99
  const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset);
62
100
  baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial;
101
+ frameMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial;
63
102
  revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial;
64
103
  glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial;
104
+ hardwareMaterial = textures ? defaultHardwareMaterial : frameJoineryMaterial;
105
+ currentShading = shading;
106
+ currentTextures = textures;
107
+ currentColorPreset = colorPreset;
108
+ currentSceneMaterials = sceneMaterials;
65
109
  const nodes = useScene.getState().nodes;
66
110
  const dirtyDoorIds = [];
67
111
  dirtyNodes.forEach((id) => {
68
112
  const node = nodes[id];
69
- if (!node || node.type !== 'door')
113
+ if (node?.type !== 'door')
70
114
  return;
71
115
  dirtyDoorIds.push(id);
72
116
  });
@@ -84,7 +128,7 @@ export const DoorSystem = () => {
84
128
  }
85
129
  }
86
130
  const node = nodes[id];
87
- if (!node || node.type !== 'door')
131
+ if (node?.type !== 'door')
88
132
  continue;
89
133
  const mesh = sceneRegistry.nodes.get(id);
90
134
  if (!mesh)
@@ -107,21 +151,77 @@ export const DoorSystem = () => {
107
151
  }, 3);
108
152
  return null;
109
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
+ }
110
210
  function addBox(parent, material, w, h, d, x, y, z) {
111
- 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);
112
214
  m.position.set(x, y, z);
215
+ tagDoorSlot(m);
113
216
  parent.add(m);
114
217
  }
115
218
  function addRotatedBox(parent, material, w, h, d, x, y, z, rotationY) {
116
- 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);
117
222
  m.position.set(x, y, z);
118
223
  m.rotation.y = rotationY;
119
- parent.add(m);
120
- }
121
- function addBoxWithRotation(parent, material, w, h, d, x, y, z, rotation) {
122
- const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
123
- m.position.set(x, y, z);
124
- m.rotation.set(rotation[0], rotation[1], rotation[2]);
224
+ tagDoorSlot(m);
125
225
  parent.add(m);
126
226
  }
127
227
  function addShape(parent, material, shape, depth) {
@@ -132,6 +232,7 @@ function addShape(parent, material, shape, depth) {
132
232
  });
133
233
  geometry.translate(0, 0, -depth / 2);
134
234
  const mesh = new THREE.Mesh(geometry, material);
235
+ tagDoorSlot(mesh);
135
236
  parent.add(mesh);
136
237
  }
137
238
  function addShapeAt(parent, material, shape, depth, x, y, z) {
@@ -142,6 +243,7 @@ function addShapeAt(parent, material, shape, depth, x, y, z) {
142
243
  });
143
244
  geometry.translate(x, y, z - depth / 2);
144
245
  const mesh = new THREE.Mesh(geometry, material);
246
+ tagDoorSlot(mesh);
145
247
  parent.add(mesh);
146
248
  }
147
249
  function getClampedArchHeight(width, height, archHeight) {
@@ -443,11 +545,13 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
443
545
  const cpX = contentPadding[0];
444
546
  const cpY = contentPadding[1];
445
547
  if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) {
548
+ currentDoorSlot = 'panel';
446
549
  addLeafBox(baseMaterial, leafWidth, cpY, leafDepth, leafCenterX, leafCenterY + leafHeight / 2 - cpY / 2, 0);
447
550
  addLeafBox(baseMaterial, leafWidth, cpY, leafDepth, leafCenterX, leafCenterY - leafHeight / 2 + cpY / 2, 0);
448
551
  }
449
552
  if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) {
450
553
  const innerH = leafHeight - 2 * cpY;
554
+ currentDoorSlot = 'panel';
451
555
  addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafCenterX - leafWidth / 2 + cpX / 2, leafCenterY, 0);
452
556
  addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafCenterX + leafWidth / 2 - cpX / 2, leafCenterY, 0);
453
557
  }
@@ -496,6 +600,7 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
496
600
  }
497
601
  if (seg.type !== 'empty') {
498
602
  cx = leafCenterX - contentW / 2;
603
+ currentDoorSlot = 'panel';
499
604
  for (let c = 0; c < numCols - 1; c++) {
500
605
  cx += colWidths[c];
501
606
  const dividerLeft = cx;
@@ -516,6 +621,7 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
516
621
  const colW = colWidths[c];
517
622
  const colX = colXCenters[c];
518
623
  if (seg.type === 'glass') {
624
+ currentDoorSlot = 'glass';
519
625
  const glassDepth = Math.max(0.004, leafDepth * 0.15);
520
626
  const segmentLeft = colX - colW / 2;
521
627
  const segmentRight = colX + colW / 2;
@@ -530,6 +636,7 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
530
636
  }
531
637
  }
532
638
  else if (seg.type === 'panel') {
639
+ currentDoorSlot = 'panel';
533
640
  const segmentLeft = colX - colW / 2;
534
641
  const segmentRight = colX + colW / 2;
535
642
  const outerPanelShape = hasShapedTop && addLeafShape
@@ -565,11 +672,15 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
565
672
  segY -= segH;
566
673
  }
567
674
  }
568
- function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, hingeX, hingeSide, swingRotation, segments, contentPadding, handle, handleBothSides = false, handleHeight, handleSide, doorCloser, panicBar, panicBarHeight, doorHeight, openingShape, openingTopRadii, archHeight, roundedBoundary, archedBoundary, archOuterSide, }) {
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, }) {
569
676
  const hasLeafContent = segments.some((seg) => seg.type !== 'empty');
570
677
  const leafGroup = new THREE.Group();
571
678
  leafGroup.position.set(hingeX, 0, 0);
572
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 };
573
684
  mesh.add(leafGroup);
574
685
  const addLeafBox = (material, w, h, d, x, y, z) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z);
575
686
  const addLeafShape = (material, shape, depth) => addShapeAt(leafGroup, material, shape, depth, -hingeX, 0, 0);
@@ -578,6 +689,7 @@ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, le
578
689
  const leafTop = leafCenterY + leafHeight / 2;
579
690
  const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch';
580
691
  if (usesShapedLeafFrame && hasLeafContent) {
692
+ currentDoorSlot = 'panel';
581
693
  if (openingShape === 'rounded') {
582
694
  const roundedLeafShape = roundedBoundary
583
695
  ? createRoundedClippedLeafFrameShape(leafCenterX - leafWidth / 2, leafCenterX + leafWidth / 2, leafBottom, leafTop, roundedBoundary.fullLeft, roundedBoundary.fullRight, roundedBoundary.radii, contentPadding[0], contentPadding[1])
@@ -624,68 +736,217 @@ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, le
624
736
  archOuterSide,
625
737
  });
626
738
  if (hasLeafContent && handle) {
739
+ currentDoorSlot = 'hardware';
627
740
  const handleY = handleHeight - doorHeight / 2;
628
741
  const faceZ = leafDepth / 2;
629
742
  const handleX = handleSide === 'right'
630
743
  ? leafCenterX + leafWidth / 2 - 0.045
631
744
  : leafCenterX - leafWidth / 2 + 0.045;
632
- addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005);
633
- 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);
634
747
  if (handleBothSides) {
635
- addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005);
636
- 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);
637
750
  }
638
751
  }
639
752
  if (hasLeafContent && doorCloser) {
753
+ currentDoorSlot = 'hardware';
640
754
  const closerY = leafCenterY + leafHeight / 2 - 0.04;
641
- addLeafBox(baseMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03);
642
- 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);
643
757
  }
644
758
  if (hasLeafContent && panicBar) {
759
+ currentDoorSlot = 'hardware';
645
760
  const barY = panicBarHeight - doorHeight / 2;
646
- 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);
647
762
  }
648
763
  if (hasLeafContent) {
764
+ currentDoorSlot = 'hardware';
649
765
  const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012;
650
766
  const hingeH = 0.1;
651
767
  const hingeW = 0.024;
652
768
  const hingeD = leafDepth + 0.016;
653
- addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0);
654
- addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0);
655
- 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);
656
779
  }
657
780
  }
658
- function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, leafCount, doorHeight, handleHeight, segments, contentPadding, }) {
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;
929
+ }
930
+ }
931
+ function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, leafCount, doorHeight, handleHeight, segments, contentPadding, }) {
659
932
  const panelCount = leafCount === 2 ? 2 : 4;
660
- const foldAmount = clampDoorOperationState(operationState);
661
933
  const panelLength = insideWidth / panelCount;
662
- const foldAngle = Math.PI * 0.44 * foldAmount;
663
- 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);
664
- const vertices = [{ x: -insideWidth / 2, z: 0 }];
665
- for (let index = 0; index < panelCount; index++) {
666
- const previous = vertices[index];
667
- const direction = index % 2 === 0 ? -1 : 1;
668
- const angle = direction * foldAngle;
669
- vertices.push({
670
- x: previous.x + panelLength * Math.cos(angle),
671
- z: previous.z + panelLength * Math.sin(angle),
672
- });
673
- }
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;
674
941
  for (let index = 0; index < panelCount; index++) {
675
- const start = vertices[index];
676
- const end = vertices[index + 1];
677
- const dx = end.x - start.x;
678
- const dz = end.z - start.z;
679
- const centerX = (start.x + end.x) / 2;
680
- const centerZ = (start.z + end.z) / 2;
681
- const rotationY = Math.atan2(-dz, dx);
682
- const localX = {
683
- x: Math.cos(rotationY),
684
- z: -Math.sin(rotationY),
685
- };
686
- const addFoldingLeafBox = (material, w, h, d, x, y, z) => {
687
- addRotatedBox(mesh, material, w, h, d, centerX + localX.x * x + Math.sin(rotationY) * z, y, centerZ + localX.z * x + Math.cos(rotationY) * z, rotationY);
688
- };
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);
689
950
  addLeafSegmentContent({
690
951
  addLeafBox: addFoldingLeafBox,
691
952
  leafWidth: Math.max(0.08, panelLength),
@@ -697,79 +958,96 @@ function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
697
958
  contentPadding,
698
959
  keepFrameWhenEmpty: true,
699
960
  });
700
- for (const point of [start, end]) {
701
- 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);
702
965
  }
966
+ parent = group;
703
967
  }
704
- const handlePoint = vertices[vertices.length - 1];
968
+ // Handle on the free end of the last panel (its local x=panelLength edge).
705
969
  const handleY = handleHeight - doorHeight / 2;
706
- addBox(mesh, baseMaterial, 0.035, 0.16, leafDepth + 0.035, handlePoint.x - 0.035, handleY, handlePoint.z + 0.045);
707
- 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);
708
973
  }
709
- function addPocketDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
710
- const openAmount = clampDoorOperationState(operationState);
974
+ function addPocketDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
711
975
  const slideSign = slideDirection === 'right' ? 1 : -1;
712
976
  const leafWidth = insideWidth;
713
- const leafCenterX = slideSign * insideWidth * openAmount;
714
977
  const topY = leafCenterY + leafHeight / 2;
715
978
  const pocketCenterX = slideSign * insideWidth;
716
979
  const handleY = handleHeight - doorHeight / 2;
717
- const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055);
718
- 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;
719
986
  addBox(mesh, revealMaterial, insideWidth * 0.9, 0.018, Math.max(frameDepth * 0.32, 0.026), pocketCenterX, topY - 0.055, 0);
720
987
  addBox(mesh, revealMaterial, 0.018, leafHeight * 0.94, leafDepth + 0.014, slideSign * insideWidth * 0.5, leafCenterY, 0);
721
- 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);
722
992
  addLeafSegmentContent({
723
993
  addLeafBox: addPocketLeafBox,
724
994
  leafWidth,
725
995
  leafHeight,
726
- leafCenterX,
996
+ leafCenterX: 0,
727
997
  leafCenterY,
728
998
  leafDepth,
729
999
  segments,
730
1000
  contentPadding,
731
1001
  });
732
- addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02);
733
- 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);
734
1005
  }
735
- function addBarnDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
736
- const openAmount = clampDoorOperationState(operationState);
1006
+ function addBarnDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
737
1007
  const slideSign = slideDirection === 'right' ? 1 : -1;
738
1008
  const leafWidth = insideWidth * 1.06;
739
- const leafCenterX = slideSign * insideWidth * openAmount;
740
1009
  const faceZ = frameDepth / 2 + leafDepth / 2 + 0.028;
741
1010
  const trackY = leafCenterY + leafHeight / 2 + Math.max(frameThickness * 0.55, 0.045);
742
1011
  const railLength = insideWidth * 2.25;
743
1012
  const railCenterX = slideSign * (insideWidth * 0.56);
744
1013
  const handleY = handleHeight - doorHeight / 2;
745
- 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);
746
1018
  const wheelY = trackY - 0.075;
747
- addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01);
748
- addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01);
749
- addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01);
750
- 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);
751
1027
  addLeafSegmentContent({
752
1028
  addLeafBox: addBarnLeafBox,
753
1029
  leafWidth,
754
1030
  leafHeight,
755
- leafCenterX,
1031
+ leafCenterX: 0,
756
1032
  leafCenterY,
757
1033
  leafDepth,
758
1034
  segments,
759
1035
  contentPadding,
760
1036
  keepFrameWhenEmpty: true,
761
1037
  });
762
- addRotatedBox(mesh, revealMaterial, 0.018, leafHeight * 0.86, 0.012, leafCenterX, leafCenterY, faceZ + leafDepth / 2 + 0.014, -0.52);
763
- 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';
764
1042
  for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) {
765
- addBox(mesh, revealMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022);
766
- 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);
767
1045
  }
768
- addBox(mesh, baseMaterial, 0.032, 0.22, leafDepth + 0.034, handleX, handleY, faceZ + leafDepth / 2 + 0.02);
769
- 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);
770
1049
  }
771
- function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
772
- const openAmount = clampDoorOperationState(operationState);
1050
+ function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
773
1051
  const activeOnRight = slideDirection === 'left';
774
1052
  const fixedSign = activeOnRight ? -1 : 1;
775
1053
  const activeSign = activeOnRight ? 1 : -1;
@@ -777,16 +1055,22 @@ function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
777
1055
  const panelHeight = leafHeight;
778
1056
  const closedActiveX = activeSign * insideWidth * 0.23;
779
1057
  const fixedX = fixedSign * insideWidth * 0.23;
780
- const activeX = closedActiveX - activeSign * insideWidth * 0.44 * openAmount;
781
1058
  const frontZ = leafDepth / 2 + 0.016;
782
1059
  const backZ = -leafDepth / 2 - 0.006;
783
1060
  const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02);
784
1061
  const handleY = handleHeight - doorHeight / 2;
785
- const handleX = activeX + activeSign * (panelWidth / 2 - 0.06);
786
- addBox(mesh, revealMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0);
787
- 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);
788
1069
  const addFixedPanelBox = (material, w, h, d, x, y, z) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ);
789
- 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);
790
1074
  addLeafSegmentContent({
791
1075
  addLeafBox: addFixedPanelBox,
792
1076
  leafWidth: panelWidth,
@@ -809,51 +1093,39 @@ function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
809
1093
  contentPadding,
810
1094
  keepFrameWhenEmpty: true,
811
1095
  });
812
- addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01);
813
- 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);
814
1099
  }
815
- function addGarageSectionalDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, garagePanelCount, }) {
816
- const openAmount = getDoorRenderOpenAmount('garage-sectional', operationState);
1100
+ function addGarageSectionalDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, garagePanelCount, }) {
817
1101
  const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount)));
818
1102
  const panelHeight = leafHeight / panelCount;
819
1103
  const panelGap = Math.min(0.012, panelHeight * 0.08);
820
1104
  const travelDepth = Math.max(leafHeight, 1.4);
821
- const curveRadius = panelHeight * 0.58;
822
- const curveLength = (Math.PI / 2) * curveRadius;
823
- const travel = openAmount * ((panelCount - 1) * panelHeight + curveLength + panelHeight * 0.65);
824
- const overheadY = leafCenterY + leafHeight / 2 - panelHeight / 2;
825
1105
  const railY = leafCenterY + leafHeight / 2 - 0.04;
826
1106
  const railZ = -travelDepth / 2;
827
- addBox(mesh, revealMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, -insideWidth / 2 + 0.035, railY, railZ);
828
- 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;
829
1117
  for (let index = 0; index < panelCount; index++) {
830
- const orderFromTop = panelCount - 1 - index;
831
- const pathPosition = travel - orderFromTop * panelHeight;
832
- let y = overheadY + pathPosition;
833
- let z = 0;
834
- let rotationX = 0;
835
- if (pathPosition > 0 && pathPosition <= curveLength) {
836
- const theta = pathPosition / curveRadius;
837
- rotationX = -theta;
838
- y = overheadY + curveRadius * Math.sin(theta);
839
- z = -curveRadius * (1 - Math.cos(theta));
840
- }
841
- else if (pathPosition > curveLength) {
842
- rotationX = -Math.PI / 2;
843
- y = overheadY + curveRadius;
844
- z = -(curveRadius + pathPosition - curveLength);
845
- }
846
- const revealOffset = (panelHeight - panelGap) * 0.22;
847
- const trimDepth = 0.01;
848
- const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006;
849
- const addSectionalTrim = (localY) => {
850
- 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]);
851
- };
852
- addBoxWithRotation(mesh, baseMaterial, insideWidth, Math.max(0.04, panelHeight - panelGap), leafDepth, 0, y, z, [rotationX, 0, 0]);
853
- addSectionalTrim(revealOffset);
854
- addSectionalTrim(-revealOffset);
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);
855
1126
  }
856
- addBox(mesh, revealMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0);
1127
+ currentDoorSlot = 'hardware';
1128
+ addBox(mesh, hardwareMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0);
857
1129
  }
858
1130
  function addGarageRollupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, }) {
859
1131
  const openAmount = clampDoorOperationState(operationState);
@@ -861,50 +1133,71 @@ function addGarageRollupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafD
861
1133
  const visibleHeight = leafHeight * (1 - openAmount);
862
1134
  const visibleSlatCount = Math.ceil(visibleHeight / slatHeight);
863
1135
  const topY = leafCenterY + leafHeight / 2;
864
- const curtainCenterY = topY - visibleHeight / 2;
865
1136
  const drumMaxRadius = Math.max(0.12, Math.min(0.22, leafHeight * 0.075));
866
1137
  const drumY = topY + drumMaxRadius * 0.12;
867
1138
  const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72;
868
- addBox(mesh, revealMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), -insideWidth / 2 + 0.03, leafCenterY, 0);
869
- 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);
870
1142
  if (visibleHeight > 0.01) {
871
- 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;
872
1155
  for (let index = 0; index < visibleSlatCount; index++) {
873
- const y = topY - Math.min(visibleHeight, index * slatHeight);
874
- 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);
875
1158
  }
876
- 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);
877
1161
  }
1162
+ currentDoorSlot = 'panel';
878
1163
  const drum = new THREE.Mesh(new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36), baseMaterial);
879
1164
  drum.position.set(0, drumY, drumZ);
880
1165
  drum.rotation.z = Math.PI / 2;
1166
+ tagDoorSlot(drum);
881
1167
  mesh.add(drum);
882
- 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);
883
1170
  }
884
- function addGarageTiltupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, operationState, }) {
885
- const openAmount = clampDoorOperationState(operationState);
886
- const angle = (Math.PI / 2) * openAmount;
1171
+ function addGarageTiltupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, }) {
887
1172
  const hingeY = leafCenterY + leafHeight / 2;
888
- const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2);
889
- 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;
890
1176
  const railLength = Math.max(leafHeight * 0.72, 1.2);
891
1177
  const railY = hingeY - frameThickness * 0.35;
892
1178
  const railZ = -railLength / 2;
893
- addBox(mesh, revealMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, -insideWidth / 2 + 0.04, railY, railZ);
894
- addBox(mesh, revealMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, insideWidth / 2 - 0.04, railY, railZ);
895
- 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);
896
1187
  const insetWidth = Math.max(0.1, insideWidth - 0.22);
897
1188
  const insetHeight = Math.max(0.1, leafHeight - 0.28);
898
1189
  const trimDepth = 0.012;
899
1190
  const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006;
900
1191
  const addTiltupTrim = (localX, localY, trimWidth, trimHeight) => {
901
- 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);
902
1194
  };
903
1195
  addTiltupTrim(0, insetHeight / 2, insetWidth, 0.018);
904
1196
  addTiltupTrim(0, -insetHeight / 2, insetWidth, 0.018);
905
1197
  addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight);
906
1198
  addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight);
907
- 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);
908
1201
  }
909
1202
  function getEffectiveOpeningShape(node) {
910
1203
  return node.doorType === 'folding' ||
@@ -916,6 +1209,7 @@ function getEffectiveOpeningShape(node) {
916
1209
  }
917
1210
  function updateDoorMesh(rawNode, mesh) {
918
1211
  const node = normalizeDoorNodeForRender(rawNode);
1212
+ currentDoorSlot = undefined;
919
1213
  // Root mesh is an invisible hitbox; all visuals live in child meshes
920
1214
  mesh.geometry.dispose();
921
1215
  mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth);
@@ -930,6 +1224,13 @@ function updateDoorMesh(rawNode, mesh) {
930
1224
  disposeObject(child);
931
1225
  mesh.remove(child);
932
1226
  }
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');
933
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;
934
1235
  const openingShape = getEffectiveOpeningShape(node) ?? rawOpeningShape;
935
1236
  const runtimeDoorState = useInteractive.getState().doors[node.id];
@@ -946,6 +1247,7 @@ function updateDoorMesh(rawNode, mesh) {
946
1247
  const leafCenterY = -frameThickness / 2;
947
1248
  const swingDirectionSign = swingDirection === 'inward' ? 1 : -1;
948
1249
  // ── Frame members ──
1250
+ currentDoorSlot = 'frame';
949
1251
  if (openingShape === 'arch') {
950
1252
  const frameBottom = -height / 2;
951
1253
  const frameTop = height / 2;
@@ -956,26 +1258,27 @@ function updateDoorMesh(rawNode, mesh) {
956
1258
  const useShallowHeadBar = frameArchHeight <= frameThickness * 2;
957
1259
  const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY;
958
1260
  const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01);
959
- addBox(mesh, baseMaterial, frameThickness, postHeight, frameDepth, -width / 2 + frameThickness / 2, frameBottom + postHeight / 2, 0);
960
- addBox(mesh, baseMaterial, frameThickness, postHeight, frameDepth, width / 2 - frameThickness / 2, frameBottom + postHeight / 2, 0);
961
- 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
962
1264
  ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop)
963
1265
  : createArchBandShape(width, frameSpringY, frameTop, frameInnerSpringY, frameInnerTopY, frameThickness), frameDepth);
964
1266
  }
965
1267
  else if (openingShape === 'rounded') {
966
- 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);
967
1269
  }
968
1270
  else {
969
1271
  // Left post — full height
970
- 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);
971
1273
  // Right post — full height
972
- 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);
973
1275
  // Head (top bar) — full width
974
- 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);
975
1277
  }
976
1278
  // ── Threshold (inside the frame) ──
977
1279
  if (threshold) {
978
- 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);
979
1282
  }
980
1283
  if (doorType === 'garage-sectional') {
981
1284
  addGarageSectionalDoor(mesh, {
@@ -985,7 +1288,6 @@ function updateDoorMesh(rawNode, mesh) {
985
1288
  leafDepth,
986
1289
  frameThickness,
987
1290
  frameDepth,
988
- operationState,
989
1291
  garagePanelCount,
990
1292
  });
991
1293
  }
@@ -1008,7 +1310,6 @@ function updateDoorMesh(rawNode, mesh) {
1008
1310
  leafDepth,
1009
1311
  frameThickness,
1010
1312
  frameDepth,
1011
- operationState,
1012
1313
  });
1013
1314
  }
1014
1315
  else if (doorType === 'folding') {
@@ -1019,7 +1320,6 @@ function updateDoorMesh(rawNode, mesh) {
1019
1320
  leafDepth,
1020
1321
  frameThickness,
1021
1322
  frameDepth,
1022
- operationState,
1023
1323
  leafCount,
1024
1324
  doorHeight: height,
1025
1325
  handleHeight,
@@ -1035,7 +1335,6 @@ function updateDoorMesh(rawNode, mesh) {
1035
1335
  leafDepth,
1036
1336
  frameThickness,
1037
1337
  frameDepth,
1038
- operationState,
1039
1338
  slideDirection,
1040
1339
  doorHeight: height,
1041
1340
  handleHeight,
@@ -1051,7 +1350,6 @@ function updateDoorMesh(rawNode, mesh) {
1051
1350
  leafDepth,
1052
1351
  frameThickness,
1053
1352
  frameDepth,
1054
- operationState,
1055
1353
  slideDirection,
1056
1354
  doorHeight: height,
1057
1355
  handleHeight,
@@ -1067,7 +1365,6 @@ function updateDoorMesh(rawNode, mesh) {
1067
1365
  leafDepth,
1068
1366
  frameThickness,
1069
1367
  frameDepth,
1070
- operationState,
1071
1368
  slideDirection,
1072
1369
  doorHeight: height,
1073
1370
  handleHeight,
@@ -1094,6 +1391,7 @@ function updateDoorMesh(rawNode, mesh) {
1094
1391
  hingeX: -insideWidth / 2,
1095
1392
  hingeSide: 'left',
1096
1393
  swingRotation: -clampedSwingAngle * swingDirectionSign,
1394
+ openRotationY: (-Math.PI / 2) * swingDirectionSign,
1097
1395
  segments,
1098
1396
  contentPadding,
1099
1397
  handle,
@@ -1121,6 +1419,7 @@ function updateDoorMesh(rawNode, mesh) {
1121
1419
  hingeX: insideWidth / 2,
1122
1420
  hingeSide: 'right',
1123
1421
  swingRotation: clampedSwingAngle * swingDirectionSign,
1422
+ openRotationY: (Math.PI / 2) * swingDirectionSign,
1124
1423
  segments,
1125
1424
  contentPadding,
1126
1425
  handle,
@@ -1152,6 +1451,7 @@ function updateDoorMesh(rawNode, mesh) {
1152
1451
  hingeX,
1153
1452
  hingeSide: hingesSide,
1154
1453
  swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
1454
+ openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
1155
1455
  segments,
1156
1456
  contentPadding,
1157
1457
  handle,
@@ -1167,6 +1467,13 @@ function updateDoorMesh(rawNode, mesh) {
1167
1467
  archHeight: node.archHeight ?? 0.45,
1168
1468
  });
1169
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
+ }
1170
1477
  syncDoorCutout(node, mesh);
1171
1478
  // Guard: some degenerate door configs can leave a child mesh with an
1172
1479
  // empty (0-vertex) geometry — e.g. a zero-area extruded leaf frame.
@@ -1186,33 +1493,59 @@ function hideEmptyGeometryMeshes(root) {
1186
1493
  });
1187
1494
  }
1188
1495
  function syncDoorCutout(node, mesh) {
1189
- // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
1496
+ // ── Cutout: invisible raycast hit target for the whole opening ──
1190
1497
  let cutout = mesh.getObjectByName('cutout');
1191
1498
  if (!cutout) {
1192
1499
  cutout = new THREE.Mesh();
1193
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).
1194
1507
  mesh.add(cutout);
1195
1508
  }
1196
1509
  cutout.geometry.dispose();
1510
+ const depth = resolveOpeningCutoutProxyDepth(node);
1197
1511
  const openingShape = getEffectiveOpeningShape(node);
1198
1512
  if (openingShape === 'arch') {
1199
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)), {
1200
- depth: 1,
1514
+ depth,
1201
1515
  bevelEnabled: false,
1202
1516
  curveSegments: 24,
1203
1517
  });
1204
- cutout.geometry.translate(0, 0, -0.5);
1518
+ cutout.geometry.translate(0, 0, -depth / 2);
1205
1519
  }
1206
1520
  else if (openingShape === 'rounded') {
1207
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)), {
1208
- depth: 1,
1522
+ depth,
1209
1523
  bevelEnabled: false,
1210
1524
  curveSegments: 24,
1211
1525
  });
1212
- cutout.geometry.translate(0, 0, -0.5);
1526
+ cutout.geometry.translate(0, 0, -depth / 2);
1213
1527
  }
1214
1528
  else {
1215
- cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0);
1529
+ cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth);
1216
1530
  }
1217
1531
  cutout.visible = false;
1218
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
+ }