@pascal-app/viewer 1.0.5 → 1.0.7

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 (199) hide show
  1. package/dist/components/viewer/daylight-atmosphere.d.ts +9 -0
  2. package/dist/components/viewer/daylight-atmosphere.d.ts.map +1 -0
  3. package/dist/components/viewer/daylight-atmosphere.js +315 -0
  4. package/dist/components/viewer/drone-controller.d.ts +15 -0
  5. package/dist/components/viewer/drone-controller.d.ts.map +1 -0
  6. package/dist/components/viewer/drone-controller.js +194 -0
  7. package/dist/components/viewer/glb-interactive.d.ts +4 -18
  8. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  9. package/dist/components/viewer/glb-interactive.js +32 -268
  10. package/dist/components/viewer/glb-scene-motion.d.ts +8 -0
  11. package/dist/components/viewer/glb-scene-motion.d.ts.map +1 -0
  12. package/dist/components/viewer/glb-scene-motion.js +57 -0
  13. package/dist/components/viewer/glb-scene.d.ts +3 -1
  14. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  15. package/dist/components/viewer/glb-scene.js +289 -49
  16. package/dist/components/viewer/glb-selection-callout.d.ts +17 -0
  17. package/dist/components/viewer/glb-selection-callout.d.ts.map +1 -0
  18. package/dist/components/viewer/glb-selection-callout.js +54 -0
  19. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  20. package/dist/components/viewer/glb-walkthrough-controller.js +6 -3
  21. package/dist/components/viewer/index.d.ts +5 -0
  22. package/dist/components/viewer/index.d.ts.map +1 -1
  23. package/dist/components/viewer/index.js +30 -6
  24. package/dist/components/viewer/lights.d.ts.map +1 -1
  25. package/dist/components/viewer/lights.js +192 -5
  26. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  27. package/dist/components/viewer/post-processing.js +150 -94
  28. package/dist/components/viewer/scene-environment.js +4 -5
  29. package/dist/components/viewer/selection-callout.d.ts +51 -0
  30. package/dist/components/viewer/selection-callout.d.ts.map +1 -0
  31. package/dist/components/viewer/selection-callout.js +197 -0
  32. package/dist/components/viewer/use-daylight-rooms.d.ts +13 -0
  33. package/dist/components/viewer/use-daylight-rooms.d.ts.map +1 -0
  34. package/dist/components/viewer/use-daylight-rooms.js +236 -0
  35. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  36. package/dist/components/viewer/viewer-camera.js +12 -1
  37. package/dist/index.d.ts +21 -7
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +23 -7
  40. package/dist/lib/daylight/ground.d.ts +21 -0
  41. package/dist/lib/daylight/ground.d.ts.map +1 -0
  42. package/dist/lib/daylight/ground.js +53 -0
  43. package/dist/lib/daylight/location.d.ts +32 -0
  44. package/dist/lib/daylight/location.d.ts.map +1 -0
  45. package/dist/lib/daylight/location.js +74 -0
  46. package/dist/lib/daylight/look.d.ts +33 -0
  47. package/dist/lib/daylight/look.d.ts.map +1 -0
  48. package/dist/lib/daylight/look.js +13 -0
  49. package/dist/lib/daylight/material-hooks.d.ts +2 -0
  50. package/dist/lib/daylight/material-hooks.d.ts.map +1 -0
  51. package/dist/lib/daylight/material-hooks.js +150 -0
  52. package/dist/lib/daylight/room-context.d.ts +12 -0
  53. package/dist/lib/daylight/room-context.d.ts.map +1 -0
  54. package/dist/lib/daylight/room-context.js +53 -0
  55. package/dist/lib/daylight/room-scene.d.ts +54 -0
  56. package/dist/lib/daylight/room-scene.d.ts.map +1 -0
  57. package/dist/lib/daylight/room-scene.js +356 -0
  58. package/dist/lib/daylight/rooms.d.ts +262 -0
  59. package/dist/lib/daylight/rooms.d.ts.map +1 -0
  60. package/dist/lib/daylight/rooms.js +1223 -0
  61. package/dist/lib/daylight/runtime.d.ts +33 -0
  62. package/dist/lib/daylight/runtime.d.ts.map +1 -0
  63. package/dist/lib/daylight/runtime.js +42 -0
  64. package/dist/lib/daylight/sky.d.ts +26 -0
  65. package/dist/lib/daylight/sky.d.ts.map +1 -0
  66. package/dist/lib/daylight/sky.js +217 -0
  67. package/dist/lib/daylight/solar.d.ts +46 -0
  68. package/dist/lib/daylight/solar.d.ts.map +1 -0
  69. package/dist/lib/daylight/solar.js +156 -0
  70. package/dist/lib/device-tier.d.ts +29 -0
  71. package/dist/lib/device-tier.d.ts.map +1 -0
  72. package/dist/lib/device-tier.js +71 -0
  73. package/dist/lib/dispose-object3d.d.ts.map +1 -1
  74. package/dist/lib/dispose-object3d.js +4 -2
  75. package/dist/lib/drone-flight.d.ts +44 -0
  76. package/dist/lib/drone-flight.d.ts.map +1 -0
  77. package/dist/lib/drone-flight.js +121 -0
  78. package/dist/lib/editor-highlight.d.ts +16 -0
  79. package/dist/lib/editor-highlight.d.ts.map +1 -0
  80. package/dist/lib/editor-highlight.js +33 -0
  81. package/dist/lib/foundation-materials.d.ts.map +1 -1
  82. package/dist/lib/foundation-materials.js +2 -4
  83. package/dist/lib/glb-merge.d.ts +23 -0
  84. package/dist/lib/glb-merge.d.ts.map +1 -0
  85. package/dist/lib/glb-merge.js +184 -0
  86. package/dist/lib/hardware-geometry.d.ts +21 -0
  87. package/dist/lib/hardware-geometry.d.ts.map +1 -0
  88. package/dist/lib/hardware-geometry.js +283 -0
  89. package/dist/lib/layer-pass.d.ts +2 -0
  90. package/dist/lib/layer-pass.d.ts.map +1 -1
  91. package/dist/lib/layer-pass.js +20 -0
  92. package/dist/lib/materials.d.ts.map +1 -1
  93. package/dist/lib/materials.js +50 -23
  94. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  95. package/dist/lib/merged-outline-node.js +7 -1
  96. package/dist/lib/occlusion-culler.d.ts +34 -0
  97. package/dist/lib/occlusion-culler.d.ts.map +1 -0
  98. package/dist/lib/occlusion-culler.js +698 -0
  99. package/dist/lib/post-processing-resources.d.ts +4 -0
  100. package/dist/lib/post-processing-resources.d.ts.map +1 -1
  101. package/dist/lib/post-processing-resources.js +5 -0
  102. package/dist/lib/readback-rows.d.ts +14 -0
  103. package/dist/lib/readback-rows.d.ts.map +1 -0
  104. package/dist/lib/readback-rows.js +31 -0
  105. package/dist/lib/reveal-pose.d.ts.map +1 -1
  106. package/dist/lib/reveal-pose.js +3 -0
  107. package/dist/lib/scene-matrices.d.ts +12 -0
  108. package/dist/lib/scene-matrices.d.ts.map +1 -0
  109. package/dist/lib/scene-matrices.js +287 -0
  110. package/dist/lib/scene-pass-mrt.d.ts +5 -2
  111. package/dist/lib/scene-pass-mrt.d.ts.map +1 -1
  112. package/dist/lib/scene-pass-mrt.js +7 -2
  113. package/dist/lib/scene-themes.d.ts +0 -2
  114. package/dist/lib/scene-themes.d.ts.map +1 -1
  115. package/dist/lib/scene-themes.js +0 -2
  116. package/dist/lib/scene-visibility.d.ts +2 -0
  117. package/dist/lib/scene-visibility.d.ts.map +1 -1
  118. package/dist/lib/scene-visibility.js +12 -3
  119. package/dist/lib/site-surfaces.d.ts +1 -8
  120. package/dist/lib/site-surfaces.d.ts.map +1 -1
  121. package/dist/lib/site-surfaces.js +0 -19
  122. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  123. package/dist/lib/snapshot-pipeline.js +24 -40
  124. package/dist/lib/texture-reference.d.ts.map +1 -1
  125. package/dist/lib/texture-reference.js +10 -8
  126. package/dist/lib/underground-ambiance.d.ts +0 -2
  127. package/dist/lib/underground-ambiance.d.ts.map +1 -1
  128. package/dist/lib/underground-ambiance.js +0 -2
  129. package/dist/store/use-item-light-pool.d.ts +3 -6
  130. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  131. package/dist/store/use-item-light-pool.js +12 -0
  132. package/dist/store/use-viewer.d.ts +72 -15
  133. package/dist/store/use-viewer.d.ts.map +1 -1
  134. package/dist/store/use-viewer.js +103 -18
  135. package/dist/systems/ceiling/ceiling-system.d.ts +5 -0
  136. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  137. package/dist/systems/ceiling/ceiling-system.js +11 -1
  138. package/dist/systems/door/door-system.d.ts.map +1 -1
  139. package/dist/systems/door/door-system.js +501 -145
  140. package/dist/systems/door/leaf-profile.d.ts +11 -0
  141. package/dist/systems/door/leaf-profile.d.ts.map +1 -0
  142. package/dist/systems/door/leaf-profile.js +140 -0
  143. package/dist/systems/interactive/callout-actions.d.ts +72 -0
  144. package/dist/systems/interactive/callout-actions.d.ts.map +1 -0
  145. package/dist/systems/interactive/callout-actions.js +212 -0
  146. package/dist/systems/interactive/interactive-system.d.ts +5 -1
  147. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  148. package/dist/systems/interactive/interactive-system.js +44 -121
  149. package/dist/systems/interactive/opening-settled.d.ts +16 -0
  150. package/dist/systems/interactive/opening-settled.d.ts.map +1 -0
  151. package/dist/systems/interactive/opening-settled.js +53 -0
  152. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  153. package/dist/systems/item-light/item-light-system.js +22 -214
  154. package/dist/systems/item-light/pooled-lights.d.ts +70 -0
  155. package/dist/systems/item-light/pooled-lights.d.ts.map +1 -0
  156. package/dist/systems/item-light/pooled-lights.js +449 -0
  157. package/dist/systems/level/level-system.d.ts.map +1 -1
  158. package/dist/systems/level/level-system.js +19 -1
  159. package/dist/systems/roof/roof-material-slots.d.ts +14 -0
  160. package/dist/systems/roof/roof-material-slots.d.ts.map +1 -0
  161. package/dist/systems/roof/roof-material-slots.js +13 -0
  162. package/dist/systems/roof/roof-materials.d.ts +25 -2
  163. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  164. package/dist/systems/roof/roof-materials.js +85 -26
  165. package/dist/systems/roof/roof-system.d.ts +34 -1
  166. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  167. package/dist/systems/roof/roof-system.js +477 -105
  168. package/dist/systems/wall/opening-casing-geometry.d.ts +14 -0
  169. package/dist/systems/wall/opening-casing-geometry.d.ts.map +1 -0
  170. package/dist/systems/wall/opening-casing-geometry.js +102 -0
  171. package/dist/systems/wall/opening-cutout-geometry.d.ts +9 -0
  172. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -1
  173. package/dist/systems/wall/opening-cutout-geometry.js +7 -3
  174. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  175. package/dist/systems/wall/wall-materials.js +28 -35
  176. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  177. package/dist/systems/wall/wall-system.js +34 -19
  178. package/dist/systems/wall/wall-t-junctions.d.ts +15 -0
  179. package/dist/systems/wall/wall-t-junctions.d.ts.map +1 -0
  180. package/dist/systems/wall/wall-t-junctions.js +149 -0
  181. package/dist/systems/window/window-animation-system.d.ts +3 -12
  182. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  183. package/dist/systems/window/window-animation-system.js +37 -122
  184. package/dist/systems/window/window-pose.d.ts +31 -0
  185. package/dist/systems/window/window-pose.d.ts.map +1 -0
  186. package/dist/systems/window/window-pose.js +152 -0
  187. package/dist/systems/window/window-roller-shutter.d.ts +8 -0
  188. package/dist/systems/window/window-roller-shutter.d.ts.map +1 -0
  189. package/dist/systems/window/window-roller-shutter.js +64 -0
  190. package/dist/systems/window/window-system.d.ts +2 -11
  191. package/dist/systems/window/window-system.d.ts.map +1 -1
  192. package/dist/systems/window/window-system.js +470 -303
  193. package/package.json +6 -6
  194. package/dist/systems/interactive/control-widget.d.ts +0 -10
  195. package/dist/systems/interactive/control-widget.d.ts.map +0 -1
  196. package/dist/systems/interactive/control-widget.js +0 -35
  197. package/dist/systems/interactive/procedural-controls.d.ts +0 -13
  198. package/dist/systems/interactive/procedural-controls.d.ts.map +0 -1
  199. package/dist/systems/interactive/procedural-controls.js +0 -17
