@pascal-app/viewer 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/dist/components/error-boundary.d.ts +6 -0
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +6 -0
  4. package/dist/components/renderers/node-renderer.d.ts +5 -1
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +5 -2
  7. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  8. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  9. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  10. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  11. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  12. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  13. package/dist/components/viewer/frame-limiter.js +39 -4
  14. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  15. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-interactive.js +394 -0
  17. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  19. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  20. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-replace-instances.js +61 -0
  23. package/dist/components/viewer/glb-scene.d.ts +54 -0
  24. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  25. package/dist/components/viewer/glb-scene.js +920 -0
  26. package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
  29. package/dist/components/viewer/index.d.ts +19 -2
  30. package/dist/components/viewer/index.d.ts.map +1 -1
  31. package/dist/components/viewer/index.js +104 -12
  32. package/dist/components/viewer/lights.d.ts.map +1 -1
  33. package/dist/components/viewer/lights.js +36 -5
  34. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  35. package/dist/components/viewer/perf-monitor.js +17 -2
  36. package/dist/components/viewer/post-processing.d.ts +7 -1
  37. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  38. package/dist/components/viewer/post-processing.js +179 -24
  39. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  40. package/dist/components/viewer/registered-systems.js +6 -4
  41. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  42. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  43. package/dist/components/viewer/scene-bvh.js +3 -0
  44. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  46. package/dist/components/viewer/scene-bvh.test.js +13 -0
  47. package/dist/components/viewer/scene-environment.d.ts +2 -0
  48. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  49. package/dist/components/viewer/scene-environment.js +79 -0
  50. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  51. package/dist/components/viewer/selection-manager.js +18 -10
  52. package/dist/components/viewer/walkthrough-controls.d.ts +1 -0
  53. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  54. package/dist/components/viewer/walkthrough-controls.js +20 -0
  55. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  56. package/dist/hooks/use-gltf-ktx2.js +3 -22
  57. package/dist/hooks/use-node-events.d.ts +1 -1
  58. package/dist/hooks/use-node-events.d.ts.map +1 -1
  59. package/dist/hooks/use-node-events.js +22 -14
  60. package/dist/index.d.ts +19 -8
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +22 -8
  63. package/dist/lib/backdrop.d.ts +33 -0
  64. package/dist/lib/backdrop.d.ts.map +1 -0
  65. package/dist/lib/backdrop.js +92 -0
  66. package/dist/lib/box-uv.d.ts +13 -0
  67. package/dist/lib/box-uv.d.ts.map +1 -0
  68. package/dist/lib/box-uv.js +32 -0
  69. package/dist/lib/csg-utils.d.ts +3 -9
  70. package/dist/lib/csg-utils.d.ts.map +1 -1
  71. package/dist/lib/csg-utils.js +65 -5
  72. package/dist/lib/csg-utils.test.d.ts +2 -0
  73. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  74. package/dist/lib/csg-utils.test.js +45 -0
  75. package/dist/lib/edge-style.d.ts +1 -0
  76. package/dist/lib/edge-style.d.ts.map +1 -1
  77. package/dist/lib/edge-style.js +26 -6
  78. package/dist/lib/ink-edges.d.ts +1 -1
  79. package/dist/lib/ink-edges.d.ts.map +1 -1
  80. package/dist/lib/ink-edges.js +13 -7
  81. package/dist/lib/ktx2-loader.d.ts +43 -0
  82. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  83. package/dist/lib/ktx2-loader.js +136 -0
  84. package/dist/lib/layers.d.ts +10 -0
  85. package/dist/lib/layers.d.ts.map +1 -1
  86. package/dist/lib/layers.js +10 -0
  87. package/dist/lib/materials.d.ts +14 -1
  88. package/dist/lib/materials.d.ts.map +1 -1
  89. package/dist/lib/materials.js +103 -26
  90. package/dist/lib/scene-themes.d.ts +1 -0
  91. package/dist/lib/scene-themes.d.ts.map +1 -1
  92. package/dist/lib/scene-themes.js +25 -16
  93. package/dist/lib/shadow-only.d.ts +4 -0
  94. package/dist/lib/shadow-only.d.ts.map +1 -0
  95. package/dist/lib/shadow-only.js +36 -0
  96. package/dist/lib/tsl-compat.d.ts +12 -0
  97. package/dist/lib/tsl-compat.d.ts.map +1 -0
  98. package/dist/lib/tsl-compat.js +13 -0
  99. package/dist/store/use-viewer.d.ts +60 -18
  100. package/dist/store/use-viewer.d.ts.map +1 -1
  101. package/dist/store/use-viewer.js +175 -3
  102. package/dist/store/use-viewer.test.d.ts +2 -0
  103. package/dist/store/use-viewer.test.d.ts.map +1 -0
  104. package/dist/store/use-viewer.test.js +42 -0
  105. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  106. package/dist/systems/ceiling/ceiling-system.js +4 -1
  107. package/dist/systems/door/door-animation.test.d.ts +2 -0
  108. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  109. package/dist/systems/door/door-animation.test.js +51 -0
  110. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  111. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  112. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  113. package/dist/systems/door/door-system.d.ts +24 -0
  114. package/dist/systems/door/door-system.d.ts.map +1 -1
  115. package/dist/systems/door/door-system.js +494 -161
  116. package/dist/systems/fence/fence-system.d.ts +17 -0
  117. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  118. package/dist/systems/fence/fence-system.js +221 -53
  119. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  120. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  121. package/dist/systems/geometry/geometry-system.js +95 -26
  122. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  123. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  124. package/dist/systems/geometry/geometry-system.test.js +30 -0
  125. package/dist/systems/interactive/control-widget.d.ts +10 -0
  126. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  127. package/dist/systems/interactive/control-widget.js +35 -0
  128. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  129. package/dist/systems/interactive/interactive-system.js +55 -56
  130. package/dist/systems/item/item-system.d.ts.map +1 -1
  131. package/dist/systems/item/item-system.js +20 -6
  132. package/dist/systems/level/level-system.d.ts.map +1 -1
  133. package/dist/systems/level/level-system.js +28 -4
  134. package/dist/systems/level/level-utils.d.ts +0 -3
  135. package/dist/systems/level/level-utils.d.ts.map +1 -1
  136. package/dist/systems/level/level-utils.js +2 -41
  137. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  138. package/dist/systems/roof/roof-materials.js +29 -8
  139. package/dist/systems/roof/roof-system.d.ts +21 -8
  140. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  141. package/dist/systems/roof/roof-system.js +1079 -221
  142. package/dist/systems/slab/slab-system.d.ts +5 -3
  143. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  144. package/dist/systems/slab/slab-system.js +41 -23
  145. package/dist/systems/slab/slab-system.test.js +3 -2
  146. package/dist/systems/stair/stair-system.js +6 -3
  147. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  148. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  149. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  150. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  151. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  152. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  153. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  154. package/dist/systems/wall/wall-cutout.js +46 -17
  155. package/dist/systems/wall/wall-materials.d.ts +11 -7
  156. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  157. package/dist/systems/wall/wall-materials.js +244 -35
  158. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  159. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  160. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  161. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  162. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  163. package/dist/systems/wall/wall-support-extension.test.js +48 -0
  164. package/dist/systems/wall/wall-system.d.ts +2 -2
  165. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  166. package/dist/systems/wall/wall-system.js +329 -174
  167. package/dist/systems/window/window-animation-system.d.ts +12 -0
  168. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  169. package/dist/systems/window/window-animation-system.js +16 -5
  170. package/dist/systems/window/window-system.d.ts +7 -0
  171. package/dist/systems/window/window-system.d.ts.map +1 -1
  172. package/dist/systems/window/window-system.js +240 -14
  173. package/package.json +4 -4
  174. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  175. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  176. package/dist/components/viewer/ground-occluder.js +0 -67
