@pascal-app/editor 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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -1,7 +1,40 @@
1
- import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core'
2
- import { useViewer } from '@pascal-app/viewer'
3
- import { useEffect, useRef } from 'react'
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ getActiveRoofHeight,
5
+ getDutchRoofMetrics,
6
+ getEffectiveNode,
7
+ getRoofSegmentVisibleTopBounds,
8
+ getSegmentSlopeFrame,
9
+ MIN_ROOF_SEGMENT_TRIM_SPAN,
10
+ nodeRegistry,
11
+ normalizeRoofSegmentTrim,
12
+ ROOF_SHAPE_DEFAULTS,
13
+ type RoofNode,
14
+ type RoofSegmentNode,
15
+ type RoofSegmentTrim,
16
+ sceneRegistry,
17
+ useLiveNodeOverrides,
18
+ useScene,
19
+ } from '@pascal-app/core'
20
+ import {
21
+ Brush,
22
+ csgEvaluator,
23
+ generateRoofSegmentGeometry,
24
+ INTERSECTION,
25
+ prepareBrushForCSG,
26
+ useViewer,
27
+ } from '@pascal-app/viewer'
28
+ import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
29
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
30
  import * as THREE from 'three'
31
+ import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
32
+ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
33
+ import { EDITOR_LAYER } from '../../../lib/constants'
34
+ import { isHistoryShortcut } from '../../../lib/history'
35
+ import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy'
36
+ import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope'
37
+ import { swallowNextClick } from '../../editor/handles/use-handle-drag'
5
38
 
6
39
  // Empty placeholder geometry used when we reveal segments-wrapper for
7
40
  // accessory editing. The roof's CSG-merged shell is the only thing
@@ -18,6 +51,9 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
18
51
  // meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer
19
52
  // slot 0 unbound and the draw is rejected, poisoning the command encoder.
20
53
  g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
54
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
55
+ g.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
56
+ g.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
21
57
  // Match the four material slots the roof-segment renderer's material
22
58
  // array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
23
59
  // groups, mesh.material is a single-material lookup that mismatches
@@ -30,14 +66,1706 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
30
66
  return g
31
67
  }
32
68
 