@@ -1,13 +1,17 @@
1
- import { clampDoorOperationState, DEFAULT_WALL_THICKNESS, DoorNode as DoorNodeSchema, getDoorRenderOpenAmount, getEffectiveNode, getOpeningWallPlacement, getWallThickness, sceneRegistry, scriptedSize, useInteractive, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
1
+ import { clampDoorOperationState, DEFAULT_WALL_THICKNESS, DoorNode as DoorNodeSchema, doorHandleStyle, getDoorOpeningSize, getDoorRenderOpenAmount, getEffectiveNode, getOpeningWallPlacement, getWallThickness, nodeRegistry, resolveOpeningCasingHost, resolveOpeningFrameDepth, sceneRegistry, scriptedSize, useInteractive, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
2
+ import { doorStyleLook, doorStylesOf, isLegacyDefaultDoorLook } from '@pascal-app/core/building';
2
3
  import { useFrame } from '@react-three/fiber';
3
4
  import { useEffect, useRef } from 'react';
4
5
  import * as THREE from 'three';
5
6
  import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
7
+ import { flushPull, hingeJambPart, hingeLeafPart, knobOnRose, leverOnPlate, leverOnRose, leverOnRoseCurved, pullBar, roller, roundedBox, sphere, tube, } from '../../lib/hardware-geometry';
6
8
  import { createDefaultMaterial, createMaterial, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, resolveMaterialRef, } from '../../lib/materials';
7
9
  import { timeSpan } from '../../lib/perf-tracks';
8
10
  import { settleScriptedOpening } from '../../lib/scripted-opening';
9
11
  import useViewer from '../../store/use-viewer';
12
+ import { buildOpeningCasingMeshes } from '../wall/opening-casing-geometry';
10
13
  import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry';
14
+ import { buildPanelField, panelProfile } from './leaf-profile';
11
15
  // Invisible material for root mesh — used as selection hitbox only
12
16
  const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false });
13
17
  const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' });
@@ -16,8 +20,8 @@ const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' });
16
20
  // only a fallback if the catalog ref ever fails to resolve.
17
21
  const HARDWARE_DEFAULT_REF = 'library:metal-chrome';
18
22
  // Door body defaults to a catalog colour (generic approach). Glass keeps the
19
- // built-in FrontSide glass material — the catalog `preset-glass` is DoubleSide,
20
- // which poisons the WebGPU MRT scene pass.
23
+ // built-in FrontSide glass material. Glass overrides must also stay FrontSide:
24
+ // DoubleSide glass poisons the WebGPU MRT scene pass.
21
25
  const PANEL_DEFAULT_REF = 'library:preset-softwhite';
22
26
  const FRAME_DEFAULT_REF = 'library:preset-softwhite';
23
27
  const GLASS_DEFAULT_REF = 'library:preset-glass';
@@ -228,20 +232,38 @@ function resolveDoorSlotMaterial(node, slotId) {
228
232
  }
229
233
  return fallback;
230
234
  }
231
- function addBox(parent, material, w, h, d, x, y, z) {
232
- const geometry = new THREE.BoxGeometry(w, h, d);
233
- applyWorldScaleBoxUVs(geometry, w, h, d);
235
+ function addBox(parent, material, w, h, d, x, y, z, customGeometry) {
236
+ const geometry = customGeometry ??
237
+ (currentDoorSlot === 'hardware' ? roundedBox(w, h, d) : new THREE.BoxGeometry(w, h, d));
238
+ if (geometry instanceof THREE.BoxGeometry && !geometry.userData.shared) {
239
+ applyWorldScaleBoxUVs(geometry, w, h, d);
240
+ }
234
241
  const m = new THREE.Mesh(geometry, material);
235
242
  m.position.set(x, y, z);
236
243
  tagDoorSlot(m);
237
244
  parent.add(m);
245
+ return m;
246
+ }
247
+ function addHardware(parent, geometry, x, y, z) {
248
+ const part = new THREE.Mesh(geometry, hardwareMaterial);
249
+ part.userData.slotId = 'hardware';
250
+ part.position.set(x, y, z);
251
+ parent.add(part);
252
+ return part;
238
253
  }
239
- function addRotatedBox(parent, material, w, h, d, x, y, z, rotationY) {
254
+ function addPulls(parent, x, y, faceZ, centerZ = 0, recessed = false) {
255
+ for (const face of [-1, 1]) {
256
+ const pull = addHardware(parent, recessed ? flushPull() : pullBar(), x, y, centerZ + face * faceZ);
257
+ if (face < 0)
258
+ pull.rotateY(Math.PI);
259
+ }
260
+ }
261
+ function addRotatedBox(parent, material, w, h, d, x, y, z, rotationZ) {
240
262
  const geometry = new THREE.BoxGeometry(w, h, d);
241
263
  applyWorldScaleBoxUVs(geometry, w, h, d);
242
264
  const m = new THREE.Mesh(geometry, material);
243
265
  m.position.set(x, y, z);
244
- m.rotation.y = rotationY;
266
+ m.rotation.z = rotationZ;
245
267
  tagDoorSlot(m);
246
268
  parent.add(m);
247
269
  }
@@ -556,11 +578,46 @@ function createTopClippedRectShape(left, right, bottom, top, getBoundaryY) {
556
578
  }
557
579
  function disposeObject(object) {
558
580
  object.traverse((child) => {
559
- if (child instanceof THREE.Mesh)
581
+ if (child instanceof THREE.Mesh && !child.geometry.userData.shared)
560
582
  child.geometry.dispose();
561
583
  });
562
584
  }
563
- function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, segments, contentPadding, keepFrameWhenEmpty = false, renderPerimeterFrame = true, openingShape = 'rectangle', openingTopRadii, archHeight, archOuterSide, }) {
585
+ function addLeafSegmentContent({ addLeafBox: buildLeafBox, addLeafShape, leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, segments, contentPadding, keepFrameWhenEmpty = false, flushPullAt, renderPerimeterFrame = true, openingShape = 'rectangle', openingTopRadii, archHeight, archOuterSide, }) {
586
+ leafWidth = Math.max(0.001, leafWidth - 0.006);
587
+ leafHeight = Math.max(0.001, leafHeight - 0.006);
588
+ let pullFaceZ = leafDepth / 2;
589
+ const addLeafBox = (material, w, h, d, x, y, z, geometry) => {
590
+ if (geometry) {
591
+ if (flushPullAt) {
592
+ geometry.computeBoundingBox();
593
+ pullFaceZ = Math.max(pullFaceZ, geometry.boundingBox.max.z);
594
+ }
595
+ return buildLeafBox(material, w, h, d, x, y, z, geometry);
596
+ }
597
+ if (!flushPullAt)
598
+ return buildLeafBox(material, w, h, d, x, y, z);
599
+ const left = x - w / 2;
600
+ const right = x + w / 2;
601
+ const bottom = y - h / 2;
602
+ const top = y + h / 2;
603
+ const cutLeft = Math.max(left, flushPullAt.x - 0.02);
604
+ const cutRight = Math.min(right, flushPullAt.x + 0.02);
605
+ const cutBottom = Math.max(bottom, flushPullAt.y - 0.06);
606
+ const cutTop = Math.min(top, flushPullAt.y + 0.06);
607
+ if (cutLeft >= cutRight || cutBottom >= cutTop) {
608
+ return buildLeafBox(material, w, h, d, x, y, z);
609
+ }
610
+ pullFaceZ = Math.max(pullFaceZ, Math.abs(z) + d / 2);
611
+ for (const [l, r, b, t] of [
612
+ [left, cutLeft, bottom, top],
613
+ [cutRight, right, bottom, top],
614
+ [cutLeft, cutRight, bottom, cutBottom],
615
+ [cutLeft, cutRight, cutTop, top],
616
+ ]) {
617
+ if (r > l && t > b)
618
+ buildLeafBox(material, r - l, t - b, d, (l + r) / 2, (b + t) / 2, z);
619
+ }
620
+ };
564
621
  const hasLeafContent = segments.some((seg) => seg.type !== 'empty');
565
622
  const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty;
566
623
  const cpX = contentPadding[0];
@@ -626,14 +683,15 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
626
683
  cx += colWidths[c];
627
684
  const dividerLeft = cx;
628
685
  const dividerRight = cx + seg.dividerThickness;
686
+ const dividerDepth = seg.type === 'panel' ? leafDepth : leafDepth + 0.001;
629
687
  const dividerShape = hasShapedTop && addLeafShape
630
688
  ? createTopClippedRectShape(dividerLeft, dividerRight, segBottom, segTop, topBoundaryAtX)
631
689
  : null;
632
690
  if (dividerShape && addLeafShape) {
633
- addLeafShape(baseMaterial, dividerShape, leafDepth + 0.001);
691
+ addLeafShape(baseMaterial, dividerShape, dividerDepth);
634
692
  }
635
693
  else {
636
- addLeafBox(baseMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0);
694
+ addLeafBox(baseMaterial, seg.dividerThickness, segH, dividerDepth, cx + seg.dividerThickness / 2, segCenterY, 0);
637
695
  }
638
696
  cx += seg.dividerThickness;
639
697
  }
@@ -655,6 +713,23 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
655
713
  else {
656
714
  addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0);
657
715
  }
