@pascal-app/viewer 1.0.6 → 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,14 +1,19 @@
1
- import { cornerPair, DEFAULT_WALL_THICKNESS, getEffectiveNode, getOpeningWallPlacement, getWallThickness, sceneRegistry, scriptedSize, useInteractive, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
1
+ import { cornerPair, DEFAULT_WALL_THICKNESS, getEffectiveNode, getOpeningWallPlacement, getWallThickness, openingCasingWidth, openingExteriorFace, openingHostFacing, resolveOpeningCasingHost, resolveOpeningFrameDepth, sceneRegistry, scriptedSize, 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
5
  import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
6
+ import { hingeJambPart, hingeLeafPart, roundedBox, tube } from '../../lib/hardware-geometry';
6
7
  import { createMaterial, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, resolveMaterialRef, } from '../../lib/materials';
7
8
  import { timeSpan } from '../../lib/perf-tracks';
8
9
  import { settleScriptedOpening } from '../../lib/scripted-opening';
9
10
  import useViewer from '../../store/use-viewer';
11
+ import { buildOpeningCasingMeshes } from '../wall/opening-casing-geometry';
10
12
  import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry';
11
13
  import { cornerFrameStale, FUSED_CORNER_SIDE } from './corner-frame';
14
+ import { AWNING_WINDOW_SASH_NAME, CASEMENT_WINDOW_SASH_NAME, DOUBLE_HUNG_BOTTOM_SASH_NAME, DOUBLE_HUNG_TOP_SASH_NAME, FRENCH_CASEMENT_LEFT_SASH_NAME, FRENCH_CASEMENT_RIGHT_SASH_NAME, HOPPER_WINDOW_SASH_NAME, LOUVERED_WINDOW_SLATS_NAME, poseWindowMovingParts, SINGLE_HUNG_ACTIVE_SASH_NAME, SLIDING_WINDOW_ACTIVE_PANEL_NAME, TILT_TURN_WINDOW_SASH_NAME, WINDOW_LEFT_SHUTTER_NAME, WINDOW_RIGHT_SHUTTER_NAME, WINDOW_SHUTTER_OUTER_NAME, } from './window-pose';
15
+ import { buildWindowRollerCurtain, getWindowRollerCurtainZ } from './window-roller-shutter';
16
+ export * from './window-pose';
12
17
  // Invisible material for root mesh — used as selection hitbox only
13
18
  const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false });
14
19
  let baseMaterial = getBaseMaterial();
@@ -20,16 +25,6 @@ let currentShading = 'rendered';
20
25
  let currentTextures = true;
21
26
  let currentColorPreset = 'clay';
22
27
  let currentSceneMaterials;
23
- export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash';
24
- export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash';
25
- export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash';
26
- export const SLIDING_WINDOW_ACTIVE_PANEL_NAME = 'sliding-window-active-panel';
27
- export const SINGLE_HUNG_ACTIVE_SASH_NAME = 'single-hung-active-sash';
28
- export const DOUBLE_HUNG_TOP_SASH_NAME = 'double-hung-top-sash';
29
- export const DOUBLE_HUNG_BOTTOM_SASH_NAME = 'double-hung-bottom-sash';
30
- export const LOUVERED_WINDOW_SLATS_NAME = 'louvered-window-slats';
31
- export const AWNING_WINDOW_SASH_NAME = 'awning-window-sash';
32
- export const HOPPER_WINDOW_SASH_NAME = 'hopper-window-sash';
33
28
  const MAX_WINDOW_REBUILDS_PER_FRAME = 16;
34
29
  const WINDOW_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WINDOW_REBUILDS_PER_FRAME;
35
30
  const WINDOW_PROGRESSIVE_TIME_BUDGET_MS = 8;
@@ -205,17 +200,30 @@ function windowSlotDefault(slotId) {
205
200
  // only) → the window-wide `material` for the frame (glass keeps its own) → the
206
201
  // role/base default. Textures-off ignores overrides — the monochrome escape hatch.
207
202
  function resolveWindowSlotMaterial(node, slotId) {
208
- const fallback = windowSlotDefault(slotId);
203
+ const fallback = slotId === 'hardware' || slotId === 'shutters' || slotId === 'sill'
204
+ ? resolveWindowSlotMaterial(node, 'frame')
205
+ : windowSlotDefault(slotId === 'glass' ? 'glass' : 'frame');
209
206
  if (!currentTextures)
210
207
  return fallback;
211
208
  const ref = node.slots?.[slotId];
212
209
  const painted = ref ? resolveMaterialRef(ref, currentSceneMaterials, currentShading) : null;
213
210
  if (painted)
214
211
  return painted;
212
+ if (slotId === 'shutters')
213
+ return (resolveMaterialRef('library:preset-charcoal', currentSceneMaterials, currentShading) ??
214
+ fallback);
215
215
  if (node.material && slotId === 'frame')
216
216
  return createMaterial(node.material, currentShading);
217
217
  return fallback;
218
218
  }
219
+ /** Frame depth follows the wall; sash, bars and glass keep buildable section sizes. */
220
+ function windowDepths(frameDepth) {
221
+ return {
222
+ sash: Math.min(frameDepth * 0.72, 0.068),
223
+ bar: Math.min(frameDepth, 0.05),
224
+ glass: Math.min(Math.max(0.004, frameDepth * 0.08), 0.024),
225
+ };
226
+ }
219
227
  function addBox(parent, material, w, h, d, x, y, z) {
220
228
  const geometry = new THREE.BoxGeometry(w, h, d);
221
229
  applyWorldScaleBoxUVs(geometry, w, h, d);
@@ -223,6 +231,30 @@ function addBox(parent, material, w, h, d, x, y, z) {
223
231
  m.position.set(x, y, z);
224
232
  tagWindowSlot(m);
225
233
  parent.add(m);
234
+ return m;
235
+ }
236
+ /**
237
+ * How far the sill's top stands over the opening's bottom: level with it, the top would share the
238
+ * plane of the wall's reveal under the frame and of a face finish's cut edge, and flicker with them.
239
+ */
240
+ const SILL_LIFT = 0.001;
241
+ /** Both sills sit on finished host faces, independent of the frame's inset or placement face. */
242
+ function windowFaceFrame(node, mesh) {
243
+ const nodes = useScene.getState().nodes;
244
+ const exteriorFace = openingExteriorFace(node, nodes);
245
+ const facing = openingHostFacing({ rotation: [0, mesh.rotation.y, 0] });
246
+ const exterior = (exteriorFace === 'a' ? 1 : -1) * facing;
247
+ const hostId = node.roofSegmentId ?? node.parentId;
248
+ const hostNode = hostId ? nodes[hostId] : undefined;
249
+ const host = resolveOpeningCasingHost(node, nodes, hostNode ? getEffectiveNode(hostNode) : undefined);
250
+ const wallDepth = host?.thickness ?? (hostNode?.type === 'wall' ? getWallThickness(hostNode) : node.frameDepth);
251
+ const cased = (face) => face === 'a' ? node.casingFaceA === true : node.casingFaceB === true;
252
+ return { exterior, exteriorFace, cased, wallDepth, offset: (node.position[2] ?? 0) * facing };
253
+ }
254
+ function addSill(parent, node) {
255
+ const { exterior, wallDepth, offset } = windowFaceFrame(node, parent);
256
+ currentWindowSlot = 'sill';
257
+ addBox(parent, resolveWindowSlotMaterial(node, 'sill'), node.width + 2 * (node.sillOverhang ?? node.sillDepth * 0.4), node.sillThickness, node.sillDepth, 0, -node.height / 2 - node.sillThickness / 2 + SILL_LIFT, exterior * (wallDepth / 2 + node.sillDepth / 2) - offset).name = 'window-exterior-sill';
226
258
  }
227
259
  function addShape(parent, material, shape, depth, z = 0) {
228
260
  const geometry = new THREE.ExtrudeGeometry(shape, {
@@ -237,7 +269,7 @@ function addShape(parent, material, shape, depth, z = 0) {
237
269
  }
238
270
  function disposeObjectGeometry(object) {
239
271
  object.traverse((child) => {
240
- if (child instanceof THREE.Mesh)
272
+ if (child instanceof THREE.Mesh && !child.geometry.userData.shared)
241
273
  child.geometry.dispose();
242
274
  });
243
275
  }
@@ -436,7 +468,7 @@ function getRoundedHorizontalBoundsAtY(y, left, right, top, radii) {
436
468
  return { minX, maxX };
437
469
  }
438
470
  function addRoundedWindowVisuals(node, mesh) {
439
- const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, } = node;
471
+ const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, } = node;
440
472
  const halfWidth = width / 2;
441
473
  const bottom = -height / 2;
442
474
  const top = height / 2;
@@ -452,7 +484,7 @@ function addRoundedWindowVisuals(node, mesh) {
452
484
  currentWindowSlot = 'frame';
453
485
  addShape(mesh, baseMaterial, createRoundedFrameShape(width, height, inset, outerRadii), frameDepth);
454
486
  if (innerW > 0.01 && innerH > 0.01) {
455
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
487
+ const glassDepth = windowDepths(frameDepth).glass;
456
488
  currentWindowSlot = 'glass';
457
489
  addShape(mesh, glassMaterial, createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii), glassDepth);
458
490
  const numCols = columnRatios.length;
@@ -471,7 +503,7 @@ function addRoundedWindowVisuals(node, mesh) {
471
503
  const x2 = x + columnDividerThickness;
472
504
  const dividerTop = Math.min(getRoundedBoundaryYAtX(x1, innerLeft, innerRight, innerTop, innerRadii), getRoundedBoundaryYAtX(x2, innerLeft, innerRight, innerTop, innerRadii));
473
505
  if (dividerTop > innerBottom + 0.01) {
474
- addShape(mesh, baseMaterial, createRectShape(x1, x2, innerBottom, dividerTop), frameDepth + 0.001);
506
+ addShape(mesh, baseMaterial, createRectShape(x1, x2, innerBottom, dividerTop), windowDepths(frameDepth).bar);
475
507
  }
476
508
  x += columnDividerThickness;
477
509
  }
@@ -483,20 +515,16 @@ function addRoundedWindowVisuals(node, mesh) {
483
515
  const yBottom = y - rowDividerThickness;
484
516
  const { minX, maxX } = getRoundedHorizontalBoundsAtY(yTop, innerLeft, innerRight, innerTop, innerRadii);
485
517
  if (maxX - minX > 0.01 && yTop > innerBottom) {
486
- addShape(mesh, baseMaterial, createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop), frameDepth + 0.001);
518
+ addShape(mesh, baseMaterial, createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop), windowDepths(frameDepth).bar);
487
519
  }
488
520
  y -= rowDividerThickness;
489
521
  }
490
522
  }
491
- if (sill) {
492
- const sillW = width + sillDepth * 0.4;
493
- const sillZ = frameDepth / 2 + sillDepth / 2;
494
- currentWindowSlot = 'frame';
495
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
496
- }
523
+ if (sill)
524
+ addSill(mesh, node);
497
525
  }
498
526
  function addArchedWindowVisuals(node, mesh) {
499
- const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, } = node;
527
+ const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, } = node;
500
528
  const halfWidth = width / 2;
