@pascal-app/editor 1.0.1 → 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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -1,6 +1,13 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ collectSubtree,
7
+ useLiveNodeOverrides,
8
+ useLiveTransforms,
9
+ useScene,
10
+ } from '@pascal-app/core'
4
11
  import {
5
12
  beginPerfAction,
6
13
  commitPerfAction,
@@ -10,6 +17,7 @@ import {
10
17
  import { useRef } from 'react'
11
18
  import { create } from 'zustand'
12
19
  import { useShallow } from 'zustand/react/shallow'
20
+ import { discardFreshPlacementSubtree } from '../lib/fresh-planar-placement'
13
21
  import {
14
22
  type ActiveInteractionScope,
15
23
  controlPointReshapeInfo,
@@ -25,6 +33,7 @@ import {
25
33
  reshapingNodeId,
26
34
  tangentReshapeInfo,
27
35
  } from '../lib/interaction/scope'
36
+ import usePlacementPreview from './use-placement-preview'
28
37
 
29
38
  // The authoritative interaction state machine. A single owner holds exactly one
30
39
  // scope at a time. `begin` enters an interaction (atomically replacing any prior
@@ -33,8 +42,21 @@ import {
33
42
  // the end of its interaction. There is no setter that can leave the store in an
34
43
  // illegal half-state: the only writable shape is `InteractionScope`.
35
44
 
45
+ type SubtreeCreation = {
46
+ rootId: AnyNodeId
47
+ node: AnyNode
48
+ hydrationId: object | null
49
+ }
50
+ type OwnedSubtree = { creation: SubtreeCreation; gesture: object }
51
+
36
52
  export type InteractionScopeState = {
37
53
  scope: InteractionScope
54
+ gesture: object | null
55
+ pendingSubtree: SubtreeCreation | null
56
+ ownedSubtree: OwnedSubtree | null
57
+ noteSubtreeCreation: (node: AnyNode) => void
58
+ adoptSubtree: (rootId: AnyNodeId) => boolean
59
+ finishSubtree: (rootId: AnyNodeId) => void
38
60
  // Enter an interaction. If one is already active it is ended first, so the
39
61
  // store is always single-owner.
40
62
  begin: (scope: ActiveInteractionScope) => void
@@ -101,9 +123,62 @@ function commitScopePerfAction(): void {
101
123
 
102
124
  const useInteractionScope = create<InteractionScopeState>((set, get) => ({
103
125
  scope: IDLE_SCOPE,
126
+ gesture: null,
127
+ pendingSubtree: null,
128
+ ownedSubtree: null,
129
+ noteSubtreeCreation: (node) =>
130
+ set({
131
+ pendingSubtree: { rootId: node.id, node, hydrationId: useScene.getState().hydrationId },
132
+ }),
133
+ adoptSubtree: (rootId) => {
134
+ const state = get()
135
+ if (state.ownedSubtree?.creation.rootId === rootId)
136
+ return (
137
+ state.ownedSubtree.gesture === state.gesture &&
138
+ isCurrentCreation(state.ownedSubtree.creation)
139
+ )
140
+ const creation = state.pendingSubtree
141
+ if (
142
+ !creation ||
143
+ creation.rootId !== rootId ||
144
+ !state.gesture ||
145
+ movingNodeOf(state.scope)?.id !== rootId ||
146
+ !isCurrentCreation(creation)
147
+ )
148
+ return false
149
+ set({ ownedSubtree: { creation, gesture: state.gesture }, pendingSubtree: null })
150
+ return true
151
+ },
152
+ finishSubtree: (rootId) =>
153
+ set((state) => {
154
+ if (state.ownedSubtree?.creation.rootId !== rootId && state.pendingSubtree?.rootId !== rootId)
155
+ return state
156
+ return {
157
+ ownedSubtree: state.ownedSubtree?.creation.rootId === rootId ? null : state.ownedSubtree,
158
+ pendingSubtree: state.pendingSubtree?.rootId === rootId ? null : state.pendingSubtree,
159
+ }
160
+ }),
104
161
  begin: (scope) => {
162
+ const state = get()
163
+ const nodeId = movingNodeOf(scope)?.id
164
+ const creation = state.ownedSubtree?.creation ?? state.pendingSubtree
165
+ if (
166
+ nodeId &&
167
+ nodeId === movingNodeOf(state.scope)?.id &&
168
+ creation?.rootId === nodeId &&
169
+ isCurrentCreation(creation)
170
+ ) {
171
+ set({ scope })
172
+ return
173
+ }
105
174
  beginScopePerfAction(scope)
106
- set({ scope })
175
+ set({
176
+ scope,
177
+ gesture: {},
178
+ ownedSubtree: null,
179
+ pendingSubtree:
180
+ get().pendingSubtree?.rootId === movingNodeOf(scope)?.id ? get().pendingSubtree : null,
181
+ })
107
182
  },
108
183
  update: (patch) =>
109
184
  set((state) => {
@@ -114,18 +189,92 @@ const useInteractionScope = create<InteractionScopeState>((set, get) => ({
114
189
  end: () => {
115
190
  if (get().scope.kind === 'idle') return
116
191
  commitScopePerfAction()
117
- set({ scope: IDLE_SCOPE })
192
+ set({ scope: IDLE_SCOPE, gesture: null, ownedSubtree: null, pendingSubtree: null })
118
193
  },
119
194
  endIf: (match) => {
120
195
  const scope = get().scope
121
196
  if (scope.kind === 'idle') return
122
197
  if (match(scope)) {
123
198
  commitScopePerfAction()
124
- set({ scope: IDLE_SCOPE })
199
+ set({ scope: IDLE_SCOPE, gesture: null, ownedSubtree: null, pendingSubtree: null })
125
200
  }
126
201
  },
127
202
  }))
128
203
 
204
+ function isCurrentCreation(creation: SubtreeCreation): boolean {
205
+ const scene = useScene.getState()
206
+ return (
207
+ scene.hydrationId === creation.hydrationId && scene.nodes[creation.rootId] === creation.node
208
+ )
209
+ }
210
+
211
+ export function isInteractionSubtreeDraft(
212
+ rootId = movingNodeOf(useInteractionScope.getState().scope)?.id,
213
+ ): boolean {
214
+ const state = useInteractionScope.getState()
215
+ const creation = state.ownedSubtree?.creation ?? state.pendingSubtree
216
+ return Boolean(
217
+ creation &&
218
+ state.gesture &&
219
+ rootId === movingNodeOf(state.scope)?.id &&
220
+ creation.rootId === rootId &&
221
+ (!state.ownedSubtree || state.ownedSubtree.gesture === state.gesture) &&
222
+ isCurrentCreation(creation),
223
+ )
224
+ }
225
+
226
+ // Track the identity of the factory-created node through scene patches, never through metadata.
227
+ // Hydration or deletion invalidates the creation even if a later scene reuses the same id.
228
+ useScene.subscribe((scene, previous) => {
229
+ const state = useInteractionScope.getState()
230
+ for (const creation of [state.pendingSubtree, state.ownedSubtree?.creation]) {
231
+ if (!creation) continue
232
+ if (
233
+ scene.hydrationId !== creation.hydrationId ||
234
+ previous.nodes[creation.rootId] !== creation.node ||
235
+ !scene.nodes[creation.rootId]
236
+ ) {
237
+ state.finishSubtree(creation.rootId)
238
+ } else creation.node = scene.nodes[creation.rootId]!
239
+ }
240
+ })
241
+
242
+ // Factory drafts belong to their gesture even before a mover mounts to adopt them.
243
+ useInteractionScope.subscribe((state, previous) => {
244
+ if (
245
+ state.gesture === previous.gesture &&
246
+ movingNodeOf(state.scope)?.id === movingNodeOf(previous.scope)?.id
247
+ )
248
+ return
249
+ const owned =
250
+ previous.ownedSubtree ??
251
+ (previous.pendingSubtree && previous.gesture
252
+ ? { creation: previous.pendingSubtree, gesture: previous.gesture }
253
+ : null)
254
+ if (
255
+ !owned ||
256
+ owned.gesture !== previous.gesture ||
257
+ movingNodeOf(previous.scope)?.id !== owned.creation.rootId ||
258
+ !isCurrentCreation(owned.creation)
259
+ )
260
+ return
261
+ const node = owned.creation.node
262
+ const subtree = collectSubtree(useScene.getState().nodes, node.id)
263
+ const temporal = useScene.temporal.getState()
264
+ const wasTracking = temporal.isTracking
265
+ temporal.pause()
266
+ try {
267
+ discardFreshPlacementSubtree(node.id)
268
+ for (const entry of subtree ? [subtree.root, ...subtree.descendants] : []) {
269
+ useLiveTransforms.getState().clear(entry.id)
270
+ useLiveNodeOverrides.getState().clear(entry.id)
271
+ }
272
+ if (usePlacementPreview.getState().node?.id === node.id) usePlacementPreview.getState().clear()
273
+ } finally {
274
+ if (wasTracking) temporal.resume()
275
+ }
276
+ })
277
+
129
278
  // Derived, reference-stable views of the active scope, replacing the legacy
130
279
  // `useEditor.activeHandleDrag` / `useEditor.editingHole` flags. `useShallow`
131
280
  // keeps the result reference-stable across unrelated scope changes, so hot-path
@@ -190,8 +339,8 @@ export const useReshapingNode = (): AnyNode | null => {
190
339
  // The node currently being placed or moved. Replaces the legacy
191
340
  // `useEditor.movingNode` flag. Unlike `useReshapingNode`, no `useRef` snapshot is
192
341
  // needed: the node is carried inline in the scope and set once at `begin`, so it
193
- // is already a stable reference for the whole gesture (nothing calls `begin` mid
194
- // drag). Returns null whenever no placing/moving interaction is active.
342
+ // remains stable until a caller explicitly re-arms the same interaction.
343
+ // Returns null whenever no placing/moving interaction is active.
195
344
  export const useMovingNode = (): AnyNode | null => useInteractionScope((s) => movingNodeOf(s.scope))
196
345
 
197
346
  // Imperative (non-React) read for event handlers / effects.