@@ -1,13 +1,22 @@
1
- import { getEffectiveNode, sceneRegistry, useInteractive, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
1
+ import { DEFAULT_WALL_THICKNESS, 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 { 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
  let baseMaterial = getBaseMaterial();
10
12
  let glassMaterial = defaultGlassMaterial;
13
+ let currentWindowSlot;
14
+ // Per-frame viewer state, captured so the per-node mesh builder (which runs
15
+ // outside React) can resolve each window's slot materials.
16
+ let currentShading = 'rendered';
17
+ let currentTextures = true;
18
+ let currentColorPreset = 'clay';
19
+ let currentSceneMaterials;
11
20
  export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash';
12
21
  export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash';
13
22
  export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash';
@@ -27,6 +36,7 @@ export const WindowSystem = () => {
27
36
  const shading = useViewer((state) => state.shading);
28
37
  const textures = useViewer((state) => state.textures);
29
38
  const colorPreset = useViewer((state) => state.colorPreset);
39
+ const sceneMaterials = useScene((state) => state.materials);
30
40
  const materialRevisionRef = useRef(null);
31
41
  // Subscribe so override-only updates re-run this component. Mirrors
32
42
  // WallSystem + DoorSystem.
@@ -49,6 +59,20 @@ export const WindowSystem = () => {
49
59
  }
50
60
  }
51
61
  });
62
+ // Editing a scene material a window slot references must rebuild that window
63
+ // (window meshes are built by this system, not <GeometrySystem>, so its
64
+ // scene-material re-dirty doesn't cover them).
65
+ useEffect(() => {
66
+ void sceneMaterials;
67
+ const nodes = useScene.getState().nodes;
68
+ for (const node of Object.values(nodes)) {
69
+ if (node?.type !== 'window')
70
+ continue;
71
+ if (!nodeReferencesSceneMaterial(node))
72
+ continue;
73
+ useScene.getState().dirtyNodes.add(node.id);
74
+ }
75
+ }, [sceneMaterials]);
52
76
  useFrame(() => {
53
77
  if (dirtyNodes.size === 0)
54
78
  return;
@@ -58,11 +82,15 @@ export const WindowSystem = () => {
58
82
  glassMaterial = textures
59
83
  ? defaultGlassMaterial
60
84
  : createSurfaceRoleMaterial('glazing', colorPreset);
85
+ currentShading = shading;
86
+ currentTextures = textures;
87
+ currentColorPreset = colorPreset;
88
+ currentSceneMaterials = sceneMaterials;
61
89
  const nodes = useScene.getState().nodes;
62
90
  const dirtyWindowIds = [];
63
91
  dirtyNodes.forEach((id) => {
64
92
  const node = nodes[id];
65
- if (!node || node.type !== 'window')
93
+ if (node?.type !== 'window')
66
94
  return;
67
95
  dirtyWindowIds.push(id);
68
96
  });
@@ -80,7 +108,7 @@ export const WindowSystem = () => {
80
108
  }
81
109
  }
82
110
  const node = nodes[id];
83
- if (!node || node.type !== 'window')
111
+ if (node?.type !== 'window')
84
112
  continue;
85
113
  const mesh = sceneRegistry.nodes.get(id);
86
114
  if (!mesh)
@@ -102,9 +130,70 @@ export const WindowSystem = () => {
102
130
  }, 3);
103
131
  return null;
104
132
  };
133
+ function tagWindowSlot(mesh) {
134
+ mesh.userData.slotId = currentWindowSlot;
135
+ return mesh;
136
+ }
137
+ const NO_RAYCAST = () => { };
138
+ // An open casement sash swings perpendicular to the wall, so in a top-down view
139
+ // its flat panel blankets the room interior and wins the selection raycast over
140
+ // the slab/items beneath it. Drop the swung sash out of the raycast so a floor
141
+ // click falls through; the window stays selectable via its proud invisible
142
+ // cutout proxy at the opening (see syncWindowCutout). Skipped while closed so
143
+ // paint-by-slot still resolves on the sash.
144
+ function disableSubtreeRaycastIfSwung(object, rotationY) {
145
+ if (Math.abs(rotationY) <= 1e-3)
146
+ return;
147
+ object.traverse((child) => {
148
+ ;
149
+ child.raycast = NO_RAYCAST;
150
+ });
151
+ }
152
+ function nodeReferencesSceneMaterial(node) {
153
+ const slots = node.slots;
154
+ if (!slots)
155
+ return false;
156
+ for (const ref of Object.values(slots)) {
157
+ if (typeof ref === 'string' && ref.startsWith('scene:'))
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+ // Window frame/glass default to catalog finishes (generic approach). `preset-glass`
163
+ // is now FrontSide (it was the only glass we use), so it's safe for the WebGPU
164
+ // MRT scene pass.
165
+ const FRAME_DEFAULT_REF = 'library:preset-softwhite';
166
+ const GLASS_DEFAULT_REF = 'library:preset-glass';
167
+ function windowSlotDefault(slotId) {
168
+ if (slotId === 'glass') {
169
+ if (!currentTextures)
170
+ return createSurfaceRoleMaterial('glazing', currentColorPreset);
171
+ return (resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ??
172
+ defaultGlassMaterial);
173
+ }
174
+ if (!currentTextures)
175
+ return createSurfaceRoleMaterial('joinery', currentColorPreset);
176
+ return (resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ??
177
+ getBaseMaterial(currentShading));
178
+ }
179
+ // Resolve a window's slot to a material: the `node.slots` override (colored mode
180
+ // only) → the role/base default. Textures-off ignores overrides — the monochrome
181
+ // escape hatch.
182
+ function resolveWindowSlotMaterial(node, slotId) {
183
+ const fallback = windowSlotDefault(slotId);
184
+ if (!currentTextures)
185
+ return fallback;
186
+ const ref = node.slots?.[slotId];
187
+ if (!ref)
188
+ return fallback;
189
+ return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback;
190
+ }
105
191
  function addBox(parent, material, w, h, d, x, y, z) {
106
- const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
192
+ const geometry = new THREE.BoxGeometry(w, h, d);
193
+ applyWorldScaleBoxUVs(geometry, w, h, d);
194
+ const m = new THREE.Mesh(geometry, material);
107
195
  m.position.set(x, y, z);
196
+ tagWindowSlot(m);
108
197
  parent.add(m);
109
198
  }
110
199
  function addShape(parent, material, shape, depth, z = 0) {
@@ -115,6 +204,7 @@ function addShape(parent, material, shape, depth, z = 0) {
115
204
  });
116
205
  geometry.translate(0, 0, -depth / 2 + z);
117
206
  const mesh = new THREE.Mesh(geometry, material);
207
+ tagWindowSlot(mesh);
118
208
  parent.add(mesh);
119
209
  }
120
210
  function disposeObjectGeometry(object) {
@@ -331,9 +421,11 @@ function addRoundedWindowVisuals(node, mesh) {
331
421
  const innerW = innerRight - innerLeft;
332
422
  const innerH = innerTop - innerBottom;
333
423
  const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH);
424
+ currentWindowSlot = 'frame';
334
425
  addShape(mesh, baseMaterial, createRoundedFrameShape(width, height, inset, outerRadii), frameDepth);
335
426
  if (innerW > 0.01 && innerH > 0.01) {
336
427
  const glassDepth = Math.max(0.004, frameDepth * 0.08);
428
+ currentWindowSlot = 'glass';
337
429
  addShape(mesh, glassMaterial, createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii), glassDepth);
338
430
  const numCols = columnRatios.length;
339
431
  const numRows = rowRatios.length;
@@ -344,6 +436,7 @@ function addRoundedWindowVisuals(node, mesh) {
344
436
  const colWidths = columnRatios.map((r) => (r / colSum) * usableW);
345
437
  const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH);
346
438
  let x = innerLeft;
439
+ currentWindowSlot = 'frame';
347
440
  for (let c = 0; c < numCols - 1; c++) {
348
441
  x += colWidths[c];
349
442
  const x1 = x;
@@ -355,6 +448,7 @@ function addRoundedWindowVisuals(node, mesh) {
355
448
  x += columnDividerThickness;
356
449
  }
357
450
  let y = innerTop;
451
+ currentWindowSlot = 'frame';
358
452
  for (let r = 0; r < numRows - 1; r++) {
359
453
  y -= rowHeights[r];
360
454
  const yTop = y;
@@ -369,6 +463,7 @@ function addRoundedWindowVisuals(node, mesh) {
369
463
  if (sill) {
370
464
  const sillW = width + sillDepth * 0.4;
371
465
  const sillZ = frameDepth / 2 + sillDepth / 2;
466
+ currentWindowSlot = 'frame';
372
467
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
373
468
  }
374
469
  }
@@ -387,9 +482,11 @@ function addArchedWindowVisuals(node, mesh) {
387
482
  const innerH = innerTop - innerBottom;
388
483
  const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset);
389
484
  const innerSpringY = innerTop - innerArchHeight;
485
+ currentWindowSlot = 'frame';
390
486
  addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth);
391
487
  if (innerW > 0.01 && innerH > 0.01) {
392
488
  const glassDepth = Math.max(0.004, frameDepth * 0.08);
489
+ currentWindowSlot = 'glass';
393
490
  addShape(mesh, glassMaterial, createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight), glassDepth);
394
491
  const numCols = columnRatios.length;
395
492
  const numRows = rowRatios.length;
@@ -401,6 +498,7 @@ function addArchedWindowVisuals(node, mesh) {
401
498
  const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH);
402
499
  const innerHalfWidth = innerW / 2;
403
500
  let x = innerLeft;
501
+ currentWindowSlot = 'frame';
404
502
  for (let c = 0; c < numCols - 1; c++) {
405
503
  x += colWidths[c];
406
504
  const x1 = x;
@@ -412,6 +510,7 @@ function addArchedWindowVisuals(node, mesh) {
412
510
  x += columnDividerThickness;
413
511
  }
414
512
  let y = innerTop;
513
+ currentWindowSlot = 'frame';
415
514
  for (let r = 0; r < numRows - 1; r++) {
416
515
  y -= rowHeights[r];
417
516
  const yTop = y;
@@ -428,6 +527,7 @@ function addArchedWindowVisuals(node, mesh) {
428
527
  if (sill) {
429
528
  const sillW = width + sillDepth * 0.4;
430
529
  const sillZ = frameDepth / 2 + sillDepth / 2;
530
+ currentWindowSlot = 'frame';
431
531
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
432
532
  }
433
533
  }
@@ -450,6 +550,7 @@ function addSlidingWindowVisuals(node, mesh) {
450
550
  const innerW = width - 2 * frameThickness;
451
551
  const innerH = height - 2 * frameThickness;
452
552
  // Outer frame.
553
+ currentWindowSlot = 'frame';
453
554
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
454
555
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
455
556
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -473,11 +574,14 @@ function addSlidingWindowVisuals(node, mesh) {
473
574
  activePanel.position.set(leftPanelX, 0, leftZ);
474
575
  mesh.add(activePanel);
475
576
  // Twin tracks signal the sliding operation without adding editor-only state.
577
+ currentWindowSlot = 'frame';
476
578
  addBox(mesh, baseMaterial, innerW, trackThickness, frameDepth, 0, innerH / 2 - trackThickness / 2, 0);
477
579
  addBox(mesh, baseMaterial, innerW, trackThickness, frameDepth, 0, -innerH / 2 + trackThickness / 2, 0);
580
+ currentWindowSlot = 'glass';
478
581
  addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0);
479
582
  addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ);
480
583
  // The right sash stays fixed. The left sash is the active panel that slides across it.
584
+ currentWindowSlot = 'frame';
481
585
  addBox(activePanel, baseMaterial, railThickness, panelH, frameDepth * 0.72, -panelWidth / 2 + railThickness / 2, 0, 0);
482
586
  addBox(mesh, baseMaterial, railThickness, panelH, frameDepth * 0.72, rightPanelX + panelWidth / 2 - railThickness / 2, 0, rightZ);
483
587
  addBox(activePanel, baseMaterial, railThickness, panelH, frameDepth * 0.78, panelWidth / 2 - railThickness / 2, 0, 0);
@@ -486,6 +590,7 @@ function addSlidingWindowVisuals(node, mesh) {
486
590
  if (sill) {
487
591
  const sillW = width + sillDepth * 0.4;
488
592
  const sillZ = frameDepth / 2 + sillDepth / 2;
593
+ currentWindowSlot = 'frame';
489
594
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
490
595
  }
491
596
  }
@@ -500,15 +605,19 @@ function addRectCasementSash(parent, name, panelW, panelH, frameThickness, frame
500
605
  sash.position.set(pivotX, 0, frameDepth * 0.06);
501
606
  sash.rotation.y = rotationY;
502
607
  parent.add(sash);
608
+ currentWindowSlot = 'frame';
503
609
  addBox(sash, baseMaterial, panelW, sashFrameThickness, sashDepth, sashCenterX, panelH / 2 - sashFrameThickness / 2, 0);
504
610
  addBox(sash, baseMaterial, panelW, sashFrameThickness, sashDepth, sashCenterX, -panelH / 2 + sashFrameThickness / 2, 0);
505
611
  addBox(sash, baseMaterial, sashFrameThickness, panelH, sashDepth, sashCenterX - panelW / 2 + sashFrameThickness / 2, 0, 0);
506
612
  addBox(sash, baseMaterial, sashFrameThickness, panelH, sashDepth, sashCenterX + panelW / 2 - sashFrameThickness / 2, 0, 0);
613
+ currentWindowSlot = 'glass';
507
614
  addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08);
615
+ disableSubtreeRaycastIfSwung(sash, rotationY);
508
616
  }
509
617
  function addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth) {
510
618
  const markerW = Math.max(frameThickness * 0.38, 0.018);
511
619
  const markerH = innerH * 0.24;
620
+ currentWindowSlot = 'frame';
512
621
  for (const pivotX of [-innerW / 2, innerW / 2]) {
513
622
  addBox(mesh, baseMaterial, markerW, markerH, frameDepth * 1.1, pivotX, innerH * 0.25, frameDepth * 0.08);
514
623
  addBox(mesh, baseMaterial, markerW, markerH, frameDepth * 1.1, pivotX, -innerH * 0.25, frameDepth * 0.08);
@@ -599,25 +708,32 @@ function addShapedFrenchCasementSash(parent, node, name, side, fullW, leafW, lea
599
708
  const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight);
600
709
  const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness);
601
710
  const sashSpringY = node.height / 2 - outerArchHeight;
711
+ currentWindowSlot = 'frame';
602
712
  addShape(sashVisual, baseMaterial, createFrenchArchLeafFrameShape(leafW, leafH, fullW, sashSpringY, sashArchHeight, sashFrameThickness, side), sashDepth);
603
713
  const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005);
604
714
  if (glassInset > 0.001) {
715
+ currentWindowSlot = 'glass';
605
716
  addShape(sashVisual, glassMaterial, createFrenchArchLeafShape(leafW, leafH, fullW, sashSpringY, sashArchHeight, side, glassInset), glassDepth, sashDepth * 0.08);
606
717
  }
718
+ disableSubtreeRaycastIfSwung(sash, rotationY);
607
719
  return;
608
720
  }
609
721
  const frameRadii = insetCornerRadii(getWindowRoundedRadii(node, node.width, node.height), frameThickness, fullW, leafH);
722
+ currentWindowSlot = 'frame';
610
723
  addShape(sashVisual, baseMaterial, createFrenchRoundedLeafFrameShape(leafW, leafH, fullW, frameRadii, sashFrameThickness, side), sashDepth);
611
724
  const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005);
612
725
  if (glassInset > 0.001) {
726
+ currentWindowSlot = 'glass';
613
727
  addShape(sashVisual, glassMaterial, createFrenchRoundedLeafShape(leafW, leafH, fullW, frameRadii, side, glassInset), glassDepth, sashDepth * 0.08);
614
728
  }
729
+ disableSubtreeRaycastIfSwung(sash, rotationY);
615
730
  }
616
731
  function addFrenchCasementWindowVisuals(node, mesh) {
617
732
  const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
618
733
  const innerW = width - 2 * frameThickness;
619
734
  const innerH = height - 2 * frameThickness;
620
735
  // Fixed outer frame.
736
+ currentWindowSlot = 'frame';
621
737
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
622
738
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
623
739
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -632,6 +748,7 @@ function addFrenchCasementWindowVisuals(node, mesh) {
632
748
  if (sill) {
633
749
  const sillW = width + sillDepth * 0.4;
634
750
  const sillZ = frameDepth / 2 + sillDepth / 2;
751
+ currentWindowSlot = 'frame';
635
752
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
636
753
  }
637
754
  }
@@ -640,9 +757,11 @@ function addShapedCasementWindowVisuals(node, mesh) {
640
757
  const innerW = width - 2 * frameThickness;
641
758
  const innerH = height - 2 * frameThickness;
642
759
  if (node.openingShape === 'arch') {
760
+ currentWindowSlot = 'frame';
643
761
  addShape(mesh, baseMaterial, createArchedFrameShape(width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness), frameDepth);
644
762
  }
645
763
  else {
764
+ currentWindowSlot = 'frame';
646
765
  addShape(mesh, baseMaterial, createRoundedFrameShape(width, height, frameThickness, getWindowRoundedRadii(node, width, height)), frameDepth);
647
766
  }
648
767
  if ((node.casementStyle ?? 'single') === 'french') {
@@ -656,6 +775,7 @@ function addShapedCasementWindowVisuals(node, mesh) {
656
775
  if (sill) {
657
776
  const sillW = width + sillDepth * 0.4;
658
777
  const sillZ = frameDepth / 2 + sillDepth / 2;
778
+ currentWindowSlot = 'frame';
659
779
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
660
780
  }
661
781
  return;
@@ -680,30 +800,37 @@ function addShapedCasementWindowVisuals(node, mesh) {
680
800
  mesh.add(sash);
681
801
  if (node.openingShape === 'arch') {
682
802
  const sashArchHeight = getClampedArchHeight(innerW, innerH, (node.archHeight ?? innerW / 2) - frameThickness);
803
+ currentWindowSlot = 'frame';
683
804
  addShape(sashVisual, baseMaterial, createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness), sashDepth);
684
805
  const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
685
806
  if (glassInset > 0.001) {
686
807
  const glassW = innerW - 2 * glassInset;
687
808
  const glassH = innerH - 2 * glassInset;
809
+ currentWindowSlot = 'glass';
688
810
  addShape(sashVisual, glassMaterial, createArchShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset)), glassDepth, sashDepth * 0.08);
689
811
  }
690
812
  }
691
813
  else {
692
814
  const outerRadii = getWindowRoundedRadii(node, innerW, innerH);
815
+ currentWindowSlot = 'frame';
693
816
  addShape(sashVisual, baseMaterial, createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii), sashDepth);
694
817
  const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
695
818
  if (glassInset > 0.001) {
696
819
  const glassW = innerW - 2 * glassInset;
697
820
  const glassH = innerH - 2 * glassInset;
821
+ currentWindowSlot = 'glass';
698
822
  addShape(sashVisual, glassMaterial, createRoundedShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH)), glassDepth, sashDepth * 0.08);
699
823
  }
700
824
  }