501
529
  const bottom = -height / 2;
502
530
  const top = height / 2;
@@ -513,7 +541,7 @@ function addArchedWindowVisuals(node, mesh) {
513
541
  currentWindowSlot = 'frame';
514
542
  addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth);
515
543
  if (innerW > 0.01 && innerH > 0.01) {
516
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
544
+ const glassDepth = windowDepths(frameDepth).glass;
517
545
  currentWindowSlot = 'glass';
518
546
  addShape(mesh, glassMaterial, createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight), glassDepth);
519
547
  const numCols = columnRatios.length;
@@ -533,7 +561,7 @@ function addArchedWindowVisuals(node, mesh) {
533
561
  const x2 = x + columnDividerThickness;
534
562
  const dividerTop = Math.min(getArchBoundaryY(x1, innerHalfWidth, innerSpringY, innerArchHeight), getArchBoundaryY(x2, innerHalfWidth, innerSpringY, innerArchHeight));
535
563
  if (dividerTop > innerBottom + 0.01) {
536
- addShape(mesh, baseMaterial, createRectShape(x1, x2, innerBottom, dividerTop), frameDepth + 0.001);
564
+ addShape(mesh, baseMaterial, createRectShape(x1, x2, innerBottom, dividerTop), windowDepths(frameDepth).bar);
537
565
  }
538
566
  x += columnDividerThickness;
539
567
  }
@@ -547,34 +575,38 @@ function addArchedWindowVisuals(node, mesh) {
547
575
  const x1 = -halfAtTop;
548
576
  const x2 = halfAtTop;
549
577
  if (x2 - x1 > 0.01 && yTop > innerBottom) {
550
- addShape(mesh, baseMaterial, createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop), frameDepth + 0.001);
578
+ addShape(mesh, baseMaterial, createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop), windowDepths(frameDepth).bar);
551
579
  }
552
580
  y -= rowDividerThickness;
553
581
  }
554
582
  }
555
- if (sill) {
556
- const sillW = width + sillDepth * 0.4;
557
- const sillZ = frameDepth / 2 + sillDepth / 2;
558
- currentWindowSlot = 'frame';
559
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
560
- }
583
+ if (sill)
584
+ addSill(mesh, node);
561
585
  }
562
586
  function getWindowRenderOpenAmount(node) {
563
587
  const runtimeValue = useInteractive.getState().windows[node.id]?.operationState;
564
588
  return Math.min(Math.max(runtimeValue ?? node.operationState ?? 0, 0), 1);
565
589
  }
590
+ function getWindowRenderShutterAmount(node, field) {
591
+ const interactive = useInteractive.getState();
592
+ return Math.min(Math.max(interactive.windows[node.id]?.[field] ??
593
+ interactive.windowAnimations[node.id]?.[field]?.from ??
594
+ node[field] ??
595
+ 1, 0), 1);
596
+ }
566
597
  function getAwningDirection(node) {
567
598
  return node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up');
568
599
  }
569
600
  function isRectangleOnlyWindowType(node) {
570
- return (node.windowType === 'sliding' ||
601
+ return (node.windowType === 'tilt-turn' ||
602
+ node.windowType === 'sliding' ||
571
603
  node.windowType === 'single-hung' ||
572
604
  node.windowType === 'double-hung' ||
573
605
  node.windowType === 'bay' ||
574
606
  node.windowType === 'bow');
575
607
  }
576
608
  function addSlidingWindowVisuals(node, mesh) {
577
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
609
+ const { width, height, frameDepth, frameThickness, sill } = node;
578
610
  const innerW = width - 2 * frameThickness;
579
611
  const innerH = height - 2 * frameThickness;
580
612
  // Outer frame.
@@ -584,7 +616,7 @@ function addSlidingWindowVisuals(node, mesh) {
584
616
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
585
617
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0);
586
618
  if (innerW > 0.01 && innerH > 0.01) {
587
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
619
+ const glassDepth = windowDepths(frameDepth).glass;
588
620
  const railThickness = Math.max(frameThickness * 0.55, 0.025);
589
621
  const trackThickness = Math.max(frameThickness * 0.35, 0.018);
590
622
  const panelOverlap = Math.min(Math.max(frameThickness * 0.9, 0.04), innerW * 0.12);
@@ -610,23 +642,19 @@ function addSlidingWindowVisuals(node, mesh) {
610
642
  addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ);
611
643
  // The right sash stays fixed. The left sash is the active panel that slides across it.
612
644
  currentWindowSlot = 'frame';
613
- addBox(activePanel, baseMaterial, railThickness, panelH, frameDepth * 0.72, -panelWidth / 2 + railThickness / 2, 0, 0);
614
- addBox(mesh, baseMaterial, railThickness, panelH, frameDepth * 0.72, rightPanelX + panelWidth / 2 - railThickness / 2, 0, rightZ);
615
- addBox(activePanel, baseMaterial, railThickness, panelH, frameDepth * 0.78, panelWidth / 2 - railThickness / 2, 0, 0);
616
- addBox(mesh, baseMaterial, railThickness, panelH, frameDepth * 0.78, rightPanelX - panelWidth / 2 + railThickness / 2, 0, rightZ);
617
- }
618
- if (sill) {
619
- const sillW = width + sillDepth * 0.4;
620
- const sillZ = frameDepth / 2 + sillDepth / 2;
621
- currentWindowSlot = 'frame';
622
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
645
+ addBox(activePanel, baseMaterial, railThickness, panelH, windowDepths(frameDepth).sash, -panelWidth / 2 + railThickness / 2, 0, 0);
646
+ addBox(mesh, baseMaterial, railThickness, panelH, windowDepths(frameDepth).sash, rightPanelX + panelWidth / 2 - railThickness / 2, 0, rightZ);
647
+ addBox(activePanel, baseMaterial, railThickness, panelH, windowDepths(frameDepth).sash, panelWidth / 2 - railThickness / 2, 0, 0);
648
+ addBox(mesh, baseMaterial, railThickness, panelH, windowDepths(frameDepth).sash, rightPanelX - panelWidth / 2 + railThickness / 2, 0, rightZ);
623
649
  }
650
+ if (sill)
651
+ addSill(mesh, node);
624
652
  }
625
653
  function addRectCasementSash(parent, name, panelW, panelH, frameThickness, frameDepth, pivotX, sashCenterX, rotationY) {
626
654
  const sash = new THREE.Group();
627
655
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
628
- const sashDepth = frameDepth * 0.72;
629
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
656
+ const sashDepth = windowDepths(frameDepth).sash;
657
+ const glassDepth = windowDepths(frameDepth).glass;
630
658
  const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01);
631
659
  const glassH = Math.max(panelH - 2 * sashFrameThickness, 0.01);
632
660
  sash.name = name;
@@ -642,15 +670,6 @@ function addRectCasementSash(parent, name, panelW, panelH, frameThickness, frame
642
670
  addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08);
643
671
  disableSubtreeRaycastIfSwung(sash, rotationY);
644
672
  }
