@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
@@ -6,19 +6,28 @@ import type {
6
6
  GridEvent,
7
7
  ItemEvent,
8
8
  ItemNode,
9
+ RoofEvent,
10
+ RoofNode,
11
+ RoofSegmentNode,
12
+ RoofWallFaceId,
9
13
  ShelfEvent,
10
14
  ShelfNode,
11
15
  WallEvent,
12
16
  WallNode,
13
17
  } from '@pascal-app/core'
14
18
  import {
19
+ canHostOnTop,
20
+ clampRectToRoofWallFace,
21
+ getRoofSegmentWallFace,
15
22
  getScaledDimensions,
16
23
  isLowProfileItemSurface,
17
24
  nodeRegistry,
25
+ roofFacePointToSegment,
18
26
  sceneRegistry,
19
27
  useScene,
20
28
  } from '@pascal-app/core'
21
29
  import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
30
+ import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
22
31
  import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
23
32
  import {
24
33
  calculateCursorRotation,
@@ -56,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
56
65
  }
57
66
 
58
67
  function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
68
+ if (!canHostOnTop(surfaceItem)) return null
59
69
  if (isLowProfileItemSurface(surfaceItem)) return null
60
70
  if (!isUpwardItemSurfaceHit(event)) return null
61
71
 
@@ -105,10 +115,13 @@ export const floorStrategy = {
105
115
  // is rotated; then project the world point back into building-local
106
116
  // for storage. Without this, a rotated building drags placement off
107
117
  // the world grid.
108
- const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
109
- const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
110
- const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
111
- const [x, z] = local
118
+ // Snapping is governed by the active mode (snapToGrid returns raw in Off /
119
+ // non-grid modes); Alt is force-place only and never bypasses snapping here.
120
+ const [x, z] = snapWorldXZForActiveBuilding(
121
+ snapToGrid(event.position[0], swapDims ? dimZ : dimX),
122
+ snapToGrid(event.position[2], swapDims ? dimX : dimZ),
123
+ 0,
124
+ ).local
112
125
  const y = ctx.gridPosition.y
113
126
 
114
127
  return {
@@ -132,6 +145,7 @@ export const floorStrategy = {
132
145
  ): CommitResult | null {
133
146
  if (ctx.state.surface !== 'floor') return null
134
147
  if (!(ctx.levelId && ctx.draftItem)) return null
148
+ if (ctx.draftItem.asset.attachTo) return null
135
149
 
136
150
  const pos: [number, number, number] = [
137
151
  ctx.gridPosition.x,
@@ -211,10 +225,13 @@ export const wallStrategy = {
211
225
  const adjustedY = validation.adjustedY ?? y
212
226
 
213
227
  return {
214
- stateUpdate: { surface: 'wall', wallId: event.node.id },
228
+ stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
215
229
  nodeUpdate: {
216
230
  position: [x, adjustedY, z],
217
231
  parentId: event.node.id,
232
+ // The draft may arrive from a roof-segment wall face.
233
+ roofSegmentId: undefined,
234
+ roofFace: undefined,
218
235
  side,
219
236
  rotation: [0, itemRotation, 0],
220
237
  },
@@ -313,6 +330,8 @@ export const wallStrategy = {
313
330
  nodeUpdate: {
314
331
  position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
315
332
  parentId: event.node.id,
333
+ roofSegmentId: undefined,
334
+ roofFace: undefined,
316
335
  side: ctx.draftItem.side,
317
336
  rotation: ctx.draftItem.rotation,
318
337
  metadata: stripTransient(ctx.draftItem.metadata),
@@ -342,6 +361,225 @@ export const wallStrategy = {
342
361
  },
343
362
  }
344
363
 
364
+ // ============================================================================
365
+ // ROOF WALL STRATEGY
366
+ // ============================================================================
367
+
368
+ type RoofWallTarget = {
369
+ segment: RoofSegmentNode
370
+ faceId: RoofWallFaceId
371
+ faceYaw: number
372
+ /** Stored node position: FACE-LOCAL, y = bottom edge. */
373
+ position: [number, number, number]
374
+ /** Face-coord center of the placed rect (for the overlap guard). */
375
+ centerU: number
376
+ centerV: number
377
+ width: number
378
+ height: number
379
+ cursorPosition: [number, number, number]
380
+ cursorRotationY: number
381
+ }
382
+
383
+ /**
384
+ * Resolve a roof pointer event to an item placement on a segment wall
385
+ * face. Items snap u / bottom-v to the 0.5m grid, then the rect is
386
+ * clamped inside the face profile (sliding under the gable slopes).
387
+ * Position frame matches wall hosting: y anchors the BOTTOM edge;
388
+ * `wall-side` items mount on the outer surface, `wall` items center in
389
+ * the wall thickness.
390
+ *
391
+ * `freePlace` mirrors the wall flow's Alt override (stubbed
392
+ * validators): the profile clamp is skipped, so the rect may overhang
393
+ * the face edges — placement follows the snapped cursor as-is.
394
+ */
395
+ function resolveRoofWallTarget(
396
+ ctx: PlacementContext,
397
+ event: RoofEvent,
398
+ freePlace = false,
399
+ ): RoofWallTarget | null {
400
+ const attachTo = ctx.asset.attachTo
401
+ if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
402
+
403
+ const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
404
+ if (!hit) return null
405
+
406
+ const rawDims = ctx.draftItem
407
+ ? getScaledDimensions(ctx.draftItem)
408
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
409
+ const dims = getGridAlignedDimensions(rawDims, attachTo)
410
+ const [width, height] = dims
411
+
412
+ // Snap follows the active mode (snapToHalf returns raw in Off/non-grid);
413
+ // `freePlace` (Alt) is force-place — it only skips the face-fit validity gate.
414
+ const u = snapToHalf(hit.u)
415
+ const centerV = snapToHalf(hit.v) + height / 2
416
+ const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
417
+ if (!fitted && !freePlace) return null
418
+ const finalU = fitted?.u ?? u
419
+ const finalV = fitted?.v ?? centerV
420
+
421
+ // FACE-LOCAL storage (z = 0 → wall mid-plane; ItemSystem pushes
422
+ // wall-side items to the outer surface, exactly like wall hosting).
423
+ // The renderer mounts the node inside the live face frame, so items
424
+ // track segment resizes without any re-anchoring.
425
+ const position: [number, number, number] = [finalU, finalV - height / 2, 0]
426
+
427
+ const segObj = sceneRegistry.nodes.get(hit.segment.id)
428
+ if (!segObj) return null
429
+ segObj.updateWorldMatrix(true, false)
430
+ const segLocal = roofFacePointToSegment(hit.segment, hit.face.id, position)
431
+ const worldPos = segObj.localToWorld(new Vector3(segLocal[0], segLocal[1], segLocal[2]))
432
+
433
+ const nodes = useScene.getState().nodes
434
+ const roof = hit.segment.parentId
435
+ ? (nodes[hit.segment.parentId as AnyNodeId] as RoofNode | undefined)
436
+ : undefined
437
+
438
+ return {
439
+ segment: hit.segment,
440
+ faceId: hit.face.id,
441
+ faceYaw: hit.face.yaw,
442
+ position,
443
+ centerU: finalU,
444
+ centerV: finalV,
445
+ width,
446
+ height,
447
+ cursorPosition: [worldPos.x, worldPos.y, worldPos.z],
448
+ cursorRotationY: (roof?.rotation ?? 0) + (hit.segment.rotation ?? 0) + hit.face.yaw,
449
+ }
450
+ }
451
+
452
+ /** Validation half of `checkCanPlace` for the roof-wall surface. */
453
+ function canPlaceOnRoofWall(ctx: PlacementContext): boolean {
454
+ const segmentId = ctx.state.roofSegmentId
455
+ if (!(segmentId && ctx.draftItem)) return false
456
+ const segment = useScene.getState().nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
457
+ if (segment?.type !== 'roof-segment') return false
458
+ const faceId = ctx.draftItem.roofFace
459
+ if (!faceId) return false
460
+ const face = getRoofSegmentWallFace(segment, faceId)
461
+
462
+ const dims = getGridAlignedDimensions(
463
+ getScaledDimensions(ctx.draftItem),
464
+ ctx.draftItem.asset.attachTo,
465
+ )
466
+ const [width, height] = dims
467
+ // gridPosition carries the stored FACE-LOCAL coords (u, bottom-v, z).
468
+ const u = ctx.gridPosition.x
469
+ const centerV = ctx.gridPosition.y + height / 2
470
+ const clamped = clampRectToRoofWallFace(face, u, centerV, width, height)
471
+ if (!clamped || Math.abs(clamped.u - u) > 1e-3 || Math.abs(clamped.v - centerV) > 1e-3) {
472
+ return false
473
+ }
474
+ return !hasRoofFaceChildOverlap(segment, faceId, u, centerV, width, height, ctx.draftItem.id)
475
+ }
476
+
477
+ export const roofWallStrategy = {
478
+ /**
479
+ * Handle roof:enter / first hover — transition onto a segment wall
480
+ * face. Returns null when the item doesn't wall-attach or the pointer
481
+ * isn't over a placeable face.
482
+ */
483
+ enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
484
+ const target = resolveRoofWallTarget(ctx, event, freePlace)
485
+ if (!target) return null
486
+
487
+ return {
488
+ stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null },
489
+ nodeUpdate: {
490
+ position: target.position,
491
+ parentId: target.segment.id,
492
+ roofSegmentId: target.segment.id,
493
+ roofFace: target.faceId,
494
+ wallId: undefined,
495
+ side: 'front',
496
+ rotation: [0, 0, 0],
497
+ },
498
+ cursorRotationY: target.cursorRotationY,
499
+ gridPosition: target.position,
500
+ cursorPosition: target.cursorPosition,
501
+ stopPropagation: true,
502
+ }
503
+ },
504
+
505
+ /**
506
+ * Handle roof:move while on a segment wall face. Returns null when the
507
+ * pointer resolves to a DIFFERENT segment (the coordinator re-enters —
508
+ * segment transitions inside one roof never re-fire roof:enter) or to
509
+ * no placeable face.
510
+ */
511
+ move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
512
+ if (ctx.state.surface !== 'roof-wall') return null
513
+ if (!ctx.draftItem) return null
514
+
515
+ const target = resolveRoofWallTarget(ctx, event, freePlace)
516
+ if (!target) return null
517
+ if (target.segment.id !== ctx.state.roofSegmentId) return null
518
+
519
+ return {
520
+ gridPosition: target.position,
521
+ cursorPosition: target.cursorPosition,
522
+ cursorRotationY: target.cursorRotationY,
523
+ nodeUpdate: {
524
+ position: target.position,
525
+ side: 'front',
526
+ rotation: [0, 0, 0],
527
+ roofFace: target.faceId,
528
+ },
529
+ stopPropagation: true,
530
+ // Items don't cut the roof — no geometry rebuild needed.
531
+ dirtyNodeId: null,
532
+ }
533
+ },
534
+
535
+ /**
536
+ * Handle roof:click — commit placement on the segment wall face.
537
+ */
538
+ click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
539
+ if (ctx.state.surface !== 'roof-wall') return null
540
+ if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
541
+ // Alt mirrors the wall flow's stubbed validators: skip profile-fit
542
+ // and overlap checks entirely.
543
+ if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
544
+
545
+ return {
546
+ nodeUpdate: {
547
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
548
+ parentId: ctx.state.roofSegmentId,
549
+ roofSegmentId: ctx.state.roofSegmentId,
550
+ roofFace: ctx.draftItem.roofFace,
551
+ wallId: undefined,
552
+ side: 'front',
553
+ rotation: [0, 0, 0],
554
+ metadata: stripTransient(ctx.draftItem.metadata),
555
+ },
556
+ stopPropagation: true,
557
+ dirtyNodeId: null,
558
+ }
559
+ },
560
+
561
+ /**
562
+ * Handle roof:leave — transition back to floor surface.
563
+ */
564
+ leave(ctx: PlacementContext): TransitionResult | null {
565
+ if (ctx.state.surface !== 'roof-wall') return null
566
+
567
+ return {
568
+ stateUpdate: { surface: 'floor', roofSegmentId: null },
569
+ nodeUpdate: {
570
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
571
+ parentId: ctx.levelId,
572
+ roofSegmentId: undefined,
573
+ roofFace: undefined,
574
+ },
575
+ cursorRotationY: 0,
576
+ gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
577
+ cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
578
+ stopPropagation: true,
579
+ }
580
+ },
581
+ }
582
+
345
583
  // ============================================================================
346
584
  // CEILING STRATEGY
347
585
  // ============================================================================
@@ -794,6 +1032,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
794
1032
  }
795
1033
 
796
1034
  if (attachTo === 'wall' || attachTo === 'wall-side') {
1035
+ if (ctx.state.surface === 'roof-wall') {
1036
+ return canPlaceOnRoofWall(ctx)
1037
+ }
797
1038
  if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
798
1039
  return validators.canPlaceOnWall(
799
1040
  ctx.levelId,
@@ -12,7 +12,13 @@ import type { Vector3 } from 'three'
12
12
  // PLACEMENT STATE
13
13
  // ============================================================================
14
14
 
15
- export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
15
+ export type SurfaceType =
16
+ | 'floor'
17
+ | 'wall'
18
+ | 'roof-wall'
19
+ | 'ceiling'
20
+ | 'item-surface'
21
+ | 'shelf-surface'
16
22
 
17
23
  /**
18
24
  * Tracks which surface the draft item is currently on.
@@ -21,6 +27,12 @@ export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf
21
27
  export interface PlacementState {
22
28
  surface: SurfaceType
23
29
  wallId: string | null
30
+ /**
31
+ * Active roof-segment when `surface === 'roof-wall'` — wall-attach
32
+ * items also host on the vertical wall faces a roof segment generates
33
+ * (base walls + coplanar gable ends).
34
+ */
35
+ roofSegmentId: string | null
24
36
  ceilingId: string | null
25
37
  surfaceItemId: string | null
26
38
  /**
@@ -15,6 +15,10 @@ interface OriginalState {
15
15
  rotation: [number, number, number]
16
16
  side: ItemNode['side']
17
17
  parentId: string | null
18
+ // Roof-segment wall hosting — cleared/changed by surface transitions
19
+ // mid-move, so reverts must restore it alongside parentId.
20
+ roofSegmentId: ItemNode['roofSegmentId']
21
+ roofFace: ItemNode['roofFace']
18
22
  metadata: ItemNode['metadata']
19
23
  }
20
24
 
@@ -23,12 +27,14 @@ export interface DraftNodeHandle {
23
27
  readonly current: ItemNode | null
24
28
  /** Whether the current draft was adopted (move mode) vs created (create mode) */
25
29
  readonly isAdopted: boolean
26
- /** Create a new draft item at the given position. Returns the created node or null. */
30
+ /** Create a new draft item at the given position. Returns the created node or null.
31
+ * `slots` seeds painted slot overrides so duplicates keep their materials. */
27
32
  create: (
28
33
  gridPosition: Vector3,
29
34
  asset: AssetInput,
30
35
  rotation?: [number, number, number],
31
36
  scale?: [number, number, number],
37
+ slots?: ItemNode['slots'],
32
38
  ) => ItemNode | null
33
39
  /** Take ownership of an existing scene node as the draft (for move mode). */
34
40
  adopt: (node: ItemNode) => void
@@ -57,6 +63,7 @@ export function useDraftNode(): DraftNodeHandle {
57
63
  asset: AssetInput,
58
64
  rotation?: [number, number, number],
59
65
  scale?: [number, number, number],
66
+ slots?: ItemNode['slots'],
60
67
  ): ItemNode | null => {
61
68
  const currentLevelId = useViewer.getState().selection.levelId
62
69
  if (!currentLevelId) return null
@@ -69,6 +76,7 @@ export function useDraftNode(): DraftNodeHandle {
69
76
  asset,
70
77
  parentId: currentLevelId,
71
78
  metadata: { isTransient: true },
79
+ ...(slots ? { slots } : {}),
72
80
  })
73
81
 
74
82
  useScene.getState().createNode(node, currentLevelId)
@@ -92,6 +100,8 @@ export function useDraftNode(): DraftNodeHandle {
92
100
  rotation: [...node.rotation] as [number, number, number],
93
101
  side: node.side,
94
102
  parentId: node.parentId,
103
+ roofSegmentId: node.roofSegmentId,
104
+ roofFace: node.roofFace,
95
105
  metadata: node.metadata,
96
106
  }
97
107
 
@@ -121,6 +131,8 @@ export function useDraftNode(): DraftNodeHandle {
121
131
  rotation: original.rotation,
122
132
  side: original.side,
123
133
  parentId: original.parentId,
134
+ roofSegmentId: original.roofSegmentId,
135
+ roofFace: original.roofFace,
124
136
  metadata: original.metadata,
125
137
  })
126
138
 
@@ -133,6 +145,15 @@ export function useDraftNode(): DraftNodeHandle {
133
145
  side: updateProps.side ?? draft.side,
134
146
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
135
147
  parentId: parentId as string,
148
+ // Forward the roof host explicitly: strategies set it on every
149
+ // commit (segment id on a roof face, undefined elsewhere), and
150
+ // dropping it here strands the item in the roof frame without
151
+ // the segment transform.
152
+ roofSegmentId: updateProps.roofSegmentId,
153
+ roofFace: updateProps.roofFace,
154
+ // Only when the strategy decided about wallId (roof commits clear
155
+ // it) — floor/ceiling commits never managed the field.
156
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
136
157
  })
137
158
 
138
159
  useScene.temporal.getState().pause()
@@ -163,6 +184,13 @@ export function useDraftNode(): DraftNodeHandle {
163
184
  rotation: updateProps.rotation ?? draft.rotation,
164
185
  scale: updateProps.scale ?? draft.scale,
165
186
  side: updateProps.side ?? draft.side,
187
+ // Carry painted slot overrides so a duplicated item keeps its materials.
188
+ ...(draft.slots ? { slots: draft.slots } : {}),
189
+ // Roof host — see the move-mode commit above for why this must be
190
+ // forwarded explicitly.
191
+ roofSegmentId: updateProps.roofSegmentId,
192
+ roofFace: updateProps.roofFace,
193
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
166
194
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
167
195
  })
168
196
  useScene.getState().createNode(finalNode, parentId)
@@ -207,6 +235,8 @@ export function useDraftNode(): DraftNodeHandle {
207
235
  rotation: original.rotation,
208
236
  side: original.side,
209
237
  parentId: original.parentId,
238
+ roofSegmentId: original.roofSegmentId,
239
+ roofFace: original.roofFace,
210
240
  metadata: original.metadata,
211
241
  })
212
242