716
+ currentDoorSlot = 'panel';
717
+ const beadWidth = Math.min(0.012, colW / 4, segH / 4);
718
+ if (glassShape && addLeafShape) {
719
+ const bead = glassShape.clone();
720
+ const hole = createTopClippedRectShape(segmentLeft + beadWidth, segmentRight - beadWidth, segBottom + beadWidth, segTop - beadWidth, (x) => topBoundaryAtX(x) - beadWidth);
721
+ if (hole)
722
+ bead.holes.push(shapeToReversedPath(hole));
723
+ addLeafShape(baseMaterial, bead, leafDepth + 0.012);
724
+ }
725
+ else {
726
+ for (const face of [-1, 1])
727
+ for (const side of [-1, 1]) {
728
+ const z = face * (leafDepth / 2 + 0.003);
729
+ addLeafBox(baseMaterial, colW, beadWidth, 0.006, colX, segCenterY + (side * (segH - beadWidth)) / 2, z);
730
+ addLeafBox(baseMaterial, beadWidth, segH - 2 * beadWidth, 0.006, colX + (side * (colW - beadWidth)) / 2, segCenterY, z);
731
+ }
732
+ }
658
733
  }
659
734
  else if (seg.type === 'panel') {
660
735
  currentDoorSlot = 'panel';
@@ -663,81 +738,84 @@ function addLeafSegmentContent({ addLeafBox, addLeafShape, leafWidth, leafHeight
663
738
  const outerPanelShape = hasShapedTop && addLeafShape
664
739
  ? createTopClippedRectShape(segmentLeft, segmentRight, segBottom, segTop, topBoundaryAtX)
665
740
  : null;
741
+ const panelW = colW - 2 * seg.panelInset;
742
+ const panelH = segH - 2 * seg.panelInset;
743
+ if (panelW <= 0.01 || panelH <= 0.01 || seg.panelDepth === 0) {
744
+ if (outerPanelShape && addLeafShape)
745
+ addLeafShape(baseMaterial, outerPanelShape, leafDepth);
746
+ else
747
+ addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0);
748
+ continue;
749
+ }
750
+ const fieldShape = createTopClippedRectShape(colX - panelW / 2, colX + panelW / 2, segBottom + seg.panelInset, segTop - seg.panelInset, hasShapedTop ? topBoundaryAtX : () => segTop);
751
+ if (!fieldShape)
752
+ continue;
666
753
  if (outerPanelShape && addLeafShape) {
754
+ outerPanelShape.holes.push(shapeToReversedPath(fieldShape));
667
755
  addLeafShape(baseMaterial, outerPanelShape, leafDepth);
668
756
  }
669
757
  else {
670
- addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0);
671
- }
672
- const panelW = colW - 2 * seg.panelInset;
673
- const panelH = segH - 2 * seg.panelInset;
674
- if (panelW > 0.01 && panelH > 0.01) {
675
- const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth);
676
- const panelZ = leafDepth / 2 + effectiveDepth / 2;
677
- const insetLeft = colX - panelW / 2;
678
- const insetRight = colX + panelW / 2;
679
- const insetTop = segTop - seg.panelInset;
680
- const insetBottom = segBottom + seg.panelInset;
681
- const innerPanelShape = hasShapedTop && addLeafShape
682
- ? createTopClippedRectShape(insetLeft, insetRight, insetBottom, insetTop, topBoundaryAtX)
683
- : null;
684
- if (innerPanelShape && addLeafShape) {
685
- addLeafShape(baseMaterial, innerPanelShape, effectiveDepth);
686
- }
687
- else {
688
- // A leaf is paneled on both faces, so the person sees the style from either side.
689
- addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ);
690
- addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, -panelZ);
758
+ for (const side of [-1, 1]) {
759
+ addLeafBox(baseMaterial, colW, seg.panelInset, leafDepth, colX, segCenterY + (side * (segH - seg.panelInset)) / 2, 0);
760
+ addLeafBox(baseMaterial, seg.panelInset, panelH, leafDepth, colX + (side * (colW - seg.panelInset)) / 2, segCenterY, 0);
691
761
  }
692
762
  }
763
+ addLeafBox(baseMaterial, panelW, panelH, leafDepth, 0, 0, 0, buildPanelField(fieldShape.getPoints(), leafDepth, seg.panelDepth, seg.panelInset, flushPullAt));
693
764
  }
694
765
  }
695
766
  segY -= segH;
696
767
  }
768
+ return pullFaceZ;
697
769
  }
698
- 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, }) {
770
+ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, leafDepth, frameDepth, hingeX, hingeSide, swingRotation, openRotationY, segments, contentPadding, handle, handleStyle, handleBothSides = false, handleHeight, handleSide, doorCloser, panicBar, panicBarStyle, panicBarRods, panicBarHeight, doorHeight, openingShape, openingTopRadii, archHeight, roundedBoundary, archedBoundary, archOuterSide, }) {
699
771
  const hasLeafContent = segments.some((seg) => seg.type !== 'empty');
700
772
  const leafGroup = new THREE.Group();
701
- leafGroup.position.set(hingeX, 0, 0);
773
+ const swingFace = Math.sign(openRotationY) * (hingeSide === 'right' ? 1 : -1);
774
+ const pivotZ = (swingFace * leafDepth) / 2;
775
+ leafGroup.position.set(hingeX, 0, pivotZ);
702
776
  leafGroup.rotation.y = swingRotation;
703
777
  // Marks this group as the swing leaf and records its fully-open angle so the
704
778
  // GLB exporter can bake an open/close animation clip from a single pose. The
705
779
  // exporter strips this marker before writing the file.
706
780
  leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY };
707
781
  mesh.add(leafGroup);
708
- const addLeafBox = (material, w, h, d, x, y, z) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z);
709
- const addLeafShape = (material, shape, depth) => addShapeAt(leafGroup, material, shape, depth, -hingeX, 0, 0);
782
+ const addLeafBox = (material, w, h, d, x, y, z, geometry) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z - pivotZ, geometry);
783
+ const addLeafShape = (material, shape, depth) => addShapeAt(leafGroup, material, shape, depth, -hingeX, 0, -pivotZ);
710
784
  const localLeafCenterX = leafCenterX - hingeX;
711
785
  const leafBottom = leafCenterY - leafHeight / 2;
712
786
  const leafTop = leafCenterY + leafHeight / 2;
713
787
  const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch';