645
- function addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth) {
646
- const markerW = Math.max(frameThickness * 0.38, 0.018);
647
- const markerH = innerH * 0.24;
648
- currentWindowSlot = 'frame';
649
- for (const pivotX of [-innerW / 2, innerW / 2]) {
650
- addBox(mesh, baseMaterial, markerW, markerH, frameDepth * 1.1, pivotX, innerH * 0.25, frameDepth * 0.08);
651
- addBox(mesh, baseMaterial, markerW, markerH, frameDepth * 1.1, pivotX, -innerH * 0.25, frameDepth * 0.08);
652
- }
653
- }
654
673
  function createFrenchArchLeafShape(leafW, leafH, fullW, springY, archHeight, side, inset = 0) {
655
674
  const left = -leafW / 2 + inset;
656
675
  const right = leafW / 2 - inset;
@@ -724,8 +743,8 @@ function addShapedFrenchCasementSash(parent, node, name, side, fullW, leafW, lea
724
743
  const sash = new THREE.Group();
725
744
  const sashVisual = new THREE.Group();
726
745
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
727
- const sashDepth = frameDepth * 0.72;
728
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
746
+ const sashDepth = windowDepths(frameDepth).sash;
747
+ const glassDepth = windowDepths(frameDepth).glass;
729
748
  sash.name = name;
730
749
  sash.position.set(pivotX, 0, frameDepth * 0.06);
731
750
  sash.rotation.y = rotationY;
@@ -757,7 +776,7 @@ function addShapedFrenchCasementSash(parent, node, name, side, fullW, leafW, lea
757
776
  disableSubtreeRaycastIfSwung(sash, rotationY);
758
777
  }
759
778
  function addFrenchCasementWindowVisuals(node, mesh) {
760
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
779
+ const { width, height, frameDepth, frameThickness, sill } = node;
761
780
  const innerW = width - 2 * frameThickness;
762
781
  const innerH = height - 2 * frameThickness;
763
782
  // Fixed outer frame.
@@ -771,17 +790,12 @@ function addFrenchCasementWindowVisuals(node, mesh) {
771
790
  const leafW = innerW / 2;
772
791
  addRectCasementSash(mesh, FRENCH_CASEMENT_LEFT_SASH_NAME, leafW, innerH, frameThickness, frameDepth, -innerW / 2, leafW / 2, -openAngle);
773
792
  addRectCasementSash(mesh, FRENCH_CASEMENT_RIGHT_SASH_NAME, leafW, innerH, frameThickness, frameDepth, innerW / 2, -leafW / 2, openAngle);
774
- addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth);
775
- }
776
- if (sill) {
777
- const sillW = width + sillDepth * 0.4;
778
- const sillZ = frameDepth / 2 + sillDepth / 2;
779
- currentWindowSlot = 'frame';
780
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
781
793
  }
794
+ if (sill)
795
+ addSill(mesh, node);
782
796
  }
783
797
  function addShapedCasementWindowVisuals(node, mesh) {
784
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
798
+ const { width, height, frameDepth, frameThickness, sill } = node;
785
799
  const innerW = width - 2 * frameThickness;
786
800
  const innerH = height - 2 * frameThickness;
787
801
  if (node.openingShape === 'arch') {
@@ -798,14 +812,9 @@ function addShapedCasementWindowVisuals(node, mesh) {
798
812
  const leafW = innerW / 2;
799
813
  addShapedFrenchCasementSash(mesh, node, FRENCH_CASEMENT_LEFT_SASH_NAME, 'left', innerW, leafW, innerH, frameThickness, frameDepth, -innerW / 2, leafW / 2, -openAngle);
800
814
  addShapedFrenchCasementSash(mesh, node, FRENCH_CASEMENT_RIGHT_SASH_NAME, 'right', innerW, leafW, innerH, frameThickness, frameDepth, innerW / 2, -leafW / 2, openAngle);
801
- addFrenchCasementHingeMarkers(mesh, innerW, innerH, frameThickness, frameDepth);
802
- }
803
- if (sill) {
804
- const sillW = width + sillDepth * 0.4;
805
- const sillZ = frameDepth / 2 + sillDepth / 2;
806
- currentWindowSlot = 'frame';
807
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
808
815
  }
816
+ if (sill)
817
+ addSill(mesh, node);
809
818
  return;
810
819
  }
811
820
  if (innerW > 0.01 && innerH > 0.01) {
@@ -816,8 +825,8 @@ function addShapedCasementWindowVisuals(node, mesh) {
816
825
  const pivotX = hingeSide === 'left' ? -innerW / 2 : innerW / 2;
817
826
  const sashCenterX = hingeSide === 'left' ? innerW / 2 : -innerW / 2;
818
827
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
819
- const sashDepth = frameDepth * 0.72;
820
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
828
+ const sashDepth = windowDepths(frameDepth).sash;
829
+ const glassDepth = windowDepths(frameDepth).glass;
821
830
  const sash = new THREE.Group();
822
831
  const sashVisual = new THREE.Group();
823
832
  sash.name = CASEMENT_WINDOW_SASH_NAME;
@@ -851,19 +860,12 @@ function addShapedCasementWindowVisuals(node, mesh) {
851
860
  }
852
861
  }
853
862
  disableSubtreeRaycastIfSwung(sash, sash.rotation.y);
854
- currentWindowSlot = 'frame';
855
- addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, innerH * 0.24, frameDepth * 0.08);
856
- addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, -innerH * 0.24, frameDepth * 0.08);
857
- }
858
- if (sill) {
859
- const sillW = width + sillDepth * 0.4;
860
- const sillZ = frameDepth / 2 + sillDepth / 2;
861
- currentWindowSlot = 'frame';
862
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
863
863
  }
864
+ if (sill)
865
+ addSill(mesh, node);
864
866
  }
865
867
  function addCasementWindowVisuals(node, mesh) {
866
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
868
+ const { width, height, frameDepth, frameThickness, sill } = node;
867
869
  if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
868
870
  addShapedCasementWindowVisuals(node, mesh);
869
871
  return;
@@ -889,8 +891,8 @@ function addCasementWindowVisuals(node, mesh) {
889
891
  const pivotX = hingeSide === 'left' ? -innerW / 2 : innerW / 2;
890
892
  const sashCenterX = hingeSide === 'left' ? innerW / 2 : -innerW / 2;
891
893
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
892
- const sashDepth = frameDepth * 0.72;
893
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
894
+ const sashDepth = windowDepths(frameDepth).sash;
895
+ const glassDepth = windowDepths(frameDepth).glass;
894
896
  const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01);
895
897
  const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01);
896
898
  sash.name = CASEMENT_WINDOW_SASH_NAME;
@@ -905,20 +907,37 @@ function addCasementWindowVisuals(node, mesh) {
905
907
  currentWindowSlot = 'glass';
906
908
  addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08);
907
909
  disableSubtreeRaycastIfSwung(sash, sash.rotation.y);
908
- // Small hinge markers make the pivot side legible when the sash is closed.
909
- currentWindowSlot = 'frame';
910
- addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, innerH * 0.24, frameDepth * 0.08);
911
- addBox(mesh, baseMaterial, Math.max(frameThickness * 0.38, 0.018), innerH * 0.28, frameDepth * 1.1, pivotX, -innerH * 0.24, frameDepth * 0.08);
912
910
  }
913
- if (sill) {
914
- const sillW = width + sillDepth * 0.4;
915
- const sillZ = frameDepth / 2 + sillDepth / 2;
916
- currentWindowSlot = 'frame';
917
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
911
+ if (sill)
912
+ addSill(mesh, node);
913
+ }
914
+ function addTiltTurnWindowVisuals(node, mesh) {
915
+ const { width, height, frameDepth, frameThickness: rail } = node;
916
+ const innerW = width - 2 * rail;
917
+ const innerH = height - 2 * rail;
918
+ currentWindowSlot = 'frame';
919
+ for (const side of [-1, 1]) {
920
+ addBox(mesh, baseMaterial, width, rail, frameDepth, 0, (side * (height - rail)) / 2, 0);
921
+ addBox(mesh, baseMaterial, rail, innerH, frameDepth, (side * (width - rail)) / 2, 0, 0);
922
+ }
923
+ if (innerW > 0.01 && innerH > 0.01) {
924
+ const turn = node.tiltTurnMode === 'turn';
925
+ const side = node.hingesSide === 'right' ? 1 : -1;
926
+ const sash = new THREE.Group();
927
+ sash.name = TILT_TURN_WINDOW_SASH_NAME;
928
+ sash.position.set(turn ? (side * innerW) / 2 : 0, turn ? 0 : -innerH / 2, frameDepth * 0.06);
929
+ const content = new THREE.Group();
930
+ content.position.set(turn ? (-side * innerW) / 2 : 0, turn ? 0 : innerH / 2, 0);
931
+ sash.add(content);
932
+ mesh.add(sash);
933
+ const sashRail = Math.max(rail * 0.72, 0.032);
934
+ addHungSash(content, innerW, innerH, sashRail, frameDepth, windowDepths(frameDepth).glass, Math.max(innerW - 2 * sashRail, 0.01), Math.max(innerH - 2 * sashRail, 0.01));
918
935
  }
936
+ if (node.sill)
937
+ addSill(mesh, node);
919
938
  }
920
939
  function addAwningWindowVisuals(node, mesh) {
921
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
940
+ const { width, height, frameDepth, frameThickness, sill } = node;
922
941
  if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
923
942
  addShapedAwningWindowVisuals(node, mesh);
924
943
  return;
@@ -939,8 +958,8 @@ function addAwningWindowVisuals(node, mesh) {
939
958
  const pivotY = isDownward ? -innerH / 2 : innerH / 2;
940
959
  const sashCenterY = isDownward ? innerH / 2 : -innerH / 2;
941
960
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
942
- const sashDepth = frameDepth * 0.72;
943
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
961
+ const sashDepth = windowDepths(frameDepth).sash;
962
+ const glassDepth = windowDepths(frameDepth).glass;
944
963
  const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01);
945
964
  const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01);