69
+ type RoofTrimSide =
70
+ | 'left'
71
+ | 'right'
72
+ | 'front'
73
+ | 'back'
74
+ | 'frontLeft'
75
+ | 'frontRight'
76
+ | 'backLeft'
77
+ | 'backRight'
78
+ | 'frontLeftX'
79
+ | 'frontLeftZ'
80
+ | 'frontRightX'
81
+ | 'frontRightZ'
82
+ | 'backLeftX'
83
+ | 'backLeftZ'
84
+ | 'backRightX'
85
+ | 'backRightZ'
86
+
87
+ type DiagonalTrimSide = 'frontLeft' | 'frontRight' | 'backLeft' | 'backRight'
88
+ type DiagonalTrimAxisSide = Exclude<
89
+ RoofTrimSide,
90
+ 'left' | 'right' | 'front' | 'back' | DiagonalTrimSide
91
+ >
92
+ type DiagonalTrimAxisKey = DiagonalTrimAxisSide
93
+
94
+ const TRIM_PLANE_COLOR = '#93c5fd'
95
+ const TRIM_PLANE_OPACITY = 0.18
96
+ const TRIM_PLANE_HOVER_OPACITY = 0.32
97
+ const TRIM_RAIL_COLOR = '#2563eb'
98
+ const TRIM_RAIL_HOVER_COLOR = '#4f46e5'
99
+ const TRIM_CAP_COLOR = TRIM_RAIL_COLOR
100
+ const TRIM_CAP_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR
101
+ const TRIM_ADD_COLOR = TRIM_RAIL_COLOR
102
+ const TRIM_ADD_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR
103
+ const TRIM_PLANE_RENDER_ORDER = 1001
104
+ const TRIM_RAIL_RENDER_ORDER = 1003
105
+ const TRIM_HANDLE_BASE_SCALE = 0.65
106
+ const TRIM_RAIL_SURFACE_OFFSET = 0
107
+ const TRIM_RAIL_HIT_HEIGHT = 0.18
108
+ const TRIM_RAIL_HIT_DEPTH = 0.16
109
+ const TRIM_CAP_HIT_SIZE = 0.22
110
+ const TRIM_LIVE_REBUILD_INTERVAL_MS = 80
111
+
112
+ const TRIM_UNIT_PLANE_GEOMETRY = new THREE.PlaneGeometry(1, 1)
113
+ const TRIM_UNIT_RAIL_GEOMETRY = new THREE.BoxGeometry(1, 1, 1)
114
+ const TRIM_UNIT_RAIL_CAP_GEOMETRY = new THREE.SphereGeometry(0.5, 16, 8)
115
+ const TRIM_UNIT_ADD_GEOMETRY = new THREE.OctahedronGeometry(0.5, 0)
116
+
117
+ const trimPlaneMaterial = new MeshBasicNodeMaterial({
118
+ color: TRIM_PLANE_COLOR,
119
+ depthTest: false,
120
+ depthWrite: false,
121
+ opacity: TRIM_PLANE_OPACITY,
122
+ side: THREE.DoubleSide,
123
+ transparent: true,
124
+ })
125
+ const trimPlaneHoverMaterial = new MeshBasicNodeMaterial({
126
+ color: TRIM_PLANE_COLOR,
127
+ depthTest: false,
128
+ depthWrite: false,
129
+ opacity: TRIM_PLANE_HOVER_OPACITY,
130
+ side: THREE.DoubleSide,
131
+ transparent: true,
132
+ })
133
+ const trimRailMaterial = new MeshBasicNodeMaterial({
134
+ color: TRIM_RAIL_COLOR,
135
+ depthTest: false,
136
+ depthWrite: false,
137
+ })
138
+ const trimRailHoverMaterial = new MeshBasicNodeMaterial({
139
+ color: TRIM_RAIL_HOVER_COLOR,
140
+ depthTest: false,
141
+ depthWrite: false,
142
+ })
143
+ const trimCapMaterial = new MeshBasicNodeMaterial({
144
+ color: TRIM_CAP_COLOR,
145
+ depthTest: false,
146
+ depthWrite: false,
147
+ opacity: 1,
148
+ transparent: false,
149
+ })
150
+ const trimCapHoverMaterial = new MeshBasicNodeMaterial({
151
+ color: TRIM_CAP_HOVER_COLOR,
152
+ depthTest: false,
153
+ depthWrite: false,
154
+ opacity: 1,
155
+ transparent: false,
156
+ })
157
+ const trimAddMaterial = new MeshBasicNodeMaterial({
158
+ color: TRIM_ADD_COLOR,
159
+ depthTest: false,
160
+ depthWrite: false,
161
+ })
162
+ const trimAddHoverMaterial = new MeshBasicNodeMaterial({
163
+ color: TRIM_ADD_HOVER_COLOR,
164
+ depthTest: false,
165
+ depthWrite: false,
166
+ })
167
+ const trimDiagonalPreviewRailMaterial = new MeshBasicNodeMaterial({
168
+ color: TRIM_RAIL_COLOR,
169
+ depthTest: false,
170
+ depthWrite: false,
171
+ opacity: 0.42,
172
+ transparent: true,
173
+ })
174
+
175
+ // ─── Section-cut (cutaway) feedback ──────────────────────────────────
176
+ // The cross-section the trim removes: a thin slab at each cut line is
177
+ // intersected with the untrimmed roof shell AND every hosted accessory
178
+ // (chimney, vents, skylight, dormer, …), so the fill shows real material only
179
+ // (wall + deck bands + any accessory the cut passes through) and leaves the
180
+ // hollow attic empty. The outline is the edge silhouette of that fill. A
181
+ // translucent red fill + darker red outline read together as a SketchUp-style
182
+ // section cut of what the trim removes.
183
+ // Matches the app's destructive red (`--destructive`, oklch(0.577 0.245 27.325)
184
+ // ≈ #dc2626 / red-600 — the delete/destructive button color). Three's
185
+ // MeshBasicNodeMaterial color doesn't parse oklch() strings, so use the sRGB hex
186
+ // equivalent; the outline is a darker shade of the same hue.
187
+ const SECTION_FILL_COLOR = '#dc2626'
188
+ const SECTION_OUTLINE_COLOR = '#991b1b'
189
+ const SECTION_FILL_RENDER_ORDER = 1000
190
+ const SECTION_OUTLINE_RENDER_ORDER = 1002
191
+ // Build the cut line just inside the kept material so the section plane never
192
+ // sits coplanar with the mesh's own cut face.
193
+ const SECTION_PLANE_INSET = 0.004
194
+
195
+ // A vertical cut plane defined by a horizontal line through the XZ ground
196
+ // plane. `origin` is a point on the line, `dir` is the unit in-plane
197
+ // horizontal direction (in XZ), and `normal` is the unit XZ normal. Vertices
198
+ // are projected to (u = dir·xz, v = world Y) for slicing and lifted back via
199
+ // `origin + dir`. This handles both the axis-aligned sides (left/right cut on
200
+ // x, front/back on z) and the angled diagonal/corner cuts.
201
+ type SectionPlaneSpec = {
202
+ origin: THREE.Vector2 // point on the cut line, (x, z)
203
+ dir: THREE.Vector2 // unit in-plane horizontal direction, (x, z)
204
+ normal: THREE.Vector2 // unit normal in the XZ plane, (x, z)
205
+ // The cut plane is infinite, but the visible cut only spans the footprint
206
+ // edge between the two endpoints. We clip slice segments to this u-range
207
+ // (u = projection along `dir`) so the silhouette doesn't sprout lines where
208
+ // the infinite plane grazes the rest of the roof.
209
+ uMin: number
210
+ uMax: number
211
+ // How far the slab extends past each end of the cut line. A FREE (untrimmed)
212
+ // end has eave/overhang material beyond the footprint edge, so we extend to
213
+ // capture it; a TRIMMED end has none, so we clamp to 0 — otherwise the slab
214
+ // grabs phantom material from the untrimmed shell and the red section pokes
215
+ // out past the trim box. `dir` points A→B, so uMin is always endpoint A and
216
+ // uMax endpoint B; `extendMin` applies at A, `extendMax` at B.
217
+ extendMin: number
218
+ extendMax: number
219
+ }
220
+
221
+ // Build a section plane from two XZ points on the cut line. `inset` shifts the
222
+ // plane along its normal toward the supplied "inside" point so the slice sits
223
+ // just inside kept material instead of coplanar with the mesh's own cut face.
224
+ // `extendA` / `extendB` set the slab overrun past endpoint A / B (0 at trimmed
225
+ // ends, a small overhang allowance at free ends).
226
+ function makeSectionPlane(
227
+ ax: number,
228
+ az: number,
229
+ bx: number,
230
+ bz: number,
231
+ inset = 0,
232
+ insidePoint?: readonly [number, number],
233
+ extendA = 0,
234
+ extendB = 0,
235
+ ): SectionPlaneSpec {
236
+ const dir = new THREE.Vector2(bx - ax, bz - az)
237
+ if (dir.lengthSq() < 1e-12) dir.set(1, 0)
238
+ dir.normalize()
239
+ const normal = new THREE.Vector2(dir.y, -dir.x)
240
+ const origin = new THREE.Vector2(ax, az)
241
+ if (inset !== 0 && insidePoint) {
242
+ const toInsideX = insidePoint[0] - ax
243
+ const toInsideZ = insidePoint[1] - az
244
+ const sign = normal.x * toInsideX + normal.y * toInsideZ >= 0 ? 1 : -1
245
+ origin.x += normal.x * inset * sign
246
+ origin.y += normal.y * inset * sign
247
+ }
248
+ // dir = (B-A).normalized, so dir·B - dir·A = |B-A| > 0 → uB is always uMax.
249
+ const uA = dir.x * ax + dir.y * az
250
+ const uB = dir.x * bx + dir.y * bz
251
+ return { origin, dir, normal, uMin: uA, uMax: uB, extendMin: extendA, extendMax: extendB }
252
+ }
253
+
254
+ // Lift a 2D plane-frame point (u = projection along dir, v = world Y) back to
255
+ // segment-local 3D using the plane's origin and direction.
256
+ function liftSectionPoint(plane: SectionPlaneSpec, u: number, v: number): [number, number, number] {
257
+ const uOrigin = plane.dir.x * plane.origin.x + plane.dir.y * plane.origin.y
258
+ const t = u - uOrigin
259
+ return [plane.origin.x + plane.dir.x * t, v, plane.origin.y + plane.dir.y * t]
260
+ }
261
+
262
+ const sectionFillMaterial = new MeshBasicNodeMaterial({
263
+ color: SECTION_FILL_COLOR,
264
+ depthTest: false,
265
+ depthWrite: false,
266
+ opacity: 0.85,
267
+ side: THREE.DoubleSide,
268
+ transparent: true,
269
+ })
270
+
271
+ const sectionOutlineMaterial = new LineBasicNodeMaterial({
272
+ color: SECTION_OUTLINE_COLOR,
273
+ depthTest: false,
274
+ depthWrite: false,
275
+ linewidth: 2,
276
+ })
277
+
278
+ const hoverOutlineProxyMaterial = new THREE.MeshBasicMaterial({
279
+ colorWrite: false,
280
+ depthWrite: false,
281
+ side: THREE.DoubleSide,
282
+ transparent: true,
283
+ opacity: 0,
284
+ })
285
+
286
+ // Half-thickness of the slab brush intersected with the roof shell. The wafer
287
+ // must be thin enough to read as a flat cut face but thick enough that CSG
288
+ // produces a stable, non-degenerate solid.
289
+ const SECTION_SLAB_HALF_THICKNESS = 0.006
290
+
291
+ // Builds a thin oriented slab brush straddling one cut line, spanning the full
292
+ // segment height. Intersecting it with the roof shell yields exactly the
293
+ // material the cut passes through (roof slab + wall bands), leaving the hollow
294
+ // attic empty — a true section cut.
295
+ function buildSectionSlabBrush(plane: SectionPlaneSpec, vMin: number, vMax: number): Brush | null {
296
+ const span = plane.uMax - plane.uMin
297
+ const height = vMax - vMin
298
+ if (!(span > 1e-4 && height > 1e-4)) return null
299
+
300
+ // Extend past each end only as far as that end allows: a free edge gets an
301
+ // overhang allowance so eave/shingle material is captured; a trimmed end gets
302
+ // 0 so the slab stops at the cut line and the section never pokes past the
303
+ // trim box. The box is centred on the extended span's midpoint.
304
+ const uLo = plane.uMin - plane.extendMin
305
+ const uHi = plane.uMax + plane.extendMax
306
+ const length = uHi - uLo
307
+ const geometry = new THREE.BoxGeometry(length, height, SECTION_SLAB_HALF_THICKNESS * 2)
308
+ const yaw = Math.atan2(-plane.dir.y, plane.dir.x)
309
+ geometry.rotateY(yaw)
310
+ const midU = (uLo + uHi) / 2
311
+ const [cx, , cz] = liftSectionPoint(plane, midU, 0)
312
+ geometry.translate(cx, (vMin + vMax) / 2, cz)
313
+
314
+ const brush = new Brush(geometry)
315
+ prepareBrushForCSG(brush)
316
+ return brush
317
+ }
318
+
319
+ // Builds the combined fill + outline geometries (segment-local 3D) for all
320
+ // active trim planes. The fill is the CSG intersection of the untrimmed roof
321
+ // shell with a thin slab at each cut line (material only — attic stays hollow).
322
+ // The outline is the edge silhouette of that same fill (via EdgesGeometry), so
323
+ // it traces the real cut shape — wall/deck band boundaries and the hollow-attic
324
+ // edge — instead of just the top surface line. Both in segment-local space, to
325
+ // be mounted under the segment-world-matrix group.
326
+ function buildSectionGeometries(
327
+ segment: RoofSegmentNode,
328
+ planes: SectionPlaneSpec[],
329
+ accessoryGeometries: THREE.BufferGeometry[] = [],
330
+ ): { fill: THREE.BufferGeometry; outline: THREE.BufferGeometry } | null {
331
+ if (planes.length === 0) return null
332
+
333
+ // Untrimmed shell — the source we intersect slabs against. Dutch rake boards
334
+ // are decorative overhang geometry; slicing them makes the red preview sprout
335
+ // tall phantom triangles, so the section fill uses the stable roof shell.
336
+ const sectionSourceSegment: RoofSegmentNode =
337
+ segment.roofType === 'dutch'
338
+ ? { ...segment, trim: ZERO_TRIM, dutchGabletRake: 0 }
339
+ : { ...segment, trim: ZERO_TRIM }
340
+ const shellGeometry = generateRoofSegmentGeometry(sectionSourceSegment)
341
+ const shell = new Brush(shellGeometry)
342
+ prepareBrushForCSG(shell)
343
+
344
+ const slopeFrame = getSegmentSlopeFrame(segment)
345
+ // Span the full material height — wall bands (base→eave) plus the deck/shingle
346
+ // wedge above — so the cut face fills completely. The earlier red bars that
347
+ // poked below the box were horizontal overshoot (the untrimmed slab dragging
348
+ // wall material past a perpendicular cut), now fixed by the per-end slab
349
+ // extension clamp; clipping the wall band off here only left gaps.
350
+ const vMin = -0.05
351
+ const vMax =
352
+ segment.wallHeight +
353
+ slopeFrame.activeRh +
354
+ segment.deckThickness +
355
+ segment.shingleThickness +
356
+ 0.5
357
+
358
+ const fillPositions: number[] = []
359
+ const outlinePositions: number[] = []
360
+ // Section cut only needs material presence, not per-face materials — disable
361
+ // group bookkeeping on the shared evaluator for this pass so mismatched slab /
362
+ // shell material slots can't misalign group indices and crash.
363
+ const prevUseGroups = csgEvaluator.useGroups
364
+ const prevAttributes = csgEvaluator.attributes
365
+ csgEvaluator.useGroups = false
366
+ csgEvaluator.attributes = ['position']
367
+
368
+ // Intersect every active section slab with a source solid (the roof shell or
369
+ // an accessory mesh), appending the resulting cross-section to the shared
370
+ // fill + outline buffers. The slab sits just inside the kept material, so the
371
+ // intersection yields the material face the cut exposes.
372
+ const sliceSourceBySlabs = (source: Brush) => {
373
+ for (const plane of planes) {
374
+ const slab = buildSectionSlabBrush(plane, vMin, vMax)
375
+ if (!slab) continue
376
+ try {
377
+ const result = csgEvaluator.evaluate(source, slab, INTERSECTION) as Brush
378
+ const geo = result.geometry as THREE.BufferGeometry
379
+ const pos = geo.getAttribute('position') as THREE.BufferAttribute | undefined
380
+ if (pos && pos.count > 0) {
381
+ const index = geo.getIndex()
382
+ const count = index ? index.count : pos.count
383
+ for (let i = 0; i < count; i++) {
384
+ const vi = index ? index.getX(i) : i
385
+ fillPositions.push(pos.getX(vi), pos.getY(vi), pos.getZ(vi))
386
+ }
387
+ // Trace the silhouette of the cut face itself. EdgesGeometry emits a
388
+ // boundary/crease line list; the thin wafer's flat cut caps give a
389
+ // crisp outline of the actual material shape.
390
+ const edges = new THREE.EdgesGeometry(geo, 1)
391
+ const ep = edges.getAttribute('position') as THREE.BufferAttribute | undefined
392
+ if (ep) {
393
+ for (let i = 0; i < ep.count; i++) {
394
+ outlinePositions.push(ep.getX(i), ep.getY(i), ep.getZ(i))
395
+ }
396
+ }
397
+ edges.dispose()
398
+ }
399
+ geo.dispose()
400
+ } catch (e) {
401
+ console.error('Roof section-cut CSG failed:', e)
402
+ } finally {
403
+ slab.geometry.dispose()
404
+ }
405
+ }
406
+ }
407
+
408
+ // Brushes built from accessory meshes; disposed after the slice pass.
409
+ const accessoryBrushes: Brush[] = []
410
+ try {
411
+ sliceSourceBySlabs(shell)
412
+ // Accessories that fall in the trimmed region contribute their own
413
+ // cross-section to the same red fill (chimney, vents, skylight, dormer, …).
414
+ // Each geometry arrives already in segment-local space.
415
+ for (const accGeo of accessoryGeometries) {
416
+ try {
417
+ // Weld coincident verts so the brush is a valid indexed solid. Most
418
+ // accessories are clean THREE primitives, but some (e.g. the ridge
419
+ // vent) are hand-wound non-indexed triangle soup; three-bvh-csg's
420
+ // INTERSECTION classifies inside/outside off a welded, indexed mesh and
421
+ // silently yields nothing for raw soup — the same `mergeVertices` step
422
+ // the viewer's own accessory CSG runs before any boolean op.
423
+ const welded = mergeVertices(accGeo, 1e-4)
424
+ const accBrush = new Brush(welded)
425
+ prepareBrushForCSG(accBrush)
426
+ accessoryBrushes.push(accBrush)
427
+ sliceSourceBySlabs(accBrush)
428
+ } catch (e) {
429
+ console.error('Roof section-cut accessory CSG failed:', e)
430
+ }
431
+ }
432
+ } finally {
433
+ csgEvaluator.useGroups = prevUseGroups
434
+ csgEvaluator.attributes = prevAttributes
435
+ shellGeometry.dispose()
436
+ for (const b of accessoryBrushes) b.geometry.dispose()
437
+ }
438
+
439
+ if (fillPositions.length === 0) return null
440
+
441
+ const fill = new THREE.BufferGeometry()
442
+ fill.setAttribute('position', new THREE.Float32BufferAttribute(fillPositions, 3))
443
+ const outline = new THREE.BufferGeometry()
444
+ outline.setAttribute('position', new THREE.Float32BufferAttribute(outlinePositions, 3))
445
+ return { fill, outline }
446
+ }
447
+
448
+ // Zeroed trim — the section fill intersects the FULL (untrimmed) roof shell at
449
+ // the cut line, so we regenerate the shell with no trim and slab-intersect it.
450
+ const ZERO_TRIM: RoofSegmentTrim = {
451
+ left: 0,
452
+ right: 0,
453
+ front: 0,
454
+ back: 0,
455
+ frontLeft: 0,
456
+ frontRight: 0,
457
+ backLeft: 0,
458
+ backRight: 0,
459
+ frontLeftX: 0,
460
+ frontLeftZ: 0,
461
+ frontRightX: 0,
462
+ frontRightZ: 0,
463
+ backLeftX: 0,
464
+ backLeftZ: 0,
465
+ backRightX: 0,
466
+ backRightZ: 0,
467
+ }
468
+
469
+ type TrimVisibleBounds = ReturnType<typeof getRoofSegmentVisibleTopBounds>
470
+
471
+ function getTrimVisibleTopBounds(segment: RoofSegmentNode): TrimVisibleBounds {
472
+ const bounds = getRoofSegmentVisibleTopBounds(segment)
473
+ if (segment.roofType !== 'dutch') return bounds
474
+
475
+ const trim = normalizeRoofSegmentTrim(segment)
476
+ const metrics = getDutchRoofMetrics(segment)
477
+ const requestedRake = Math.max(0, segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake)
478
+ const rakeReach = Math.min(
479
+ requestedRake,
480
+ (metrics.axis === 'x' ? metrics.shoulderInsetAlongWidth : metrics.shoulderInsetAlongDepth) *
481
+ 0.98,
482
+ )
483
+ if (!(rakeReach > 0.001)) return bounds
484
+
485
+ const next = { ...bounds }
486
+ if (metrics.axis === 'x') {
487
+ if (!(trim.left > 0)) next.minX -= rakeReach
488
+ if (!(trim.right > 0)) next.maxX += rakeReach
489
+ } else {
490
+ if (!(trim.back > 0)) next.minZ -= rakeReach
491
+ if (!(trim.front > 0)) next.maxZ += rakeReach
492
+ }
493
+
494
+ next.width = Math.max(0.01, next.maxX - next.minX)
495
+ next.depth = Math.max(0.01, next.maxZ - next.minZ)
496
+ return next
497
+ }
498
+
499
+ // Shape fields that affect the segment's 3D volume. Trim is excluded — the cut
500
+ // lines arrive via `planes`, whose endpoints already encode the trim — so the
501
+ // memo recomputes when either the roof shape or any trim changes, and the shell
502
+ // regeneration only reruns when the actual roof shape changes.
503
+ function segmentShapeKey(segment: RoofSegmentNode): string {
504
+ return JSON.stringify([
505
+ segment.roofType,
506
+ segment.width,
507
+ segment.depth,
508
+ segment.wallHeight,
509
+ segment.pitch,
510
+ segment.wallThickness,
511
+ segment.deckThickness,
512
+ segment.overhang,
513
+ segment.shingleThickness,
514
+ segment.gambrelLowerWidthRatio,
515
+ segment.gambrelLowerHeightRatio,
516
+ segment.mansardSteepWidthRatio,
517
+ segment.mansardSteepHeightRatio,
518
+ segment.dutchHipWidthRatio,
519
+ segment.dutchHipHeightRatio,
520
+ segment.dutchWaistLengthRatio,
521
+ segment.dutchGabletRake,
522
+ segment.dutchTopRakeThickness,
523
+ ])
524
+ }
525
+
526
+ // Collect every roof-accessory mesh hosted on `segment` as a segment-local
527
+ // geometry, so the section-cut pass can intersect each with the cut slabs and
528
+ // show its cross-section in the red fill. Registry-driven — an accessory is any
529
+ // child kind declaring the `roofAccessory` capability — so no kind is named
530
+ // here. The meshes come straight from `sceneRegistry` (already live: each
531
+ // renderer re-clips against the live trim), so the geometry reflects the
532
+ // in-flight drag. Returned geometries are fresh clones the caller owns +
533
+ // disposes.
534
+ const _segWorldInverse = new THREE.Matrix4()
535
+ const _accWorld = new THREE.Matrix4()
536
+ function collectAccessorySectionGeometries(segment: RoofSegmentNode): THREE.BufferGeometry[] {
537
+ const childIds = segment.children
538
+ if (!childIds || childIds.length === 0) return []
539
+
540
+ const segSource = sceneRegistry.nodes.get(segment.id)
541
+ if (!segSource) return []
542
+ segSource.updateWorldMatrix(true, false)
543
+ _segWorldInverse.copy(segSource.matrixWorld).invert()
544
+
545
+ const nodes = useScene.getState().nodes
546
+ const out: THREE.BufferGeometry[] = []
547
+ for (const childId of childIds) {
548
+ const childNode = nodes[childId as AnyNodeId]
549
+ if (!childNode) continue
550
+ if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue
551
+ const obj = sceneRegistry.nodes.get(childId)
552
+ if (!obj) continue
553
+ obj.updateWorldMatrix(true, true)
554
+ obj.traverse((child) => {
555
+ const mesh = child as THREE.Mesh
556
+ if (!mesh.isMesh || !mesh.geometry) return
557
+ const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined
558
+ if (!posAttr || posAttr.count === 0) return
559
+ // mesh-world → segment-local: segmentWorld⁻¹ · meshWorld.
560
+ _accWorld.multiplyMatrices(_segWorldInverse, mesh.matrixWorld)
561
+ const geo = mesh.geometry.clone()
562
+ geo.applyMatrix4(_accWorld)
563
+ out.push(geo)
564
+ })
565
+ }
566
+ return out
567
+ }
568
+
569
+ // A change key over the hosted accessories' kinds + world transforms, so the
570
+ // section-cut memo recomputes when an accessory moves, is added/removed, or its
571
+ // host pose shifts (mirrors how `planes` keys the trim cut). Geometry-shape
572
+ // changes are caught by the renderer re-clipping (new mesh world matrix on
573
+ // resize is not guaranteed, but trim drag changes `planes` every tick, which
574
+ // already forces the recompute during the gesture we care about).
575
+ function accessorySectionKey(segment: RoofSegmentNode): string {
576
+ const childIds = segment.children
577
+ if (!childIds || childIds.length === 0) return 'none'
578
+ const nodes = useScene.getState().nodes
579
+ const parts: string[] = []
580
+ for (const childId of childIds) {
581
+ const childNode = nodes[childId as AnyNodeId]
582
+ if (!childNode) continue
583
+ if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue
584
+ const obj = sceneRegistry.nodes.get(childId)
585
+ if (!obj) continue
586
+ parts.push(`${childId}:${obj.matrixWorld.elements.map((n) => n.toFixed(3)).join(',')}`)
587
+ }
588
+ return parts.join('|')
589
+ }
590
+
591
+ // Renders the cutaway section cut (material fill + cut-edge outline) for the
592
+ // active trim planes, in segment-local space (mounted under the
593
+ // segment-world-matrix group). The fill is the CSG intersection of the
594
+ // untrimmed roof shell (and every hosted accessory) with a thin slab at each
595
+ // cut line, so only real material is shown and the hollow attic stays empty;
596
+ // the outline is the analytic surface edge.
597
+ function SectionCut({ segment, planes }: { segment: RoofSegmentNode; planes: SectionPlaneSpec[] }) {
598
+ const shapeKey = segmentShapeKey(segment)
599
+ const accessoryKey = accessorySectionKey(segment)
600
+
601
+ const geometries = useMemo(() => {
602
+ if (planes.length === 0) return null
603
+ const accessoryGeometries = collectAccessorySectionGeometries(segment)
604
+ const result = buildSectionGeometries(segment, planes, accessoryGeometries)
605
+ for (const g of accessoryGeometries) g.dispose()
606
+ return result
607
+ // Recompute when the roof shape (shapeKey), the cut lines (planes), or the
608
+ // hosted accessories (accessoryKey) change.
609
+ // eslint-disable-next-line react-hooks/exhaustive-deps
610
+ }, [segment, planes.length, planes])
611
+
612
+ useEffect(() => {
613
+ return () => {
614
+ geometries?.fill.dispose()
615
+ geometries?.outline.dispose()
616
+ }
617
+ }, [geometries])
618
+
619
+ if (!geometries) return null
620
+
621
+ return (
622
+ <group layers={EDITOR_LAYER}>
623
+ <mesh
624
+ geometry={geometries.fill}
625
+ layers={EDITOR_LAYER}
626
+ material={sectionFillMaterial}
627
+ raycast={() => null}
628
+ renderOrder={SECTION_FILL_RENDER_ORDER}
629
+ />
630
+ <lineSegments
631
+ frustumCulled={false}
632
+ geometry={geometries.outline}
633
+ layers={EDITOR_LAYER}
634
+ material={sectionOutlineMaterial}
635
+ renderOrder={SECTION_OUTLINE_RENDER_ORDER}
636
+ />
637
+ </group>
638
+ )
639
+ }
640
+
641
+ function HoveredRoofSegmentOutlineProxy() {
642
+ const hoveredId = useViewer((s) => s.hoveredId)
643
+ const segment = useScene((s) => {
644
+ if (!hoveredId) return null
645
+ const node = s.nodes[hoveredId as AnyNodeId]
646
+ return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null
647
+ })
648
+ const nodes = useScene((s) => s.nodes)
649
+ const ref = useRef<THREE.Mesh>(null)
650
+
651
+ const geometry = useMemo(() => {
652
+ if (!segment) return null
653
+ return generateRoofSegmentGeometry(segment, nodes)
654
+ }, [nodes, segment])
655
+ const source = segment ? (sceneRegistry.nodes.get(segment.id) ?? null) : null
656
+ const roofRoot =
657
+ segment?.parentId && nodes[segment.parentId as AnyNodeId]?.type === 'roof'
658
+ ? (sceneRegistry.nodes.get(segment.parentId) ?? null)
659
+ : null
660
+
661
+ useEffect(() => {
662
+ return () => {
663
+ geometry?.dispose()
664
+ }
665
+ }, [geometry])
666
+
667
+ useFrame(() => {
668
+ const mesh = ref.current
669
+ if (!(mesh && source && roofRoot)) return
670
+ source.updateWorldMatrix(true, false)
671
+ roofRoot.updateWorldMatrix(true, false)
672
+ mesh.matrix.copy(roofRoot.matrixWorld).invert().multiply(source.matrixWorld)
673
+ mesh.matrixAutoUpdate = false
674
+ })
675
+
676
+ if (!(source && geometry && roofRoot && segment)) return null
677
+
678
+ return createPortal(
679
+ <mesh
680
+ frustumCulled={false}
681
+ geometry={geometry}
682
+ layers={EDITOR_LAYER}
683
+ material={hoverOutlineProxyMaterial}
684
+ matrixAutoUpdate={false}
685
+ name={getHoveredRoofSegmentOutlineProxyName(segment.id)}
686
+ raycast={() => null}
687
+ ref={ref}
688
+ />,
689
+ roofRoot,
690
+ )
691
+ }
692
+
693
+ const _dragNdc = new THREE.Vector2()
694
+ const _dragRaycaster = new THREE.Raycaster()
695
+ const _dragPlaneHit = new THREE.Vector3()
696
+ const _dragLocalPoint = new THREE.Vector3()
697
+ const _dragInverseMatrix = new THREE.Matrix4()
698
+ const _trimHitInverseMatrix = new THREE.Matrix4()
699
+ const _trimHitRay = new THREE.Ray()
700
+ const _trimHitBox = new THREE.Box3()
701
+ const _trimHitPoint = new THREE.Vector3()
702
+
703
+ function makeExpandedTrimRaycast(
704
+ visualScale: readonly [number, number, number],
705
+ hitScale: readonly [number, number, number],
706
+ ) {
707
+ const halfX = Math.max(0.5, hitScale[0] / Math.max(visualScale[0], 1e-6) / 2)
708
+ const halfY = Math.max(0.5, hitScale[1] / Math.max(visualScale[1], 1e-6) / 2)
709
+ const halfZ = Math.max(0.5, hitScale[2] / Math.max(visualScale[2], 1e-6) / 2)
710
+ return function expandedTrimRaycast(
711
+ this: THREE.Mesh,
712
+ raycaster: THREE.Raycaster,
713
+ intersects: THREE.Intersection[],
714
+ ) {
715
+ _trimHitInverseMatrix.copy(this.matrixWorld).invert()
716
+ _trimHitRay.copy(raycaster.ray).applyMatrix4(_trimHitInverseMatrix)
717
+ _trimHitBox.min.set(-halfX, -halfY, -halfZ)
718
+ _trimHitBox.max.set(halfX, halfY, halfZ)
719
+ const localHit = _trimHitRay.intersectBox(_trimHitBox, _trimHitPoint)
720
+ if (!localHit) return
721
+ const point = localHit.clone().applyMatrix4(this.matrixWorld)
722
+ const distance = raycaster.ray.origin.distanceTo(point)
723
+ if (distance < raycaster.near || distance > raycaster.far) return
724
+ intersects.push({ distance, point, object: this })
725
+ }
726
+ }
727
+
728
+ function trimEquals(a: RoofSegmentTrim, b: RoofSegmentTrim): boolean {
729
+ return (
730
+ a.left === b.left &&
731
+ a.right === b.right &&
732
+ a.front === b.front &&
733
+ a.back === b.back &&
734
+ a.frontLeft === b.frontLeft &&
735
+ a.frontRight === b.frontRight &&
736
+ a.backLeft === b.backLeft &&
737
+ a.backRight === b.backRight &&
738
+ a.frontLeftX === b.frontLeftX &&
739
+ a.frontLeftZ === b.frontLeftZ &&
740
+ a.frontRightX === b.frontRightX &&
741
+ a.frontRightZ === b.frontRightZ &&
742
+ a.backLeftX === b.backLeftX &&
743
+ a.backLeftZ === b.backLeftZ &&
744
+ a.backRightX === b.backRightX &&
745
+ a.backRightZ === b.backRightZ
746
+ )
747
+ }
748
+
749
+ function clamp(value: number, min: number, max: number): number {
750
+ return Math.min(max, Math.max(min, value))
751
+ }
752
+
753
+ function isDiagonalTrimSide(side: RoofTrimSide): side is DiagonalTrimSide {
754
+ return (
755
+ side === 'frontLeft' || side === 'frontRight' || side === 'backLeft' || side === 'backRight'
756
+ )
757
+ }
758
+
759
+ function getDiagonalAxisKeys(side: DiagonalTrimSide): [DiagonalTrimAxisKey, DiagonalTrimAxisKey] {
760
+ switch (side) {
761
+ case 'frontLeft':
762
+ return ['frontLeftX', 'frontLeftZ']
763
+ case 'frontRight':
764
+ return ['frontRightX', 'frontRightZ']
765
+ case 'backLeft':
766
+ return ['backLeftX', 'backLeftZ']
767
+ case 'backRight':
768
+ return ['backRightX', 'backRightZ']
769
+ }
770
+ }
771
+
772
+ function getDiagonalResetCorner(side: RoofTrimSide): DiagonalTrimSide | null {
773
+ if (isDiagonalTrimSide(side)) return side
774
+ if (side.endsWith('X') || side.endsWith('Z')) {
775
+ return getDiagonalAxisCorner(side as DiagonalTrimAxisSide)
776
+ }
777
+ return null
778
+ }
779
+
780
+ function getDiagonalAxisCorner(side: DiagonalTrimAxisSide): DiagonalTrimSide {
781
+ if (side === 'frontLeftX' || side === 'frontLeftZ') return 'frontLeft'
782
+ if (side === 'frontRightX' || side === 'frontRightZ') return 'frontRight'
783
+ if (side === 'backLeftX' || side === 'backLeftZ') return 'backLeft'
784
+ return 'backRight'
785
+ }
786
+
787
+ function getOppositeDiagonalAxis(side: DiagonalTrimAxisKey): DiagonalTrimAxisKey {
788
+ switch (side) {
789
+ case 'frontLeftX':
790
+ return 'frontRightX'
791
+ case 'frontRightX':
792
+ return 'frontLeftX'
793
+ case 'backLeftX':
794
+ return 'backRightX'
795
+ case 'backRightX':
796
+ return 'backLeftX'
797
+ case 'frontLeftZ':
798
+ return 'backLeftZ'
799
+ case 'backLeftZ':
800
+ return 'frontLeftZ'
801
+ case 'frontRightZ':
802
+ return 'backRightZ'
803
+ case 'backRightZ':
804
+ return 'frontRightZ'
805
+ }
806
+ }
807
+
808
+ function getMaxDiagonalAxisTrim(
809
+ segment: RoofSegmentNode,
810
+ trim: RoofSegmentTrim,
811
+ axis: DiagonalTrimAxisKey,
812
+ ): number {
813
+ const keptWidth = Math.max(0, segment.width - trim.left - trim.right)
814
+ const keptDepth = Math.max(0, segment.depth - trim.front - trim.back)
815
+ const opposite = trim[getOppositeDiagonalAxis(axis)]
816
+ const span = axis.endsWith('X') ? keptWidth : keptDepth
817
+ return Math.max(0, span - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite)
818
+ }
819
+
820
+ function getStarterDiagonalTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim): number {
821
+ const keptWidth = Math.max(0, segment.width - trim.left - trim.right)
822
+ const keptDepth = Math.max(0, segment.depth - trim.front - trim.back)
823
+ const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN)
824
+ return Math.min(maxDiagonalTrim, Math.max(0.75, maxDiagonalTrim * 0.2))
825
+ }
826
+
827
+ function patchTrimSide(
828
+ segment: RoofSegmentNode,
829
+ baseTrim: RoofSegmentTrim,
830
+ side: RoofTrimSide,
831
+ rawValue: number,
832
+ ): RoofSegmentTrim {
833
+ const next = { ...baseTrim }
834
+ if (side === 'left' || side === 'right') {
835
+ const opposite = side === 'left' ? baseTrim.right : baseTrim.left
836
+ const max = Math.max(0, segment.width - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite)
837
+ next[side] = clamp(rawValue, 0, max)
838
+ } else {
839
+ if (isDiagonalTrimSide(side)) {
840
+ const [xAxis, zAxis] = getDiagonalAxisKeys(side)
841
+ next[xAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, xAxis))
842
+ next[zAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, zAxis))
843
+ next[side] = Math.min(next[xAxis], next[zAxis])
844
+ return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
845
+ }
846
+
847
+ if (side.endsWith('X') || side.endsWith('Z')) {
848
+ const axis = side as DiagonalTrimAxisKey
849
+ const corner = getDiagonalAxisCorner(axis)
850
+ const [xAxis, zAxis] = getDiagonalAxisKeys(corner)
851
+ const otherAxis = axis === xAxis ? zAxis : xAxis
852
+ const starter = getStarterDiagonalTrim(segment, baseTrim)
853
+ next[axis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, axis))
854
+ if (next[otherAxis] <= 0 && next[corner] <= 0) {
855
+ next[otherAxis] = Math.min(starter, getMaxDiagonalAxisTrim(segment, baseTrim, otherAxis))
856
+ }
857
+ next[corner] = Math.min(next[xAxis], next[zAxis])
858
+ return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
859
+ }
860
+
861
+ const opposite = side === 'front' ? baseTrim.back : baseTrim.front
862
+ const max = Math.max(0, segment.depth - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite)
863
+ next[side] = clamp(rawValue, 0, max)
864
+ }
865
+
866
+ return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
867
+ }
868
+
869
+ function patchTrimSideByDelta(
870
+ segment: RoofSegmentNode,
871
+ baseTrim: RoofSegmentTrim,
872
+ side: RoofTrimSide,
873
+ delta: number,
874
+ ): RoofSegmentTrim {
875
+ if (isDiagonalTrimSide(side)) {
876
+ const [xAxis, zAxis] = getDiagonalAxisKeys(side)
877
+ const next = { ...baseTrim }
878
+ next[xAxis] = clamp(
879
+ baseTrim[xAxis] + delta,
880
+ 0,
881
+ getMaxDiagonalAxisTrim(segment, baseTrim, xAxis),
882
+ )
883
+ next[zAxis] = clamp(
884
+ baseTrim[zAxis] + delta,
885
+ 0,
886
+ getMaxDiagonalAxisTrim(segment, baseTrim, zAxis),
887
+ )
888
+ next[side] = Math.min(next[xAxis], next[zAxis])
889
+ return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next })
890
+ }
891
+
892
+ const baseValue = baseTrim[side]
893
+ return patchTrimSide(segment, baseTrim, side, baseValue + delta)
894
+ }
895
+
896
+ function getTrimValueFromLocalPoint(
897
+ segment: RoofSegmentNode,
898
+ baseTrim: RoofSegmentTrim,
899
+ side: RoofTrimSide,
900
+ localPoint: THREE.Vector3,
901
+ ): number {
902
+ const leftX = -segment.width / 2 + baseTrim.left
903
+ const rightX = segment.width / 2 - baseTrim.right
904
+ const frontZ = segment.depth / 2 - baseTrim.front
905
+ const backZ = -segment.depth / 2 + baseTrim.back
906
+
907
+ switch (side) {
908
+ case 'left':
909
+ return localPoint.x + segment.width / 2
910
+ case 'right':
911
+ return segment.width / 2 - localPoint.x
912
+ case 'front':
913
+ return segment.depth / 2 - localPoint.z
914
+ case 'back':
915
+ return localPoint.z + segment.depth / 2
916
+ case 'frontLeft':
917
+ return localPoint.x - leftX + (frontZ - localPoint.z)
918
+ case 'frontRight':
919
+ return rightX - localPoint.x + (frontZ - localPoint.z)
920
+ case 'backLeft':
921
+ return localPoint.x - leftX + (localPoint.z - backZ)
922
+ case 'backRight':
923
+ return rightX - localPoint.x + (localPoint.z - backZ)
924
+ case 'frontLeftX':
925
+ return localPoint.x - leftX
926
+ case 'frontLeftZ':
927
+ return frontZ - localPoint.z
928
+ case 'frontRightX':
929
+ return rightX - localPoint.x
930
+ case 'frontRightZ':
931
+ return frontZ - localPoint.z
932
+ case 'backLeftX':
933
+ return localPoint.x - leftX
934
+ case 'backLeftZ':
935
+ return localPoint.z - backZ
936
+ case 'backRightX':
937
+ return rightX - localPoint.x
938
+ case 'backRightZ':
939
+ return localPoint.z - backZ
940
+ }
941
+ }
942
+
943
+ function getTrimLabel(side: RoofTrimSide): string {
944
+ switch (side) {
945
+ case 'left':
946
+ return 'trim left'
947
+ case 'right':
948
+ return 'trim right'
949
+ case 'front':
950
+ return 'trim front'
951
+ case 'back':
952
+ return 'trim back'
953
+ case 'frontLeft':
954
+ return 'trim front left diagonal'
955
+ case 'frontRight':
956
+ return 'trim front right diagonal'
957
+ case 'backLeft':
958
+ return 'trim back left diagonal'
959
+ case 'backRight':
960
+ return 'trim back right diagonal'
961
+ case 'frontLeftX':
962
+ return 'trim front left diagonal width'
963
+ case 'frontLeftZ':
964
+ return 'trim front left diagonal depth'
965
+ case 'frontRightX':
966
+ return 'trim front right diagonal width'
967
+ case 'frontRightZ':
968
+ return 'trim front right diagonal depth'
969
+ case 'backLeftX':
970
+ return 'trim back left diagonal width'
971
+ case 'backLeftZ':
972
+ return 'trim back left diagonal depth'
973
+ case 'backRightX':
974
+ return 'trim back right diagonal width'
975
+ case 'backRightZ':
976
+ return 'trim back right diagonal depth'
977
+ }
978
+ }
979
+
980
+ function getTrimCursor(side: RoofTrimSide): string {
981
+ switch (side) {
982
+ case 'left':
983
+ case 'right':
984
+ return 'ew-resize'
985
+ case 'front':
986
+ case 'back':
987
+ return 'ns-resize'
988
+ case 'frontLeft':
989
+ case 'backRight':
990
+ return 'nwse-resize'
991
+ case 'frontRight':
992
+ case 'backLeft':
993
+ return 'nesw-resize'
994
+ case 'frontLeftX':
995
+ case 'frontRightX':
996
+ case 'backLeftX':
997
+ case 'backRightX':
998
+ return 'ew-resize'
999
+ case 'frontLeftZ':
1000
+ case 'frontRightZ':
1001
+ case 'backLeftZ':
1002
+ case 'backRightZ':
1003
+ return 'ns-resize'
1004
+ }
1005
+ }
1006
+
1007
+ function shouldShowTrimPlanes(metadata: unknown): boolean {
1008
+ return (
1009
+ typeof metadata === 'object' &&
1010
+ metadata !== null &&
1011
+ !Array.isArray(metadata) &&
1012
+ (metadata as Record<string, unknown>).showTrimPlanes === true
1013
+ )
1014
+ }
1015
+
1016
+ function commitSegmentTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim) {
1017
+ const scene = useScene.getState()
1018
+ scene.applyNodeChanges({
1019
+ update: [{ id: segment.id as AnyNodeId, data: { trim } as Partial<AnyNode> }],
1020
+ })
1021
+ }
1022
+
1023
+ function RoofTrimHandles() {
1024
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
1025
+ const selectedId = selectedIds.length === 1 ? (selectedIds[0] as AnyNodeId) : null
1026
+ const segment = useScene((s) => {
1027
+ if (!selectedId) return null
1028
+ const node = s.nodes[selectedId]
1029
+ return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null
1030
+ })
1031
+ const readOnly = useScene((s) => s.readOnly)
1032
+ const liveOverrideKey = useLiveNodeOverrides((s) =>
1033
+ segment ? JSON.stringify(s.overrides.get(segment.id) ?? null) : null,
1034
+ )
1035
+ const [hoveredSide, setHoveredSide] = useState<RoofTrimSide | null>(null)
1036
+ const [draggingSide, setDraggingSide] = useState<RoofTrimSide | null>(null)
1037
+ const groupRef = useRef<THREE.Group>(null)
1038
+ const dragCleanupRef = useRef<(() => void) | null>(null)
1039
+ const { camera, gl } = useThree()
1040
+ const zoom = camera instanceof THREE.OrthographicCamera ? 1 / camera.zoom : 1
1041
+ const handleBaseScale = zoom * TRIM_HANDLE_BASE_SCALE
1042
+
1043
+ useEffect(() => () => dragCleanupRef.current?.(), [])
1044
+
1045
+ const liveSegment = useMemo(() => {
1046
+ if (!segment) return null
1047
+ void liveOverrideKey
1048
+ return getEffectiveNode(segment)
1049
+ }, [segment, liveOverrideKey])
1050
+
1051
+ useFrame(() => {
1052
+ if (!liveSegment || !groupRef.current) return
1053
+ const source = sceneRegistry.nodes.get(liveSegment.id)
1054
+ if (!source) return
1055
+ source.updateWorldMatrix(true, false)
1056
+ groupRef.current.matrix.copy(source.matrixWorld)
1057
+ groupRef.current.matrixAutoUpdate = false
1058
+ })
1059
+
1060
+ const showTrimPlanes = shouldShowTrimPlanes(liveSegment?.metadata)
1061
+
1062
+ if (readOnly || !segment || !liveSegment || !showTrimPlanes) return null
1063
+
1064
+ const trim = normalizeRoofSegmentTrim(liveSegment)
1065
+ const activeRh = getActiveRoofHeight(liveSegment)
1066
+ const handleY = Math.max(0.45, liveSegment.wallHeight + activeRh + 0.45)
1067
+ const keptWidth = Math.max(0.01, liveSegment.width - trim.left - trim.right)
1068
+ const keptDepth = Math.max(0.01, liveSegment.depth - trim.front - trim.back)
1069
+ const leftX = -liveSegment.width / 2 + trim.left
1070
+ const rightX = liveSegment.width / 2 - trim.right
1071
+ const frontZ = liveSegment.depth / 2 - trim.front
1072
+ const backZ = -liveSegment.depth / 2 + trim.back
1073
+ const visibleBounds = getTrimVisibleTopBounds({
1074
+ ...liveSegment,
1075
+ trim: {
1076
+ ...trim,
1077
+ frontLeft: 0,
1078
+ frontRight: 0,
1079
+ backLeft: 0,
1080
+ backRight: 0,
1081
+ frontLeftX: 0,
1082
+ frontLeftZ: 0,
1083
+ frontRightX: 0,
1084
+ frontRightZ: 0,
1085
+ backLeftX: 0,
1086
+ backLeftZ: 0,
1087
+ backRightX: 0,
1088
+ backRightZ: 0,
1089
+ },
1090
+ })
1091
+ const visibleCenterX = (visibleBounds.minX + visibleBounds.maxX) / 2
1092
+ const visibleCenterZ = (visibleBounds.minZ + visibleBounds.maxZ) / 2
1093
+ const visibleWidth = Math.max(0.01, visibleBounds.maxX - visibleBounds.minX)
1094
+ const visibleDepth = Math.max(0.01, visibleBounds.maxZ - visibleBounds.minZ)
1095
+ const visualLeftX = trim.left > 0 ? leftX : visibleBounds.minX
1096
+ const visualRightX = trim.right > 0 ? rightX : visibleBounds.maxX
1097
+ const visualFrontZ = trim.front > 0 ? frontZ : visibleBounds.maxZ
1098
+ const visualBackZ = trim.back > 0 ? backZ : visibleBounds.minZ
1099
+ const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN)
1100
+
1101
+ const pointOnTrimLineAtX = (
1102
+ start: readonly [number, number],
1103
+ end: readonly [number, number],
1104
+ x: number,
1105
+ ): [number, number] => {
1106
+ const dx = end[0] - start[0]
1107
+ if (Math.abs(dx) < 1e-6) return [x, start[1]]
1108
+ const t = (x - start[0]) / dx
1109
+ return [x, start[1] + (end[1] - start[1]) * t]
1110
+ }
1111
+
1112
+ const pointOnTrimLineAtZ = (
1113
+ start: readonly [number, number],
1114
+ end: readonly [number, number],
1115
+ z: number,
1116
+ ): [number, number] => {
1117
+ const dz = end[1] - start[1]
1118
+ if (Math.abs(dz) < 1e-6) return [start[0], z]
1119
+ const t = (z - start[1]) / dz
1120
+ return [start[0] + (end[0] - start[0]) * t, z]
1121
+ }
1122
+
1123
+ const getDiagonalRailLine = (
1124
+ side: DiagonalTrimSide,
1125
+ start: readonly [number, number],
1126
+ end: readonly [number, number],
1127
+ ): [[number, number], [number, number]] => {
1128
+ switch (side) {
1129
+ case 'frontLeft':
1130
+ return [
1131
+ pointOnTrimLineAtZ(start, end, visualFrontZ),
1132
+ pointOnTrimLineAtX(start, end, visualLeftX),
1133
+ ]
1134
+ case 'frontRight':
1135
+ return [
1136
+ pointOnTrimLineAtX(start, end, visualRightX),
1137
+ pointOnTrimLineAtZ(start, end, visualFrontZ),
1138
+ ]
1139
+ case 'backLeft':
1140
+ return [
1141
+ pointOnTrimLineAtX(start, end, visualLeftX),
1142
+ pointOnTrimLineAtZ(start, end, visualBackZ),
1143
+ ]
1144
+ case 'backRight':
1145
+ return [
1146
+ pointOnTrimLineAtZ(start, end, visualBackZ),
1147
+ pointOnTrimLineAtX(start, end, visualRightX),
1148
+ ]
1149
+ }
1150
+ }
1151
+
1152
+ // Cross-section planes the active trim cuts expose. Slice the live roof
1153
+ // mesh just inside the kept material (the cut sits coplanar with the mesh's
1154
+ // own face otherwise) so the cutaway shows real construction layers.
1155
+ const sectionPlanes: SectionPlaneSpec[] = []
1156
+ // Inside reference: the kept-region center, on the kept side of every cut so
1157
+ // the inset always shifts the slice into solid material.
1158
+ const insideRef: readonly [number, number] = [0, 0]
1159
+ if (trim.left > 0) {
1160
+ sectionPlanes.push(
1161
+ makeSectionPlane(leftX, visualBackZ, leftX, visualFrontZ, SECTION_PLANE_INSET, insideRef),
1162
+ )
1163
+ }
1164
+ if (trim.right > 0) {
1165
+ sectionPlanes.push(
1166
+ makeSectionPlane(rightX, visualBackZ, rightX, visualFrontZ, SECTION_PLANE_INSET, insideRef),
1167
+ )
1168
+ }
1169
+ if (trim.front > 0) {
1170
+ sectionPlanes.push(
1171
+ makeSectionPlane(visualLeftX, frontZ, visualRightX, frontZ, SECTION_PLANE_INSET, insideRef),
1172
+ )
1173
+ }
1174
+ if (trim.back > 0) {
1175
+ sectionPlanes.push(
1176
+ makeSectionPlane(visualLeftX, backZ, visualRightX, backZ, SECTION_PLANE_INSET, insideRef),
1177
+ )
1178
+ }
1179
+
1180
+ // Diagonal/corner cuts run at an angle, so they need a generic vertical
1181
+ // plane through the corner cut line. The endpoints match the rail line
1182
+ // geometry in renderDiagonalTrimPlane (start/end before the visual-bounds
1183
+ // extension; only direction matters for slicing).
1184
+ const diagonalCutLine = (side: DiagonalTrimSide): [[number, number], [number, number]] | null => {
1185
+ const [xKey, zKey] = getDiagonalAxisKeys(side)
1186
+ const dx = trim[xKey]
1187
+ const dz = trim[zKey]
1188
+ if (!(dx > 0 && dz > 0)) return null
1189
+ switch (side) {
1190
+ case 'frontLeft':
1191
+ return [
1192
+ [leftX + dx, frontZ],
1193
+ [leftX, frontZ - dz],
1194
+ ]
1195
+ case 'frontRight':
1196
+ return [
1197
+ [rightX, frontZ - dz],
1198
+ [rightX - dx, frontZ],
1199
+ ]
1200
+ case 'backLeft':
1201
+ return [
1202
+ [leftX, backZ + dz],
1203
+ [leftX + dx, backZ],
1204
+ ]
1205
+ case 'backRight':
1206
+ return [
1207
+ [rightX - dx, backZ],
1208
+ [rightX, backZ + dz],
1209
+ ]
1210
+ }
1211
+ }
1212
+ for (const side of ['frontLeft', 'frontRight', 'backLeft', 'backRight'] as const) {
1213
+ const line = diagonalCutLine(side)
1214
+ if (!line) continue
1215
+ const [s, e] = line
1216
+ const [railStart, railEnd] = getDiagonalRailLine(side, s, e)
1217
+ sectionPlanes.push(
1218
+ makeSectionPlane(
1219
+ railStart[0],
1220
+ railStart[1],
1221
+ railEnd[0],
1222
+ railEnd[1],
1223
+ SECTION_PLANE_INSET,
1224
+ insideRef,
1225
+ ),
1226
+ )
1227
+ }
1228
+
1229
+ const resetDiagonalTrim = (side: RoofTrimSide, event: ThreeEvent<MouseEvent>) => {
1230
+ event.stopPropagation()
1231
+ const corner = getDiagonalResetCorner(side)
1232
+ if (!corner) return
1233
+
1234
+ const baseSegment = getEffectiveNode(segment)
1235
+ const baseTrim = normalizeRoofSegmentTrim(baseSegment)
1236
+ const next = { ...baseTrim }
1237
+ const [xAxis, zAxis] = getDiagonalAxisKeys(corner)
1238
+ next[corner] = 0
1239
+ next[xAxis] = 0
1240
+ next[zAxis] = 0
1241
+ const normalized = normalizeRoofSegmentTrim({
1242
+ width: baseSegment.width,
1243
+ depth: baseSegment.depth,
1244
+ trim: next,
1245
+ })
1246
+ useLiveNodeOverrides.getState().clear(segment.id as AnyNodeId)
1247
+ if (!trimEquals(normalized, baseTrim)) {
1248
+ commitSegmentTrim(baseSegment, normalized)
1249
+ }
1250
+ useScene.getState().markDirty(segment.id as AnyNodeId)
1251
+ }
1252
+
1253
+ const startDrag = (side: RoofTrimSide, event: ThreeEvent<PointerEvent>) => {
1254
+ if (event.button !== 0) return
1255
+ event.stopPropagation()
1256
+ const source = sceneRegistry.nodes.get(segment.id)
1257
+ if (!source) return
1258
+
1259
+ source.updateWorldMatrix(true, false)
1260
+ const startMatrix = source.matrixWorld.clone()
1261
+ _dragInverseMatrix.copy(startMatrix).invert()
1262
+ const dragPlanePoint = new THREE.Vector3(0, handleY, 0).applyMatrix4(startMatrix)
1263
+ const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -dragPlanePoint.y)
1264
+ const baseSegment = getEffectiveNode(segment)
1265
+ const baseTrim = normalizeRoofSegmentTrim(baseSegment)
1266
+ const segmentId = segment.id as AnyNodeId
1267
+ let pendingTrim = baseTrim
1268
+ let lastDirtyMarkAt = 0
1269
+ let pendingDirtyTimeout: number | null = null
1270
+
1271
+ const clearPendingDirtyTimeout = () => {
1272
+ if (pendingDirtyTimeout === null) return
1273
+ window.clearTimeout(pendingDirtyTimeout)
1274
+ pendingDirtyTimeout = null
1275
+ }
1276
+
1277
+ const flushDirtyMark = () => {
1278
+ clearPendingDirtyTimeout()
1279
+ lastDirtyMarkAt = performance.now()
1280
+ useScene.getState().markDirty(segmentId)
1281
+ }
1282
+
1283
+ const scheduleDirtyMark = () => {
1284
+ const now = performance.now()
1285
+ if (now - lastDirtyMarkAt >= TRIM_LIVE_REBUILD_INTERVAL_MS) {
1286
+ flushDirtyMark()
1287
+ return
1288
+ }
1289
+ if (pendingDirtyTimeout !== null) return
1290
+ pendingDirtyTimeout = window.setTimeout(
1291
+ () => {
1292
+ flushDirtyMark()
1293
+ },
1294
+ Math.max(0, TRIM_LIVE_REBUILD_INTERVAL_MS - (now - lastDirtyMarkAt)),
1295
+ )
1296
+ }
1297
+
1298
+ const getPointerTrimValue = (clientX: number, clientY: number): number | null => {
1299
+ const rect = gl.domElement.getBoundingClientRect()
1300
+ _dragNdc.set(
1301
+ ((clientX - rect.left) / rect.width) * 2 - 1,
1302
+ -(((clientY - rect.top) / rect.height) * 2 - 1),
1303
+ )
1304
+ _dragRaycaster.setFromCamera(_dragNdc, camera)
1305
+ if (!_dragRaycaster.ray.intersectPlane(dragPlane, _dragPlaneHit)) return null
1306
+ _dragLocalPoint.copy(_dragPlaneHit).applyMatrix4(_dragInverseMatrix)
1307
+ return getTrimValueFromLocalPoint(baseSegment, baseTrim, side, _dragLocalPoint)
1308
+ }
1309
+
1310
+ const initialPointerValue = getPointerTrimValue(event.clientX, event.clientY)
1311
+ if (initialPointerValue === null) return
1312
+
1313
+ document.body.style.cursor = getTrimCursor(side)
1314
+ setDraggingSide(side)
1315
+ useInteractionScope
1316
+ .getState()
1317
+ .begin({ kind: 'handle-drag', nodeId: segmentId, handle: getTrimLabel(side) })
1318
+ useViewer.getState().setInputDragging(true)
1319
+ useScene.temporal.getState().pause()
1320
+
1321
+ const updateFromPointer = (clientX: number, clientY: number) => {
1322
+ const pointerValue = getPointerTrimValue(clientX, clientY)
1323
+ if (pointerValue === null) return
1324
+ pendingTrim = patchTrimSideByDelta(
1325
+ baseSegment,
1326
+ baseTrim,
1327
+ side,
1328
+ pointerValue - initialPointerValue,
1329
+ )
1330
+ useLiveNodeOverrides.getState().set(segmentId, { trim: pendingTrim })
1331
+ // Coalesce live merged-shell rebuilds during trim drag. The override
1332
+ // still updates every pointer move for local trim affordances, but the
1333
+ // full roof CSG only refreshes at a capped cadence instead of at raw
1334
+ // pointer-event frequency.
1335
+ scheduleDirtyMark()
1336
+ }
1337
+
1338
+ updateFromPointer(event.clientX, event.clientY)
1339
+
1340
+ const cleanup = () => {
1341
+ clearPendingDirtyTimeout()
1342
+ window.removeEventListener('pointermove', onMove)
1343
+ window.removeEventListener('pointerup', onUp)
1344
+ window.removeEventListener('pointercancel', onCancel)
1345
+ window.removeEventListener('keydown', onKeyDown, true)
1346
+ if (
1347
+ document.body.style.cursor === 'ew-resize' ||
1348
+ document.body.style.cursor === 'ns-resize' ||
1349
+ document.body.style.cursor === 'nwse-resize' ||
1350
+ document.body.style.cursor === 'nesw-resize' ||
1351
+ document.body.style.cursor === 'move'
1352
+ ) {
1353
+ document.body.style.cursor = ''
1354
+ }
1355
+ useScene.temporal.getState().resume()
1356
+ useInteractionScope
1357
+ .getState()
1358
+ .endIf((scope) => scope.kind === 'handle-drag' && scope.nodeId === segmentId)
1359
+ useViewer.getState().setInputDragging(false)
1360
+ setDraggingSide(null)
1361
+ dragCleanupRef.current = null
1362
+ }
1363
+
1364
+ const onMove = (moveEvent: PointerEvent) => {
1365
+ updateFromPointer(moveEvent.clientX, moveEvent.clientY)
1366
+ }
1367
+
1368
+ const onUp = () => {
1369
+ swallowNextClick()
1370
+ if (!trimEquals(pendingTrim, baseTrim)) {
1371
+ commitSegmentTrim(baseSegment, pendingTrim)
1372
+ }
1373
+ useLiveNodeOverrides.getState().clear(segmentId)
1374
+ flushDirtyMark()
1375
+ cleanup()
1376
+ }
1377
+
1378
+ const onCancel = () => {
1379
+ useLiveNodeOverrides.getState().clear(segmentId)
1380
+ flushDirtyMark()
1381
+ cleanup()
1382
+ }
1383
+
1384
+ // Escape / ⌘Z abort the trim drag — capture phase so they win over the
1385
+ // global use-keyboard arms (⌘Z must never history-jump mid-gesture).
1386
+ const onKeyDown = (e: KeyboardEvent) => {
1387
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
1388
+ e.preventDefault()
1389
+ e.stopPropagation()
1390
+ swallowNextClick()
1391
+ onCancel()
1392
+ }
1393
+
1394
+ dragCleanupRef.current = cleanup
1395
+ window.addEventListener('pointermove', onMove)
1396
+ window.addEventListener('pointerup', onUp)
1397
+ window.addEventListener('pointercancel', onCancel)
1398
+ window.addEventListener('keydown', onKeyDown, true)
1399
+ }
1400
+
1401
+ const renderTrimPlane = (
1402
+ side: RoofTrimSide,
1403
+ position: [number, number, number],
1404
+ args: [number, number],
1405
+ rotation: [number, number, number] = [0, 0, 0],
1406
+ handles: readonly { side: RoofTrimSide; offsetX: number }[] = [{ side, offsetX: 0 }],
1407
+ showPlane = true,
1408
+ ) => {
1409
+ const [planeWidth, planeHeight] = args
1410
+ const isHovered = handles.some((handle) => handle.side === hoveredSide)
1411
+ const railY = planeHeight / 2
1412
+ const railVisualHeight = Math.max(0.012, handleBaseScale * 0.022)
1413
+ const railVisualDepth = Math.max(0.01, handleBaseScale * 0.018)
1414
+ const railVisualLength = planeWidth + railVisualDepth * 2
1415
+ const capSize = Math.max(0.045, handleBaseScale * 0.085)
1416
+ const primaryHandle = handles[0] ?? { side, offsetX: 0 }
1417
+ const endpointHandles = handles.slice(1)
1418
+
1419
+ const renderRailHitTarget = (
1420
+ handle: { side: RoofTrimSide; offsetX: number },
1421
+ scale: [number, number, number],
1422
+ visual: 'rail' | 'cap',
1423
+ ) => {
1424
+ const hovered = hoveredSide === handle.side
1425
+ const visualScale: [number, number, number] =
1426
+ visual === 'rail' ? scale : [capSize, capSize, capSize]
1427
+ const hitScale: [number, number, number] =
1428
+ visual === 'rail'
1429
+ ? [scale[0], TRIM_RAIL_HIT_HEIGHT, TRIM_RAIL_HIT_DEPTH]
1430
+ : [TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE]
1431
+ const resetCorner = getDiagonalResetCorner(handle.side)
1432
+ return (
1433
+ <group key={handle.side} position={[handle.offsetX, railY, TRIM_RAIL_SURFACE_OFFSET]}>
1434
+ <mesh
1435
+ geometry={visual === 'rail' ? TRIM_UNIT_RAIL_GEOMETRY : TRIM_UNIT_RAIL_CAP_GEOMETRY}
1436
+ layers={EDITOR_LAYER}
1437
+ material={
1438
+ visual === 'rail'
1439
+ ? hovered
1440
+ ? trimRailHoverMaterial
1441
+ : trimRailMaterial
1442
+ : hovered
1443
+ ? trimCapHoverMaterial
1444
+ : trimCapMaterial
1445
+ }
1446
+ raycast={makeExpandedTrimRaycast(visualScale, hitScale)}
1447
+ onDoubleClick={
1448
+ resetCorner ? (event) => resetDiagonalTrim(handle.side, event) : undefined
1449
+ }
1450
+ onPointerDown={(event) => startDrag(handle.side, event)}
1451
+ onPointerEnter={(event) => {
1452
+ event.stopPropagation()
1453
+ setHoveredSide(handle.side)
1454
+ document.body.style.cursor = getTrimCursor(handle.side)
1455
+ }}
1456
+ onPointerLeave={(event) => {
1457
+ event.stopPropagation()
1458
+ if (!dragCleanupRef.current) {
1459
+ setHoveredSide((current) => (current === handle.side ? null : current))
1460
+ document.body.style.cursor = ''
1461
+ }
1462
+ }}
1463
+ renderOrder={TRIM_RAIL_RENDER_ORDER}
1464
+ scale={visualScale}
1465
+ />
1466
+ </group>
1467
+ )
1468
+ }
1469
+
1470
+ return (
1471
+ <group
1472
+ key={side}
1473
+ layers={EDITOR_LAYER}
1474
+ position={position}
1475
+ rotation={rotation}
1476
+ renderOrder={TRIM_RAIL_RENDER_ORDER}
1477
+ >
1478
+ {showPlane ? (
1479
+ <mesh
1480
+ geometry={TRIM_UNIT_PLANE_GEOMETRY}
1481
+ layers={EDITOR_LAYER}
1482
+ material={isHovered ? trimPlaneHoverMaterial : trimPlaneMaterial}
1483
+ raycast={() => null}
1484
+ renderOrder={TRIM_PLANE_RENDER_ORDER}
1485
+ scale={[planeWidth, planeHeight, 1]}
1486
+ />
1487
+ ) : null}
1488
+
1489
+ {renderRailHitTarget(
1490
+ primaryHandle,
1491
+ [railVisualLength, railVisualHeight, railVisualDepth],
1492
+ 'rail',
1493
+ )}
1494
+ {endpointHandles.map((handle) =>
1495
+ renderRailHitTarget(handle, [capSize, capSize, capSize], 'cap'),
1496
+ )}
1497
+ </group>
1498
+ )
1499
+ }
1500
+
1501
+ const renderDiagonalAddHandle = (side: DiagonalTrimSide) => {
1502
+ if (maxDiagonalTrim <= 0) return null
1503
+
1504
+ let position: [number, number, number]
1505
+ let xDir = 1
1506
+ let zDir = 1
1507
+ switch (side) {
1508
+ case 'frontLeft':
1509
+ position = [leftX, handleY, frontZ]
1510
+ xDir = 1
1511
+ zDir = -1
1512
+ break
1513
+ case 'frontRight':
1514
+ position = [rightX, handleY, frontZ]
1515
+ xDir = -1
1516
+ zDir = -1
1517
+ break
1518
+ case 'backLeft':
1519
+ position = [leftX, handleY, backZ]
1520
+ xDir = 1
1521
+ zDir = 1
1522
+ break
1523
+ case 'backRight':
1524
+ position = [rightX, handleY, backZ]
1525
+ xDir = -1
1526
+ zDir = 1
1527
+ break
1528
+ default:
1529
+ return null
1530
+ }
1531
+
1532
+ const hovered = hoveredSide === side
1533
+ const addSize = Math.max(0.055, handleBaseScale * 0.1)
1534
+ const addVisualScale: [number, number, number] = [addSize, addSize, addSize]
1535
+ const addHitScale: [number, number, number] = [
1536
+ TRIM_CAP_HIT_SIZE,
1537
+ TRIM_CAP_HIT_SIZE,
1538
+ TRIM_CAP_HIT_SIZE,
1539
+ ]
1540
+ const bracketLength = Math.min(0.55, Math.max(0.28, maxDiagonalTrim * 0.22))
1541
+ const bracketHeight = Math.max(0.012, handleBaseScale * 0.022)
1542
+ const bracketDepth = Math.max(0.01, handleBaseScale * 0.018)
1543
+ const bracketArmLength = bracketLength + bracketDepth
1544
+ const bracketVisualScale: [number, number, number] = [
1545
+ bracketArmLength,
1546
+ bracketHeight,
1547
+ bracketDepth,
1548
+ ]
1549
+ const bracketHitScale: [number, number, number] = [
1550
+ bracketArmLength,
1551
+ TRIM_RAIL_HIT_HEIGHT,
1552
+ TRIM_RAIL_HIT_DEPTH,
1553
+ ]
1554
+ const previewAmount = getStarterDiagonalTrim(liveSegment, trim)
1555
+
1556
+ let previewStart: [number, number]
1557
+ let previewEnd: [number, number]
1558
+ switch (side) {
1559
+ case 'frontLeft':
1560
+ previewStart = [leftX + previewAmount, frontZ]
1561
+ previewEnd = [leftX, frontZ - previewAmount]
1562
+ break
1563
+ case 'frontRight':
1564
+ previewStart = [rightX, frontZ - previewAmount]
1565
+ previewEnd = [rightX - previewAmount, frontZ]
1566
+ break
1567
+ case 'backLeft':
1568
+ previewStart = [leftX, backZ + previewAmount]
1569
+ previewEnd = [leftX + previewAmount, backZ]
1570
+ break
1571
+ case 'backRight':
1572
+ previewStart = [rightX - previewAmount, backZ]
1573
+ previewEnd = [rightX, backZ + previewAmount]
1574
+ break
1575
+ }
1576
+
1577
+ const [previewRailStart, previewRailEnd] = getDiagonalRailLine(side, previewStart, previewEnd)
1578
+ const previewDx = previewRailEnd[0] - previewRailStart[0]
1579
+ const previewDz = previewRailEnd[1] - previewRailStart[1]
1580
+ const previewWidth = Math.hypot(previewDx, previewDz)
1581
+ const previewYaw = Math.atan2(-previewDz, previewDx)
1582
+ const handlePointerEnter = (event: ThreeEvent<PointerEvent>) => {
1583
+ event.stopPropagation()
1584
+ setHoveredSide(side)
1585
+ document.body.style.cursor = getTrimCursor(side)
1586
+ }
1587
+ const handlePointerLeave = (event: ThreeEvent<PointerEvent>) => {
1588
+ event.stopPropagation()
1589
+ if (!dragCleanupRef.current) {
1590
+ setHoveredSide((current) => (current === side ? null : current))
1591
+ document.body.style.cursor = ''
1592
+ }
1593
+ }
1594
+
1595
+ return (
1596
+ <group key={`${side}-add`} layers={EDITOR_LAYER}>
1597
+ {hovered && previewWidth > 0 ? (
1598
+ <group
1599
+ position={[
1600
+ (previewRailStart[0] + previewRailEnd[0]) / 2,
1601
+ handleY / 2,
1602
+ (previewRailStart[1] + previewRailEnd[1]) / 2,
1603
+ ]}
1604
+ rotation={[0, previewYaw, 0]}
1605
+ >
1606
+ <mesh
1607
+ geometry={TRIM_UNIT_RAIL_GEOMETRY}
1608
+ layers={EDITOR_LAYER}
1609
+ material={trimDiagonalPreviewRailMaterial}
1610
+ position={[0, handleY / 2, TRIM_RAIL_SURFACE_OFFSET]}
1611
+ raycast={() => null}
1612
+ renderOrder={TRIM_RAIL_RENDER_ORDER}
1613
+ scale={[previewWidth + bracketDepth * 2, bracketHeight, bracketDepth]}
1614
+ />
1615
+ </group>
1616
+ ) : null}
1617
+
1618
+ <mesh
1619
+ geometry={TRIM_UNIT_RAIL_GEOMETRY}
1620
+ layers={EDITOR_LAYER}
1621
+ material={hovered ? trimRailHoverMaterial : trimRailMaterial}
1622
+ onDoubleClick={(event) => resetDiagonalTrim(side, event)}
1623
+ onPointerDown={(event) => startDrag(side, event)}
1624
+ onPointerEnter={handlePointerEnter}
1625
+ onPointerLeave={handlePointerLeave}
1626
+ position={[position[0] + (xDir * bracketArmLength) / 2, position[1], position[2]]}
1627
+ raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)}
1628
+ renderOrder={TRIM_RAIL_RENDER_ORDER}
1629
+ scale={bracketVisualScale}
1630
+ />
1631
+ <mesh
1632
+ geometry={TRIM_UNIT_RAIL_GEOMETRY}
1633
+ layers={EDITOR_LAYER}
1634
+ material={hovered ? trimRailHoverMaterial : trimRailMaterial}
1635
+ onDoubleClick={(event) => resetDiagonalTrim(side, event)}
1636
+ onPointerDown={(event) => startDrag(side, event)}
1637
+ onPointerEnter={handlePointerEnter}
1638
+ onPointerLeave={handlePointerLeave}
1639
+ position={[position[0], position[1], position[2] + (zDir * bracketArmLength) / 2]}
1640
+ raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)}
1641
+ renderOrder={TRIM_RAIL_RENDER_ORDER}
1642
+ rotation={[0, zDir > 0 ? -Math.PI / 2 : Math.PI / 2, 0]}
1643
+ scale={bracketVisualScale}
1644
+ />
1645
+ <mesh
1646
+ geometry={TRIM_UNIT_ADD_GEOMETRY}
1647
+ layers={EDITOR_LAYER}
1648
+ material={hovered ? trimAddHoverMaterial : trimAddMaterial}
1649
+ onDoubleClick={(event) => resetDiagonalTrim(side, event)}
1650
+ onPointerDown={(event) => startDrag(side, event)}
1651
+ onPointerEnter={handlePointerEnter}
1652
+ onPointerLeave={handlePointerLeave}
1653
+ position={position}
1654
+ raycast={makeExpandedTrimRaycast(addVisualScale, addHitScale)}
1655
+ renderOrder={TRIM_RAIL_RENDER_ORDER}
1656
+ scale={addVisualScale}
1657
+ />
1658
+ </group>
1659
+ )
1660
+ }
1661
+
1662
+ const renderDiagonalTrimPlane = (side: DiagonalTrimSide, xAmount: number, zAmount: number) => {
1663
+ if (maxDiagonalTrim <= 0) {
1664
+ return null
1665
+ }
1666
+
1667
+ if (!(xAmount > 0 && zAmount > 0)) {
1668
+ return renderDiagonalAddHandle(side)
1669
+ }
1670
+
1671
+ const displayX = xAmount
1672
+ const displayZ = zAmount
1673
+ if (!(displayX > 0 && displayZ > 0)) return null
1674
+
1675
+ let start: [number, number]
1676
+ let end: [number, number]
1677
+ let xOffset = 0
1678
+ let zOffset = 0
1679
+ const [xSide, zSide] = getDiagonalAxisKeys(side)
1680
+ switch (side) {
1681
+ case 'frontLeft':
1682
+ start = [leftX + displayX, frontZ]
1683
+ end = [leftX, frontZ - displayZ]
1684
+ xOffset = -1
1685
+ zOffset = 1
1686
+ break
1687
+ case 'frontRight':
1688
+ start = [rightX, frontZ - displayZ]
1689
+ end = [rightX - displayX, frontZ]
1690
+ zOffset = -1
1691
+ xOffset = 1
1692
+ break
1693
+ case 'backLeft':
1694
+ start = [leftX, backZ + displayZ]
1695
+ end = [leftX + displayX, backZ]
1696
+ zOffset = -1
1697
+ xOffset = 1
1698
+ break
1699
+ case 'backRight':
1700
+ start = [rightX - displayX, backZ]
1701
+ end = [rightX, backZ + displayZ]
1702
+ xOffset = -1
1703
+ zOffset = 1
1704
+ break
1705
+ default:
1706
+ return null
1707
+ }
1708
+
1709
+ const [railStart, railEnd] = getDiagonalRailLine(side, start, end)
1710
+ const dx = railEnd[0] - railStart[0]
1711
+ const dz = railEnd[1] - railStart[1]
1712
+ const width = Math.hypot(dx, dz)
1713
+ const yaw = Math.atan2(-dz, dx)
1714
+ return renderTrimPlane(
1715
+ side,
1716
+ [(railStart[0] + railEnd[0]) / 2, handleY / 2, (railStart[1] + railEnd[1]) / 2],
1717
+ [width, handleY],
1718
+ [0, yaw, 0],
1719
+ [
1720
+ { side, offsetX: 0 },
1721
+ { side: xSide, offsetX: (width / 2) * xOffset },
1722
+ { side: zSide, offsetX: (width / 2) * zOffset },
1723
+ ],
1724
+ false,
1725
+ )
1726
+ }
1727
+
1728
+ return (
1729
+ <group ref={groupRef}>
1730
+ {sectionPlanes.length > 0 ? (
1731
+ <SectionCut planes={sectionPlanes} segment={liveSegment} />
1732
+ ) : null}
1733
+ {renderTrimPlane(
1734
+ 'left',
1735
+ [visualLeftX, handleY / 2, visibleCenterZ],
1736
+ [visibleDepth, handleY],
1737
+ [0, Math.PI / 2, 0],
1738
+ )}
1739
+ {renderTrimPlane(
1740
+ 'right',
1741
+ [visualRightX, handleY / 2, visibleCenterZ],
1742
+ [visibleDepth, handleY],
1743
+ [0, Math.PI / 2, 0],
1744
+ )}
1745
+ {renderTrimPlane(
1746
+ 'front',
1747
+ [visibleCenterX, handleY / 2, visualFrontZ],
1748
+ [visibleWidth, handleY],
1749
+ )}
1750
+ {renderTrimPlane('back', [visibleCenterX, handleY / 2, visualBackZ], [visibleWidth, handleY])}
1751
+ {renderDiagonalTrimPlane('frontLeft', trim.frontLeftX, trim.frontLeftZ)}
1752
+ {renderDiagonalTrimPlane('frontRight', trim.frontRightX, trim.frontRightZ)}
1753
+ {renderDiagonalTrimPlane('backLeft', trim.backLeftX, trim.backLeftZ)}
1754
+ {renderDiagonalTrimPlane('backRight', trim.backRightX, trim.backRightZ)}
1755
+ </group>
1756
+ )
1757
+ }
1758
+
33
1759
  /**
34
1760
  * Imperatively toggles the Three.js visibility of roof objects based on the
35
1761
  * editor selection — without causing React re-renders in RoofRenderer.
36
1762
  *
37
1763
  * Full edit-mode (segment selected):
38
- * - merged-roof mesh is hidden
39
- * - segments-wrapper group is shown (individual segments visible for editing)
40
- * - all children are marked dirty so RoofSystem rebuilds their geometry
1764
+ * - merged-roof mesh stays VISIBLE — it rebuilds live from each segment's
1765
+ * trim override, so the edited cutaway matches the clean commit instead of
1766
+ * exposing the per-segment meshes' abutting end-cap faces
1767
+ * - segments-wrapper group stays hidden (handles render from RoofTrimHandles)
1768
+ * - all children are marked dirty so RoofSystem rebuilds the merged shell
41
1769
  *
42
1770
  * Accessory-reveal mode (a dormer/chimney/etc. hosted on a segment is selected):
43
1771
  * - merged-roof mesh stays visible (we don't want the appearance to jump)
@@ -52,7 +1780,9 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
52
1780
  */