788
+ const clearLeafWidth = Math.max(0.001, leafWidth - 0.006);
789
+ const clearLeafHeight = Math.max(0.001, leafHeight - 0.006);
714
790
  if (usesShapedLeafFrame && hasLeafContent) {
791
+ const clearLeafBottom = leafCenterY - clearLeafHeight / 2;
792
+ const clearLeafTop = leafCenterY + clearLeafHeight / 2;
715
793
  currentDoorSlot = 'panel';
716
794
  if (openingShape === 'rounded') {
717
795
  const roundedLeafShape = roundedBoundary
718
- ? createRoundedClippedLeafFrameShape(leafCenterX - leafWidth / 2, leafCenterX + leafWidth / 2, leafBottom, leafTop, roundedBoundary.fullLeft, roundedBoundary.fullRight, roundedBoundary.radii, contentPadding[0], contentPadding[1])
719
- : createRoundedLeafFrameShape(leafWidth, leafBottom, leafTop, openingTopRadii, contentPadding[0], contentPadding[1]);
796
+ ? createRoundedClippedLeafFrameShape(leafCenterX - clearLeafWidth / 2, leafCenterX + clearLeafWidth / 2, clearLeafBottom, clearLeafTop, roundedBoundary.fullLeft, roundedBoundary.fullRight, roundedBoundary.radii, contentPadding[0], contentPadding[1])
797
+ : createRoundedLeafFrameShape(clearLeafWidth, clearLeafBottom, clearLeafTop, openingTopRadii, contentPadding[0], contentPadding[1]);
720
798
  if (roundedLeafShape) {
721
799
  if (roundedBoundary) {
722
800
  addLeafShape(baseMaterial, roundedLeafShape, leafDepth);
723
801
  }
724
802
  else {
725
- addShapeAt(leafGroup, baseMaterial, roundedLeafShape, leafDepth, localLeafCenterX, 0, 0);
803
+ addShapeAt(leafGroup, baseMaterial, roundedLeafShape, leafDepth, localLeafCenterX, 0, -pivotZ);
726
804
  }
727
805
  }
728
806
  }
729
807
  else if (openingShape === 'arch') {
730
808
  const archedLeafShape = archOuterSide
731
- ? createOneSidedArchLeafFrameShape(leafCenterX - leafWidth / 2, leafCenterX + leafWidth / 2, leafBottom, leafTop, archHeight, contentPadding[0], contentPadding[1], archOuterSide)
809
+ ? createOneSidedArchLeafFrameShape(leafCenterX - clearLeafWidth / 2, leafCenterX + clearLeafWidth / 2, clearLeafBottom, clearLeafTop, archHeight, contentPadding[0], contentPadding[1], archOuterSide)
732
810
  : archedBoundary
733
- ? createArchedClippedLeafFrameShape(leafCenterX - leafWidth / 2, leafCenterX + leafWidth / 2, leafBottom, leafTop, archedBoundary.fullLeft, archedBoundary.fullRight, archedBoundary.archHeight, contentPadding[0], contentPadding[1])
734
- : createArchedLeafFrameShape(leafWidth, leafBottom, leafTop, getClampedArchHeight(leafWidth, leafHeight, archHeight), contentPadding[0], contentPadding[1]);
811
+ ? createArchedClippedLeafFrameShape(leafCenterX - clearLeafWidth / 2, leafCenterX + clearLeafWidth / 2, clearLeafBottom, clearLeafTop, archedBoundary.fullLeft, archedBoundary.fullRight, archedBoundary.archHeight, contentPadding[0], contentPadding[1])
812
+ : createArchedLeafFrameShape(clearLeafWidth, clearLeafBottom, clearLeafTop, getClampedArchHeight(clearLeafWidth, clearLeafHeight, archHeight), contentPadding[0], contentPadding[1]);
735
813
  if (archedLeafShape) {
736
814
  if (archOuterSide || archedBoundary) {
737
815
  addLeafShape(baseMaterial, archedLeafShape, leafDepth);
738
816
  }
739
817
  else {
740
- addShapeAt(leafGroup, baseMaterial, archedLeafShape, leafDepth, localLeafCenterX, 0, 0);
818
+ addShapeAt(leafGroup, baseMaterial, archedLeafShape, leafDepth, localLeafCenterX, 0, -pivotZ);
741
819
  }
742
820
  }
743
821
  }
@@ -761,34 +839,158 @@ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, le
761
839
  if (hasLeafContent && handle) {
762
840
  currentDoorSlot = 'hardware';
763
841
  const handleY = handleHeight - doorHeight / 2;
764
- const faceZ = leafDepth / 2;
765
842
  const handleX = handleSide === 'right'
766
- ? leafCenterX + leafWidth / 2 - 0.045
767
- : leafCenterX - leafWidth / 2 + 0.045;
768
- addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005);
769
- addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025);
770
- if (handleBothSides) {
771
- addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005);
772
- addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025);
843
+ ? leafCenterX + leafWidth / 2 - 0.06
844
+ : leafCenterX - leafWidth / 2 + 0.06;
845
+ const contentH = clearLeafHeight - 2 * contentPadding[1];
846
+ const totalRatio = segments.reduce((sum, segment) => sum + segment.heightRatio, 0);
847
+ let segmentTop = leafCenterY + contentH / 2;
848
+ let panelDepth = 0;
849
+ for (const segment of segments) {
850
+ const segmentBottom = segmentTop - (contentH * segment.heightRatio) / totalRatio;
851
+ if (handleY >= segmentBottom && handleY <= segmentTop && segment.type === 'panel') {
852
+ const contentW = clearLeafWidth - 2 * contentPadding[0];
853
+ const sum = segment.columnRatios.reduce((a, b) => a + b, 0);
854
+ const usableW = contentW - (segment.columnRatios.length - 1) * segment.dividerThickness;
855
+ let columnLeft = leafCenterX - contentW / 2;
856
+ for (const ratio of segment.columnRatios) {
857
+ const columnW = (usableW * ratio) / sum;
858
+ const fieldW = columnW - 2 * segment.panelInset;
859
+ const fieldH = segmentTop - segmentBottom - 2 * segment.panelInset;
860
+ const edgeDistance = Math.min(fieldW / 2 - Math.abs(handleX - columnLeft - columnW / 2), fieldH / 2 - Math.abs(handleY - (segmentTop + segmentBottom) / 2));
861
+ if (edgeDistance > 0 && fieldW > 0.01 && fieldH > 0.01 && segment.panelDepth !== 0) {
862
+ const profile = panelProfile(leafDepth, segment.panelDepth, segment.panelInset, fieldW, fieldH);
863
+ panelDepth = profile[profile.length - 1].depth;
864
+ for (let i = 1; i < profile.length; i++) {
865
+ const outside = profile[i - 1];
866
+ const inside = profile[i];
867
+ if (edgeDistance <= inside.inset) {
868
+ const t = (edgeDistance - outside.inset) / (inside.inset - outside.inset);
869
+ panelDepth = outside.depth + t * (inside.depth - outside.depth);
870
+ break;
871
+ }
872
+ }
873
+ break;
874
+ }
875
+ columnLeft += columnW + segment.dividerThickness;
876
+ }
877
+ break;
878
+ }
879
+ segmentTop = segmentBottom;
880
+ }
881
+ const faceZ = leafDepth / 2 + panelDepth;
882
+ // Panic hardware owns the push face (+z): no lever beside the bar there.
883
+ const leverFaces = (handleBothSides ? [1, -1] : [1]).filter((face) => !(panicBar && face > 0));
884
+ for (const face of leverFaces) {
885
+ const lever = addHardware(leafGroup, handleStyle === 'knob'
886
+ ? knobOnRose()
887
+ : handleStyle === 'bar'
888
+ ? pullBar(true)
889
+ : handleStyle === 'plate'
890
+ ? leverOnPlate()
891
+ : handleStyle === 'lever-curved'
892
+ ? leverOnRoseCurved()
893
+ : leverOnRose(), handleX - hingeX, handleY, face * faceZ - pivotZ);
894
+ if (face < 0)
895
+ lever.rotateY(Math.PI);
896
+ const gripDirection = face * (hingeSide === 'left' ? -1 : 1);
897
+ if (gripDirection < 0 && handleStyle !== 'bar' && handleStyle !== 'knob')
898
+ lever.rotateZ(Math.PI);
773
899
  }
774
900
  }
775
901
  if (hasLeafContent && doorCloser) {
776
902
  currentDoorSlot = 'hardware';
777
- const closerY = leafCenterY + leafHeight / 2 - 0.04;
778
- addLeafBox(hardwareMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03);
779
- addLeafBox(hardwareMaterial, 0.14, 0.015, 0.015, leafCenterX + leafWidth / 4, closerY + 0.025, leafDepth / 2 + 0.015);
903
+ const latchSign = hingeSide === 'left' ? 1 : -1;
904
+ const offsetScale = Math.min(1, leafWidth / 0.4);
905
+ const closerY = leafTop - 0.04;
906
+ const bodyX = hingeX + latchSign * 0.16 * offsetScale;
907
+ const pullFace = -swingFace;
908
+ const bodyZ = pullFace * (leafDepth / 2 + 0.03);
909
+ addLeafBox(hardwareMaterial, 0.28, 0.055, 0.055, bodyX, closerY, bodyZ);
910
+ const armY = closerY + 0.0275;
911
+ // The latch-end spindle recedes from the head shoe as the leaf opens.
912
+ const closedPivot = new THREE.Vector3(hingeX + latchSign * 0.27 * offsetScale, armY, bodyZ);
913
+ const closedElbow = new THREE.Vector3(hingeX + latchSign * 0.18 * offsetScale, armY, pullFace * (frameDepth / 2 + 0.19));
914
+ const shoe = new THREE.Vector3(hingeX + latchSign * 0.34 * offsetScale, armY, pullFace * (frameDepth / 2 + 0.015));
915
+ // The shoe overlaps the head face; its underside shares the body pivot's plane below the soffit.
916
+ addHardware(mesh, roundedBox(0.09, 0.025, 0.03), shoe.x, armY + 0.0125, shoe.z);
917
+ const pivotOffset = new THREE.Vector3(hingeX, 0, pivotZ);
918
+ const axis = new THREE.Vector3(0, 1, 0);
919
+ const pivot = closedPivot
920
+ .clone()
921
+ .sub(pivotOffset)
922
+ .applyAxisAngle(axis, swingRotation)
923
+ .add(pivotOffset);
924
+ // Use the cached tubes' millimetre lengths as the actual linkage lengths.
925
+ const l1 = Math.round(closedPivot.distanceTo(closedElbow) * 1000) / 1000;
926
+ const l2 = Math.round(shoe.distanceTo(closedElbow) * 1000) / 1000;
927
+ const delta = shoe.clone().sub(pivot);
928
+ const distance = delta.length();
929
+ const direction = distance > 1e-9 ? delta.divideScalar(distance) : new THREE.Vector3(latchSign, 0, 0);
930
+ // Unreachable anchors keep fixed-length links collinear rather than stretching them.
931
+ const reach = Math.max(Math.abs(l1 - l2), Math.min(l1 + l2, distance));
932
+ const along = reach > 1e-9 ? (l1 * l1 - l2 * l2 + reach * reach) / (2 * reach) : 0;
933
+ const bow = Math.sqrt(Math.max(0, l1 * l1 - along * along));
934
+ const normal = new THREE.Vector3(-direction.z, 0, direction.x);
935
+ if (normal.z * pullFace < 0)
936
+ normal.negate();
937
+ const elbow = pivot.clone().addScaledVector(direction, along).addScaledVector(normal, bow);
938
+ for (const [parent, radius, start, end] of [
939
+ [leafGroup, 0.0075, pivot, elbow],
940
+ [mesh, 0.006, shoe, elbow],
941
+ ]) {
942
+ const localStart = start.clone();
943
+ const localEnd = end.clone();
944
+ if (parent === leafGroup) {
945
+ localStart.sub(pivotOffset).applyAxisAngle(axis, -swingRotation);
946
+ localEnd.sub(pivotOffset).applyAxisAngle(axis, -swingRotation);
947
+ }
948
+ const vector = localEnd.clone().sub(localStart);
949
+ const length = parent === leafGroup ? l1 : l2;
950
+ const center = localStart.clone().addScaledVector(vector.clone().normalize(), length / 2);
951
+ const arm = addHardware(parent, tube(radius, length), center.x, center.y, center.z);
952
+ arm.quaternion.setFromUnitVectors(axis, vector.normalize());
953
+ }
954
+ addHardware(mesh, sphere(0.009), elbow.x, elbow.y, elbow.z);
780
955
  }