825
+ disableSubtreeRaycastIfSwung(sash, sash.rotation.y);
826
+ currentWindowSlot = 'frame';
701
827
  addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, innerH * 0.24, frameDepth * 0.08);
702
828
  addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, -innerH * 0.24, frameDepth * 0.08);
703
829
  }
704
830
  if (sill) {
705
831
  const sillW = width + sillDepth * 0.4;
706
832
  const sillZ = frameDepth / 2 + sillDepth / 2;
833
+ currentWindowSlot = 'frame';
707
834
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
708
835
  }
709
836
  }
@@ -720,6 +847,7 @@ function addCasementWindowVisuals(node, mesh) {
720
847
  const innerW = width - 2 * frameThickness;
721
848
  const innerH = height - 2 * frameThickness;
722
849
  // Fixed outer frame.
850
+ currentWindowSlot = 'frame';
723
851
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
724
852
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
725
853
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -741,18 +869,23 @@ function addCasementWindowVisuals(node, mesh) {
741
869
  sash.position.set(pivotX, 0, frameDepth * 0.06);
742
870
  sash.rotation.y = hingeSign * openAngle;
743
871
  mesh.add(sash);
872
+ currentWindowSlot = 'frame';
744
873
  addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, sashCenterX, innerH / 2 - sashFrameThickness / 2, 0);
745
874
  addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, sashCenterX, -innerH / 2 + sashFrameThickness / 2, 0);