946
965
  sash.name = AWNING_WINDOW_SASH_NAME;
@@ -954,19 +973,12 @@ function addAwningWindowVisuals(node, mesh) {
954
973
  addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, innerW / 2 - sashFrameThickness / 2, sashCenterY, 0);
955
974
  currentWindowSlot = 'glass';
956
975
  addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08);
957
- // Compact hinge rail, visible even when the sash is closed.
958
- currentWindowSlot = 'frame';
959
- addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
960
- }
961
- if (sill) {
962
- const sillW = width + sillDepth * 0.4;
963
- const sillZ = frameDepth / 2 + sillDepth / 2;
964
- currentWindowSlot = 'frame';
965
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
966
976
  }
977
+ if (sill)
978
+ addSill(mesh, node);
967
979
  }
968
980
  function addShapedAwningWindowVisuals(node, mesh) {
969
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
981
+ const { width, height, frameDepth, frameThickness, sill } = node;
970
982
  const innerW = width - 2 * frameThickness;
971
983
  const innerH = height - 2 * frameThickness;
972
984
  if (node.openingShape === 'arch') {
@@ -983,8 +995,8 @@ function addShapedAwningWindowVisuals(node, mesh) {
983
995
  const isDownward = getAwningDirection(node) === 'down';
984
996
  const pivotY = isDownward ? -innerH / 2 : innerH / 2;
985
997
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
986
- const sashDepth = frameDepth * 0.72;
987
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
998
+ const sashDepth = windowDepths(frameDepth).sash;
999
+ const glassDepth = windowDepths(frameDepth).glass;
988
1000
  const sash = new THREE.Group();
989
1001
  const sashVisual = new THREE.Group();
990
1002
  sash.name = AWNING_WINDOW_SASH_NAME;
@@ -1017,134 +1029,21 @@ function addShapedAwningWindowVisuals(node, mesh) {
1017
1029
  addShape(sashVisual, glassMaterial, createRoundedShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH)), glassDepth, sashDepth * 0.08);
1018
1030
  }
1019
1031
  }
1020
- currentWindowSlot = 'frame';
1021
- addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
1022
- }
1023
- if (sill) {
1024
- const sillW = width + sillDepth * 0.4;
1025
- const sillZ = frameDepth / 2 + sillDepth / 2;
1026
- currentWindowSlot = 'frame';
1027
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1028
- }
1029
- }
1030
- function addHopperWindowVisuals(node, mesh) {
1031
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1032
- if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
1033
- addShapedHopperWindowVisuals(node, mesh);
1034
- return;
1035
- }
1036
- const innerW = width - 2 * frameThickness;
1037
- const innerH = height - 2 * frameThickness;
1038
- // Fixed outer frame.
1039
- currentWindowSlot = 'frame';
1040
- addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
1041
- addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0);
1042
- addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
1043
- addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0);
1044
- if (innerW > 0.01 && innerH > 0.01) {
1045
- const openAmount = getWindowRenderOpenAmount(node);
1046
- const openAngle = openAmount * (Math.PI / 3);
1047
- const sash = new THREE.Group();
1048
- const pivotY = -innerH / 2;
1049
- const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
1050
- const sashDepth = frameDepth * 0.72;
1051
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
1052
- const glassW = Math.max(innerW - 2 * sashFrameThickness, 0.01);
1053
- const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01);
1054
- sash.name = HOPPER_WINDOW_SASH_NAME;
1055
- sash.position.set(0, pivotY, frameDepth * 0.06);
1056
- sash.rotation.x = -openAngle;
1057
- mesh.add(sash);
1058
- currentWindowSlot = 'frame';
1059
- addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, innerH - sashFrameThickness / 2, 0);
1060
- addBox(sash, baseMaterial, innerW, sashFrameThickness, sashDepth, 0, sashFrameThickness / 2, 0);
1061
- addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, -innerW / 2 + sashFrameThickness / 2, innerH / 2, 0);
1062
- addBox(sash, baseMaterial, sashFrameThickness, innerH, sashDepth, innerW / 2 - sashFrameThickness / 2, innerH / 2, 0);
1063
- currentWindowSlot = 'glass';
1064
- addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08);
1065
- // Compact bottom hinge rail, visible even when the sash is closed.
1066
- currentWindowSlot = 'frame';
1067
- addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
1068
- }
1069
- if (sill) {
1070
- const sillW = width + sillDepth * 0.4;
1071
- const sillZ = frameDepth / 2 + sillDepth / 2;
1072
- currentWindowSlot = 'frame';
1073
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1074
- }
1075
- }
1076
- function addShapedHopperWindowVisuals(node, mesh) {
1077
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1078
- const innerW = width - 2 * frameThickness;
1079
- const innerH = height - 2 * frameThickness;
1080
- if (node.openingShape === 'arch') {
1081
- currentWindowSlot = 'frame';
1082
- addShape(mesh, baseMaterial, createArchedFrameShape(width, height, getClampedArchHeight(width, height, node.archHeight), frameThickness), frameDepth);
1083
- }
1084
- else {
1085
- currentWindowSlot = 'frame';
1086
- addShape(mesh, baseMaterial, createRoundedFrameShape(width, height, frameThickness, getWindowRoundedRadii(node, width, height)), frameDepth);
1087
- }
1088
- if (innerW > 0.01 && innerH > 0.01) {
1089
- const openAmount = getWindowRenderOpenAmount(node);
1090
- const openAngle = openAmount * (Math.PI / 3);
1091
- const pivotY = -innerH / 2;
1092
- const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
1093
- const sashDepth = frameDepth * 0.72;
1094
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
1095
- const sash = new THREE.Group();
1096
- const sashVisual = new THREE.Group();
1097
- sash.name = HOPPER_WINDOW_SASH_NAME;
1098
- sash.position.set(0, pivotY, frameDepth * 0.06);
1099
- sash.rotation.x = -openAngle;
1100
- sashVisual.position.y = innerH / 2;
1101
- sash.add(sashVisual);
1102
- mesh.add(sash);
1103
- if (node.openingShape === 'arch') {
1104
- const sashArchHeight = getClampedArchHeight(innerW, innerH, (node.archHeight ?? innerW / 2) - frameThickness);
1105
- currentWindowSlot = 'frame';
1106
- addShape(sashVisual, baseMaterial, createArchedFrameShape(innerW, innerH, sashArchHeight, sashFrameThickness), sashDepth);
1107
- const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
1108
- if (glassInset > 0.001) {
1109
- const glassW = innerW - 2 * glassInset;
1110
- const glassH = innerH - 2 * glassInset;
1111
- currentWindowSlot = 'glass';
1112
- addShape(sashVisual, glassMaterial, createArchShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, getClampedArchHeight(glassW, glassH, sashArchHeight - glassInset)), glassDepth, sashDepth * 0.08);
1113
- }
1114
- }
1115
- else {
1116
- const outerRadii = getWindowRoundedRadii(node, innerW, innerH);
1117
- currentWindowSlot = 'frame';
1118
- addShape(sashVisual, baseMaterial, createRoundedFrameShape(innerW, innerH, sashFrameThickness, outerRadii), sashDepth);
1119
- const glassInset = Math.min(sashFrameThickness, innerW / 2 - 0.005, innerH / 2 - 0.005);
1120
- if (glassInset > 0.001) {
1121
- const glassW = innerW - 2 * glassInset;
1122
- const glassH = innerH - 2 * glassInset;
1123
- currentWindowSlot = 'glass';
1124
- addShape(sashVisual, glassMaterial, createRoundedShape(-glassW / 2, glassW / 2, -glassH / 2, glassH / 2, insetCornerRadii(outerRadii, glassInset, glassW, glassH)), glassDepth, sashDepth * 0.08);
1125
- }
1126
- }
1127
- currentWindowSlot = 'frame';
1128
- addBox(mesh, baseMaterial, innerW * 0.42, Math.max(frameThickness * 0.38, 0.018), frameDepth * 1.1, 0, pivotY, frameDepth * 0.08);
1129
- }
1130
- if (sill) {
1131
- const sillW = width + sillDepth * 0.4;
1132
- const sillZ = frameDepth / 2 + sillDepth / 2;
1133
- currentWindowSlot = 'frame';
1134
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1135
1032
  }
1033
+ if (sill)
1034
+ addSill(mesh, node);
1136
1035
  }