781
956
  if (hasLeafContent && panicBar) {
782
- currentDoorSlot = 'hardware';
783
957
  const barY = panicBarHeight - doorHeight / 2;
784
- addLeafBox(hardwareMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03);
958
+ const latchSign = hingeSide === 'left' ? 1 : -1;
959
+ const latchX = leafCenterX + latchSign * (leafWidth / 2 - 0.055);
960
+ const hingeEndX = leafCenterX - latchSign * (leafWidth / 2 - 0.04);
961
+ const faceZ = (swingFace * leafDepth) / 2;
962
+ const device = (geometry, x, y, offFace) => {
963
+ const part = addHardware(leafGroup, geometry, x - hingeX, y, faceZ + swingFace * offFace - pivotZ);
964
+ if (swingFace < 0)
965
+ part.rotateY(Math.PI);
966
+ return part;
967
+ };
968
+ const touchbar = panicBarStyle === 'touchbar';
969
+ const caseHeight = touchbar ? 0.1 : 0.07;
970
+ device(roundedBox(touchbar ? 0.09 : 0.075, caseHeight, touchbar ? 0.055 : 0.06), latchX, barY, touchbar ? 0.0275 : 0.03);
971
+ device(roundedBox(touchbar ? 0.035 : 0.04, 0.07, 0.05), hingeEndX, barY, 0.025);
972
+ const barLength = Math.abs(latchX - hingeEndX);
973
+ const bar = device(touchbar ? roundedBox(barLength, 0.06, 0.03) : tube(0.0125, barLength), (latchX + hingeEndX) / 2, barY, touchbar ? 0.035 : 0.045);
974
+ if (!touchbar)
975
+ bar.rotateZ(Math.PI / 2);
976
+ if (panicBarRods) {
977
+ for (const end of [-1, 1]) {
978
+ const latchY = end > 0 ? leafTop - 0.05 : leafBottom + 0.05;
979
+ device(roundedBox(0.035, 0.06, 0.04), latchX, latchY, 0.02);
980
+ const startY = barY + (end * caseHeight) / 2;
981
+ const endY = latchY - end * 0.03;
982
+ const rodY = (startY + endY) / 2;
983
+ device(tube(0.006, Math.abs(endY - startY)), latchX, rodY, 0.025);
984
+ device(roundedBox(0.03, 0.02, 0.03), latchX, rodY, 0.015);
985
+ }
986
+ }
785
987
  }
786
988
  if (hasLeafContent) {
787
989
  currentDoorSlot = 'hardware';
788
- const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012;
990
+ const leafHinges = new THREE.Group();
991
+ leafHinges.name = 'door-leaf-hinges';
992
+ leafGroup.add(leafHinges);
789
993
  const hingeH = 0.1;
790
- const hingeW = 0.024;
791
- const hingeD = leafDepth + 0.016;
792
994
  const hingeRadii = roundedBoundary?.radii ?? openingTopRadii;
793
995
  const cornerDrop = openingShape === 'rounded'
794
996
  ? hingeSide === 'left'
@@ -802,7 +1004,14 @@ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, le
802
1004
  for (const hingeY of [leafBottom + 0.25, (leafBottom + leafTop) / 2, leafTop - 0.25]) {
803
1005
  if (hingeY + hingeH / 2 > straightEdgeTop + 1e-6)
804
1006
  continue;
805
- addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, hingeY, 0);
1007
+ const jambPart = addHardware(mesh, hingeJambPart(), hingeX, hingeY, pivotZ);
1008
+ const leafPart = addHardware(leafHinges, hingeLeafPart(), 0, hingeY, 0);
1009
+ for (const part of [jambPart, leafPart]) {
1010
+ if (hingeSide === 'right')
1011
+ part.rotateZ(Math.PI);
1012
+ if (swingFace < 0)
1013
+ part.rotateX(Math.PI);
1014
+ }
806
1015
  }
807
1016
  }
808
1017
  // When the leaf is swung open it projects into the room and would otherwise