746
875
  addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, sashCenterX - innerW / 2 + sashFrameThickness / 2, 0, 0);
747
876
  addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, sashCenterX + innerW / 2 - sashFrameThickness / 2, 0, 0);
877
+ currentWindowSlot = 'glass';
748
878
  addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08);
879
+ disableSubtreeRaycastIfSwung(sash, sash.rotation.y);
749
880
  // Small hinge markers make the pivot side legible when the sash is closed.
881
+ currentWindowSlot = 'frame';
750
882
  addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, innerH * 0.24, frameDepth * 0.08);
751
883
  addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, -innerH * 0.24, frameDepth * 0.08);
752
884
  }
753
885
  if (sill) {
754
886
  const sillW = width + sillDepth * 0.4;
755
887
  const sillZ = frameDepth / 2 + sillDepth / 2;
888
+ currentWindowSlot = 'frame';
756
889
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
757
890
  }
758
891
  }
@@ -765,6 +898,7 @@ function addAwningWindowVisuals(node, mesh) {
765
898
  const innerW = width - 2 * frameThickness;
766
899
  const innerH = height - 2 * frameThickness;
767
900
  // Fixed outer frame.
901
+ currentWindowSlot = 'frame';
768
902
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
769
903
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
770
904
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -785,17 +919,21 @@ function addAwningWindowVisuals(node, mesh) {
785
919
  sash.position.set(0, pivotY, frameDepth * 0.06);
786
920
  sash.rotation.x = -openAngle;
787
921
  mesh.add(sash);
922
+ currentWindowSlot = 'frame';
788
923
  addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashCenterY + innerH / 2 - sashFrameThickness / 2, 0);
789
924
  addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashCenterY - innerH / 2 + sashFrameThickness / 2, 0);