1137
1036
  function addHungSash(parent, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH) {
1138
1037
  currentWindowSlot = 'frame';
1139
- addBox(parent, baseMaterial, panelW, sashFrameThickness, frameDepth * 0.72, 0, panelHeight / 2 - sashFrameThickness / 2, 0);
1140
- addBox(parent, baseMaterial, panelW, sashFrameThickness, frameDepth * 0.72, 0, -panelHeight / 2 + sashFrameThickness / 2, 0);
1141
- addBox(parent, baseMaterial, sashFrameThickness, panelHeight, frameDepth * 0.72, -panelW / 2 + sashFrameThickness / 2, 0, 0);
1142
- addBox(parent, baseMaterial, sashFrameThickness, panelHeight, frameDepth * 0.72, panelW / 2 - sashFrameThickness / 2, 0, 0);
1038
+ addBox(parent, baseMaterial, panelW, sashFrameThickness, windowDepths(frameDepth).sash, 0, panelHeight / 2 - sashFrameThickness / 2, 0);
1039
+ addBox(parent, baseMaterial, panelW, sashFrameThickness, windowDepths(frameDepth).sash, 0, -panelHeight / 2 + sashFrameThickness / 2, 0);
1040
+ addBox(parent, baseMaterial, sashFrameThickness, panelHeight, windowDepths(frameDepth).sash, -panelW / 2 + sashFrameThickness / 2, 0, 0);
1041
+ addBox(parent, baseMaterial, sashFrameThickness, panelHeight, windowDepths(frameDepth).sash, panelW / 2 - sashFrameThickness / 2, 0, 0);
1143
1042
  currentWindowSlot = 'glass';
1144
1043
  addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0);
1145
1044
  }