@@ -821,6 +1030,34 @@ function addDoorLeaf(mesh, { leafWidth, leafHeight, leafCenterX, leafCenterY, le
821
1030
  // registered mesh, and the GLB exporter poses an export clone to sample the
822
1031
  // open/close keyframes for a baked animation clip.
823
1032
  const SLIDING_ACTIVE_PANEL_NAME = 'door-sliding-active';
1033
+ function slidingPanels(node, width) {
1034
+ const count = Math.max(2, Math.min(4, node.leafCount ?? 2));
1035
+ const sign = node.slideDirection === 'right' ? 1 : -1;
1036
+ const pitch = width / count;
1037
+ return Array.from({ length: count }, (_, index) => {
1038
+ const front = node.slidingStyle === 'bypass'
1039
+ ? index % 2 === (count === 3 || sign < 0 ? 1 : 0)
1040
+ : count === 4
1041
+ ? index === 1 || index === 2
1042
+ : count === 3
1043
+ ? index === 1
1044
+ : index === (sign < 0 ? 1 : 0);
1045
+ const travel = front
1046
+ ? (count === 4 && node.slidingStyle !== 'bypass' ? (index === 1 ? -1 : 1) : sign) *
1047
+ (count === 2 ? width * 0.44 : pitch)
1048
+ : 0;
1049
+ return {
1050
+ index,
1051
+ front,
1052
+ travel,
1053
+ x: count === 2 ? (index === 0 ? -1 : 1) * width * 0.23 : -width / 2 + pitch * (index + 0.5),
1054
+ width: count === 2 ? width * 0.54 : pitch + 0.02,
1055
+ };
1056
+ });
1057
+ }
1058
+ function slidingPanelName(index, first) {
1059
+ return index === first ? SLIDING_ACTIVE_PANEL_NAME : `${SLIDING_ACTIVE_PANEL_NAME}-${index}`;
1060
+ }
824
1061
  const POCKET_LEAF_NAME = 'door-pocket-leaf';
825
1062
  const BARN_LEAF_NAME = 'door-barn-leaf';
826
1063
  const TILTUP_LEAF_NAME = 'door-tiltup-leaf';
@@ -857,12 +1094,17 @@ export function poseDoorMovingParts(node, mesh, value) {
857
1094
  const leafCenterY = -frameThickness / 2;
858
1095
  switch (node.doorType) {
859
1096
  case 'sliding': {
860
- const group = mesh.getObjectByName(SLIDING_ACTIVE_PANEL_NAME);
861
- if (!group)
862
- return false;
863
- const activeSign = node.slideDirection === 'left' ? 1 : -1;
864
- group.position.x = -activeSign * insideWidth * 0.44 * t;
865
- return true;
1097
+ const panels = slidingPanels(node, insideWidth);
1098
+ const first = panels.find((panel) => panel.front).index;
1099
+ let posed = false;
1100
+ for (const panel of panels) {
1101
+ const group = mesh.getObjectByName(slidingPanelName(panel.index, first));
1102
+ if (!group)
1103
+ continue;
1104
+ group.position.x = panel.travel * t;
1105
+ posed = true;
1106
+ }
1107
+ return posed;
866
1108
  }
867
1109
  case 'pocket': {
868
1110
  const group = mesh.getObjectByName(POCKET_LEAF_NAME);
@@ -894,7 +1136,7 @@ export function poseDoorMovingParts(node, mesh, value) {
894
1136
  return true;
895
1137
  }
896
1138
  case 'folding': {
897
- const panelCount = node.leafCount === 2 ? 2 : 4;
1139
+ const panelCount = node.leafCount >= 6 ? node.leafCount : node.leafCount === 2 ? 2 : 4;
898
1140
  const foldAngle = Math.PI * 0.44 * t;
899
1141
  // Each panel group is parented to the previous, so its rotation is the
900
1142
  // joint angle (the change in absolute segment direction), not the absolute
@@ -904,7 +1146,7 @@ export function poseDoorMovingParts(node, mesh, value) {
904
1146
  let prevDirection = 0;
905
1147
  for (let index = 0; index < panelCount; index++) {
906
1148
  const group = mesh.getObjectByName(`${FOLDING_PANEL_NAME}${index}`);
907
- const direction = index % 2 === 0 ? -1 : 1;
1149
+ const direction = (index % 2 === 0 ? -1 : 1) * (panelCount > 4 && node.slideDirection === 'right' ? -1 : 1);
908
1150
  if (group) {
909
1151
  posed = true;
910
1152
  // Set the full triple (not just `.y`): when the export clones and
@@ -963,8 +1205,9 @@ export function poseDoorMovingParts(node, mesh, value) {
963
1205
  return false;
964
1206
  }
965
1207
  }
966
- function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, leafCount, doorHeight, handleHeight, segments, contentPadding, }) {
967
- const panelCount = leafCount === 2 ? 2 : 4;
1208
+ function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, leafCount, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
1209
+ const panelCount = leafCount >= 6 ? leafCount : leafCount === 2 ? 2 : 4;
1210
+ const sign = panelCount > 4 && slideDirection === 'right' ? -1 : 1;
968
1211
  const panelLength = insideWidth / panelCount;
969
1212
  currentDoorSlot = 'hardware';
970
1213
  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);
@@ -973,17 +1216,20 @@ function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
973
1216
  // x), starting from the left jamb. At rest every joint is straight, so the
974
1217
  // panels lie flat across the opening; `poseDoorMovingParts` folds the joints.
975
1218
  let parent = mesh;
1219
+ let pullFaceZ = leafDepth / 2;
1220
+ const handleY = handleHeight - doorHeight / 2;
976
1221
  for (let index = 0; index < panelCount; index++) {
977
1222
  const group = new THREE.Group();
978
1223
  group.name = `${FOLDING_PANEL_NAME}${index}`;
979
- group.position.set(index === 0 ? -insideWidth / 2 : panelLength, 0, 0);
1224
+ group.position.set(index === 0 ? (-sign * insideWidth) / 2 : sign * panelLength, 0, 0);
980
1225
  parent.add(group);
981
1226
  // Panel content runs from local x=0 to x=panelLength, so it is centred at
982
1227
  // panelLength/2 within the group (the segment helper centres on leafCenterX).
983
1228
  currentDoorSlot = undefined;
984
- const addFoldingLeafBox = (material, w, h, d, x, y, z) => addBox(group, material, w, h, d, panelLength / 2 + x, y, z);
985
- addLeafSegmentContent({
1229
+ const addFoldingLeafBox = (material, w, h, d, x, y, z, geometry) => addBox(group, material, w, h, d, sign * (panelLength / 2 + x), y, z, geometry);
1230
+ pullFaceZ = addLeafSegmentContent({
986
1231
  addLeafBox: addFoldingLeafBox,
1232
+ flushPullAt: index === panelCount - 1 ? { x: panelLength / 2 - 0.035, y: handleY } : undefined,
987
1233
  leafWidth: Math.max(0.08, panelLength),
988
1234
  leafHeight,
989
1235
  leafCenterX: 0,
@@ -996,15 +1242,13 @@ function addFoldingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
996
1242
  // Reveal posts at the panel's hinge edges (local x=0 and x=panelLength).
997
1243
  currentDoorSlot = undefined;
998
1244
  for (const px of [0, panelLength]) {
999
- addBox(group, revealMaterial, 0.018, leafHeight * 0.92, leafDepth + 0.016, px, leafCenterY, 0);
1245
+ addBox(group, revealMaterial, 0.018, leafHeight * 0.92, leafDepth + 0.016, sign * px, leafCenterY, 0);
1000
1246
  }
1001
1247
  parent = group;
1002
1248
  }
1003
1249
  // Handle on the free end of the last panel (its local x=panelLength edge).
1004
- const handleY = handleHeight - doorHeight / 2;
1005
1250
  currentDoorSlot = 'hardware';
1006
- addBox(parent, hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, panelLength - 0.035, handleY, 0.045);
1007
- addBox(parent, hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, panelLength - 0.035, handleY, -0.045);
1251
+ addPulls(parent, sign * (panelLength - 0.035), handleY, pullFaceZ, 0, true);
1008
1252
  }
1009
1253
  function addPocketDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
1010
1254
  const slideSign = slideDirection === 'right' ? 1 : -1;
@@ -1023,9 +1267,10 @@ function addPocketDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
1023
1267
  const leafGroup = new THREE.Group();
1024
1268
  leafGroup.name = POCKET_LEAF_NAME;
1025
1269
  mesh.add(leafGroup);
1026
- const addPocketLeafBox = (material, w, h, d, x, y, z) => addBox(leafGroup, material, w, h, d, x, y, z);
1027
- addLeafSegmentContent({
1270
+ const addPocketLeafBox = (material, w, h, d, x, y, z, geometry) => addBox(leafGroup, material, w, h, d, x, y, z, geometry);
1271
+ const pullFaceZ = addLeafSegmentContent({
1028
1272
  addLeafBox: addPocketLeafBox,
1273
+ flushPullAt: { x: handleX, y: handleY },
1029
1274
  leafWidth,
1030
1275
  leafHeight,
1031
1276
  leafCenterX: 0,
@@ -1035,8 +1280,7 @@ function addPocketDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth,
1035
1280
  contentPadding,
1036
1281
  });
1037
1282
  currentDoorSlot = 'hardware';
1038
- addBox(leafGroup, hardwareMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02);
1039
- addBox(leafGroup, hardwareMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, -leafDepth / 2 - 0.02);
1283
+ addPulls(leafGroup, handleX, handleY, pullFaceZ, 0, true);
1040
1284
  }
1041
1285
  function addBarnDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
1042
1286
  const slideSign = slideDirection === 'right' ? 1 : -1;
@@ -1050,15 +1294,16 @@ function addBarnDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, fr
1050
1294
  // in the opening); `poseDoorMovingParts` slides the group along the rail. The
1051
1295
  // rail and end stops stay static on the mesh.
1052
1296
  const handleX = -slideSign * (leafWidth / 2 - 0.075);
1053
- const wheelY = trackY - 0.075;
1297
+ const wheelY = trackY + 0.035 / 2 + 0.0425;
1054
1298
  currentDoorSlot = 'hardware';
1055
1299
  addBox(mesh, hardwareMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01);
1056
- addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01);
1057
- addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01);
1300
+ for (const end of [-1, 1]) {
1301
+ addHardware(mesh, roller(0.02, 0.03), railCenterX + (end * railLength) / 2, trackY, faceZ + 0.01);
1302
+ }
1058
1303
  const leafGroup = new THREE.Group();
1059
1304
  leafGroup.name = BARN_LEAF_NAME;
1060
1305
  mesh.add(leafGroup);
1061
- const addBarnLeafBox = (material, w, h, d, x, y, z) => addBox(leafGroup, material, w, h, d, x, y, faceZ + z);
1306
+ const addBarnLeafBox = (material, w, h, d, x, y, z, geometry) => addBox(leafGroup, material, w, h, d, x, y, faceZ + z, geometry);
1062
1307
  addLeafSegmentContent({
1063
1308
  addLeafBox: addBarnLeafBox,
1064
1309
  leafWidth,
@@ -1075,62 +1320,49 @@ function addBarnDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, fr
1075
1320
  addRotatedBox(leafGroup, revealMaterial, 0.018, leafHeight * 0.86, 0.012, 0, leafCenterY, faceZ + leafDepth / 2 + 0.014, 0.52);
1076
1321
  currentDoorSlot = 'hardware';
1077
1322
  for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) {
1078
- addBox(leafGroup, hardwareMaterial, 0.085, 0.085, 0.035, offset, wheelY, faceZ + 0.022);
1079
- addBox(leafGroup, hardwareMaterial, 0.026, 0.16, 0.026, offset, wheelY - 0.075, faceZ + 0.022);
1323
+ addHardware(leafGroup, roller(0.0425, 0.02), offset, wheelY, faceZ + 0.01);
1324
+ addBox(leafGroup, hardwareMaterial, 0.026, 0.16, 0.026, offset, wheelY - 0.075, faceZ + 0.033);
1080
1325
  }
1081
1326
  currentDoorSlot = 'hardware';
1082
- addBox(leafGroup, hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, handleX, handleY, faceZ + leafDepth / 2 + 0.02);
1083
- addBox(leafGroup, hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, handleX, handleY, faceZ - leafDepth / 2 - 0.02);
1327
+ for (const face of [-1, 1]) {
1328
+ const pull = addHardware(leafGroup, pullBar(), handleX, handleY, faceZ + face * (leafDepth / 2 + 0.023));
1329
+ if (face < 0)
1330
+ pull.rotateY(Math.PI);
1331
+ }
1084
1332
  }
1085
- function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, doorHeight, handleHeight, segments, contentPadding, }) {
1086
- const activeOnRight = slideDirection === 'left';
1087
- const fixedSign = activeOnRight ? -1 : 1;
1088
- const activeSign = activeOnRight ? 1 : -1;
1089
- const panelWidth = insideWidth * 0.54;
1090
- const panelHeight = leafHeight;
1091
- const closedActiveX = activeSign * insideWidth * 0.23;
1092
- const fixedX = fixedSign * insideWidth * 0.23;
1093
- const frontZ = leafDepth / 2 + 0.016;
1094
- const backZ = -leafDepth / 2 - 0.006;
1095
- const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02);
1333
+ function addSlidingDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, slideDirection, leafCount, slidingStyle, doorHeight, handleHeight, segments, contentPadding, }) {
1334
+ const panels = slidingPanels({ leafCount, slideDirection, slidingStyle }, insideWidth);
1335
+ const first = panels.find((panel) => panel.front).index;
1096
1336
  const handleY = handleHeight - doorHeight / 2;
1097
- // Active panel + handle ride the SLIDING_ACTIVE_PANEL group, built at the
1098
- // closed position; `poseDoorMovingParts` slides the group behind the fixed
1099
- // panel. The fixed panel and rails stay static on the mesh.
1100
- const handleX = closedActiveX + activeSign * (panelWidth / 2 - 0.06);
1101
1337
  currentDoorSlot = 'hardware';
1102
- addBox(mesh, hardwareMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0);
1338
+ addBox(mesh, hardwareMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, leafCenterY + leafHeight / 2 - Math.min(frameThickness * 0.35, 0.02), 0);
1103
1339
  addBox(mesh, hardwareMaterial, insideWidth, 0.018, Math.max(frameDepth * 0.28, 0.022), 0, -leafHeight / 2 + 0.04, 0);
1104
- const addFixedPanelBox = (material, w, h, d, x, y, z) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ);
1105
- const activePanelGroup = new THREE.Group();
1106
- activePanelGroup.name = SLIDING_ACTIVE_PANEL_NAME;
1107
- mesh.add(activePanelGroup);
1108
- const addActivePanelBox = (material, w, h, d, x, y, z) => addBox(activePanelGroup, material, w, h, d, x + closedActiveX, y, z + frontZ);
1109
- addLeafSegmentContent({
1110
- addLeafBox: addFixedPanelBox,
1111
- leafWidth: panelWidth,
1112
- leafHeight: panelHeight,
1113
- leafCenterX: 0,
1114
- leafCenterY,
1115
- leafDepth,
1116
- segments,
1117
- contentPadding,
1118
- keepFrameWhenEmpty: true,
1119
- });
1120
- addLeafSegmentContent({
1121
- addLeafBox: addActivePanelBox,
1122
- leafWidth: panelWidth,
1123
- leafHeight: panelHeight,
1124
- leafCenterX: 0,
1125
- leafCenterY,
1126
- leafDepth,
1127
- segments,
1128
- contentPadding,
1129
- keepFrameWhenEmpty: true,
1130
- });
1131
- currentDoorSlot = 'hardware';
1132
- addBox(activePanelGroup, hardwareMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01);
1133
- addBox(activePanelGroup, hardwareMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ - leafDepth / 2 - 0.01);
1340
+ for (const panel of panels) {
1341
+ const group = new THREE.Group();
1342
+ group.name = slidingPanelName(panel.index, first);
1343
+ mesh.add(group);
1344
+ const z = panel.front ? leafDepth / 2 + 0.016 : -leafDepth / 2 - 0.006;
1345
+ const hasPull = panel.front || slidingStyle === 'bypass';
1346
+ const handleX = (slideDirection === 'left' ? 1 : -1) * (panel.width / 2 - 0.06);
1347
+ const addPanelBox = (material, w, h, d, x, y, depth, geometry) => addBox(group, material, w, h, d, x + panel.x, y, depth + z, geometry);
1348
+ currentDoorSlot = undefined;
1349
+ const pullFaceZ = addLeafSegmentContent({
1350
+ addLeafBox: addPanelBox,
1351
+ ...(hasPull ? { flushPullAt: { x: handleX, y: handleY } } : {}),
1352
+ leafWidth: panel.width,
1353
+ leafHeight,
1354
+ leafCenterX: 0,
1355
+ leafCenterY,
1356
+ leafDepth,
1357
+ segments,
1358
+ contentPadding,
1359
+ keepFrameWhenEmpty: true,
1360
+ });
1361
+ if (hasPull) {
1362
+ currentDoorSlot = 'hardware';
1363
+ addPulls(group, panel.x + handleX, handleY, pullFaceZ, z, true);
1364
+ }
1365
+ }
1134
1366
  }
1135
1367
  function addGarageSectionalDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafDepth, frameThickness, frameDepth, garagePanelCount, }) {
1136
1368
  const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount)));