790
925
  addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, -innerW / 2 + sashFrameThickness / 2, sashCenterY, 0);
791
926
  addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, innerW / 2 - sashFrameThickness / 2, sashCenterY, 0);
927
+ currentWindowSlot = 'glass';
792
928
  addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08);
793
929
  // Compact hinge rail, visible even when the sash is closed.
930
+ currentWindowSlot = 'frame';
794
931
  addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
795
932
  }
796
933
  if (sill) {
797
934
  const sillW = width + sillDepth * 0.4;
798
935
  const sillZ = frameDepth / 2 + sillDepth / 2;
936
+ currentWindowSlot = 'frame';
799
937
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
800
938
  }
801
939
  }
@@ -804,9 +942,11 @@ function addShapedAwningWindowVisuals(node, mesh) {
804
942
  const innerW = width - 2 * frameThickness;
805
943
  const innerH = height - 2 * frameThickness;
806
944
  if (node.openingShape === 'arch') {
945
+ currentWindowSlot = 'frame';
807
946
  addShape(mesh, baseMaterial, createArchedFrameShape(width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness), frameDepth);
808
947
  }
809
948
  else {
949
+ currentWindowSlot = 'frame';
810
950
  addShape(mesh, baseMaterial, createRoundedFrameShape(width, height, frameThickness, getWindowRoundedRadii(node, width, height)), frameDepth);
811
951
  }
812
952
  if (innerW > 0.01 && innerH > 0.01) {
@@ -827,29 +967,35 @@ function addShapedAwningWindowVisuals(node, mesh) {
827
967
  mesh.add(sash);
828
968
  if (node.openingShape === 'arch') {
829
969
  const sashArchHeight = getClampedArchHeight(innerW, innerH, (node.archHeight ?? innerW / 2) - frameThickness);
970
+ currentWindowSlot = 'frame';
830
971
  addShape(sashVisual, baseMaterial, createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness), sashDepth);
831
972
  const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
832
973
  if (glassInset > 0.001) {
833
974
  const glassW = innerW - 2 * glassInset;
834
975
  const glassH = innerH - 2 * glassInset;
976
+ currentWindowSlot = 'glass';
835
977
  addShape(sashVisual, glassMaterial, createArchShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset)), glassDepth, sashDepth * 0.08);
836
978
  }
837
979
  }
838
980
  else {
839
981
  const outerRadii = getWindowRoundedRadii(node, innerW, innerH);
982
+ currentWindowSlot = 'frame';
840
983
  addShape(sashVisual, baseMaterial, createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii), sashDepth);
841
984
  const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
842
985
  if (glassInset > 0.001) {
843
986
  const glassW = innerW - 2 * glassInset;
844
987
  const glassH = innerH - 2 * glassInset;
988
+ currentWindowSlot = 'glass';
845
989
  addShape(sashVisual, glassMaterial, createRoundedShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH)), glassDepth, sashDepth * 0.08);
846
990
  }
847
991
  }
992
+ currentWindowSlot = 'frame';
848
993
  addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
849
994
  }
850
995
  if (sill) {
851
996
  const sillW = width + sillDepth * 0.4;
852
997
  const sillZ = frameDepth / 2 + sillDepth / 2;
998
+ currentWindowSlot = 'frame';
853
999
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
854
1000
  }
855
1001
  }
@@ -862,6 +1008,7 @@ function addHopperWindowVisuals(node, mesh) {
862
1008
  const innerW = width - 2 * frameThickness;
863
1009
  const innerH = height - 2 * frameThickness;
864
1010
  // Fixed outer frame.
1011
+ currentWindowSlot = 'frame';
865
1012
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
866
1013
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
867
1014
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -880,17 +1027,21 @@ function addHopperWindowVisuals(node, mesh) {
880
1027
  sash.position.set(0, pivotY, frameDepth * 0.06);
881
1028
  sash.rotation.x = -openAngle;
882
1029
  mesh.add(sash);
1030
+ currentWindowSlot = 'frame';
883
1031
  addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, innerH - sashFrameThickness / 2, 0);
884
1032
  addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashFrameThickness / 2, 0);
885
1033
  addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, -innerW / 2 + sashFrameThickness / 2, innerH / 2, 0);
886
1034
  addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, innerW / 2 - sashFrameThickness / 2, innerH / 2, 0);
1035
+ currentWindowSlot = 'glass';
887
1036
  addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08);
888
1037
  // Compact bottom hinge rail, visible even when the sash is closed.
1038
+ currentWindowSlot = 'frame';
889
1039
  addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
890
1040
  }
891
1041
  if (sill) {
892
1042
  const sillW = width + sillDepth * 0.4;
893
1043
  const sillZ = frameDepth / 2 + sillDepth / 2;
1044
+ currentWindowSlot = 'frame';
894
1045
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
895
1046
  }
896
1047
  }