1146
1045
  function addSingleHungWindowVisuals(node, mesh) {
1147
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1046
+ const { width, height, frameDepth, frameThickness, sill } = node;
1148
1047
  const innerW = width - 2 * frameThickness;
1149
1048
  const innerH = height - 2 * frameThickness;
1150
1049
  // Fixed outer frame.
@@ -1154,7 +1053,7 @@ function addSingleHungWindowVisuals(node, mesh) {
1154
1053
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
1155
1054
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0);
1156
1055
  if (innerW > 0.01 && innerH > 0.01) {
1157
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
1056
+ const glassDepth = windowDepths(frameDepth).glass;
1158
1057
  const railThickness = Math.max(frameThickness * 0.55, 0.025);
1159
1058
  const trackThickness = Math.max(frameThickness * 0.35, 0.018);
1160
1059
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
@@ -1184,18 +1083,14 @@ function addSingleHungWindowVisuals(node, mesh) {
1184
1083
  addHungSash(activeSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH);
1185
1084
  // Meeting rails: top sash fixed, bottom sash moves upward over it.
1186
1085
  currentWindowSlot = 'frame';
1187
- addBox(mesh, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, topPanelY - panelHeight / 2 + railThickness / 2, topZ);
1188
- addBox(activeSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, panelHeight / 2 - railThickness / 2, 0);
1189
- }
1190
- if (sill) {
1191
- const sillW = width + sillDepth * 0.4;
1192
- const sillZ = frameDepth / 2 + sillDepth / 2;
1193
- currentWindowSlot = 'frame';
1194
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1086
+ addBox(mesh, baseMaterial, panelW, railThickness, windowDepths(frameDepth).sash, 0, topPanelY - panelHeight / 2 + railThickness / 2, topZ);
1087
+ addBox(activeSash, baseMaterial, panelW, railThickness, windowDepths(frameDepth).sash, 0, panelHeight / 2 - railThickness / 2, 0);
1195
1088
  }
1089
+ if (sill)
1090
+ addSill(mesh, node);
1196
1091
  }
1197
1092
  function addDoubleHungWindowVisuals(node, mesh) {
1198
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1093
+ const { width, height, frameDepth, frameThickness, sill } = node;
1199
1094
  const innerW = width - 2 * frameThickness;
1200
1095
  const innerH = height - 2 * frameThickness;
1201
1096
  // Fixed outer frame.
@@ -1205,7 +1100,7 @@ function addDoubleHungWindowVisuals(node, mesh) {
1205
1100
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
1206
1101
  addBox(mesh, baseMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0);
1207
1102
  if (innerW > 0.01 && innerH > 0.01) {
1208
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
1103
+ const glassDepth = windowDepths(frameDepth).glass;
1209
1104
  const railThickness = Math.max(frameThickness * 0.55, 0.025);
1210
1105
  const trackThickness = Math.max(frameThickness * 0.35, 0.018);
1211
1106
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
@@ -1236,18 +1131,14 @@ function addDoubleHungWindowVisuals(node, mesh) {
1236
1131
  addHungSash(bottomSash, panelW, panelHeight, sashFrameThickness, frameDepth, glassDepth, glassW, glassH);
1237
1132
  // Opposing meeting rails: top sash descends while bottom sash rises.
1238
1133
  currentWindowSlot = 'frame';
1239
- addBox(topSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, -panelHeight / 2 + railThickness / 2, 0);
1240
- addBox(bottomSash, baseMaterial, panelW, railThickness, frameDepth * 0.78, 0, panelHeight / 2 - railThickness / 2, 0);
1241
- }
1242
- if (sill) {
1243
- const sillW = width + sillDepth * 0.4;
1244
- const sillZ = frameDepth / 2 + sillDepth / 2;
1245
- currentWindowSlot = 'frame';
1246
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1134
+ addBox(topSash, baseMaterial, panelW, railThickness, windowDepths(frameDepth).sash, 0, -panelHeight / 2 + railThickness / 2, 0);
1135
+ addBox(bottomSash, baseMaterial, panelW, railThickness, windowDepths(frameDepth).sash, 0, panelHeight / 2 - railThickness / 2, 0);
1247
1136
  }
1137
+ if (sill)
1138
+ addSill(mesh, node);
1248
1139
  }
1249
1140
  function addBayWindowVisuals(node, mesh) {
1250
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1141
+ const { width, height, frameDepth, frameThickness, sill } = node;
1251
1142
  const innerW = width - 2 * frameThickness;
1252
1143
  const innerH = height - 2 * frameThickness;
1253
1144
  currentWindowSlot = 'frame';
@@ -1261,9 +1152,9 @@ function addBayWindowVisuals(node, mesh) {
1261
1152
  const sideRun = Math.max((innerW - centerW) / 2, 0.01);
1262
1153
  const sideW = Math.hypot(sideRun, projectionDepth);
1263
1154
  const sideAngle = Math.atan2(projectionDepth, sideRun);
1264
- const panelDepth = Math.max(frameDepth * 0.72, 0.04);
1155
+ const panelDepth = windowDepths(frameDepth).sash;
1265
1156
  const sashFrameThickness = Math.max(frameThickness * 0.72, 0.032);
1266
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
1157
+ const glassDepth = windowDepths(frameDepth).glass;
1267
1158
  const bayFootprint = [
1268
1159
  [-innerW / 2, 0],
1269
1160
  [-centerW / 2, projectionDepth],
@@ -1316,15 +1207,11 @@ function addBayWindowVisuals(node, mesh) {
1316
1207
  addBayCap(innerH / 2);
1317
1208
  addBayCap(-innerH / 2);
1318
1209
  }
1319
- if (sill) {
1320
- const sillW = width + sillDepth * 0.4;
1321
- const sillZ = frameDepth / 2 + sillDepth / 2;
1322
- currentWindowSlot = 'frame';
1323
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1324
- }
1210
+ if (sill)
1211
+ addSill(mesh, node);
1325
1212
  }
1326
1213
  function addBowWindowVisuals(node, mesh) {
1327
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1214
+ const { width, height, frameDepth, frameThickness, sill } = node;
1328
1215
  const innerW = width - 2 * frameThickness;
1329
1216
  const innerH = height - 2 * frameThickness;
1330
1217
  currentWindowSlot = 'frame';
@@ -1396,18 +1283,14 @@ function addBowWindowVisuals(node, mesh) {
1396
1283
  currentWindowSlot = 'frame';
1397
1284
  for (let index = 0; index <= mullionCount; index += 1) {
1398
1285
  const x = -halfSpan + (innerW * index) / mullionCount;
1399
- addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x));
1286
+ addBox(mesh, baseMaterial, sashFrameThickness, innerH, windowDepths(frameDepth).bar, x, 0, arcZAt(x));
1400
1287
  }
1401
1288
  }
1402
- if (sill) {
1403
- const sillW = width + sillDepth * 0.4;
1404
- const sillZ = frameDepth / 2 + sillDepth / 2;
1405
- currentWindowSlot = 'frame';
1406
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1407
- }
1289
+ if (sill)
1290
+ addSill(mesh, node);
1408
1291
  }
1409
1292
  function addLouveredWindowVisuals(node, mesh) {
1410
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1293
+ const { width, height, frameDepth, frameThickness, sill } = node;
1411
1294
  if (node.openingShape === 'rounded' || node.openingShape === 'arch') {
1412
1295
  addShapedLouveredWindowVisuals(node, mesh);
1413
1296
  return;
@@ -1424,15 +1307,15 @@ function addLouveredWindowVisuals(node, mesh) {
1424
1307
  const slatCount = Math.max(4, Math.min(9, Math.round(height / 0.22)));
1425
1308
  const slatGap = innerH / slatCount;
1426
1309
  const slatHeight = Math.max(Math.min(slatGap * 0.62, 0.14), 0.045);
1427
- const slatDepth = Math.max(frameDepth * 0.16, 0.012);
1310
+ const slatDepth = windowDepths(frameDepth).glass;
1428
1311
  const slatAngle = -openAmount * (Math.PI / 3);
1429
1312
  const railThickness = Math.max(frameThickness * 0.45, 0.022);
1430
1313
  const slats = new THREE.Group();
1431
1314
  slats.name = LOUVERED_WINDOW_SLATS_NAME;
1432
1315
  mesh.add(slats);
1433
1316
  currentWindowSlot = 'frame';
1434
- addBox(mesh, baseMaterial, railThickness, innerH, frameDepth * 0.95, -innerW / 2 + railThickness / 2, 0, 0);
1435
- addBox(mesh, baseMaterial, railThickness, innerH, frameDepth * 0.95, innerW / 2 - railThickness / 2, 0, 0);
1317
+ addBox(mesh, baseMaterial, railThickness, innerH, windowDepths(frameDepth).bar, -innerW / 2 + railThickness / 2, 0, 0);
1318
+ addBox(mesh, baseMaterial, railThickness, innerH, windowDepths(frameDepth).bar, innerW / 2 - railThickness / 2, 0, 0);
1436
1319
  currentWindowSlot = 'glass';
1437
1320
  for (let index = 0; index < slatCount; index += 1) {
1438
1321
  const y = innerH / 2 - slatGap * (index + 0.5);
@@ -1443,15 +1326,11 @@ function addLouveredWindowVisuals(node, mesh) {
1443
1326
  addBox(slat, glassMaterial, Math.max(innerW - 2 * railThickness, 0.01), slatHeight, slatDepth, 0, 0, 0);
1444
1327
  }
1445
1328
  }
1446
- if (sill) {
1447
- const sillW = width + sillDepth * 0.4;
1448
- const sillZ = frameDepth / 2 + sillDepth / 2;
1449
- currentWindowSlot = 'frame';
1450
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1451
- }
1329
+ if (sill)
1330
+ addSill(mesh, node);
1452
1331
  }
1453
1332
  function addShapedLouveredWindowVisuals(node, mesh) {
1454
- const { width, height, frameDepth, frameThickness, sill, sillDepth, sillThickness } = node;
1333
+ const { width, height, frameDepth, frameThickness, sill } = node;
1455
1334
  const halfWidth = width / 2;
1456
1335
  const bottom = -height / 2;
1457
1336
  const top = height / 2;
@@ -1475,7 +1354,7 @@ function addShapedLouveredWindowVisuals(node, mesh) {
1475
1354
  const slatCount = Math.max(4, Math.min(9, Math.round(height / 0.22)));
1476
1355
  const slatGap = innerH / slatCount;
1477
1356
  const slatHeight = Math.max(Math.min(slatGap * 0.62, 0.14), 0.045);
1478
- const slatDepth = Math.max(frameDepth * 0.16, 0.012);
1357
+ const slatDepth = windowDepths(frameDepth).glass;
1479
1358
  const slatAngle = -openAmount * (Math.PI / 3);
1480
1359
  const railThickness = Math.max(frameThickness * 0.45, 0.022);
1481
1360
  const slatInset = railThickness + 0.004;
@@ -1504,7 +1383,7 @@ function addShapedLouveredWindowVisuals(node, mesh) {
1504
1383
  const railTop = node.openingShape === 'arch'
1505
1384
  ? getArchBoundaryY(sampleX, innerW / 2, top - getClampedArchHeight(width, height, node.archHeight), getClampedArchHeight(innerW, innerH, getClampedArchHeight(width, height, node.archHeight) - inset))
1506
1385
  : getRoundedBoundaryYAtX(sampleX, innerLeft, innerRight, innerTop, insetCornerRadii(getWindowRoundedRadii(node, width, height), inset, innerW, innerH));
1507
- addShape(mesh, baseMaterial, createRectShape(Math.min(railX1, railX2), Math.max(railX1, railX2), innerBottom, railTop), frameDepth * 0.95);
1386
+ addShape(mesh, baseMaterial, createRectShape(Math.min(railX1, railX2), Math.max(railX1, railX2), innerBottom, railTop), windowDepths(frameDepth).bar);
1508
1387
  };
1509
1388
  addVerticalRail(innerLeft);
1510
1389
  addVerticalRail(innerRight);
@@ -1525,12 +1404,8 @@ function addShapedLouveredWindowVisuals(node, mesh) {
1525
1404
  addBox(slat, glassMaterial, slatW, slatHeight, slatDepth, 0, 0, 0);
1526
1405
  }
1527
1406
  }
1528
- if (sill) {
1529
- const sillW = width + sillDepth * 0.4;
1530
- const sillZ = frameDepth / 2 + sillDepth / 2;
1531
- currentWindowSlot = 'frame';
1532
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1533
- }
1407
+ if (sill)
1408
+ addSill(mesh, node);
1534
1409
  }
1535
1410
  /**
1536
1411
  * The side of a corner window fused at the corner, in the window's own frame: 1 its +x side, -1 its
@@ -1544,17 +1419,26 @@ function fusedCornerSide(node, rotationY) {
1544
1419
  const flipped = Math.abs(rotationY) > Math.PI / 2;
1545
1420
  return (pair.end === 'end') !== flipped ? 1 : -1;
1546
1421
  }
1547
- function updateWindowMesh(node, mesh) {
1422
+ function updateWindowMesh(rawNode, mesh) {
1548
1423
  currentWindowSlot = undefined;
1549
1424
  mesh.userData[FUSED_CORNER_SIDE] = 0;
1425
+ // The frame runs through its host wall: an auto depth is the wall's thickness
1426
+ // exactly, so every builder below reads one resolved number.
1427
+ const parent = rawNode.parentId
1428
+ ? useScene.getState().nodes[rawNode.parentId]
1429
+ : undefined;
1430
+ const wall = parent?.type === 'wall' ? getEffectiveNode(parent) : undefined;
1431
+ const node = {
1432
+ ...rawNode,
1433
+ frameDepth: resolveOpeningFrameDepth(rawNode, wall ? getWallThickness(wall) : DEFAULT_WALL_THICKNESS),
1434
+ };
1550
1435
  // Root mesh is an invisible hitbox; all visuals live in child meshes
1551
1436
  mesh.geometry.dispose();
1552
1437
  mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth);
1553
1438
  mesh.material = hitboxMaterial;
1554
1439
  // Sync transform from node (React may lag behind the system by a frame during drag)
1555
- const parent = node.parentId ? useScene.getState().nodes[node.parentId] : undefined;
1556
- const placement = parent?.type === 'wall' && !node.roofSegmentId
1557
- ? getOpeningWallPlacement(getEffectiveNode(parent), node, useScene.getState().nodes)
1440
+ const placement = wall && !node.roofSegmentId
1441
+ ? getOpeningWallPlacement(wall, node, useScene.getState().nodes)
1558
1442
  : node;
1559
1443
  mesh.position.set(...placement.position);
1560
1444
  mesh.rotation.set(...placement.rotation);
@@ -1577,7 +1461,9 @@ function updateWindowMesh(node, mesh) {
1577
1461
  // window resets cleanly without a restore).
1578
1462
  baseMaterial = resolveWindowSlotMaterial(node, 'frame');
1579
1463
  glassMaterial = resolveWindowSlotMaterial(node, 'glass');
1580
- const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, sillDepth, sillThickness, openingKind, openingShape, windowType, } = node;
1464
+ // The casing sits on the wall faces around the opening, whatever fills it.
1465
+ addWindowCasing(node, mesh);
1466
+ const { width, height, frameDepth, frameThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness, sill, openingKind, openingShape, windowType, } = node;
1581
1467
  if (openingKind === 'opening') {
1582
1468
  syncWindowCutout(node, mesh);
1583
1469
  return true;
@@ -1597,6 +1483,11 @@ function updateWindowMesh(node, mesh) {
1597
1483
  syncWindowCutout(node, mesh);
1598
1484
  return true;
1599
1485
  }
1486
+ if (windowType === 'tilt-turn') {
1487
+ addTiltTurnWindowVisuals(node, mesh);
1488
+ syncWindowCutout(node, mesh);
1489
+ return true;
1490
+ }
1600
1491
  if (windowType === 'hopper') {
1601
1492
  addAwningWindowVisuals(node, mesh);
1602
1493
  syncWindowCutout(node, mesh);
@@ -1685,7 +1576,7 @@ function updateWindowMesh(node, mesh) {
1685
1576
  currentWindowSlot = 'frame';
1686
1577
  for (let c = 0; c < numCols - 1; c++) {
1687
1578
  cx += colWidths[c];
1688
- addBox(mesh, baseMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0);
1579
+ addBox(mesh, baseMaterial, columnDividerThickness, innerH, windowDepths(frameDepth).bar, cx + columnDividerThickness / 2, 0, 0);
1689
1580
  cx += columnDividerThickness;
1690
1581
  }
1691
1582
  // Row dividers — per column width, so they don't overlap column dividers (top to bottom)
@@ -1695,12 +1586,12 @@ function updateWindowMesh(node, mesh) {
1695
1586
  cy -= rowHeights[r];
1696
1587
  const divY = cy - rowDividerThickness / 2;
1697
1588
  for (let c = 0; c < numCols; c++) {
1698
- addBox(mesh, baseMaterial, colWidths[c], rowDividerThickness, frameDepth, colXCenters[c], divY, 0);
1589
+ addBox(mesh, baseMaterial, colWidths[c], rowDividerThickness, windowDepths(frameDepth).bar, colXCenters[c], divY, 0);
1699
1590
  }
1700
1591
  cy -= rowDividerThickness;
1701
1592
  }
1702
1593
  // Glass panes
1703
- const glassDepth = Math.max(0.004, frameDepth * 0.08);
1594
+ const glassDepth = windowDepths(frameDepth).glass;
1704
1595
  currentWindowSlot = 'glass';
1705
1596
  for (let c = 0; c < numCols; c++) {
1706
1597
  for (let r = 0; r < numRows; r++) {
@@ -1708,17 +1599,293 @@ function updateWindowMesh(node, mesh) {
1708
1599
  }
1709
1600
  }
1710
1601
  // ── Sill ──
1711
- if (sill) {
1712
- const sillW = width + sillDepth * 0.4; // slightly wider than frame
1713
- // Protrudes from the front face of the frame (+Z)
1714
- const sillZ = frameDepth / 2 + sillDepth / 2;
1715
- currentWindowSlot = 'frame';
1716
- addBox(mesh, baseMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ);
1717
- }
1602
+ if (sill)
1603
+ addSill(mesh, node);
1718
1604
  syncWindowCutout(node, mesh);
1719
1605
  return true;
1720
1606
  }
1607
+ /**
1608
+ * The window's casing on the faces of its wall (or gable) around the cutout,
1609
+ * painted with the frame. Read from the live host, so it stays on the faces
1610
+ * while the wall's thickness is dragged.
1611
+ */
1612
+ function addWindowCasing(node, mesh) {
1613
+ const nodes = useScene.getState().nodes;
1614
+ const hostId = node.roofSegmentId ?? node.parentId;
1615
+ const hostNode = hostId ? nodes[hostId] : undefined;
1616
+ const host = resolveOpeningCasingHost(node, nodes, hostNode ? getEffectiveNode(hostNode) : undefined);
1617
+ if (!host)
1618
+ return;
1619
+ for (const casing of buildOpeningCasingMeshes(node, host, baseMaterial)) {
1620
+ const ref = node.slots?.[casing.userData.slotId];
1621
+ casing.material =
1622
+ (currentTextures && ref
1623
+ ? resolveMaterialRef(ref, currentSceneMaterials, currentShading)
1624
+ : null) ?? baseMaterial;
1625
+ mesh.add(casing);
1626
+ }
1627
+ }
1628
+ function addSashGrid(node, glass) {
1629
+ const geometry = glass.geometry;
1630
+ geometry.computeBoundingBox();
1631
+ const bounds = geometry.boundingBox;
1632
+ const size = bounds.getSize(new THREE.Vector3());
1633
+ const center = bounds.getCenter(new THREE.Vector3()).add(glass.position);
1634
+ const parent = glass.parent;
1635
+ const barDepth = windowDepths(node.frameDepth).bar;
1636
+ const vertical = node.columnRatios;
1637
+ const horizontal = node.rowRatios;
1638
+ const usableW = size.x - (vertical.length - 1) * node.columnDividerThickness;
1639
+ const usableH = size.y - (horizontal.length - 1) * node.rowDividerThickness;
1640
+ const colSum = vertical.reduce((a, b) => a + b, 0);
1641
+ const rowSum = horizontal.reduce((a, b) => a + b, 0);
1642
+ if (usableW <= 0 || usableH <= 0 || colSum <= 0 || rowSum <= 0)
1643
+ return;
1644
+ const bars = new THREE.Group();
1645
+ bars.name = 'window-sash-grid';
1646
+ bars.position.copy(center);
1647
+ parent.add(bars);
1648
+ currentWindowSlot = 'frame';
1649
+ let x = -size.x / 2;
1650
+ for (const ratio of vertical.slice(0, -1)) {
1651
+ x += (ratio / colSum) * usableW;
1652
+ addBox(bars, baseMaterial, node.columnDividerThickness, size.y, barDepth, x + node.columnDividerThickness / 2, 0, 0);
1653
+ x += node.columnDividerThickness;
1654
+ }
1655
+ let y = size.y / 2;
1656
+ for (const ratio of horizontal.slice(0, -1)) {
1657
+ y -= (ratio / rowSum) * usableH;
1658
+ let left = -size.x / 2;
1659
+ for (const column of vertical) {
1660
+ const width = (column / colSum) * usableW;
1661
+ addBox(bars, baseMaterial, width, node.rowDividerThickness, barDepth, left + width / 2, y - node.rowDividerThickness / 2, 0);
1662
+ left += width + node.columnDividerThickness;
1663
+ }
1664
+ y -= node.rowDividerThickness;
1665
+ }
1666
+ }
1667
+ function addSashHardware(node, sash, interior) {
1668
+ const rail = Math.max(node.frameThickness * 0.72, 0.032);
1669
+ const tiltTurn = node.windowType === 'tilt-turn';
1670
+ const horizontalHinge = node.windowType !== 'casement' && !tiltTurn;
1671
+ const innerW = node.width - 2 * node.frameThickness;
1672
+ const innerH = node.height - 2 * node.frameThickness;
1673
+ const french = !tiltTurn && node.casementStyle === 'french';
1674
+ const panelW = french && !horizontalHinge ? innerW / 2 : innerW;
1675
+ const leftHinge = sash.name === FRENCH_CASEMENT_LEFT_SASH_NAME ||
1676
+ ([CASEMENT_WINDOW_SASH_NAME, TILT_TURN_WINDOW_SASH_NAME].includes(sash.name) &&
1677
+ node.hingesSide === 'left');
1678
+ const downward = getAwningDirection(node) === 'down';
1679
+ const tilt = tiltTurn && node.tiltTurnMode !== 'turn';
1680
+ const centerX = tilt || horizontalHinge ? 0 : ((leftHinge ? 1 : -1) * panelW) / 2;
1681
+ const centerY = tilt ? innerH / 2 : horizontalHinge ? ((downward ? 1 : -1) * innerH) / 2 : 0;
1682
+ const surfaceZ = (interior * windowDepths(node.frameDepth).sash) / 2;
1683
+ const material = resolveWindowSlotMaterial(node, 'hardware');
1684
+ const hardware = new THREE.Group();
1685
+ hardware.name = 'window-sash-hardware';
1686
+ sash.add(hardware);
1687
+ const part = (parent, geometry, x, y, z) => {
1688
+ const mesh = new THREE.Mesh(geometry, material);
1689
+ mesh.position.set(x, y, z);
1690
+ mesh.userData.slotId = 'hardware';
1691
+ parent.add(mesh);
1692
+ return mesh;
1693
+ };
1694
+ if (node.windowHandle !== false) {
1695
+ const handle = new THREE.Group();
1696
+ handle.name = 'window-handle';
1697
+ handle.position.set(horizontalHinge ? 0 : centerX + (leftHinge ? 1 : -1) * (panelW / 2 - rail / 2), horizontalHinge ? centerY + (downward ? 1 : -1) * (innerH / 2 - rail / 2) : centerY, surfaceZ);
1698
+ if (interior < 0)
1699
+ handle.rotation.y = Math.PI;
1700
+ if (tiltTurn)
1701
+ handle.rotation.z = Math.PI / 2;
1702
+ hardware.add(handle);
1703
+ part(handle, roundedBox(0.028, 0.12, 0.006, 0.003), 0, 0, 0.003);
1704
+ part(handle, tube(0.006, 0.028), 0, 0, 0.02).rotation.x = Math.PI / 2;
1705
+ part(handle, tube(0.006, 0.09), 0, -0.045, 0.034);
1706
+ }
1707
+ for (const side of tiltTurn ? [-1, 1, 0] : [-1, 1]) {
1708
+ const bottom = tiltTurn && side === 0;
1709
+ const hinge = new THREE.Group();
1710
+ hinge.name = 'window-hinge';
1711
+ hinge.position.set(bottom
1712
+ ? centerX
1713
+ : horizontalHinge
1714
+ ? side * panelW * 0.3
1715
+ : centerX - ((leftHinge ? 1 : -1) * panelW) / 2, bottom ? centerY - innerH / 2 : horizontalHinge ? 0 : centerY + side * innerH * 0.3, surfaceZ);
1716
+ // Mirroring uses rotation so the hardware keeps outward-facing triangles.
1717
+ if (horizontalHinge || bottom)
1718
+ hinge.rotation.z = Math.PI / 2;
1719
+ if (interior < 0)
1720
+ hinge.rotation.y = Math.PI;
1721
+ hardware.add(hinge);
1722
+ for (const geometry of [hingeLeafPart(0.01, 0.06), hingeJambPart(0.01, 0.06)]) {
1723
+ part(hinge, geometry, 0, 0, 0);
1724
+ }
1725
+ }
1726
+ }
1727
+ /** Finish existing sash groups so pose/export animate every attached part together. */
1728
+ function addWindowDetails(node, mesh) {
1729
+ const hasHardware = !node.corner && ['casement', 'awning', 'hopper', 'tilt-turn'].includes(node.windowType);
1730
+ const { exterior, exteriorFace, cased, wallDepth, offset } = windowFaceFrame(node, mesh);
1731
+ const interior = -exterior;
1732
+ mesh.userData.windowExteriorSign = exterior;
1733
+ if (node.sillInterior) {
1734
+ const interiorFace = exteriorFace === 'a' ? 'b' : 'a';
1735
+ const depth = node.sillInteriorDepth ?? 0.06;
1736
+ const thickness = node.sillInteriorThickness ?? 0.03;
1737
+ const overhang = (cased(interiorFace) ? openingCasingWidth(node) : 0) + (node.sillInteriorOverhang ?? 0.015);
1738
+ currentWindowSlot = 'sill';
1739
+ addBox(mesh, resolveWindowSlotMaterial(node, 'sill'), node.width + 2 * overhang, thickness, depth, 0, -node.height / 2 - thickness / 2 + SILL_LIFT, interior * (wallDepth / 2 + depth / 2) - offset).name = 'window-interior-stool';
1740
+ }
1741
+ if (node.shutters && node.shutters !== 'none') {
1742
+ currentWindowSlot = 'shutters';
1743
+ const material = resolveWindowSlotMaterial(node, 'shutters');
1744
+ const sideWidth = node.shutterWidth ??
1745
+ node.width / (node.shutterSides === 'left' || node.shutterSides === 'right' ? 1 : 2);
1746
+ const bifold = sideWidth > 0.75;
1747
+ const width = sideWidth / (bifold ? 2 : 1);
1748
+ const height = node.height;
1749
+ const border = Math.min(0.055, width / 4, height / 8);
1750
+ for (const side of [-1, 1]) {
1751
+ if ((node.shutterSides === 'left' && side === 1) ||
1752
+ (node.shutterSides === 'right' && side === -1))
1753
+ continue;
1754
+ const shutter = new THREE.Group();
1755
+ shutter.name = side === -1 ? WINDOW_LEFT_SHUTTER_NAME : WINDOW_RIGHT_SHUTTER_NAME;
1756
+ shutter.userData.side = side === -1 ? 'left' : 'right';
1757
+ shutter.position.set((side * node.width) / 2, 0, exterior * (wallDepth / 2 + 0.02 + 0.015) - offset);
1758
+ mesh.add(shutter);
1759
+ const leaves = [shutter];
1760
+ if (bifold) {
1761
+ const outer = new THREE.Group();
1762
+ outer.name = WINDOW_SHUTTER_OUTER_NAME;
1763
+ outer.userData.slotId = 'shutters';
1764
+ // Offset the hinge axis so the folded 30 mm leaves stack without intersecting.
1765
+ outer.position.set(side * width, 0, exterior * 0.02);
1766
+ shutter.add(outer);
1767
+ leaves.push(outer);
1768
+ }
1769
+ for (const leaf of leaves) {
1770
+ const board = (w, h, d, x, y, z = 0) => addBox(leaf, material, w, h, d, (side * width) / 2 + x, y, z - (leaf === shutter ? 0 : exterior * 0.02));
1771
+ if (node.shutters === 'board-batten') {
1772
+ const count = Math.ceil(width / 0.1);
1773
+ for (let i = 0; i < count; i++) {
1774
+ const boardWidth = Math.min(0.1, width - i * 0.1);
1775
+ board(boardWidth - Math.min(0.002, boardWidth * 0.05), height, 0.02, -width / 2 + i * 0.1 + boardWidth / 2, 0);
1776
+ }
1777
+ for (const y of [-height * 0.3, height * 0.3])
1778
+ board(width, 0.07, 0.01, 0, y, -exterior * 0.01);
1779
+ if (height > 1.2)
1780
+ board(width, 0.07, 0.01, 0, 0, -exterior * 0.01);
1781
+ }
1782
+ else {
1783
+ for (const edge of [-1, 1]) {
1784
+ board(border, height, 0.03, (edge * (width - border)) / 2, 0);
1785
+ board(width - 2 * border, border, 0.03, 0, (edge * (height - border)) / 2);
1786
+ }
1787
+ const innerW = width - 2 * border;
1788
+ const innerH = height - 2 * border;
1789
+ if (node.shutters === 'louvered') {
1790
+ for (let y = -innerH / 2 + 0.02; y < innerH / 2; y += 0.04)
1791
+ board(innerW, Math.min(0.034, innerH), 0.012, 0, y).rotation.x =
1792
+ (-exterior * Math.PI) / 6;
1793
+ if (height > 1.2)
1794
+ board(innerW, border, 0.03, 0, 0);
1795
+ }
1796
+ else {
1797
+ if (height > 1.2) {
1798
+ board(innerW, border, 0.03, 0, 0);
1799
+ for (const half of [-1, 1])
1800
+ board(innerW, (innerH - border) / 2, 0.012, 0, (half * (innerH + border)) / 4, -exterior * 0.009);
1801
+ }
1802
+ else
1803
+ board(innerW, innerH, 0.012, 0, 0, -exterior * 0.009);
1804
+ }
1805
+ }
1806
+ }
1807
+ if (side === -1 && (node.shutterSides ?? 'both') === 'both') {
1808
+ const tip = leaves[leaves.length - 1];
1809
+ addBox(tip, material, 0.01, height, 0.01, side * (width + 0.005), 0, -exterior * (tip === shutter ? 0.02 : 0.04)).name = 'window-shutter-overlap';
1810
+ }
1811
+ }
1812
+ }
1813
+ if (!node.corner) {
1814
+ const gridTypes = [
1815
+ 'casement',
1816
+ 'sliding',
1817
+ 'single-hung',
1818
+ 'double-hung',
1819
+ 'awning',
1820
+ 'hopper',
1821
+ 'tilt-turn',
1822
+ ];
1823
+ // Curved sash outlines need clipped grids; rectangular sashes cover the initial grid contract.
1824
+ if (node.sashGrid &&
1825
+ gridTypes.includes(node.windowType) &&
1826
+ (node.openingShape === 'rectangle' || isRectangleOnlyWindowType(node))) {
1827
+ const panes = [];
1828
+ mesh.traverse((child) => {
1829
+ if (child instanceof THREE.Mesh && child.userData.slotId === 'glass')
1830
+ panes.push(child);
1831
+ });
1832
+ for (const pane of panes)
1833
+ addSashGrid(node, pane);
1834
+ }
1835
+ if (hasHardware) {
1836
+ for (const name of [
1837
+ CASEMENT_WINDOW_SASH_NAME,
1838
+ FRENCH_CASEMENT_LEFT_SASH_NAME,
1839
+ FRENCH_CASEMENT_RIGHT_SASH_NAME,
1840
+ AWNING_WINDOW_SASH_NAME,
1841
+ HOPPER_WINDOW_SASH_NAME,
1842
+ TILT_TURN_WINDOW_SASH_NAME,
1843
+ ]) {
1844
+ const sash = mesh.getObjectByName(name);
1845
+ if (!sash)
1846
+ continue;
1847
+ addSashHardware(node, sash, interior);
1848
+ }
1849
+ }
1850
+ }
1851
+ if (node.rollerShutter === 'box' || node.rollerShutter === 'concealed') {
1852
+ currentWindowSlot = 'shutters';
1853
+ const material = resolveWindowSlotMaterial(node, 'shutters');
1854
+ const concealed = node.rollerShutter === 'concealed';
1855
+ const faceZ = (exterior * wallDepth) / 2 - offset;
1856
+ if (!concealed) {
1857
+ const boxHeight = node.rollerShutterBoxHeight ?? 0.18;
1858
+ addBox(mesh, material, node.width + 0.08, boxHeight, 0.18, 0, node.height / 2 + boxHeight / 2, faceZ + exterior * 0.11).name = 'window-roller-box';
1859
+ }
1860
+ // Shutter clearance follows the closed unit, independent of its rendered opening angle.
1861
+ poseWindowMovingParts(node, mesh, 0);
1862
+ const guideZ = getWindowRollerCurtainZ(mesh, exterior);
1863
+ // The concealed head strip shares the curtain plane: inside the frame head it would fight its face.
1864
+ if (concealed) {
1865
+ addBox(mesh, material, node.width - 0.002, 0.03, 0.03, 0, node.height / 2 - 0.016, guideZ).name = 'window-roller-lintel';
1866
+ }
1867
+ for (const side of [-1, 1])
1868
+ addBox(mesh, material, concealed ? 0.025 : 0.04, node.height, 0.03, side * (node.width / 2 + (concealed ? -0.0125 : 0.02)), 0, guideZ).name = 'window-roller-guide';
1869
+ mesh.add(buildWindowRollerCurtain(node, material, guideZ, getWindowRenderShutterAmount(node, 'rollerShutterOpen')));
1870
+ }
1871
+ }
1721
1872
  function syncWindowCutout(node, mesh) {
1873
+ if (node.openingKind !== 'opening') {
1874
+ addWindowDetails(node, mesh);
1875
+ poseWindowMovingParts(node, mesh, getWindowRenderOpenAmount(node), getWindowRenderShutterAmount(node, 'shutterOpen'));
1876
+ for (const name of [
1877
+ CASEMENT_WINDOW_SASH_NAME,
1878
+ FRENCH_CASEMENT_LEFT_SASH_NAME,
1879
+ FRENCH_CASEMENT_RIGHT_SASH_NAME,
1880
+ AWNING_WINDOW_SASH_NAME,
1881
+ HOPPER_WINDOW_SASH_NAME,
1882
+ TILT_TURN_WINDOW_SASH_NAME,
1883
+ ]) {
1884
+ const sash = mesh.getObjectByName(name);
1885
+ if (sash)
1886
+ disableSubtreeRaycastIfSwung(sash, sash.rotation.y || sash.rotation.x);
1887
+ }
1888
+ }
1722
1889
  // ── Cutout: invisible raycast hit target for the whole opening ──
1723
1890
  let cutout = mesh.getObjectByName('cutout');
1724
1891
  if (!cutout) {