@pascal-app/editor 0.9.2 → 1.0.0-beta.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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -0,0 +1,96 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ clearSceneHistory,
7
+ LevelNode,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { installHistoryCommandDelegate, runRedo, runUndo } from './history'
11
+
12
+ type RafFn = (cb: (time: number) => void) => number
13
+ ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
14
+ cb(0)
15
+ return 0
16
+ }
17
+ ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
18
+ () => {}
19
+
20
+ const BUILDING_ID = 'building_history_controller' as AnyNodeId
21
+ const LEVEL_ID = 'level_history_controller' as AnyNodeId
22
+ let disposeController = () => {}
23
+
24
+ function levelNumber(): number {
25
+ return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level
26
+ }
27
+
28
+ describe('editor history controller', () => {
29
+ beforeEach(() => {
30
+ disposeController()
31
+ disposeController = () => {}
32
+ const level = LevelNode.parse({
33
+ id: LEVEL_ID,
34
+ parentId: BUILDING_ID,
35
+ children: [],
36
+ level: 0,
37
+ })
38
+ const building = BuildingNode.parse({
39
+ id: BUILDING_ID,
40
+ parentId: null,
41
+ children: [LEVEL_ID],
42
+ })
43
+ useScene.setState({
44
+ nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level },
45
+ rootNodeIds: [BUILDING_ID],
46
+ dirtyNodes: new Set<AnyNodeId>(),
47
+ collections: {},
48
+ materials: {},
49
+ readOnly: false,
50
+ } as never)
51
+ clearSceneHistory()
52
+ useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
53
+ })
54
+
55
+ afterEach(() => {
56
+ disposeController()
57
+ disposeController = () => {}
58
+ })
59
+
60
+ test('delegates undo and redo while a host delegate is installed', () => {
61
+ const undo = mock(() => {})
62
+ const redo = mock(() => {})
63
+ disposeController = installHistoryCommandDelegate({ undo, redo })
64
+
65
+ runUndo()
66
+ runRedo()
67
+
68
+ expect(undo).toHaveBeenCalledTimes(1)
69
+ expect(redo).toHaveBeenCalledTimes(1)
70
+ expect(levelNumber()).toBe(1)
71
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
72
+ })
73
+
74
+ test('falls back to standalone Zundo undo and redo when no controller is installed', () => {
75
+ runUndo()
76
+ expect(levelNumber()).toBe(0)
77
+ expect(useScene.temporal.getState().futureStates).toHaveLength(1)
78
+
79
+ runRedo()
80
+ expect(levelNumber()).toBe(1)
81
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
82
+ })
83
+
84
+ test('an older cleanup cannot uninstall a newer controller', () => {
85
+ const firstUndo = mock(() => {})
86
+ const stopFirst = installHistoryCommandDelegate({ undo: firstUndo, redo: () => {} })
87
+ const secondUndo = mock(() => {})
88
+ disposeController = installHistoryCommandDelegate({ undo: secondUndo, redo: () => {} })
89
+
90
+ stopFirst()
91
+ runUndo()
92
+
93
+ expect(firstUndo).toHaveBeenCalledTimes(0)
94
+ expect(secondUndo).toHaveBeenCalledTimes(1)
95
+ })
96
+ })
@@ -1,5 +1,19 @@
1
1
  import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
2
2
 
