@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -6,10 +6,13 @@ import {
6
6
  type AnyNodeId,
7
7
  bboxAnchors,
8
8
  bboxCornerAnchors,
9
+ cascadeDirty,
10
+ collectDescendants,
9
11
  createSceneApi,
10
12
  emitter,
11
13
  type FloorplanMoveTargetSession,
12
14
  type GroupMoveSnapResult,
15
+ getEffectiveNode,
13
16
  type MovableConfig,
14
17
  nodeRegistry,
15
18
  pauseSceneHistory,
@@ -18,8 +21,9 @@ import {
18
21
  useLiveTransforms,
19
22
  useScene,
20
23
  } from '@pascal-app/core'
24
+ import { nodeLevelFrame } from '@pascal-app/core/procedural-items'
21
25
  import { useViewer } from '@pascal-app/viewer'
22
- import { useEffect } from 'react'
26
+ import { useEffect, useMemo } from 'react'
23
27
  import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
24
28
  import { isHistoryShortcut } from '../../lib/history'
25
29
  import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
@@ -30,6 +34,7 @@ import {
30
34
  } from '../../lib/rigid-plan-svg-transform'
31
35
  import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
32
36
  import { sfxEmitter } from '../../lib/sfx-bus'
37
+ import { surfaceAttachmentId } from '../../lib/surface-attachment'
33
38
  import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
34
39
  import useAlignmentGuides from '../../store/use-alignment-guides'
35
40
  import useEditor, {
@@ -37,7 +42,11 @@ import useEditor, {
37
42
  isGridSnapActive,
38
43
  isMagneticSnapActive,
39
44
  } from '../../store/use-editor'
40
- import { useMovingNode } from '../../store/use-interaction-scope'
45
+ import useInteractionScope, {
46
+ isInteractionSubtreeDraft,
47
+ useMovingNode,
48
+ } from '../../store/use-interaction-scope'
49
+ import usePlacementPreview from '../../store/use-placement-preview'
41
50
  import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
42
51
 
43
52
  // Figma-style alignment snap threshold. Meters in world space; 8cm gives
@@ -67,7 +76,14 @@ const ALIGNMENT_THRESHOLD_M = 0.08
67
76
  * cursor → meters accounts for pan / zoom / building rotation.
68
77
  */
69
78
  export function FloorplanRegistryMoveOverlay() {
70
- const movingNode = useMovingNode()
79
+ const source = useMovingNode()
80
+ const movingNode = useMemo(
81
+ () =>
82
+ source && isInteractionSubtreeDraft(source.id)
83
+ ? (useScene.getState().nodes[source.id] ?? source)
84
+ : source,
85
+ [source],
86
+ )
71
87
  const setMovingNode = useEditor((s) => s.setMovingNode)
72
88
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
73
89
 
@@ -93,7 +109,15 @@ export function FloorplanRegistryMoveOverlay() {
93
109
  return [m.x, m.y]
94
110
  }
95
111
 
96
- const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
112
+ const isPointerOverFloorplanScene = (event: PointerEvent): boolean => {
113
+ // View controls overlap the SVG viewport; clicking one must not place the draft.
114
+ if (
115
+ (event.target as Element | null)?.closest?.(
116
+ 'button, input, select, textarea, [role="button"]',
117
+ )
118
+ )
119
+ return false
120
+ const { clientX, clientY } = event
97
121
  // The scene's `<g>` only covers painted SVG elements, so hovers over
98
122
  // empty grid background often target the parent SVG. Bounds keep the
99
123
  // cursor active anywhere inside the floor-plan viewport.
@@ -108,6 +132,8 @@ export function FloorplanRegistryMoveOverlay() {
108
132
  )
109
133
  }
110
134
 
135
+ const ownsSubtree = useInteractionScope.getState().adoptSubtree(movingNode.id)
136
+
111
137
  // ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
112
138
  if (hasMoveTarget && def?.floorplanMoveTarget) {
113
139
  const sceneNodes = useScene.getState().nodes
@@ -137,7 +163,12 @@ export function FloorplanRegistryMoveOverlay() {
137
163
  for (const id of session.affectedIds) {
138
164
  liveTransforms.clear(id)
139
165
  liveOverrides.clear(id)
140
- scene.markDirty(id)
166
+ const api = createSceneApi(useScene)
167
+ for (const dirtyId of new Set([
168
+ ...cascadeDirty(id, { scene: api }),
169
+ ...collectDescendants(id, { scene: api }),
170
+ ]))
171
+ scene.markDirty(dirtyId)
141
172
  }
142
173
  }
143
174
 
@@ -161,9 +192,29 @@ export function FloorplanRegistryMoveOverlay() {
161
192
  // the 2D pane the overlay claimed R forever and the 3D flip went dead.
162
193
  let pointerOverFloorplan = false
163
194
  const onPointerTrack = (event: PointerEvent) => {
164
- pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
195
+ pointerOverFloorplan = isPointerOverFloorplanScene(event)
165
196
  }
166
197
 
198
+ let rejectionIndicator: SVGCircleElement | null = null
199
+ let lastPlanPoint: [number, number] | null = null
200
+ const clearRejection = () => {
201
+ rejectionIndicator?.remove()
202
+ rejectionIndicator = null
203
+ }
204
+ const showRejection = () => {
205
+ if (!lastPlanPoint) return
206
+ clearRejection()
207
+ rejectionIndicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
208
+ rejectionIndicator.setAttribute('cx', String(lastPlanPoint[0]))
209
+ rejectionIndicator.setAttribute('cy', String(lastPlanPoint[1]))
210
+ rejectionIndicator.setAttribute('r', '0.15')
211
+ rejectionIndicator.setAttribute('fill', 'none')
212
+ rejectionIndicator.setAttribute('stroke', '#ef4444')
213
+ rejectionIndicator.setAttribute('stroke-width', '1.5')
214
+ rejectionIndicator.setAttribute('vector-effect', 'non-scaling-stroke')
215
+ rejectionIndicator.setAttribute('pointer-events', 'none')
216
+ scene.appendChild(rejectionIndicator)
217
+ }
167
218
  const onMove = (event: PointerEvent) => {
168
219
  // Skip 3D-canvas / other-UI cursor moves so the overlay only
169
220
  // tracks pointer events that actually correspond to a floor-plan
@@ -172,10 +223,12 @@ export function FloorplanRegistryMoveOverlay() {
172
223
  // hovers over empty grid background — without it, the cursor
173
224
  // only updated the shelf when it happened to brush over an
174
225
  // existing SVG entry, leaving the move feeling "stuck" elsewhere.
175
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
226
+ if (!isPointerOverFloorplanScene(event)) return
176
227
  const planPoint = toMeters(event.clientX, event.clientY)
177
228
  if (!planPoint) return
178
229
  hasMovedSinceStart = true
230
+ lastPlanPoint = planPoint
231
+ clearRejection()
179
232
  session.apply({
180
233
  planPoint,
181
234
  modifiers: {
@@ -190,7 +243,14 @@ export function FloorplanRegistryMoveOverlay() {
190
243
  // ticks as the item lands on each new snapped/free position.
191
244
  const movedId = session.affectedIds[0]
192
245
  const moved = movedId ? useScene.getState().nodes[movedId] : undefined
193
- const pos = (moved as { position?: [number, number, number] } | undefined)?.position
246
+ const effective = moved ? getEffectiveNode(moved) : undefined
247
+ const pos =
248
+ effective?.type === 'item' || effective?.type === 'procedural-item'
249
+ ? nodeLevelFrame(effective.id, {
250
+ ...useScene.getState().nodes,
251
+ [effective.id]: effective,
252
+ }).position
253
+ : (effective as { position?: [number, number, number] } | undefined)?.position
194
254
  if (pos) {
195
255
  const key = movementSfxStepKey({
196
256
  coords: [pos[0], pos[2]],
@@ -211,12 +271,19 @@ export function FloorplanRegistryMoveOverlay() {
211
271
  ?.metadata,
212
272
  )
213
273
 
214
- // Claim ownership of the drag teardown so the 3D move tool's
215
- // unmount-time cleanup skips its restore-from-snapshot — see
216
- // `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
217
- // `setMovingNode(null)`) so that by the time the 3D effect's
218
- // cleanup runs the origin is observable in the store.
219
- setMovingNodeOrigin('2d')
274
+ const atomicPreview =
275
+ freshPlacement &&
276
+ (ownsSubtree ||
277
+ ('children' in movingNode && movingNode.children.length > 0) ||
278
+ surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
279
+ if (atomicPreview && !commitValid) {
280
+ showRejection()
281
+ return false
282
+ }
283
+
284
+ // Keep ordinary teardown order. Validated replacements can refuse, so they
285
+ // claim teardown only after success, before clearing the moving node.
286
+ if (!atomicPreview) setMovingNodeOrigin('2d')
220
287
 
221
288
  // Sessions with a `commit` hook own their atomic write (e.g.
222
289
  // wall move emits creates + deletes + updates via the junction
@@ -224,17 +291,33 @@ export function FloorplanRegistryMoveOverlay() {
224
291
  // and Phase 2's resume — but Phase 2's write is delegated, and
225
292
  // we skip the snapshot-diff finalUpdates path.
226
293
  if (commitValid && freshPlacement) {
227
- session.commit?.()
294
+ // Subtrees finalize from the preview. Staging a session commit would publish
295
+ // an unvalidated graph, including for presets created outside our factory.
296
+ if (!atomicPreview) session.commit?.()
228
297
  const stagedNode = useScene.getState().nodes[movingNode.id]
298
+ const preview = usePlacementPreview.getState().node
299
+ const effective = stagedNode ? getEffectiveNode(stagedNode) : null
300
+ const candidate =
301
+ effective?.visible === false && preview?.id === movingNode.id ? preview : effective
229
302
  const committedId = stagedNode
230
303
  ? commitFreshPlacementSubtree(
231
304
  movingNode.id as AnyNodeId,
232
305
  {
306
+ ...(atomicPreview ? candidate : {}),
233
307
  metadata: stripPlacementMetadataFlags(stagedNode.metadata),
234
308
  visible: true,
235
309
  } as Partial<AnyNode>,
310
+ showRejection,
236
311
  )
237
312
  : null
313
+ if (!committedId && atomicPreview) return false
314
+ if (atomicPreview) {
315
+ setMovingNodeOrigin('2d')
316
+ for (const id of session.affectedIds) {
317
+ useLiveTransforms.getState().clear(id)
318
+ useLiveNodeOverrides.getState().clear(id)
319
+ }
320
+ }
238
321
  if (historyPaused) {
239
322
  resumeSceneHistory(useScene)
240
323
  historyPaused = false
@@ -247,7 +330,7 @@ export function FloorplanRegistryMoveOverlay() {
247
330
  }
248
331
 
249
332
  if (commitValid && session.commit) {
250
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
333
+ restoreSnapshots(snapshots)
251
334
  if (historyPaused) {
252
335
  resumeSceneHistory(useScene)
253
336
  historyPaused = false
@@ -298,7 +381,7 @@ export function FloorplanRegistryMoveOverlay() {
298
381
  // 1. Revert to baseline while history is still paused.
299
382
  // 2. Resume history.
300
383
  // 3. Re-apply the final state — recorded as one tracked change.
301
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
384
+ restoreSnapshots(snapshots)
302
385
  if (historyPaused) {
303
386
  resumeSceneHistory(useScene)
304
387
  historyPaused = false
@@ -312,7 +395,7 @@ export function FloorplanRegistryMoveOverlay() {
312
395
  // brings them back at the new position.
313
396
  useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
314
397
  } else {
315
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
398
+ restoreSnapshots(snapshots)
316
399
  if (historyPaused) {
317
400
  resumeSceneHistory(useScene)
318
401
  historyPaused = false
@@ -325,7 +408,7 @@ export function FloorplanRegistryMoveOverlay() {
325
408
  // Bounding-rect check (see `isPointerOverFloorplanScene`) — same
326
409
  // reason as `onMove`: commits should land for any pointer-up
327
410
  // inside the SVG viewport, including empty grid background.
328
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
411
+ if (!isPointerOverFloorplanScene(event)) return
329
412
  if (!hasMovedSinceStart) return
330
413
 
331
414
  // Commit using the LAST pointermove's state — no re-apply at
@@ -344,7 +427,7 @@ export function FloorplanRegistryMoveOverlay() {
344
427
  // pointermove right before pointerup, so the trade-off lands
345
428
  // on the side of WYSIWYG.
346
429
 
347
- commitFinalStateOrRevert()
430
+ if (commitFinalStateOrRevert() === false) return
348
431
  setMovingNode(null)
349
432
 
350
433
  // Swallow the click event that follows this pointer-up — the
@@ -408,7 +491,8 @@ export function FloorplanRegistryMoveOverlay() {
408
491
  setMovingNodeOrigin('2d')
409
492
  if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
410
493
  emitter.emit('tool:cancel')
411
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
494
+ if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id))
495
+ useScene.getState().deleteNode(movingNode.id)
412
496
  if (historyPaused) {
413
497
  resumeSceneHistory(useScene)
414
498
  historyPaused = false
@@ -419,7 +503,7 @@ export function FloorplanRegistryMoveOverlay() {
419
503
  return
420
504
  }
421
505
  // Revert untracked, then resume — no history entry.
422
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
506
+ restoreSnapshots(snapshots)
423
507
  if (historyPaused) {
424
508
  resumeSceneHistory(useScene)
425
509
  historyPaused = false
@@ -444,6 +528,7 @@ export function FloorplanRegistryMoveOverlay() {
444
528
  // then pre-empts it so only one handler flips.
445
529
  window.addEventListener('keydown', onKey, true)
446
530
  return () => {
531
+ clearRejection()
447
532
  window.removeEventListener('pointermove', onPointerTrack)
448
533
  window.removeEventListener('pointermove', onMove)
449
534
  window.removeEventListener('pointerup', onPointerUp)
@@ -464,11 +549,12 @@ export function FloorplanRegistryMoveOverlay() {
464
549
  // our unmount
465
550
  // are now distinguished by the origin flag — no scene-state
466
551
  // diff heuristic required.
552
+ const freshPlacement = ownsSubtree
467
553
  if (historyPaused) {
468
- if (hasMovedSinceStart) {
469
- const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
470
- if (!finalisedBy3D) {
471
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
554
+ const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
555
+ if (!finalisedBy3D && !freshPlacement) {
556
+ if (hasMovedSinceStart) {
557
+ restoreSnapshots(snapshots)
472
558
  }
473
559
  }
474
560
  resumeSceneHistory(useScene)
@@ -478,7 +564,7 @@ export function FloorplanRegistryMoveOverlay() {
478
564
  // `useLiveTransforms`; wall sessions write to
479
565
  // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
480
566
  // tool's cleanup isn't there to clear them for us.
481
- clearLivePreviews()
567
+ if (!freshPlacement) clearLivePreviews()
482
568
  // Sessions that publish Figma-style alignment guides during `apply`
483
569
  // (item / shelf / column) leave them in the store; this cleanup runs
484
570
  // after every terminal path (commit + Esc both unmount via
@@ -589,7 +675,7 @@ export function FloorplanRegistryMoveOverlay() {
589
675
  // Same target guard as Path 1 — pointer must be over the floor
590
676
  // plan scene; otherwise we'd react to 3D-canvas moves with garbage
591
677
  // plan coords.
592
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
678
+ if (!isPointerOverFloorplanScene(event)) return
593
679
  const m = toMeters(event.clientX, event.clientY)
594
680
  if (!m) return
595
681
  forcePlace = event.altKey
@@ -740,7 +826,7 @@ export function FloorplanRegistryMoveOverlay() {
740
826
 
741
827
  const onPointerUp = (event: PointerEvent) => {
742
828
  if (event.button !== 0) return
743
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
829
+ if (!isPointerOverFloorplanScene(event)) return
744
830
 
745
831
  const snapped = lastSnapped
746
832
  if (!snapped) return
@@ -754,8 +840,14 @@ export function FloorplanRegistryMoveOverlay() {
754
840
  ]
755
841
  : snapped.rotation
756
842
  const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
757
- setMovingNodeOrigin('2d')
843
+ const atomicPreview =
844
+ isFreshPlacement &&
845
+ (ownsSubtree ||
846
+ ('children' in movingNode && movingNode.children.length > 0) ||
847
+ surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
848
+ if (!atomicPreview) setMovingNodeOrigin('2d')
758
849
  if (!lastPositionValid && !forcePlace) {
850
+ if (atomicPreview) setMovingNodeOrigin('2d')
759
851
  for (const relatedEntry of relatedEntries) {
760
852
  relatedEntry.removeAttribute('transform')
761
853
  }
@@ -766,6 +858,7 @@ export function FloorplanRegistryMoveOverlay() {
766
858
  }
767
859
  let selectedId = movingNode.id as AnyNodeId
768
860
  if (originalPath) {
861
+ if (atomicPreview) setMovingNodeOrigin('2d')
769
862
  // Polyline kinds: shift every point by the committed delta and
770
863
  // write `path`. Strip the fresh-placement flags on first drop.
771
864
  const dx = sx - originalPosition[0]
@@ -795,18 +888,21 @@ export function FloorplanRegistryMoveOverlay() {
795
888
  return
796
889
  }
797
890
  if (isFreshPlacement) {
798
- selectedId =
799
- commitFreshPlacementSubtree(
800
- movingNode.id as AnyNodeId,
801
- {
802
- position: [sx, oldY, sz],
803
- ...rotationPatch,
804
- metadata: stripPlacementMetadataFlags(
805
- (movingNode as { metadata?: unknown }).metadata,
806
- ),
807
- visible: true,
808
- } as Partial<AnyNode>,
809
- ) ?? selectedId
891
+ const committedId = commitFreshPlacementSubtree(
892
+ movingNode.id as AnyNodeId,
893
+ {
894
+ position: [sx, oldY, sz],
895
+ ...rotationPatch,
896
+ metadata: stripPlacementMetadataFlags((movingNode as { metadata?: unknown }).metadata),
897
+ visible: true,
898
+ } as Partial<AnyNode>,
899
+ )
900
+ if (!committedId && atomicPreview) {
901
+ boxEl.setAttribute('stroke', '#ef4444')
902
+ return
903
+ }
904
+ if (atomicPreview) setMovingNodeOrigin('2d')
905
+ selectedId = committedId ?? selectedId
810
906
  } else {
811
907
  useScene.getState().updateNode(
812
908
  movingNode.id as AnyNodeId,
@@ -836,11 +932,13 @@ export function FloorplanRegistryMoveOverlay() {
836
932
  setMovingNodeOrigin('2d')
837
933
  if (isFreshPlacement) {
838
934
  emitter.emit('tool:cancel')
839
- const temporal = useScene.temporal.getState()
840
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
841
- if (wasTracking) temporal.pause()
842
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
843
- if (wasTracking) temporal.resume()
935
+ if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id)) {
936
+ const temporal = useScene.temporal.getState()
937
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
938
+ if (wasTracking) temporal.pause()
939
+ useScene.getState().deleteNode(movingNode.id)
940
+ if (wasTracking) temporal.resume()
941
+ }
844
942
  }
845
943
  for (const relatedEntry of relatedEntries) {
846
944
  relatedEntry.removeAttribute('transform')
@@ -888,8 +986,15 @@ function snapshotNode(node: AnyNode): NodeSnapshot {
888
986
  return { id: node.id, data }
889
987
  }
890
988
 
891
- function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
892
- return snapshots.map((s) => ({ id: s.id, data: s.data }))
989
+ function restoreSnapshots(snapshots: NodeSnapshot[]) {
990
+ const updates = snapshots
991
+ .filter(
992
+ (s) =>
993
+ !useScene.getState().nodes[s.id] ||
994
+ !deepEqual(s.data, snapshotNode(useScene.getState().nodes[s.id]!).data),
995
+ )
996
+ .map((s) => ({ id: s.id, data: s.data }))
997
+ if (updates.length) useScene.getState().updateNodes(updates)
893
998
  }
894
999
 
895
1000
  function deepEqual(a: unknown, b: unknown): boolean {
@@ -0,0 +1,166 @@
1
+ 'use client'
2
+
3
+ import type { WallPlanPoint } from '@pascal-app/core'
4
+
5
+ /** Length plate + angle arcs for a drafted wall segment, in plan space. */
6
+ export type DraftWallMeasurement = {
7
+ lengthLabel: string
8
+ midpoint: WallPlanPoint
9
+ direction: WallPlanPoint
10
+ angleLabels: {
11
+ id: string
12
+ label: string
13
+ center: WallPlanPoint
14
+ radius: number
15
+ startAngle: number
16
+ endAngle: number
17
+ midAngle: number
18
+ }[]
19
+ }
20
+
21
+ export function FloorplanDraftWallMeasurement({
22
+ measurement,
23
+ measurementStroke,
24
+ labelBackground,
25
+ labelText,
26
+ sceneRotationDeg,
27
+ unitsPerPixel,
28
+ }: {
29
+ measurement: DraftWallMeasurement
30
+ measurementStroke: string
31
+ labelBackground: string
32
+ labelText: string
33
+ sceneRotationDeg: number
34
+ unitsPerPixel: number
35
+ }) {
36
+ const stroke = measurementStroke
37
+ const labelBg = labelBackground
38
+
39
+ const upx = unitsPerPixel
40
+ const fontSize = Math.max(upx * 10, 0.08)
41
+ const padX = upx * 6
42
+ const padY = upx * 3
43
+
44
+ // Length plate: rotates to follow the wall direction, but flips 180°
45
+ // when its on-screen orientation would read upside-down (same trick as
46
+ // `floorplan-registry-layer.tsx` for dimension labels).
47
+ const wallAngleDeg =
48
+ (Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI
49
+ let labelAngleDeg = wallAngleDeg
50
+ let screenDeg = wallAngleDeg + sceneRotationDeg
51
+ screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
52
+ if (screenDeg > 90) labelAngleDeg -= 180
53
+ else if (screenDeg <= -90) labelAngleDeg += 180
54
+
55
+ // Push the plate perpendicular to the wall so the dashed footprint
56
+ // stays visible underneath.
57
+ const perpX = -measurement.direction[1]
58
+ const perpY = measurement.direction[0]
59
+ const offset = upx * 18
60
+ const cx = measurement.midpoint[0] + perpX * offset
61
+ const cy = measurement.midpoint[1] + perpY * offset
62
+
63
+ const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
64
+ const lengthPlateW = lengthTextWidth + padX * 2
65
+ const lengthPlateH = fontSize + padY * 2
66
+
67
+ const arcSampleCount = 32
68
+
69
+ return (
70
+ <g pointerEvents="none">
71
+ <g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
72
+ <rect
73
+ fill={labelBg}
74
+ height={lengthPlateH}
75
+ opacity={0.92}
76
+ rx={upx * 3}
77
+ ry={upx * 3}
78
+ stroke={stroke}
79
+ strokeWidth={upx * 0.5}
80
+ vectorEffect="non-scaling-stroke"
81
+ width={lengthPlateW}
82
+ x={-lengthPlateW / 2}
83
+ y={-lengthPlateH / 2}
84
+ />
85
+ <text
86
+ dominantBaseline="middle"
87
+ fill={labelText}
88
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
89
+ fontSize={fontSize}
90
+ fontWeight={600}
91
+ textAnchor="middle"
92
+ x={0}
93
+ y={0}
94
+ >
95
+ {measurement.lengthLabel}
96
+ </text>
97
+ </g>
98
+
99
+ {measurement.angleLabels.map((arc) => {
100
+ // Sample the arc as a polyline — avoids the SVG arc command's
101
+ // sweep-flag direction quirks across negative/positive sweeps.
102
+ const points: string[] = []
103
+ for (let i = 0; i <= arcSampleCount; i += 1) {
104
+ const t = i / arcSampleCount
105
+ const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t
106
+ const px = arc.center[0] + Math.cos(a) * arc.radius
107
+ const py = arc.center[1] + Math.sin(a) * arc.radius
108
+ points.push(`${px},${py}`)
109
+ }
110
+
111
+ const aFontSize = Math.max(upx * 9, 0.075)
112
+ const aPadX = upx * 5
113
+ const aPadY = upx * 2.5
114
+ const aTextWidth = arc.label.length * upx * 6.2
115
+ const aPlateW = aTextWidth + aPadX * 2
116
+ const aPlateH = aFontSize + aPadY * 2
117
+
118
+ const labelDist = arc.radius + upx * 16
119
+ const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist
120
+ const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist
121
+
122
+ return (
123
+ <g key={`draft-angle-${arc.id}`}>
124
+ <polyline
125
+ fill="none"
126
+ points={points.join(' ')}
127
+ stroke={stroke}
128
+ strokeLinecap="round"
129
+ strokeLinejoin="round"
130
+ strokeOpacity={0.95}
131
+ strokeWidth={upx * 1.2}
132
+ vectorEffect="non-scaling-stroke"
133
+ />
134
+ <g transform={`translate(${lx} ${ly})`}>
135
+ <rect
136
+ fill={labelBg}
137
+ height={aPlateH}
138
+ opacity={0.92}
139
+ rx={upx * 3}
140
+ ry={upx * 3}
141
+ stroke={stroke}
142
+ strokeWidth={upx * 0.5}
143
+ vectorEffect="non-scaling-stroke"
144
+ width={aPlateW}
145
+ x={-aPlateW / 2}
146
+ y={-aPlateH / 2}
147
+ />
148
+ <text
149
+ dominantBaseline="middle"
150
+ fill={labelText}
151
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
152
+ fontSize={aFontSize}
153
+ fontWeight={600}
154
+ textAnchor="middle"
155
+ x={0}
156
+ y={0}
157
+ >
158
+ {arc.label}
159
+ </text>
160
+ </g>
161
+ </g>
162
+ )
163
+ })}
164
+ </g>
165
+ )
166
+ }
@@ -23,8 +23,10 @@ import {
23
23
  collectFloorplanLinkedLevelNodes,
24
24
  computeAffectedSiblingIds,
25
25
  floorplanAffordanceReshapeScope,
26
+ floorplanEntryYieldsToTool,
26
27
  floorplanHandleDoubleClickAffordance,
27
28
  InteractiveGeometry,
29
+ isFloorplanHierarchyVisible,
28
30
  isFloorplanOpeningPlacementState,
29
31
  resolveFloorplanHandleUnitsPerPixel,
30
32
  siteToFloorplanTransform,
@@ -780,3 +782,50 @@ describe('collectFloorplanLinkedLevelNodes', () => {
780
782
  ).toEqual([])
781
783
  })
782
784
  })
785
+
786
+ describe('floorplan entry routing while a tool is active', () => {
787
+ test('build tools get presses on entries, as in 3D; select and delete keep selecting', () => {
788
+ expect(floorplanEntryYieldsToTool({ mode: 'build', openingPlacement: false })).toBe(true)
789
+ expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: true })).toBe(true)
790
+ expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: false })).toBe(false)
791
+ expect(floorplanEntryYieldsToTool({ mode: 'delete', openingPlacement: false })).toBe(false)
792
+ })
793
+ })
794
+
795
+ describe('isFloorplanHierarchyVisible', () => {
796
+ const node = (id: string, type: string, parentId: string | null, visible = true) =>
797
+ ({ object: 'node', id, type, parentId, visible, metadata: {} }) as unknown as AnyNode
798
+ const noOverrides = new Map<string, LiveNodeOverrides>()
799
+ const visibleUnder = (nodes: Record<string, AnyNode>, rootId: string, id: string) =>
800
+ isFloorplanHierarchyVisible(nodes[id]!, nodes, noOverrides, rootId as AnyNodeId)
801
+
802
+ test('a hidden Site root keeps the nodes on it, linked or detached', () => {
803
+ const nodes: Record<string, AnyNode> = {
804
+ site_a: node('site_a', 'site', null, false),
805
+ building_a: node('building_a', 'building', 'site_a'),
806
+ level_a: node('level_a', 'level', 'building_a'),
807
+ wall_a: node('wall_a', 'wall', 'level_a'),
808
+ wall_b: node('wall_b', 'wall', 'level_a', false),
809
+ tree_a: node('tree_a', 'trees:tree', null),
810
+ }
811
+ expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(true)
812
+ expect(visibleUnder(nodes, 'site_a', 'tree_a')).toBe(true)
813
+ expect(visibleUnder(nodes, 'site_a', 'wall_b')).toBe(false)
814
+ expect(visibleUnder(nodes, 'site_a', 'site_a')).toBe(false)
815
+ })
816
+
817
+ test('a hidden building or level root still hides what it hosts', () => {
818
+ const nodes: Record<string, AnyNode> = {
819
+ site_a: node('site_a', 'site', null),
820
+ building_a: node('building_a', 'building', 'site_a', false),
821
+ level_a: node('level_a', 'level', 'building_a'),
822
+ wall_a: node('wall_a', 'wall', 'level_a'),
823
+ elevator_a: node('elevator_a', 'elevator', null),
824
+ }
825
+ expect(visibleUnder(nodes, 'site_a', 'wall_a')).toBe(false)
826
+ expect(visibleUnder(nodes, 'building_a', 'elevator_a')).toBe(false)
827
+ // A level plan is scoped to its level: the walk stops at the root and
828
+ // never consults the building above it.
829
+ expect(visibleUnder(nodes, 'level_a', 'wall_a')).toBe(true)
830
+ })
831
+ })