@@ -899,9 +1050,11 @@ function addShapedHopperWindowVisuals(node, mesh) {
899
1050
  const innerW = width - 2 * frameThickness;
900
1051
  const innerH = height - 2 * frameThickness;
901
1052
  if (node.openingShape === 'arch') {
1053
+ currentWindowSlot = 'frame';
902
1054
  addShape(mesh, baseMaterial, createArchedFrameShape(width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness), frameDepth);
903
1055
  }
904
1056
  else {
1057
+ currentWindowSlot = 'frame';
905
1058
  addShape(mesh, baseMaterial, createRoundedFrameShape(width, height, frameThickness, getWindowRoundedRadii(node, width, height)), frameDepth);
906
1059
  }
907
1060
  if (innerW > 0.01 && innerH > 0.01) {
@@ -921,37 +1074,45 @@ function addShapedHopperWindowVisuals(node, mesh) {
921
1074
  mesh.add(sash);
922
1075
  if (node.openingShape === 'arch') {
923
1076
  const sashArchHeight = getClampedArchHeight(innerW, innerH, (node.archHeight ?? innerW / 2) - frameThickness);
1077
+ currentWindowSlot = 'frame';
924
1078
  addShape(sashVisual, baseMaterial, createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness), sashDepth);
925
1079
  const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
926
1080
  if (glassInset > 0.001) {
927
1081
  const glassW = innerW - 2 * glassInset;
928
1082
  const glassH = innerH - 2 * glassInset;
1083
+ currentWindowSlot = 'glass';
929
1084
  addShape(sashVisual, glassMaterial, createArchShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset)), glassDepth, sashDepth * 0.08);
930
1085
  }
931
1086
  }
932
1087
  else {
933
1088
  const outerRadii = getWindowRoundedRadii(node, innerW, innerH);
1089
+ currentWindowSlot = 'frame';
934
1090
  addShape(sashVisual, baseMaterial, createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii), sashDepth);
935
1091
  const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
936
1092
  if (glassInset > 0.001) {
937
1093
  const glassW = innerW - 2 * glassInset;
938
1094
  const glassH = innerH - 2 * glassInset;
1095
+ currentWindowSlot = 'glass';
939
1096
  addShape(sashVisual, glassMaterial, createRoundedShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH)), glassDepth, sashDepth * 0.08);
940
1097
  }
941
1098
  }
1099
+ currentWindowSlot = 'frame';
942
1100
  addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
943
1101
  }
944
1102
  if (sill) {
945
1103
  const sillW = width + sillDepth * 0.4;
946
1104
  const sillZ = frameDepth / 2 + sillDepth / 2;
1105
+ currentWindowSlot = 'frame';
947
1106
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
948
1107
  }
949
1108
  }
950
1109
  function addHungSash(parent, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH) {
1110
+ currentWindowSlot = 'frame';
951
1111
  addBox(parent, baseMaterial, panelW, sashFrameThickness, frameDepth * 0.72, 0, panelHeight / 2 - sashFrameThickness / 2, 0);
952
1112
  addBox(parent, baseMaterial, panelW, sashFrameThickness, frameDepth * 0.72, 0, -panelHeight / 2 + sashFrameThickness / 2, 0);
953
1113
  addBox(parent, baseMaterial, sashFrameThickness, panelHeight, frameDepth * 0.72, -panelW / 2 + sashFrameThickness / 2, 0, 0);
954
1114
  addBox(parent, baseMaterial, sashFrameThickness, panelHeight, frameDepth * 0.72, panelW / 2 - sashFrameThickness / 2, 0, 0);
1115
+ currentWindowSlot = 'glass';
955
1116
  addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0);
956
1117
  }
957
1118
  function addSingleHungWindowVisuals(node, mesh) {
@@ -959,6 +1120,7 @@ function addSingleHungWindowVisuals(node, mesh) {
959
1120
  const innerW = width - 2 * frameThickness;
960
1121
  const innerH = height - 2 * frameThickness;
961
1122
  // Fixed outer frame.
1123
+ currentWindowSlot = 'frame';
962
1124
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
963
1125
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
964
1126
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -984,6 +1146,7 @@ function addSingleHungWindowVisuals(node, mesh) {
984
1146
  activeSash.position.set(0, bottomPanelY, bottomZ);
985
1147
  mesh.add(activeSash);
986
1148
  // Side tracks show the lower sash is the moving element.
1149
+ currentWindowSlot = 'frame';
987
1150
  addBox(mesh, baseMaterial, trackThickness, innerH, frameDepth, -innerW / 2 + trackThickness / 2, 0, 0);
988
1151
  addBox(mesh, baseMaterial, trackThickness, innerH, frameDepth, innerW / 2 - trackThickness / 2, 0, 0);
989
1152
  const topSash = new THREE.Group();
@@ -992,12 +1155,14 @@ function addSingleHungWindowVisuals(node, mesh) {
992
1155
  addHungSash(topSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH);
993
1156
  addHungSash(activeSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH);
994
1157
  // Meeting rails: top sash fixed, bottom sash moves upward over it.
1158
+ currentWindowSlot = 'frame';
995
1159
  addBox(mesh, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, topPanelY - panelHeight / 2 + railThickness / 2, topZ);
996
1160
  addBox(activeSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, panelHeight / 2 - railThickness / 2, 0);
997
1161
  }
998
1162
  if (sill) {
999
1163
  const sillW = width + sillDepth * 0.4;
1000
1164
  const sillZ = frameDepth / 2 + sillDepth / 2;
1165
+ currentWindowSlot = 'frame';
1001
1166
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1002
1167
  }
1003
1168
  }
@@ -1006,6 +1171,7 @@ function addDoubleHungWindowVisuals(node, mesh) {
1006
1171
  const innerW = width - 2 * frameThickness;
1007
1172
  const innerH = height - 2 * frameThickness;
1008
1173
  // Fixed outer frame.
1174
+ currentWindowSlot = 'frame';
1009
1175
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1010
1176
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
1011
1177
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -1035,17 +1201,20 @@ function addDoubleHungWindowVisuals(node, mesh) {
1035
1201
  bottomSash.position.set(0, bottomPanelY, bottomZ);
1036
1202
  mesh.add(bottomSash);
1037
1203
  // Side tracks show both sashes move vertically.
1204
+ currentWindowSlot = 'frame';
1038
1205
  addBox(mesh, baseMaterial, trackThickness, innerH, frameDepth, -innerW / 2 + trackThickness / 2, 0, 0);
1039
1206
  addBox(mesh, baseMaterial, trackThickness, innerH, frameDepth, innerW / 2 - trackThickness / 2, 0, 0);
1040
1207
  addHungSash(topSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH);
1041
1208
  addHungSash(bottomSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH);
1042
1209
  // Opposing meeting rails: top sash descends while bottom sash rises.
1210
+ currentWindowSlot = 'frame';
1043
1211
  addBox(topSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, -panelHeight / 2 + railThickness / 2, 0);
1044
1212
  addBox(bottomSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, panelHeight / 2 - railThickness / 2, 0);
1045
1213
  }
1046
1214
  if (sill) {
1047
1215
  const sillW = width + sillDepth * 0.4;
1048
1216
  const sillZ = frameDepth / 2 + sillDepth / 2;
1217
+ currentWindowSlot = 'frame';
1049
1218
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1050
1219
  }
1051
1220
  }
@@ -1053,6 +1222,7 @@ function addBayWindowVisuals(node, mesh) {
1053
1222
  const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1054
1223
  const innerW = width - 2 * frameThickness;
1055
1224
  const innerH = height - 2 * frameThickness;
1225
+ currentWindowSlot = 'frame';
1056
1226
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1057
1227
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
1058
1228
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -1075,13 +1245,16 @@ function addBayWindowVisuals(node, mesh) {
1075
1245
  const addBayPanel = (parent, panelW) => {
1076
1246
  const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01);
1077
1247
  const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01);
1248
+ currentWindowSlot = 'frame';
1078
1249
  addBox(parent, baseMaterial, panelW, sashFrameThickness, panelDepth, 0, innerH / 2 - sashFrameThickness / 2, 0);
1079
1250
  addBox(parent, baseMaterial, panelW, sashFrameThickness, panelDepth, 0, -innerH / 2 + sashFrameThickness / 2, 0);
1080
1251
  addBox(parent, baseMaterial, sashFrameThickness, innerH, panelDepth, -panelW / 2 + sashFrameThickness / 2, 0, 0);
1081
1252
  addBox(parent, baseMaterial, sashFrameThickness, innerH, panelDepth, panelW / 2 - sashFrameThickness / 2, 0, 0);
1253
+ currentWindowSlot = 'glass';
1082
1254
  addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08);
1083
1255
  };
1084
1256
  const addBayCap = (centerY) => {
1257
+ currentWindowSlot = 'frame';
1085
1258
  const halfThickness = frameThickness / 2;
1086
1259
  const vertices = [];
1087
1260
  const indices = [];
@@ -1096,7 +1269,7 @@ function addBayWindowVisuals(node, mesh) {
1096
1269
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
1097
1270
  geometry.setIndex(indices);
1098
1271
  geometry.computeVertexNormals();
1099
- mesh.add(new THREE.Mesh(geometry, baseMaterial));
1272
+ mesh.add(tagWindowSlot(new THREE.Mesh(geometry, baseMaterial)));
1100
1273
  };
1101
1274
  const center = new THREE.Group();
1102
1275
  center.position.set(0, 0, projectionDepth);
@@ -1118,6 +1291,7 @@ function addBayWindowVisuals(node, mesh) {
1118
1291
  if (sill) {
1119
1292
  const sillW = width + sillDepth * 0.4;
1120
1293
  const sillZ = frameDepth / 2 + sillDepth / 2;
1294
+ currentWindowSlot = 'frame';
1121
1295
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1122
1296
  }
1123
1297
  }
@@ -1125,6 +1299,7 @@ function addBowWindowVisuals(node, mesh) {
1125
1299
  const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1126
1300
  const innerW = width - 2 * frameThickness;
1127
1301
  const innerH = height - 2 * frameThickness;
1302
+ currentWindowSlot = 'frame';
1128
1303
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1129
1304
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
1130
1305
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -1180,13 +1355,17 @@ function addBowWindowVisuals(node, mesh) {
1180
1355
  return geometry;
1181
1356
  };
1182
1357
  const addCurvedMesh = (material, geometry) => {
1183
- mesh.add(new THREE.Mesh(geometry, material));
1358
+ mesh.add(tagWindowSlot(new THREE.Mesh(geometry, material)));
1184
1359
  };
1360
+ currentWindowSlot = 'frame';
1185
1361
  addCurvedMesh(baseMaterial, createCurvedVerticalBand(glassTop, innerH / 2));
1186
1362
  addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom));
1363
+ currentWindowSlot = 'glass';
1187
1364
  addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04));
