@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,21 +1,14 @@
1
1
  import {
2
- type AnyNode,
3
2
  type AnyNodeId,
4
3
  DEFAULT_ANGLE_STEP,
5
- DEFAULT_LEVEL_HEIGHT,
6
- type DoorNode,
7
- GROUND_SUPPORT_ID,
8
- getScaledDimensions,
9
- type ItemNode,
10
- resolveWallSupportSlabPatch,
4
+ planWallInsertion,
5
+ planWallSplitAtPoint,
6
+ resolveWallConstruction,
11
7
  runAsSingleSceneHistoryStep,
12
8
  snapPointAlongAngleRay,
13
- spatialGridManager,
14
- terrainSupportLift,
15
9
  useScene,
10
+ type WallConstructionOptions,
16
11
  type WallNode,
17
- WallNode as WallSchema,
18
- type WindowNode,
19
12
  } from '@pascal-app/core'
20
13
  import { useViewer } from '@pascal-app/viewer'
21
14
  import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -25,7 +18,6 @@ import {
25
18
  distanceSquared,
26
19
  findWallSnapTarget,
27
20
  findWallSpecialPointSnap,
28
- projectPointOntoWall,
29
21
  WALL_CONNECT_SNAP_RADIUS,
30
22
  WALL_JOIN_SNAP_RADIUS,
31
23
  type WallDraftSnapResult,
@@ -49,17 +41,6 @@ export {
49
41
 
50
42
  export const WALL_GRID_STEP = 0.5
51
43
  export const WALL_MIN_LENGTH = 0.01
52
- // An endpoint projecting within this distance of an existing wall's corner
53
- // resolves to the corner without splitting — splitting there would mint a
54
- // sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius
55
- // can ever target again.
56
- const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
57
-
58
- type WallSplitIntersection = {
59
- /** `null` = snap-only outcome: resolve to `point` but split no wall. */
60
- wallId: WallNode['id'] | null
61
- point: WallPlanPoint
62
- }
63
44
 
64
45
  export function getSegmentGridStep(): number {
65
46
  // A 0 step means "no grid lattice" — every grid-snap consumer guards on
@@ -78,283 +59,6 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
78
59
  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
79
60
  }
80
61
 
81
- function splitWallAtPoint(
82
- wall: WallNode,
83
- splitPoint: WallPlanPoint,
84
- nodes: ReturnType<typeof useScene.getState>['nodes'],
85
- ): [WallNode, WallNode] {
86
- const { id: _id, parentId: _parentId, children, ...rest } = wall
87
-
88
- const first = WallSchema.parse({
89
- ...rest,
90
- start: wall.start,
91
- end: splitPoint,
92
- children: [],
93
- })
94
- const second = WallSchema.parse({
95
- ...rest,
96
- start: splitPoint,
97
- end: wall.end,
98
- children: [],
99
- })
100
-
101
- if (wall.supportSlabId !== GROUND_SUPPORT_ID || !wall.parentId) {
102
- return [first, second]
103
- }
104
-
105
- const levelId = wall.parentId
106
- const originalElevation =
107
- (terrainSupportLift(nodes, levelId, wall.start[0], wall.start[1]) ?? 0) +
108
- (wall.supportOffset ?? 0)
109
- const rebase = (segment: WallNode): WallNode => {
110
- const terrainElevation =
111
- terrainSupportLift(nodes, levelId, segment.start[0], segment.start[1]) ?? 0
112
- const supportOffset = originalElevation - terrainElevation
113
- return {
114
- ...segment,
115
- supportOffset: Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
116
- }
117
- }
118
- return [rebase(first), rebase(second)]
119
- }
120
-
121
- function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
122
- return distanceSquared(a, b) <= tolerance * tolerance
123
- }
124
-
125
- function findWallIntersection(
126
- point: WallPlanPoint,
127
- walls: WallNode[],
128
- radius: number,
129
- ignoreWallIds?: string[],
130
- ): WallSplitIntersection | null {
131
- const ignore = new Set(ignoreWallIds ?? [])
132
- let best: WallSplitIntersection | null = null
133
- let bestDistanceSquared = Number.POSITIVE_INFINITY
134
-
135
- for (const wall of walls) {
136
- if (ignore.has(wall.id)) continue
137
-
138
- const projected = projectPointOntoWall(point, wall)
139
- if (!projected) continue
140
-
141
- const candidateDistanceSquared = distanceSquared(point, projected)
142
- if (
143
- candidateDistanceSquared > radius * radius ||
144
- candidateDistanceSquared >= bestDistanceSquared
145
- ) {
146
- continue
147
- }
148
-
149
- const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find(
150
- (corner) =>
151
- distanceSquared(projected, corner) <=
152
- WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON,
153
- )
154
- best = nearCorner
155
- ? { wallId: null, point: [nearCorner[0], nearCorner[1]] }
156
- : { wallId: wall.id, point: projected }
157
- bestDistanceSquared = candidateDistanceSquared
158
- }
159
-
160
- return best
161
- }
162
-
163
- function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
164
- if ((wall.children?.length ?? 0) > 0) {
165
- return true
166
- }
167
-
168
- return Object.values(nodes).some((node) => {
169
- if (!node) return false
170
- if ('parentId' in node && node.parentId === wall.id) return true
171
- if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
172
- return false
173
- })
174
- }
175
-
176
- function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
177
- return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
178
- }
179
-
180
- function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null {
181
- if (node.type === 'door') {
182
- const door = node as DoorNode
183
- return {
184
- min: door.position[0] - door.width / 2,
185
- max: door.position[0] + door.width / 2,
186
- center: door.position[0],
187
- }
188
- }
189
-
190
- if (node.type === 'window') {
191
- const win = node as WindowNode
192
- return {
193
- min: win.position[0] - win.width / 2,
194
- max: win.position[0] + win.width / 2,
195
- center: win.position[0],
196
- }
197
- }
198
-
199
- if (node.type === 'item') {
200
- const item = node as ItemNode
201
- if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') {
202
- return null
203
- }
204
-
205
- const [width] = getScaledDimensions(item)
206
- return {
207
- min: item.position[0] - width / 2,
208
- max: item.position[0] + width / 2,
209
- center: item.position[0],
210
- }
211
- }
212
-
213
- return null
214
- }
215
-
216
- function remapAttachmentToWall(
217
- node: AnyNode,
218
- nextWallId: WallNode['id'],
219
- nextLocalX: number,
220
- nextWallLength: number,
221
- ): Partial<AnyNode> | null {
222
- const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX))
223
-
224
- if (node.type === 'door' || node.type === 'window' || node.type === 'item') {
225
- const currentPosition = 'position' in node ? node.position : null
226
- if (!currentPosition) return null
227
-
228
- const nextPosition: typeof currentPosition = [
229
- clampedX,
230
- currentPosition[1],
231
- currentPosition[2],
232
- ] as typeof currentPosition
233
-
234
- return {
235
- parentId: nextWallId,
236
- position: nextPosition,
237
- ...(node.type === 'item'
238
- ? {
239
- wallId: nextWallId,
240
- wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0,
241
- }
242
- : {
243
- wallId: nextWallId,
244
- }),
245
- } as Partial<AnyNode>
246
- }
247
-
248
- return null
249
- }
250
-
251
- function buildAttachmentMigrationPlan(
252
- wall: WallNode,
253
- splitPoint: WallPlanPoint,
254
- firstWall: WallNode,
255
- secondWall: WallNode,
256
- nodes: ReturnType<typeof useScene.getState>['nodes'],
257
- ): { id: AnyNodeId; data: Partial<AnyNode> }[] | null {
258
- const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1])
259
- const firstLength = wallLength(firstWall)
260
- const secondLength = wallLength(secondWall)
261
- const tolerance = 1e-4
262
- const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
263
-
264
- for (const childId of wall.children ?? []) {
265
- const childNode = nodes[childId as AnyNodeId]
266
- if (!childNode) continue
267
-
268
- const span = getWallAttachmentSpan(childNode)
269
- if (!span) {
270
- return null
271
- }
272
-
273
- if (span.max <= splitDistance + tolerance) {
274
- const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength)
275
- if (!nextUpdate) return null
276
- updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
277
- continue
278
- }
279
-
280
- if (span.min >= splitDistance - tolerance) {
281
- const nextUpdate = remapAttachmentToWall(
282
- childNode,
283
- secondWall.id,
284
- span.center - splitDistance,
285
- secondLength,
286
- )
287
- if (!nextUpdate) return null
288
- updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
289
- continue
290
- }
291
-
292
- return null
293
- }
294
-
295
- return updates
296
- }
297
-
298
- function splitWallIfNeeded(
299
- intersection: WallSplitIntersection | null,
300
- walls: WallNode[],
301
- nodes: ReturnType<typeof useScene.getState>['nodes'],
302
- createNodes: ReturnType<typeof useScene.getState>['createNodes'],
303
- updateNodes: ReturnType<typeof useScene.getState>['updateNodes'],
304
- deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
305
- ): { walls: WallNode[]; point: WallPlanPoint } | null {
306
- if (!intersection) return null
307
-
308
- if (!intersection.wallId) {
309
- return { walls, point: intersection.point }
310
- }
311
-
312
- const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
313
- if (!wallToSplit) {
314
- return { walls, point: intersection.point }
315
- }
316
-
317
- const [first, second] = splitWallAtPoint(wallToSplit, intersection.point, nodes)
318
- const attachmentUpdates = buildAttachmentMigrationPlan(
319
- wallToSplit,
320
- intersection.point,
321
- first,
322
- second,
323
- nodes,
324
- )
325
-
326
- if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) {
327
- return { walls, point: intersection.point }
328
- }
329
-
330
- createNodes([
331
- { node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
332
- { node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
333
- ])
334
- if (attachmentUpdates && attachmentUpdates.length > 0) {
335
- updateNodes(attachmentUpdates)
336
- }
337
- deleteNode(wallToSplit.id as AnyNodeId)
338
-
339
- return {
340
- walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
341
- point: intersection.point,
342
- }
343
- }
344
-
345
- /**
346
- * Commit-time split resolution for an endpoint MOVE — the sibling of the
347
- * inline resolution in `createWallOnCurrentLevel`: when a moved endpoint is
348
- * dropped on another wall's interior, split that host exactly like the draw
349
- * path (duplicate props, migrate attachments by span, skip the split when an
350
- * opening straddles the point). Mutates the scene store (create halves /
351
- * migrate attachments / delete host), so callers MUST run it inside the same
352
- * `runAsSingleSceneHistoryStep` as their endpoint write.
353
- *
354
- * Returns the resolved endpoint (projection onto the host, or a nearby corner
355
- * when the drop is within `WALL_SPLIT_ENDPOINT_EPSILON` of one — corner joins
356
- * are not splits), or `null` when the point lands on no wall.
357
- */
358
62
  export function resolveEndpointWallSplit(args: {
359
63
  point: WallPlanPoint
360
64
  /** Level the moved wall lives on — only its walls are split candidates. */
@@ -369,14 +73,23 @@ export function resolveEndpointWallSplit(args: {
369
73
  radius?: number
370
74
  }): WallPlanPoint | null {
371
75
  const { point, levelId, ignoreWallIds, radius = WALL_CONNECT_SNAP_RADIUS } = args
372
- const { nodes, createNodes, updateNodes, deleteNode } = useScene.getState()
373
- const walls = Object.values(nodes).filter(
374
- (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
375
- )
376
-
377
- const intersection = findWallIntersection(point, walls, radius, ignoreWallIds)
378
- const split = splitWallIfNeeded(intersection, walls, nodes, createNodes, updateNodes, deleteNode)
379
- return split ? split.point : null
76
+ const { nodes, applyNodeChanges } = useScene.getState()
77
+ const result = planWallSplitAtPoint(nodes, {
78
+ point,
79
+ levelId: levelId as AnyNodeId | null,
80
+ ignoreWallIds,
81
+ radius,
82
+ })
83
+ if (!result.ok) return null
84
+ const { plan } = result
85
+ if (
86
+ plan.changes.create.length > 0 ||
87
+ plan.changes.update.length > 0 ||
88
+ plan.changes.delete.length > 0
89
+ ) {
90
+ applyNodeChanges(plan.changes)
91
+ }
92
+ return plan.point
380
93
  }
381
94
 
382
95
  type SnapWallDraftArgs = {
@@ -492,176 +205,56 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b
492
205
  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
493
206
  }
494
207
 
495
- export type WallConstructionOptions = {
496
- /** Pointer-decided maximum support elevation in level-local metres. */
497
- supportCap?: number | null
498
- /** Support source selected by the first click or inherited from a snapped wall. */
499
- preferredSupportSlabId?: string | null
500
- /** Frozen level-local Y shown by the draft ghost. */
501
- constructionElevation?: number | null
502
- /** Height shown by the draft ghost. */
503
- constructionHeight?: number | null
504
- }
505
-
506
- export function resolveTerrainWallConstructionOptions(
507
- nodes: Record<string, AnyNode>,
508
- levelId: string,
509
- point: WallPlanPoint,
510
- defaults?: Record<string, unknown>,
511
- ): WallConstructionOptions | undefined {
512
- const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1])
513
- if (constructionElevation == null) return undefined
514
-
515
- const level = nodes[levelId]
516
- const constructionHeight =
517
- typeof defaults?.height === 'number'
518
- ? defaults.height
519
- : level?.type === 'level'
520
- ? (level.height ?? DEFAULT_LEVEL_HEIGHT)
521
- : DEFAULT_LEVEL_HEIGHT
522
-
523
- return {
524
- constructionElevation,
525
- constructionHeight,
526
- supportCap: constructionElevation,
527
- }
528
- }
529
-
530
208
  export function createWallOnCurrentLevel(
531
209
  start: WallPlanPoint,
532
210
  end: WallPlanPoint,
533
211
  options?: WallConstructionOptions,
534
212
  ): WallNode | null {
535
213
  const currentLevelId = useViewer.getState().selection.levelId
536
- const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
537
- const { updateNodes } = useScene.getState()
214
+ const { nodes, applyNodeChanges } = useScene.getState()
538
215
 
539
216
  if (!(currentLevelId && isSegmentLongEnough(start, end))) {
540
217
  return null
541
218
  }
542
219
 
543
- let workingWalls = Object.values(nodes).filter(
544
- (node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
545
- )
546
-
547
- let resolvedStart = start
548
- let resolvedEnd = end
549
-
550
- // The corner-join / wall-split resolution follows the snapping mode like the
551
- // draft preview does: magnetic ('lines') keeps the generous join radius,
552
- // every other mode uses the same tight connect radius the draft path already
553
- // sticks endpoints with. So an endpoint the user saw connect to a wall body
554
- // actually splits that wall (and redistributes its attachments) in every
555
- // mode, while `'off'` / `'angles'` gain no residual long-range snap.
556
220
  const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
557
221
 
558
- // One undo step for the whole commit: the split ops (create halves, migrate
559
- // attachments, delete host) plus the new wall each push their own history
560
- // entry, and a single Ctrl-Z must not strand a half-split wall network.
561
222
  return runAsSingleSceneHistoryStep(useScene, () => {
562
- const endIntersection = findWallIntersection(resolvedEnd, workingWalls, joinRadius)
563
- const splitEnd = splitWallIfNeeded(
564
- endIntersection,
565
- workingWalls,
566
- nodes,
567
- createNodes,
568
- updateNodes,
569
- deleteNode,
570
- )
571
- if (splitEnd) {
572
- workingWalls = splitEnd.walls
573
- resolvedEnd = splitEnd.point
574
- }
575
-
576
- const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius)
577
- const splitStart = splitWallIfNeeded(
578
- startIntersection,
579
- workingWalls,
580
- nodes,
581
- createNodes,
582
- updateNodes,
583
- deleteNode,
584
- )
585
- if (splitStart) {
586
- workingWalls = splitStart.walls
587
- resolvedStart = splitStart.point
588
- }
589
-
590
- if (
591
- !isSegmentLongEnough(resolvedStart, resolvedEnd) ||
592
- pointsEqual(resolvedStart, resolvedEnd)
593
- ) {
594
- return null
595
- }
596
-
597
- const duplicateWall = workingWalls.some(
598
- (wall) =>
599
- (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
600
- (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
601
- )
602
- if (duplicateWall) {
603
- return null
604
- }
605
-
606
- const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
607
- // A placed wall preset seeds `toolDefaults.wall` (thickness, height,
608
- // materials, sides) before the tool activates; merge those first so the
609
- // drawn wall reproduces the preset. Identity + endpoints always win.
610
- const defaults = useEditor.getState().toolDefaults.wall ?? {}
611
- const wall = WallSchema.parse({
612
- ...defaults,
613
- name: `Wall ${wallCount + 1}`,
614
- start: resolvedStart,
615
- end: resolvedEnd,
223
+ const result = planWallInsertion(nodes, {
224
+ levelId: currentLevelId as AnyNodeId,
225
+ start,
226
+ end,
227
+ joinRadius,
228
+ wallDefaults: useEditor.getState().toolDefaults.wall ?? {},
229
+ })
230
+ if (!result.ok) return null
231
+ const { plan } = result
232
+
233
+ const construction = resolveWallConstruction(nodes, currentLevelId, plan.insertedWalls, options)
234
+ const finalizedWalls = construction.walls
235
+ const finalizedWallsById = new Map(finalizedWalls.map((wall) => [wall.id, wall]))
236
+ const sourceUpdate = construction.sourceSupportUpdate
237
+ const sourceAlreadyUpdated = sourceUpdate
238
+ ? plan.changes.update.some((operation) => operation.id === sourceUpdate.id)
239
+ : false
240
+ applyNodeChanges({
241
+ ...plan.changes,
242
+ update: plan.changes.update
243
+ .map((operation) =>
244
+ sourceUpdate?.id === operation.id
245
+ ? { ...operation, data: { ...operation.data, ...sourceUpdate.data } }
246
+ : operation,
247
+ )
248
+ .concat(sourceUpdate && !sourceAlreadyUpdated ? [sourceUpdate] : []),
249
+ create: plan.changes.create.map((operation) => ({
250
+ ...operation,
251
+ node: finalizedWallsById.get(operation.node.id as WallNode['id']) ?? operation.node,
252
+ })),
616
253
  })
617
-
618
- createNode(wall, currentLevelId)
619
- const createdWall = useScene.getState().nodes[wall.id]
620
- if (createdWall?.type === 'wall') {
621
- const terrainBase = terrainSupportLift(
622
- useScene.getState().nodes,
623
- currentLevelId,
624
- createdWall.start[0],
625
- createdWall.start[1],
626
- )
627
- const preferredSupportSlabId =
628
- options?.preferredSupportSlabId ??
629
- (options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null)
630
- const supportPatch = resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, {
631
- maxElevation: options?.supportCap ?? null,
632
- preferredSlabId: preferredSupportSlabId,
633
- })
634
- const supportSlabId = supportPatch.supportSlabId
635
- const sourceSupport = spatialGridManager.getSlabSupportForWall(
636
- currentLevelId,
637
- createdWall.start,
638
- createdWall.end,
639
- createdWall.curveOffset,
640
- createdWall.thickness,
641
- supportSlabId,
642
- options?.supportCap ?? null,
643
- )
644
- const supportOffset =
645
- options?.constructionElevation == null
646
- ? undefined
647
- : options.constructionElevation - sourceSupport.elevation
648
- const preserveDraftHeight =
649
- createdWall.height == null &&
650
- options?.constructionHeight != null &&
651
- options.constructionElevation != null &&
652
- (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6)
653
- useScene.getState().updateNode(createdWall.id, {
654
- ...supportPatch,
655
- height: preserveDraftHeight
656
- ? (options?.constructionHeight ?? createdWall.height)
657
- : createdWall.height,
658
- supportOffset:
659
- supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
660
- })
661
- }
662
254
  sfxEmitter.emit('sfx:structure-build')
663
255
 
664
- const committedWall = useScene.getState().nodes[wall.id]
665
- return committedWall?.type === 'wall' ? committedWall : wall
256
+ const terminalWall = finalizedWalls.at(-1)!
257
+ const committedWall = useScene.getState().nodes[plan.terminalWallId]
258
+ return committedWall?.type === 'wall' ? committedWall : terminalWall
666
259
  })
667
260
  }
@@ -54,7 +54,7 @@ export function ControlModes() {
54
54
  const mode = useEditor((state) => state.mode)
55
55
  const phase = useEditor((state) => state.phase)
56
56
  const selectionTool = useEditor((state) => state.floorplanSelectionTool)
57
- const setMode = useEditor((state) => state.setMode)
57
+ const armToolMode = useEditor((state) => state.armToolMode)
58
58
  const setPhase = useEditor((state) => state.setPhase)
59
59
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
60
60
  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
@@ -82,21 +82,21 @@ export function ControlModes() {
82
82
  }
83
83
 
84
84
  if (id === 'select') {
85
- setMode('select')
85
+ armToolMode({ mode: 'select' })
86
86
  setSelectionTool('click')
87
87
  } else if (id === 'box-select') {
88
- setMode('select')
88
+ armToolMode({ mode: 'select' })
89
89
  setSelectionTool('marquee')
90
90
  } else if (id === 'zone') {
91
91
  if (getIsActive('zone')) {
92
- setMode('select')
92
+ armToolMode({ mode: 'select' })
93
93
  } else {
94
94
  setPhase('structure')
95
95
  setStructureLayer('zones')
96
- setMode('build')
96
+ armToolMode({ mode: 'build', tool: 'zone' })
97
97
  }
98
98
  } else {
99
- setMode(id)
99
+ armToolMode({ mode: id })
100
100
  }
101
101
  }
102
102
 
@@ -118,6 +118,9 @@ export function ControlModes() {
118
118
  isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
119
119
  isImageMode && !isActive && 'hover:bg-white/5',
120
120
  )}
121
+ // A static hook for a host app that wants to point a first-run
122
+ // tour at this button. Nothing here reads it.
123
+ data-guide-target={c.id === 'select' ? 'mode-select' : undefined}
121
124
  label={c.label}
122
125
  onClick={() => handleClick(c.id)}
123
126
  shortcut={c.shortcut}
@@ -1,10 +1,12 @@
1
1
  'use client'
2
2
 
3
+ import { useScene } from '@pascal-app/core'
3
4
  import { useViewer } from '@pascal-app/viewer'
4
5
  import { motion } from 'motion/react'
5
6
  import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
6
7
  import { useIsMobile } from './../../../hooks/use-mobile'
7
8
  import { useReducedMotion } from './../../../hooks/use-reduced-motion'
9
+ import { shouldShowEditingControls } from './../../../lib/interaction/overlay-policy'
8
10
  import { cn } from './../../../lib/utils'
9
11
  import useEditor from './../../../store/use-editor'
10
12
  import { CameraActions } from './camera-actions'
@@ -18,6 +20,7 @@ const MOBILE_BOTTOM_OFFSET = 24
18
20
 
19
21
  export function ActionMenu({ className }: { className?: string }) {
20
22
  const isMobile = useIsMobile()
23
+ const readOnly = useScene((s) => s.readOnly)
21
24
  const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
22
25
  const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
23
26
  const CONTEXTUAL_TABS = new Set(['ai', 'items', 'studio'])
@@ -31,7 +34,14 @@ export function ActionMenu({ className }: { className?: string }) {
31
34
  // Also hide on Chat / Items / Studio tabs; those are contextual workflows
32
35
  // (composing / picking furniture / generating renders) where the build
33
36
  // menu is irrelevant.
34
- if (hasSelectionOnMobile || hasReferenceOnMobile || isContextualPanelOnMobile) return null
37
+ if (
38
+ !shouldShowEditingControls(readOnly) ||
39
+ hasSelectionOnMobile ||
40
+ hasReferenceOnMobile ||
41
+ isContextualPanelOnMobile
42
+ ) {
43
+ return null
44
+ }
35
45
 
36
46
  const transition = reducedMotion
37
47
  ? { duration: 0 }