@@ -1235,7 +1467,10 @@ function addGarageTiltupDoor(mesh, { insideWidth, leafHeight, leafCenterY, leafD
1235
1467
  addBox(mesh, hardwareMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0);
1236
1468
  }
1237
1469
  function getEffectiveOpeningShape(node) {
1238
- return node.doorType === 'folding' ||
1470
+ const size = getDoorOpeningSize(node);
1471
+ return size.width !== node.width ||
1472
+ size.height !== node.height ||
1473
+ node.doorType === 'folding' ||
1239
1474
  node.doorType === 'pocket' ||
1240
1475
  node.doorType === 'barn' ||
1241
1476
  node.doorType === 'sliding'
@@ -1243,16 +1478,27 @@ function getEffectiveOpeningShape(node) {
1243
1478
  : (node.openingShape ?? 'rectangle');
1244
1479
  }
1245
1480
  function updateDoorMesh(rawNode, mesh) {
1246
- const node = normalizeDoorNodeForRender(rawNode);
1481
+ const normalized = normalizeDoorNodeForRender(rawNode);
1247
1482
  currentDoorSlot = undefined;
1483
+ // The frame runs through its host wall: an auto depth is the wall's thickness
1484
+ // exactly, so every builder below reads one resolved number.
1485
+ const parent = normalized.parentId
1486
+ ? useScene.getState().nodes[normalized.parentId]
1487
+ : undefined;
1488
+ const wall = parent?.type === 'wall' ? getEffectiveNode(parent) : undefined;
1489
+ const node = {
1490
+ ...normalized,
1491
+ frameDepth: resolveOpeningFrameDepth(normalized, wall ? getWallThickness(wall) : DEFAULT_WALL_THICKNESS),
1492
+ };
1248
1493
  // Root mesh is an invisible hitbox; all visuals live in child meshes
1249
1494
  mesh.geometry.dispose();
1250
- mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth);
1495
+ const openingSize = getDoorOpeningSize(node);
1496
+ mesh.geometry = new THREE.BoxGeometry(openingSize.width, openingSize.height, node.frameDepth);
1497
+ mesh.geometry.translate(-openingSize.leafOffsetX, (openingSize.height - node.height) / 2, 0);
1251
1498
  mesh.material = hitboxMaterial;
1252
1499
  // Sync transform from node (React may lag behind the system by a frame during drag)
1253
- const parent = node.parentId ? useScene.getState().nodes[node.parentId] : undefined;
1254
- const placement = parent?.type === 'wall' && !node.roofSegmentId
1255
- ? getOpeningWallPlacement(getEffectiveNode(parent), node, useScene.getState().nodes)
1500
+ const placement = wall && !node.roofSegmentId
1501
+ ? getOpeningWallPlacement(wall, node, useScene.getState().nodes)
1256
1502
  : node;
1257
1503
  mesh.position.set(...placement.position);
1258
1504
  mesh.rotation.set(...placement.rotation);
@@ -1277,13 +1523,24 @@ function updateDoorMesh(rawNode, mesh) {
1277
1523
  frameMaterial = resolveDoorSlotMaterial(node, 'frame');
1278
1524
  glassMaterial = resolveDoorSlotMaterial(node, 'glass');
1279
1525
  hardwareMaterial = resolveDoorSlotMaterial(node, 'hardware');
1280
- 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;
1526
+ // The casing sits on the wall faces around the opening, whatever fills it.
1527
+ addDoorCasing(node, mesh);
1528
+ const { width, height, openingKind, openingShape: rawOpeningShape, frameThickness, frameDepth, threshold, thresholdHeight, segments: storedSegments, handle, handleHeight, handleSide, doorCloser, panicBar, panicBarStyle, panicBarRods, panicBarHeight, contentPadding: storedPadding, hingesSide, swingDirection, swingAngle: nodeSwingAngle = 0, doorType = 'hinged', operationState: nodeOperationState = 0, leafCount = 1, slideDirection = 'left', slidingStyle, garagePanelCount = 4, } = node;
1529
+ const { segments, contentPadding } = isLegacyDefaultDoorLook({
1530
+ segments: storedSegments,
1531
+ contentPadding: storedPadding,
1532
+ })
1533
+ ? doorStyleLook('panel')
1534
+ : { segments: storedSegments, contentPadding: storedPadding };
1281
1535
  const openingShape = getEffectiveOpeningShape(node) ?? rawOpeningShape;
1282
1536
  const runtimeDoorState = useInteractive.getState().doors[node.id];
1283
1537
  const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle;
1284
1538
  const operationState = runtimeDoorState?.operationState ?? nodeOperationState;
1285
1539
  const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle));
1286
- if (openingKind === 'opening') {
1540
+ const renderContent = parent
1541
+ ? nodeRegistry.get(parent.type)?.capabilities.surfaces?.renderOpeningContent?.(parent)
1542
+ : undefined;
1543
+ if (openingKind === 'opening' || renderContent === false) {
1287
1544
  syncDoorCutout(node, mesh);
1288
1545
  return true;
1289
1546
  }
@@ -1321,6 +1578,14 @@ function updateDoorMesh(rawNode, mesh) {
1321
1578
  // Head (top bar) — full width
1322
1579
  addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1323
1580
  }
1581
+ // Curved heads need a matching curved rebate; arched/rounded frames have no stop.
1582
+ if (openingShape === 'rectangle' && ['hinged', 'double', 'french'].includes(doorType)) {
1583
+ const stopZ = -swingDirectionSign * (leafDepth / 2 + 0.016);
1584
+ for (const side of [-1, 1]) {
1585
+ addBox(mesh, frameMaterial, 0.012, leafH, 0.032, side * (insideWidth / 2 - 0.006), leafCenterY, stopZ).name = 'door-jamb-stop';
1586
+ }
1587
+ addBox(mesh, frameMaterial, insideWidth - 0.024, 0.012, 0.032, 0, height / 2 - frameThickness - 0.006, stopZ).name = 'door-jamb-stop';
1588
+ }
1324
1589
  // ── Threshold (inside the frame) ──
1325
1590
  if (threshold) {
1326
1591
  currentDoorSlot = 'frame';
@@ -1367,6 +1632,7 @@ function updateDoorMesh(rawNode, mesh) {
1367
1632
  frameThickness,
1368
1633
  frameDepth,
1369
1634
  leafCount,
1635
+ slideDirection,
1370
1636
  doorHeight: height,
1371
1637
  handleHeight,
1372
1638
  segments,
@@ -1412,6 +1678,8 @@ function updateDoorMesh(rawNode, mesh) {
1412
1678
  frameThickness,
1413
1679
  frameDepth,
1414
1680
  slideDirection,
1681
+ leafCount,
1682
+ slidingStyle,
1415
1683
  doorHeight: height,
1416
1684
  handleHeight,
1417
1685
  segments,
@@ -1434,6 +1702,7 @@ function updateDoorMesh(rawNode, mesh) {
1434
1702
  leafCenterX: -insideWidth / 4,
1435
1703
  leafCenterY,
1436
1704
  leafDepth,
1705
+ frameDepth,
1437
1706
  hingeX: -insideWidth / 2,
1438
1707
  hingeSide: 'left',
1439
1708
  swingRotation: -clampedSwingAngle * swingDirectionSign,
@@ -1441,11 +1710,14 @@ function updateDoorMesh(rawNode, mesh) {
1441
1710
  segments,
1442
1711
  contentPadding,
1443
1712
  handle,
1713
+ handleStyle: doorHandleStyle(node),
1444
1714
  handleBothSides: doorType === 'double' || doorType === 'french',
1445
1715
  handleHeight,
1446
1716
  handleSide: 'right',
1447
1717
  doorCloser,
1448
1718
  panicBar,
1719
+ panicBarStyle,
1720
+ panicBarRods,
1449
1721
  panicBarHeight,
1450
1722
  doorHeight: height,
1451
1723
  openingShape,
@@ -1462,6 +1734,7 @@ function updateDoorMesh(rawNode, mesh) {
1462
1734
  leafCenterX: insideWidth / 4,
1463
1735
  leafCenterY,
1464
1736
  leafDepth,
1737
+ frameDepth,
1465
1738
  hingeX: insideWidth / 2,
1466
1739
  hingeSide: 'right',
1467
1740
  swingRotation: clampedSwingAngle * swingDirectionSign,
@@ -1469,11 +1742,14 @@ function updateDoorMesh(rawNode, mesh) {
1469
1742
  segments,
1470
1743
  contentPadding,
1471
1744
  handle,
1745
+ handleStyle: doorHandleStyle(node),
1472
1746
  handleBothSides: doorType === 'double' || doorType === 'french',
1473
1747
  handleHeight,
1474
1748
  handleSide: 'left',
1475
1749
  doorCloser: false,
1476
1750
  panicBar,
1751
+ panicBarStyle,
1752
+ panicBarRods,
1477
1753
  panicBarHeight,
1478
1754
  doorHeight: height,
1479
1755
  openingShape,
@@ -1494,6 +1770,7 @@ function updateDoorMesh(rawNode, mesh) {
1494
1770
  leafCenterX: 0,
1495
1771
  leafCenterY,
1496
1772
  leafDepth,
1773
+ frameDepth,
1497
1774
  hingeX,
1498
1775
  hingeSide: hingesSide,
1499
1776
  swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
@@ -1501,11 +1778,14 @@ function updateDoorMesh(rawNode, mesh) {
1501
1778
  segments,
1502
1779
  contentPadding,
1503
1780
  handle,
1781
+ handleStyle: doorHandleStyle(node),
1504
1782
  handleBothSides: doorType === 'hinged',
1505
1783
  handleHeight,
1506
1784
  handleSide,
1507
1785
  doorCloser,
1508
1786
  panicBar,
1787
+ panicBarStyle,
1788
+ panicBarRods,
1509
1789
  panicBarHeight,
1510
1790
  doorHeight: height,
1511
1791
  openingShape,
@@ -1520,6 +1800,7 @@ function updateDoorMesh(rawNode, mesh) {
1520
1800
  if (doorType !== 'garage-rollup') {
1521
1801
  poseDoorMovingParts(node, mesh, operationState);
1522
1802
  }
1803
+ addDoorFixedLights(node, mesh);
1523
1804
  syncDoorCutout(node, mesh);
1524
1805
  // Guard: some degenerate door configs can leave a child mesh with an
1525
1806
  // empty (0-vertex) geometry — e.g. a zero-area extruded leaf frame.
@@ -1539,6 +1820,79 @@ function hideEmptyGeometryMeshes(root) {
1539
1820
  child.visible = false;
1540
1821
  });
1541
1822
  }
1823
+ /**
1824
+ * The door's casing (head and legs) on the faces of its wall (or gable) around
1825
+ * the cutout, inheriting the frame until each side is painted. Read from the live host, so it stays on
1826
+ * the faces while the wall's thickness is dragged.
1827
+ */
1828
+ function addDoorCasing(node, mesh) {
1829
+ const nodes = useScene.getState().nodes;
1830
+ const hostId = node.roofSegmentId ?? node.parentId;
1831
+ const hostNode = hostId ? nodes[hostId] : undefined;
1832
+ const host = resolveOpeningCasingHost(node, nodes, hostNode ? getEffectiveNode(hostNode) : undefined);
1833
+ if (!host)
1834
+ return;
1835
+ for (const casing of buildOpeningCasingMeshes(node, host, frameMaterial)) {
1836
+ const ref = node.slots?.[casing.userData.slotId];
1837
+ casing.material =
1838
+ (currentTextures && ref
1839
+ ? resolveMaterialRef(ref, currentSceneMaterials, currentShading)
1840
+ : null) ?? frameMaterial;
1841
+ mesh.add(casing);
1842
+ }
1843
+ }
1844
+ function addDoorFixedLights(node, mesh) {
1845
+ const size = getDoorOpeningSize(node);
1846
+ if (size.width === node.width && size.height === node.height)
1847
+ return;
1848
+ const group = new THREE.Group();
1849
+ group.name = 'door-fixed-lights';
1850
+ mesh.add(group);
1851
+ const f = node.frameThickness;
1852
+ const bottom = -node.height / 2;
1853
+ const top = bottom + size.height;
1854
+ const left = -size.width / 2 - size.leafOffsetX;
1855
+ const right = left + size.width;
1856
+ const box = (slot, w, h, x, y) => {
1857
+ if (w <= 0 || h <= 0)
1858
+ return;
1859
+ currentDoorSlot = slot;
1860
+ addBox(group, slot === 'glass' ? glassMaterial : slot === 'panel' ? baseMaterial : frameMaterial, w, h, slot === 'glass' ? 0.008 : node.frameDepth, x, y, 0);
1861
+ };
1862
+ const paneled = doorStylesOf(node).includes('paneled-glass');
1863
+ for (const [side, extra] of [
1864
+ [-1, node.sidelightLeft ?? 0],
1865
+ [1, node.sidelightRight ?? 0],
1866
+ ]) {
1867
+ if (!extra)
1868
+ continue;
1869
+ const outerX = side < 0 ? left + f / 2 : right - f / 2;
1870
+ box('frame', f, node.height, outerX, 0);
1871
+ const x = side * (node.width / 2 + extra / 2 - f / 2);
1872
+ const w = extra - f;
1873
+ const glassBottom = bottom + (paneled ? 0.3 : f);
1874
+ const glassTop = node.height / 2 - f;
1875
+ box('frame', extra, f, side * (node.width / 2 + extra / 2), node.height / 2 - f / 2);
1876
+ box(paneled ? 'panel' : 'frame', w, paneled ? 0.3 : f, x, bottom + (paneled ? 0.15 : f / 2));
1877
+ const mid = node.height > 1.2 ? (glassBottom + glassTop) / 2 : undefined;
1878
+ if (mid === undefined)
1879
+ box('glass', w, glassTop - glassBottom, x, (glassTop + glassBottom) / 2);
1880
+ else {
1881
+ box('frame', w, f, x, mid);
1882
+ box('glass', w, mid - f / 2 - glassBottom, x, (glassBottom + mid - f / 2) / 2);
1883
+ box('glass', w, glassTop - mid - f / 2, x, (glassTop + mid + f / 2) / 2);
1884
+ }
1885
+ }
1886
+ const transom = size.height - node.height;
1887
+ if (transom > 0) {
1888
+ box('frame', size.width, f, -size.leafOffsetX, node.height / 2 - f / 2);
1889
+ box('frame', size.width, f, -size.leafOffsetX, top - f / 2);
1890
+ box('frame', f, transom, left + f / 2, node.height / 2 + transom / 2);
1891
+ box('frame', f, transom, right - f / 2, node.height / 2 + transom / 2);
1892
+ box('glass', size.width - 2 * f, transom - f, -size.leafOffsetX, node.height / 2 + (transom - f) / 2);
1893
+ }
1894
+ currentDoorSlot = undefined;
1895
+ }
1542
1896
  function syncDoorCutout(node, mesh) {
1543
1897
  // ── Cutout: invisible raycast hit target for the whole opening ──
1544
1898
  let cutout = mesh.getObjectByName('cutout');
@@ -1563,6 +1917,7 @@ function syncDoorCutout(node, mesh) {
1563
1917
  .set(center[0] - mesh.position.x, 0, center[2] - mesh.position.z)
1564
1918
  .applyQuaternion(mesh.quaternion.clone().invert());
1565
1919
  }
1920
+ const { width, height, leafOffsetX } = getDoorOpeningSize(node);
1566
1921
  const openingShape = getEffectiveOpeningShape(node);
1567
1922
  if (openingShape === 'arch') {
1568
1923
  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)), {
@@ -1581,8 +1936,9 @@ function syncDoorCutout(node, mesh) {
1581
1936
  cutout.geometry.translate(0, 0, -depth / 2);
1582
1937
  }
1583
1938
  else {
1584
- cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth);
1939
+ cutout.geometry = new THREE.BoxGeometry(width, height, depth);
1585
1940
  }
1941
+ cutout.geometry.translate(-leafOffsetX, (height - node.height) / 2, 0);
1586
1942
  cutout.visible = false;
1587
1943
  }
1588
1944
  // Resolve the cutout proxy depth from the opening's parent wall thickness so