1365
+ currentWindowSlot = 'frame';
1188
1366
  addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness));
1189
1367
  addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness));
1368
+ currentWindowSlot = 'frame';
1190
1369
  for (let index = 0; index <= mullionCount; index += 1) {
1191
1370
  const x = -halfSpan + (innerW * index) / mullionCount;
1192
1371
  addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x));
@@ -1195,6 +1374,7 @@ function addBowWindowVisuals(node, mesh) {
1195
1374
  if (sill) {
1196
1375
  const sillW = width + sillDepth * 0.4;
1197
1376
  const sillZ = frameDepth / 2 + sillDepth / 2;
1377
+ currentWindowSlot = 'frame';
1198
1378
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1199
1379
  }
1200
1380
  }
@@ -1206,6 +1386,7 @@ function addLouveredWindowVisuals(node, mesh) {
1206
1386
  }
1207
1387
  const innerW = width - 2 * frameThickness;
1208
1388
  const innerH = height - 2 * frameThickness;
1389
+ currentWindowSlot = 'frame';
1209
1390
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1210
1391
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
1211
1392
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
@@ -1221,8 +1402,10 @@ function addLouveredWindowVisuals(node, mesh) {
1221
1402
  const slats = new THREE.Group();
1222
1403
  slats.name = LOUVERED_WINDOW_SLATS_NAME;
1223
1404
  mesh.add(slats);
1405
+ currentWindowSlot = 'frame';
1224
1406
  addBox(mesh, baseMaterial, railThickness, innerH, frameDepth * 0.95, -innerW / 2 + railThickness / 2, 0, 0);
1225
1407
  addBox(mesh, baseMaterial, railThickness, innerH, frameDepth * 0.95, innerW / 2 - railThickness / 2, 0, 0);
1408
+ currentWindowSlot = 'glass';
1226
1409
  for (let index = 0; index < slatCount; index += 1) {
1227
1410
  const y = innerH / 2 - slatGap * (index + 0.5);
1228
1411
  const slat = new THREE.Group();
@@ -1235,6 +1418,7 @@ function addLouveredWindowVisuals(node, mesh) {
1235
1418
  if (sill) {
1236
1419
  const sillW = width + sillDepth * 0.4;
1237
1420
  const sillZ = frameDepth / 2 + sillDepth / 2;
1421
+ currentWindowSlot = 'frame';
1238
1422
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1239
1423
  }
1240
1424
  }
@@ -1251,9 +1435,11 @@ function addShapedLouveredWindowVisuals(node, mesh) {
1251
1435
  const innerW = innerRight - innerLeft;
1252
1436
  const innerH = innerTop - innerBottom;
1253
1437
  if (node.openingShape === 'arch') {
1438
+ currentWindowSlot = 'frame';
1254
1439
  addShape(mesh, baseMaterial, createArchedFrameShape(width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness), frameDepth);
1255
1440
  }
1256
1441
  else {
1442
+ currentWindowSlot = 'frame';
1257
1443
  addShape(mesh, baseMaterial, createRoundedFrameShape(width, height, frameThickness, getWindowRoundedRadii(node, width, height)), frameDepth);
1258
1444
  }
1259
1445
  if (innerW > 0.01 && innerH > 0.01) {
@@ -1283,6 +1469,7 @@ function addShapedLouveredWindowVisuals(node, mesh) {
1283
1469
  return (y) => getRoundedHorizontalBoundsAtY(y, innerLeft, innerRight, innerTop, innerRadii);
1284
1470
  })();
1285
1471
  const addVerticalRail = (x) => {
1472
+ currentWindowSlot = 'frame';
1286
1473
  const railX1 = x;
1287
1474
  const railX2 = x + (x < 0 ? railThickness : -railThickness);
1288
1475
  const sampleX = x < 0 ? Math.max(railX1, railX2) : Math.min(railX1, railX2);
@@ -1293,6 +1480,7 @@ function addShapedLouveredWindowVisuals(node, mesh) {
1293
1480
  };
1294
1481
  addVerticalRail(innerLeft);
1295
1482
  addVerticalRail(innerRight);
1483
+ currentWindowSlot = 'glass';
1296
1484
  for (let index = 0; index < slatCount; index += 1) {
1297
1485
  const y = innerTop - slatGap * (index + 0.5);
1298
1486
  const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop));
@@ -1312,10 +1500,12 @@ function addShapedLouveredWindowVisuals(node, mesh) {
1312
1500
  if (sill) {
1313
1501
  const sillW = width + sillDepth * 0.4;
1314
1502
  const sillZ = frameDepth / 2 + sillDepth / 2;
1503
+ currentWindowSlot = 'frame';
1315
1504
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1316
1505
  }
1317
1506
  }
1318
1507
  function updateWindowMesh(node, mesh) {
1508
+ currentWindowSlot = undefined;
1319
1509
  // Root mesh is an invisible hitbox; all visuals live in child meshes
1320
1510
  mesh.geometry.dispose();
1321
1511
  mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth);
@@ -1330,6 +1520,11 @@ function updateWindowMesh(node, mesh) {
1330
1520
  disposeObjectGeometry(child);
1331
1521
  mesh.remove(child);
1332
1522
  }
1523
+ // Point the builder-facing frame/glass materials at this window's slot
1524
+ // overrides for the duration of its build (recomputed per node, so the next
1525
+ // window resets cleanly without a restore).
1526
+ baseMaterial = resolveWindowSlotMaterial(node, 'frame');
1527
+ glassMaterial = resolveWindowSlotMaterial(node, 'glass');
1333
1528
  const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, openingKind, openingShape, windowType, } = node;
1334
1529
  if (openingKind === 'opening') {
1335
1530
  syncWindowCutout(node, mesh);
@@ -1394,6 +1589,7 @@ function updateWindowMesh(node, mesh) {
1394
1589
  const innerH = height - 2 * frameThickness;
1395
1590
  // ── Frame members ──
1396
1591
  // Top / bottom — full width
1592
+ currentWindowSlot = 'frame';
1397
1593
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1398
1594
  addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
1399
1595
  // Left / right — inner height to avoid corner overlap
@@ -1428,6 +1624,7 @@ function updateWindowMesh(node, mesh) {
1428
1624
  }
1429
1625
  // Column dividers — full inner height
1430
1626
  cx = -innerW / 2;
1627
+ currentWindowSlot = 'frame';
1431
1628
  for (let c = 0; c < numCols - 1; c++) {
1432
1629
  cx += colWidths[c];
1433
1630
  addBox(mesh, baseMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0);
@@ -1435,6 +1632,7 @@ function updateWindowMesh(node, mesh) {
1435
1632
  }
1436
1633
  // Row dividers — per column width, so they don't overlap column dividers (top to bottom)
1437
1634
  cy = innerH / 2;
1635
+ currentWindowSlot = 'frame';
1438
1636
  for (let r = 0; r < numRows - 1; r++) {
1439
1637
  cy -= rowHeights[r];
1440
1638
  const divY = cy - rowDividerThickness / 2;
@@ -1445,6 +1643,7 @@ function updateWindowMesh(node, mesh) {
1445
1643
  }
1446
1644
  // Glass panes
1447
1645
  const glassDepth = Math.max(0.004, frameDepth * 0.08);
1646
+ currentWindowSlot = 'glass';
1448
1647
  for (let c = 0; c < numCols; c++) {
1449
1648
  for (let r = 0; r < numRows; r++) {
1450
1649
  addBox(mesh, glassMaterial, colWidths[c], rowHeights[r], glassDepth, colXCenters[c], rowYCenters[r], 0);
@@ -1455,40 +1654,67 @@ function updateWindowMesh(node, mesh) {
1455
1654
  const sillW = width + sillDepth * 0.4; // slightly wider than frame
1456
1655
  // Protrudes from the front face of the frame (+Z)
1457
1656
  const sillZ = frameDepth / 2 + sillDepth / 2;
1657
+ currentWindowSlot = 'frame';
1458
1658
  addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1459
1659
  }
1460
1660
  syncWindowCutout(node, mesh);
1461
1661
  }
1462
1662
  function syncWindowCutout(node, mesh) {
1463
- // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
1663
+ // ── Cutout: invisible raycast hit target for the whole opening ──
1464
1664
  let cutout = mesh.getObjectByName('cutout');
1465
1665
  if (!cutout) {
1466
1666
  cutout = new THREE.Mesh();
1467
1667
  cutout.name = 'cutout';
1668
+ // The cutout (invisible) is proud of the wall on both faces, so it wins the
1669
+ // scene raycast over the wall in front of the recessed window — making it
1670
+ // the selection AND paint hit target for the whole opening. The paint
1671
+ // capability then re-raycasts the window's parts to find the slot. Its depth
1672
+ // is snug to the wall (not 1m) so it no longer blankets the room floor in a
1673
+ // top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
1468
1674
  mesh.add(cutout);
1469
1675
  }
1470
1676
  cutout.geometry.dispose();
1677
+ const depth = resolveOpeningCutoutProxyDepth(node);
1471
1678
  if (isRectangleOnlyWindowType(node)) {
1472
- cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0);
1679
+ cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth);
1473
1680
  }
1474
1681
  else if (node.openingShape === 'arch') {
1475
1682
  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)), {
1476
- depth: 1,
1683
+ depth,
1477
1684
  bevelEnabled: false,
1478
1685
  curveSegments: 24,
1479
1686
  });
1480
- cutout.geometry.translate(0, 0, -0.5);
1687
+ cutout.geometry.translate(0, 0, -depth / 2);
1481
1688
  }
1482
1689
  else if (node.openingShape === 'rounded') {
1483
1690
  cutout.geometry = new THREE.ExtrudeGeometry(createRoundedShape(-node.width / 2, node.width / 2, -node.height / 2, node.height / 2, getWindowRoundedRadii(node, node.width, node.height)), {
1484
- depth: 1,
1691
+ depth,
1485
1692
  bevelEnabled: false,
1486
1693
  curveSegments: 24,
1487
1694
  });
1488
- cutout.geometry.translate(0, 0, -0.5);
1695
+ cutout.geometry.translate(0, 0, -depth / 2);
1489
1696
  }
1490
1697
  else {
1491
- cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0);
1698
+ cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth);
1492
1699
  }
1493
1700
  cutout.visible = false;
1494
1701
  }
1702
+ // Resolve the cutout proxy depth from the opening's parent wall thickness so
1703
+ // the proxy stays proud of both wall faces (front/back selection) without the
1704
+ // old 1m depth that blanketed the floor. Falls back to the default thickness
1705
+ // when the parent wall isn't a resolvable wall node.
1706
+ function resolveOpeningCutoutProxyDepth(node) {
1707
+ const parentId = node.parentId;
1708
+ const parent = parentId ? useScene.getState().nodes[parentId] : undefined;
1709
+ const wallThickness = parent?.type === 'wall' ? getWallThickness(parent) : DEFAULT_WALL_THICKNESS;
1710
+ return getOpeningCutoutProxyDepth(wallThickness);
1711
+ }
1712
+ /**
1713
+ * Build a fresh window mesh for preview/ghost rendering.
1714
+ * Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).
1715
+ */
1716
+ export function buildWindowPreviewMesh(node) {
1717
+ const mesh = new THREE.Mesh();
1718
+ updateWindowMesh(node, mesh);
1719
+ return mesh;
1720
+ }