3
+ export type HistoryCommandDelegate = {
4
+ undo: () => void
5
+ redo: () => void
6
+ }
7
+
8
+ let historyCommandDelegate: HistoryCommandDelegate | null = null
9
+
10
+ export function installHistoryCommandDelegate(delegate: HistoryCommandDelegate): () => void {
11
+ historyCommandDelegate = delegate
12
+ return () => {
13
+ if (historyCommandDelegate === delegate) historyCommandDelegate = null
14
+ }
15
+ }
16
+
3
17
  function refreshSceneAfterHistoryJump() {
4
18
  useLiveNodeOverrides.getState().clearAll()
5
19
  useLiveTransforms.getState().clearAll()
@@ -11,11 +25,19 @@ function refreshSceneAfterHistoryJump() {
11
25
  }
12
26
 
13
27
  export function runUndo() {
28
+ if (historyCommandDelegate) {
29
+ historyCommandDelegate.undo()
30
+ return
31
+ }
14
32
  useScene.temporal.getState().undo()
15
33
  refreshSceneAfterHistoryJump()
16
34
  }
17
35
 
18
36
  export function runRedo() {
37
+ if (historyCommandDelegate) {
38
+ historyCommandDelegate.redo()
39
+ return
40
+ }
19
41
  useScene.temporal.getState().redo()
20
42
  refreshSceneAfterHistoryJump()
21
43
  }
@@ -17,9 +17,10 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [
17
17
  { kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
18
18
  { kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
19
19
  { kind: 'drafting', tool: 'wall' },
20
- { kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
20
+ { kind: 'reshaping', nodeId: 's1', reshape: 'hole', driver: 'tool', holeIndex: 0 },
21
21
  { kind: 'box-select' },
22
22
  { kind: 'painting' },
23
+ { kind: 'sculpting' },
23
24
  ]
24
25
 
25
26
  describe('resolveOverlayPolicy', () => {
@@ -13,6 +13,7 @@
13
13
  import type { AnyNode } from '@pascal-app/core'
14
14
 
15
15
  export type InteractionView = '2d' | '3d'
16
+ export type ReshapeDriver = 'tool' | 'floorplan'
16
17
 
17
18
  // Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
18
19
  // node, one in-flight reshape. Grouping them as sub-states of `reshaping`
@@ -47,6 +48,8 @@ export type InteractionScope =
47
48
  kind: 'reshaping'
48
49
  nodeId: string
49
50
  reshape: ReshapeKind
51
+ // Floorplan affordances own their commit and must not mount a second 3D reshape tool.
52
+ driver: ReshapeDriver
50
53
  holeIndex?: number
51
54
  endpoint?: 'start' | 'end'
52
55
  index?: number
@@ -56,6 +59,13 @@ export type InteractionScope =
56
59
  | { kind: 'box-select' }
57
60
  // Material paint application.
58
61
  | { kind: 'painting' }
62
+ // Terrain sculpting. Held for the whole time the sculpt tool is armed, not
63
+ // just while the pointer is down — the same lifetime as `painting`, and for
64
+ // the same reason: an *active* scope is what makes every other object's
65
+ // handles, the floating action menu, and the zone labels step back
66
+ // (`resolveOverlayPolicy`). A brush that only claimed the scope between
67
+ // pointer-down and -up would flicker all of that back on between dabs.
68
+ | { kind: 'sculpting' }
59
69
 
60
70
  export type InteractionKind = InteractionScope['kind']
61
71
 
@@ -126,12 +136,14 @@ export function editingHoleInfo(
126
136
  export function holeEditScope(target: {
127
137
  nodeId: string
128
138
  holeIndex: number
139
+ driver?: ReshapeDriver
129
140
  }): ActiveInteractionScope {
130
141
  return {
131
142
  kind: 'reshaping',
132
143
  nodeId: target.nodeId,
133
144
  reshape: 'hole',
134
145
  holeIndex: target.holeIndex,
146
+ driver: target.driver ?? 'tool',
135
147
  }
136
148
  }
137
149
 
@@ -142,6 +154,14 @@ export function isCurveReshape(scope: InteractionScope): boolean {
142
154
  return scope.kind === 'reshaping' && scope.reshape === 'curve'
143
155
  }
144
156
 
157
+ export function isToolDrivenReshape(scope: InteractionScope): boolean {
158
+ return scope.kind === 'reshaping' && scope.driver === 'tool'
159
+ }
160
+
161
+ export function isFloorplanDrivenReshape(scope: InteractionScope): boolean {
162
+ return scope.kind === 'reshaping' && scope.driver === 'floorplan'
163
+ }
164
+
145
165
  // The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
146
166
  // itself (consumers fetch the node from the scene by `nodeId`; it is stable for
147
167
  // the duration of the drag).
@@ -181,31 +201,43 @@ export function reshapingNodeId(scope: InteractionScope): string | null {
181
201
  }
182
202
 
183
203
  // Builders so producers don't re-spell the discriminator at every call site.
184
- export function curveReshapeScope(nodeId: string): ActiveInteractionScope {
185
- return { kind: 'reshaping', nodeId, reshape: 'curve' }
204
+ export function curveReshapeScope(
205
+ nodeId: string,
206
+ driver: ReshapeDriver = 'tool',
207
+ ): ActiveInteractionScope {
208
+ return { kind: 'reshaping', nodeId, reshape: 'curve', driver }
186
209
  }
187
210
 
188
211
  export function endpointReshapeScope(
189
212
  nodeId: string,
190
213
  endpoint: 'start' | 'end',
214
+ driver: ReshapeDriver = 'tool',
191
215
  ): ActiveInteractionScope {
192
- return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
216
+ return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint, driver }
193
217
  }
194
218
 
195
- export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope {
196
- return { kind: 'reshaping', nodeId, reshape: 'control-point', index }
219
+ export function controlPointReshapeScope(
220
+ nodeId: string,
221
+ index: number,
222
+ driver: ReshapeDriver = 'tool',
223
+ ): ActiveInteractionScope {
224
+ return { kind: 'reshaping', nodeId, reshape: 'control-point', index, driver }
197
225
  }
198
226
 
199
227
  export function tangentReshapeScope(
200
228
  nodeId: string,
201
229
  index: number,
202
230
  side: 'in' | 'out',
231
+ driver: ReshapeDriver = 'tool',
203
232
  ): ActiveInteractionScope {
204
- return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side }
233
+ return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side, driver }
205
234
  }
206
235
 
207
236
  // Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
208
237
  // HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
209
- export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
210
- return { kind: 'reshaping', nodeId, reshape: 'boundary' }
238
+ export function boundaryReshapeScope(
239
+ nodeId: string,
240
+ driver: ReshapeDriver = 'tool',
241
+ ): ActiveInteractionScope {
242
+ return { kind: 'reshaping', nodeId, reshape: 'boundary', driver }
211
243
  }
@@ -11,7 +11,7 @@ import { buildLevelDuplicateCreateOps } from './level-duplication'
11
11
 
12
12
  describe('buildLevelDuplicateCreateOps', () => {
13
13
  test('parents a duplicated bootstrap level back to its building', () => {
14
- const level = LevelNode.parse({ level: 0, children: [] })
14
+ const level = LevelNode.parse({ level: 0, height: 3.25, children: [] })
15
15
  const building = BuildingNode.parse({ children: [level.id] })
16
16
  const wall = WallNode.parse({
17
17
  parentId: level.id,
@@ -36,6 +36,7 @@ describe('buildLevelDuplicateCreateOps', () => {
36
36
 
37
37
  expect(sourceLevel.parentId).toBeNull()
38
38
  expect(levelCreateOp?.parentId).toBe(building.id)
39
+ expect(levelCreateOp?.node.type === 'level' ? levelCreateOp.node.height : undefined).toBe(3.25)
39
40
  })
40
41
 
41
42
  test('does not copy spawn points from the source level', () => {
@@ -72,6 +72,11 @@ describe('linear measurements', () => {
72
72
  expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
73
73
  })
74
74
 
75
+ test('formats metric measurements in whole millimeters', () => {
76
+ expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm')
77
+ expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm')
78
+ })
79
+
75
80
  test('formats imperial measurements as feet and inches', () => {
76
81
  expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
77
82
  expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
@@ -1,6 +1,7 @@
1
1
  import type { MeasurementPoint } from '@pascal-app/core'
2
2
 
3
3
  export type LinearUnit = 'metric' | 'imperial'
4
+ export type MetricNotation = 'meters' | 'millimeters'
4
5
 
5
6
  export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
6
7
  export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
@@ -173,7 +174,11 @@ export function formatVolumeLabel(
173
174
  return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
174
175
  }
175
176
 
176
- export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
177
+ export function formatLinearMeasurement(
178
+ meters: number,
179
+ unit: LinearUnit,
180
+ metricNotation: MetricNotation = 'meters',
181
+ ): string {
177
182
  if (!Number.isFinite(meters)) return '--'
178
183
 
179
184
  const absoluteMeters = Math.abs(meters)
@@ -192,6 +197,12 @@ export function formatLinearMeasurement(meters: number, unit: LinearUnit): strin
192
197
  return `${sign}${wholeFeet}'${inches}"`
193
198
  }
194
199
 
200
+ if (metricNotation === 'millimeters') {
201
+ const roundedMillimeters = Math.round(absoluteMeters * 1000)
202
+ const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
203
+ return `${sign}${roundedMillimeters}mm`
204
+ }
205
+
195
206
  const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
196
207
  const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
197
208
 
@@ -0,0 +1,33 @@
1
+ const activeAnimations = new WeakMap<HTMLElement, Animation>()
2
+
3
+ export function playBlockedQuickActionFeedback(button: HTMLButtonElement, reducedMotion: boolean) {
4
+ const content = button.querySelector<HTMLElement>('[data-quick-action-feedback]')
5
+ if (!content) return
6
+
7
+ activeAnimations.get(content)?.cancel()
8
+ content.style.color = 'var(--destructive)'
9
+
10
+ const keyframes: Keyframe[] = reducedMotion
11
+ ? [{ opacity: 1 }, { opacity: 1 }]
12
+ : [
13
+ { transform: 'translateX(0)' },
14
+ { transform: 'translateX(-2.5px)', offset: 0.18 },
15
+ { transform: 'translateX(2px)', offset: 0.38 },
16
+ { transform: 'translateX(-1.5px)', offset: 0.58 },
17
+ { transform: 'translateX(1px)', offset: 0.76 },
18
+ { transform: 'translateX(0)' },
19
+ ]
20
+ const animation = content.animate(keyframes, {
21
+ duration: reducedMotion ? 240 : 320,
22
+ easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
23
+ })
24
+
25
+ activeAnimations.set(content, animation)
26
+ void animation.finished
27
+ .catch(() => undefined)
28
+ .finally(() => {
29
+ if (activeAnimations.get(content) !== animation) return
30
+ activeAnimations.delete(content)
31
+ content.style.removeProperty('color')
32
+ })
33
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import { collectQuickActionNodeScope } from './quick-action-nodes'
4
+
5
+ function fixtureNode({
6
+ id,
7
+ parentId,
8
+ children = [],
9
+ type = 'item',
10
+ }: {
11
+ id: string
12
+ parentId?: string
13
+ children?: string[]
14
+ type?: string
15
+ }) {
16
+ return {
17
+ id,
18
+ type,
19
+ parentId,
20
+ children,
21
+ } as unknown as AnyNode
22
+ }
23
+
24
+ describe('collectQuickActionNodeScope', () => {
25
+ test('includes nested children of a selected node sibling', () => {
26
+ const run = fixtureNode({
27
+ id: 'run',
28
+ children: ['left-base', 'selected-base'],
29
+ })
30
+ const leftBase = fixtureNode({
31
+ id: 'left-base',
32
+ parentId: run.id,
33
+ children: ['expanded-wall'],
34
+ })
35
+ const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id })
36
+ const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: leftBase.id })
37
+ const nodes = Object.fromEntries(
38
+ [run, leftBase, selectedBase, expandedWall].map((node) => [node.id, node]),
39
+ ) as Record<AnyNodeId, AnyNode>
40
+
41
+ const collected = collectQuickActionNodeScope(nodes, selectedBase.id)
42
+
43
+ expect(collected?.[expandedWall.id as AnyNodeId]).toBe(expandedWall)
44
+ })
45
+
46
+ test('includes other run subtrees when the provider declares level scope', () => {
47
+ const level = fixtureNode({
48
+ id: 'level',
49
+ type: 'level',
50
+ children: ['selected-run', 'other-run'],
51
+ })
52
+ const selectedRun = fixtureNode({
53
+ id: 'selected-run',
54
+ parentId: level.id,
55
+ children: ['selected-base'],
56
+ })
57
+ const selectedBase = fixtureNode({ id: 'selected-base', parentId: selectedRun.id })
58
+ const otherRun = fixtureNode({
59
+ id: 'other-run',
60
+ parentId: level.id,
61
+ children: ['other-base'],
62
+ })
63
+ const otherBase = fixtureNode({
64
+ id: 'other-base',
65
+ parentId: otherRun.id,
66
+ children: ['expanded-wall'],
67
+ })
68
+ const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: otherBase.id })
69
+ const nodes = Object.fromEntries(
70
+ [level, selectedRun, selectedBase, otherRun, otherBase, expandedWall].map((node) => [
71
+ node.id,
72
+ node,
73
+ ]),
74
+ ) as Record<AnyNodeId, AnyNode>
75
+
76
+ expect(
77
+ collectQuickActionNodeScope(nodes, selectedBase.id)?.[expandedWall.id as AnyNodeId],
78
+ ).toBeUndefined()
79
+ expect(
80
+ collectQuickActionNodeScope(nodes, selectedBase.id, 'level')?.[expandedWall.id as AnyNodeId],
81
+ ).toBe(expandedWall)
82
+ })
83
+
84
+ test('fails closed when a level-scoped provider has no level ancestor', () => {
85
+ const run = fixtureNode({ id: 'run', children: ['selected-base'] })
86
+ const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id })
87
+ const nodes = Object.fromEntries([run, selectedBase].map((node) => [node.id, node])) as Record<
88
+ AnyNodeId,
89
+ AnyNode
90
+ >
91
+
92
+ expect(collectQuickActionNodeScope(nodes, selectedBase.id, 'level')).toBeNull()
93
+ })
94
+ })
@@ -0,0 +1,47 @@
1
+ import type { AnyNode, AnyNodeId, NodeQuickActionNodeScope } from '@pascal-app/core'
2
+
3
+ export function collectQuickActionNodeScope(
4
+ nodes: Record<AnyNodeId, AnyNode>,
5
+ selectedId: string,
6
+ scope: NodeQuickActionNodeScope = 'family',
7
+ ): Record<AnyNodeId, AnyNode> | null {
8
+ const selected = nodes[selectedId as AnyNodeId]
9
+ if (!selected) return null
10
+
11
+ const collected: Record<AnyNodeId, AnyNode> = {}
12
+ const addSubtree = (rootId: string | null | undefined) => {
13
+ if (!rootId) return
14
+ const pending = [rootId]
15
+
16
+ while (pending.length > 0) {
17
+ const id = pending.pop()
18
+ if (!id || collected[id as AnyNodeId]) continue
19
+ const node = nodes[id as AnyNodeId]
20
+ if (!node) continue
21
+
22
+ collected[node.id as AnyNodeId] = node
23
+ for (const childId of (node as { children?: readonly string[] }).children ?? []) {
24
+ pending.push(childId)
25
+ }
26
+ }
27
+ }
28
+
29
+ if (scope === 'level') {
30
+ const visited = new Set<AnyNodeId>()
31
+ let current: AnyNode | undefined = selected
32
+ while (current && !visited.has(current.id as AnyNodeId)) {
33
+ visited.add(current.id as AnyNodeId)
34
+ if (current.type === 'level') {
35
+ addSubtree(current.id)
36
+ return collected
37
+ }
38
+ current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined
39
+ }
40
+ return null
41
+ }
42
+
43
+ addSubtree(selected.id)
44
+ addSubtree(selected.parentId)
45
+
46
+ return collected
47
+ }
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, test } from 'bun:test'
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeId,
@@ -8,14 +8,18 @@ import {
8
8
  type CabinetNode as CabinetNodeType,
9
9
  type LevelNode,
10
10
  MeasurementNode,
11
+ SceneMaterial,
12
+ type SceneMaterialId,
11
13
  useScene,
12
14
  WallNode,
15
+ WindowNode,
13
16
  } from '@pascal-app/core'
14
17
  import { useViewer } from '@pascal-app/viewer'
15
18
  import {
16
19
  copySelectedNodesToEditorClipboard,
17
20
  getEditorClipboardSnapshot,
18
21
  pasteEditorClipboardToLevel,
22
+ pasteSystemEditorClipboardToLevel,
19
23
  } from './scene-clipboard'
20
24
 
21
25
  const sourceLevelId = 'level_clipboard-source' as LevelNode['id']
@@ -77,6 +81,7 @@ function seedCabinetRun() {
77
81
  [leftModule.id]: leftModule as AnyNode,
78
82
  [rightModule.id]: rightModule as AnyNode,
79
83
  },
84
+ materials: {},
80
85
  rootNodeIds: [sourceLevel.id, targetLevel.id],
81
86
  } as never)
82
87
  useViewer.getState().setSelection({
@@ -193,4 +198,128 @@ describe('scene clipboard', () => {
193
198
  expect(Array.isArray(anchor)).toBe(false)
194
199
  if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id)
195
200
  })
201
+
202
+ test('detaches a standalone copied opening so its move tool can rehost it', () => {
203
+ const wall = WallNode.parse({
204
+ id: 'wall_clipboard-window-host',
205
+ parentId: sourceLevelId,
206
+ start: [0, 0],
207
+ end: [3, 0],
208
+ })
209
+ const window = WindowNode.parse({
210
+ id: 'window_clipboard-standalone',
211
+ parentId: wall.id,
212
+ wallId: wall.id,
213
+ position: [1.5, 1.2, 0],
214
+ })
215
+ useScene.setState((state) => ({
216
+ nodes: {
217
+ ...state.nodes,
218
+ [sourceLevelId]: makeLevel(sourceLevelId, [wall.id]),
219
+ [wall.id]: { ...wall, children: [window.id] },
220
+ [window.id]: window,
221
+ },
222
+ }))
223
+
224
+ expect(copySelectedNodesToEditorClipboard([window.id])).toBe(true)
225
+ const result = pasteEditorClipboardToLevel(targetLevelId)
226
+ expect(result?.pastedIds).toHaveLength(1)
227
+
228
+ const pastedWindow = result?.pastedIds[0]
229
+ ? useScene.getState().nodes[result.pastedIds[0]]
230
+ : undefined
231
+ expect(pastedWindow?.type).toBe('window')
232
+ if (pastedWindow?.type === 'window') {
233
+ expect(pastedWindow.parentId).toBe(targetLevelId)
234
+ expect(pastedWindow.wallId).toBeUndefined()
235
+ expect(pastedWindow.roofSegmentId).toBeUndefined()
236
+ }
237
+ })
238
+
239
+ test('round-trips nodes and custom scene materials through the browser clipboard', async () => {
240
+ let systemClipboardText = ''
241
+ Object.defineProperty(navigator, 'clipboard', {
242
+ configurable: true,
243
+ value: {
244
+ readText: async () => systemClipboardText,
245
+ writeText: async (text: string) => {
246
+ systemClipboardText = text
247
+ },
248
+ },
249
+ })
250
+
251
+ const materialId = 'mat_clipboard-blue' as SceneMaterialId
252
+ const material = SceneMaterial.parse({
253
+ id: materialId,
254
+ name: 'Clipboard blue',
255
+ material: { properties: { color: '#2266dd' } },
256
+ })
257
+ const wall = WallNode.parse({
258
+ id: 'wall_clipboard-material',
259
+ parentId: sourceLevelId,
260
+ start: [0, 0],
261
+ end: [3, 0],
262
+ slots: { exterior: `scene:${materialId}` },
263
+ })
264
+ useScene.setState((state) => ({
265
+ materials: { [materialId]: material },
266
+ nodes: {
267
+ ...state.nodes,
268
+ [sourceLevelId]: makeLevel(sourceLevelId, [wall.id]),
269
+ [wall.id]: wall,
270
+ },
271
+ }))
272
+
273
+ expect(copySelectedNodesToEditorClipboard([wall.id])).toBe(true)
274
+ expect(systemClipboardText).toContain('pascal.scene-nodes')
275
+
276
+ useScene.setState({
277
+ materials: {},
278
+ nodes: {
279
+ [targetLevelId]: makeLevel(targetLevelId),
280
+ },
281
+ rootNodeIds: [targetLevelId],
282
+ } as never)
283
+ useViewer.getState().setSelection({ levelId: targetLevelId, selectedIds: [] })
284
+
285
+ const result = await pasteSystemEditorClipboardToLevel()
286
+ expect(result?.pastedIds).toHaveLength(1)
287
+ expect(result?.createdMaterialIds).toEqual([materialId])
288
+ expect(useScene.getState().materials[materialId]).toEqual(material)
289
+
290
+ const pastedWall = Object.values(useScene.getState().nodes).find((node) => node.type === 'wall')
291
+ expect(pastedWall?.type).toBe('wall')
292
+ if (pastedWall?.type === 'wall') {
293
+ expect(pastedWall.slots?.exterior).toBe(`scene:${materialId}`)
294
+ }
295
+ })
296
+
297
+ test('waits for an in-flight copy before reading the browser clipboard', async () => {
298
+ let systemClipboardText = 'older clipboard contents'
299
+ let finishWrite!: () => void
300
+ const readText = mock(async () => systemClipboardText)
301
+ Object.defineProperty(navigator, 'clipboard', {
302
+ configurable: true,
303
+ value: {
304
+ readText,
305
+ writeText: (text: string) =>
306
+ new Promise<void>((resolve) => {
307
+ finishWrite = () => {
308
+ systemClipboardText = text
309
+ resolve()
310
+ }
311
+ }),
312
+ },
313
+ })
314
+
315
+ expect(copySelectedNodesToEditorClipboard([runId])).toBe(true)
316
+ const paste = pasteSystemEditorClipboardToLevel(targetLevelId)
317
+ await Promise.resolve()
318
+ expect(readText).not.toHaveBeenCalled()
319
+
320
+ finishWrite()
321
+ const result = await paste
322
+ expect(readText).toHaveBeenCalledTimes(1)
323
+ expect(result?.pastedIds).toHaveLength(1)
324
+ })
196
325
  })