53
1781
  export const RoofEditSystem = () => {
54
1782
  const selectedIds = useViewer((s) => s.selection.selectedIds)
1783
+ const movingNode = useMovingNode()
55
1784
  const prevActiveRoofIds = useRef(new Set<string>())
1785
+ const prevMovingRoofIds = useRef(new Set<string>())
56
1786
  const prevRevealRoofIds = useRef(new Set<string>())
57
1787
 
58
1788
  useEffect(() => {
@@ -65,6 +1795,10 @@ export const RoofEditSystem = () => {
65
1795
  // reveal the wrapper so handle portals into the segment mesh become
66
1796
  // visible. Merged stays on.
67
1797
  const revealRoofIds = new Set<string>()
1798
+ // Roofs whose selected segment is currently being moved in 3D. During this
1799
+ // transient state we reveal the wrapper so the moving segment mesh is
1800
+ // visible and hide the merged roof to avoid the duplicate shell fighting it.
1801
+ const movingRoofIds = new Set<string>()
68
1802
 
69
1803
  for (const id of selectedIds) {
70
1804
  const node = nodes[id as AnyNodeId]
@@ -84,11 +1818,17 @@ export const RoofEditSystem = () => {
84
1818
  }
85
1819
  }
86
1820
 
1821
+ if (movingNode?.type === 'roof-segment' && movingNode.parentId) {
1822
+ movingRoofIds.add(movingNode.parentId)
1823
+ }
1824
+
87
1825
  // Union of roofs that need ANY state change this tick.
88
1826
  const roofIdsToUpdate = new Set([
89
1827
  ...activeRoofIds,
1828
+ ...movingRoofIds,
90
1829
  ...revealRoofIds,
91
1830
  ...prevActiveRoofIds.current,
1831
+ ...prevMovingRoofIds.current,
92
1832
  ...prevRevealRoofIds.current,
93
1833
  ])
94
1834
 
@@ -99,19 +1839,28 @@ export const RoofEditSystem = () => {
99
1839
  const mergedMesh = group.getObjectByName('merged-roof')
100
1840
  const segmentsWrapper = group.getObjectByName('segments-wrapper')
101
1841
  const isActive = activeRoofIds.has(roofId)
1842
+ const isMoving = movingRoofIds.has(roofId)
102
1843
  const isReveal = revealRoofIds.has(roofId)
103
1844
 
104
- if (mergedMesh) mergedMesh.visible = !isActive
105
- if (segmentsWrapper) segmentsWrapper.visible = isActive || isReveal
1845
+ // Keep the clean merged shell visible during trim editing too (not just
1846
+ // when deselected). The merged shell rebuilds live from each segment's
1847
+ // trim override (RoofSystem reads getEffectiveNode), so the dragged
1848
+ // cutaway matches the commit. Showing the individual per-segment meshes
1849
+ // instead would expose their abutting end-cap faces (the white planes the
1850
+ // merged union removes) — exactly what the commit doesn't show.
1851
+ if (mergedMesh) mergedMesh.visible = !isMoving
1852
+ if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving
106
1853
 
107
1854
  const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
108
1855
  if (roofNode?.children?.length) {
109
1856
  const wasActive = prevActiveRoofIds.current.has(roofId)
1857
+ const wasMoving = prevMovingRoofIds.current.has(roofId)
110
1858
  const wasReveal = prevRevealRoofIds.current.has(roofId)
111
- if (isActive !== wasActive) {
1859
+ if (isActive !== wasActive || isMoving !== wasMoving) {
112
1860
  // Entering / exiting full edit mode: rebuild segment / merged
113
- // geometries. Accessory-reveal doesn't need this segments
114
- // keep their placeholder; only their visibility flips.
1861
+ // geometries. Segment-move reveal uses the same rebuild so any
1862
+ // wrapper mesh previously stripped to an empty placeholder is
1863
+ // restored before the drag begins.
115
1864
  const { markDirty } = useScene.getState()
116
1865
  for (const childId of roofNode.children) {
117
1866
  markDirty(childId as AnyNodeId)
@@ -136,8 +1885,14 @@ export const RoofEditSystem = () => {
136
1885
  }
137
1886
 
138
1887
  prevActiveRoofIds.current = activeRoofIds
1888
+ prevMovingRoofIds.current = movingRoofIds
139
1889
  prevRevealRoofIds.current = revealRoofIds
140
- }, [selectedIds])
1890
+ }, [movingNode, selectedIds])
141
1891
 
142
- return null
1892
+ return (
1893
+ <>
1894
+ <HoveredRoofSegmentOutlineProxy />
1895
+ <RoofTrimHandles />
1896
+ </>
1897
+ )
143
1898
  }