@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
@@ -0,0 +1,463 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ createSceneApi,
5
+ findLevelAncestorId,
6
+ type GridEvent,
7
+ NON_PHYSICAL_HOST_KINDS,
8
+ type NodeEvent,
9
+ nodeRegistry,
10
+ resolveSurfacePlacement,
11
+ sceneRegistry,
12
+ useLiveTransforms,
13
+ useScene,
14
+ wouldCreateHostingCycle,
15
+ } from '@pascal-app/core'
16
+ import { boxCorners } from '@pascal-app/core/procedural-items'
17
+ import { type Camera, Euler, Quaternion, Vector3 } from 'three'
18
+ import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
19
+ import {
20
+ surfaceAttachmentId,
21
+ surfaceFramePose,
22
+ updateSurfaceNode,
23
+ } from '../../../lib/surface-attachment'
24
+ import { snapToGrid, snapToHalf } from '../item/placement-math'
25
+ import { createShelfStickiness } from '../shared/shelf-stickiness'
26
+ import { itemEventToSurfaceHit } from '../shared/surface-hit'
27
+ import {
28
+ createSurfaceEventOwnership,
29
+ createSurfaceRejectionFeedback,
30
+ } from '../shared/surface-rejection'
31
+
32
+ export function createItemSurfacePointerArbitration() {
33
+ let hostId: string | null = null
34
+ let hitEvent: object | null = null
35
+ return {
36
+ hit(id: string, event: object) {
37
+ hostId = id
38
+ hitEvent = event
39
+ },
40
+ clear() {
41
+ hostId = null
42
+ hitEvent = null
43
+ },
44
+ blocksGrid(event: object) {
45
+ return hostId !== null && event === hitEvent
46
+ },
47
+ }
48
+ }
49
+
50
+ function pointerEventOf(event: GridEvent | NodeEvent<AnyNode>): object {
51
+ return event.nativeEvent.nativeEvent ?? event.nativeEvent
52
+ }
53
+
54
+ export function createItemSurfaceGridDispatch(apply: (event: GridEvent) => void) {
55
+ let pending: GridEvent | null = null
56
+ let timer: ReturnType<typeof setTimeout> | undefined
57
+ const cancel = () => {
58
+ clearTimeout(timer)
59
+ timer = undefined
60
+ pending = null
61
+ }
62
+ const flush = () => {
63
+ const event = pending
64
+ cancel()
65
+ if (event) apply(event)
66
+ }
67
+ return {
68
+ schedule(event: GridEvent) {
69
+ pending = event
70
+ // Canvas grid listeners precede R3F's wrapper listener. A task waits for both;
71
+ // a microtask can run between native DOM listeners, before the host hit arrives.
72
+ if (timer === undefined) timer = setTimeout(flush, 0)
73
+ },
74
+ flush,
75
+ cancel,
76
+ }
77
+ }
78
+
79
+ export type ItemSurfaceGrab = {
80
+ hostId: string
81
+ start: [number, number, number]
82
+ anchor: [number, number, number] | null
83
+ }
84
+
85
+ export function resolveItemSurfaceGrab(
86
+ grab: ItemSurfaceGrab | null,
87
+ hostId: string,
88
+ raw: [number, number, number],
89
+ ): { grab: ItemSurfaceGrab | null; position: [number, number, number] } {
90
+ if (!grab || grab.hostId !== hostId) return { grab: null, position: raw }
91
+ const anchor = grab.anchor ?? raw
92
+ return {
93
+ grab: { ...grab, anchor },
94
+ position: [grab.start[0] + (raw[0] - anchor[0]), raw[1], grab.start[2] + (raw[2] - anchor[2])],
95
+ }
96
+ }
97
+
98
+ function parentWorldYaw(parentId: string | null | undefined): number {
99
+ const mesh = parentId ? sceneRegistry.nodes.get(parentId) : undefined
100
+ return mesh
101
+ ? new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y
102
+ : 0
103
+ }
104
+
105
+ export function createRegistryItemSurfaceMove(node: AnyNode) {
106
+ const capabilities = nodeRegistry.get(node.type)?.capabilities
107
+ const floorPlaced = capabilities?.floorPlaced
108
+ if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) return null
109
+
110
+ const original = node as AnyNode & {
111
+ position: [number, number, number]
112
+ rotation: number | [number, number, number]
113
+ supportSlabId?: string
114
+ }
115
+ const originalSurfaceId = surfaceAttachmentId(node)
116
+ let changed = false
117
+ let valid = true
118
+ const feedback = createSurfaceRejectionFeedback()
119
+ const scene = createSceneApi(useScene)
120
+ const pointer = createItemSurfacePointerArbitration()
121
+ const ownership = createSurfaceEventOwnership()
122
+ const originalParent = original.parentId
123
+ ? useScene.getState().nodes[original.parentId as AnyNodeId]
124
+ : null
125
+ const initialGrab = (): ItemSurfaceGrab | null =>
126
+ originalParent &&
127
+ !NON_PHYSICAL_HOST_KINDS.includes(originalParent.type) &&
128
+ !isFreshPlacementMetadata(original.metadata)
129
+ ? {
130
+ hostId: originalParent.id,
131
+ start: surfaceFramePose(
132
+ original.parentId,
133
+ originalSurfaceId,
134
+ {
135
+ position: original.position,
136
+ rotation: Array.isArray(original.rotation)
137
+ ? original.rotation
138
+ : [0, original.rotation, 0],
139
+ },
140
+ false,
141
+ ).position,
142
+ anchor: null,
143
+ }
144
+ : null
145
+ let grab = initialGrab()
146
+ const cursorRayIntersectsShelf = createShelfStickiness()
147
+ const liveNode = () => useScene.getState().nodes[node.id] ?? node
148
+ const levelId = () => findLevelAncestorId(node.id, useScene.getState().nodes)
149
+ const rotation = (yaw: number) =>
150
+ Array.isArray(original.rotation)
151
+ ? ([original.rotation[0], yaw, original.rotation[2]] as [number, number, number])
152
+ : yaw
153
+ const write = (
154
+ parentId: string | null | undefined,
155
+ position: [number, number, number],
156
+ yaw: number,
157
+ fullRotation?: readonly [number, number, number],
158
+ surfaceId?: string | null,
159
+ ) => {
160
+ changed = true
161
+ // Hosted renderers and floorplan builders need the same parent-local pose.
162
+ updateSurfaceNode(
163
+ node.id,
164
+ {
165
+ parentId,
166
+ position,
167
+ rotation: fullRotation ? [...fullRotation] : rotation(yaw),
168
+ supportSlabId: undefined,
169
+ } as Partial<AnyNode>,
170
+ surfaceId,
171
+ )
172
+ useLiveTransforms.getState().clear(node.id)
173
+ // A floor move and host return can share a React batch with unchanged final props.
174
+ // Restore the local mesh pose too, so an imperative floor preview cannot survive it.
175
+ const mesh = sceneRegistry.nodes.get(node.id)
176
+ if (mesh) {
177
+ mesh.position.set(...position)
178
+ const angles = fullRotation ?? rotation(yaw)
179
+ mesh.rotation.set(...(typeof angles === 'number' ? ([0, angles, 0] as const) : angles))
180
+ }
181
+ }
182
+ const session = {
183
+ get rejection() {
184
+ return feedback.reason
185
+ },
186
+ clearRejectionForGrid(event: GridEvent) {
187
+ feedback.grid(pointerEventOf(event))
188
+ },
189
+ get valid() {
190
+ return valid && !feedback.reason
191
+ },
192
+ get hosted() {
193
+ const parentId = liveNode().parentId
194
+ const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : null
195
+ return !!parent && !NON_PHYSICAL_HOST_KINDS.includes(parent.type)
196
+ },
197
+ worldYaw(yaw: number) {
198
+ const live = liveNode() as typeof original
199
+ const pose = surfaceFramePose(
200
+ live.parentId,
201
+ surfaceAttachmentId(live),
202
+ { position: live.position, rotation: [0, yaw, 0] },
203
+ false,
204
+ )
205
+ return pose.rotation[1] + parentWorldYaw(live.parentId)
206
+ },
207
+ planPose(position: [number, number, number], yaw: number) {
208
+ const parentId = liveNode().parentId
209
+ const parentMesh = parentId ? sceneRegistry.nodes.get(parentId) : undefined
210
+ const level = levelId()
211
+ const levelMesh = level ? sceneRegistry.nodes.get(level) : undefined
212
+ const local = surfaceFramePose(
213
+ parentId,
214
+ surfaceAttachmentId(liveNode()),
215
+ { position, rotation: [0, yaw, 0] },
216
+ false,
217
+ )
218
+ const point = new Vector3(...local.position)
219
+ parentMesh?.localToWorld(point)
220
+ levelMesh?.worldToLocal(point)
221
+ return {
222
+ position: [point.x, point.y, point.z] as [number, number, number],
223
+ rotationY: session.worldYaw(yaw) - parentWorldYaw(level),
224
+ }
225
+ },
226
+ enter(event: NodeEvent<AnyNode>, dimensions: [number, number, number], yaw: number) {
227
+ if (!ownership.allows(event.node.id, pointerEventOf(event))) return null
228
+ pointer.clear()
229
+ feedback.clear()
230
+ const live = liveNode()
231
+ if (floorPlaced.applies && !floorPlaced.applies(live)) return null
232
+ const host = useScene.getState().nodes[event.node.id]
233
+ if (!host || NON_PHYSICAL_HOST_KINDS.includes(host.type)) return null
234
+ if (wouldCreateHostingCycle(node.id, host, scene)) return null
235
+ const mesh = sceneRegistry.nodes.get(host.id)
236
+ if (!mesh) return null
237
+ const raw = mesh.worldToLocal(new Vector3(...event.position)).toArray()
238
+ const corrected = resolveItemSurfaceGrab(grab, host.id, raw)
239
+ const position = mesh.localToWorld(new Vector3(...corrected.position)).toArray()
240
+ // Keep the legacy world round-trip so existing poses retain identical floating-point values.
241
+ const hit = itemEventToSurfaceHit(host, { ...event, position })
242
+ if (!hit) return null
243
+ const counterHit =
244
+ host.type === 'cabinet' || host.type === 'procedural-item'
245
+ ? itemEventToSurfaceHit(host, event)
246
+ : null
247
+ const stayingOnShelf = host.type === 'shelf' && live.parentId === host.id
248
+ const localYaw = session.worldYaw(yaw) - parentWorldYaw(host.id)
249
+ const bounds = capabilities?.dragBounds?.(live, scene.nodes())
250
+ const center = bounds?.center
251
+ const localRotation = rotation(localYaw)
252
+ const offset = center
253
+ ? new Vector3(...center).applyEuler(
254
+ new Euler(
255
+ ...(Array.isArray(localRotation) ? localRotation : ([0, localYaw, 0] as const)),
256
+ ),
257
+ )
258
+ : new Vector3()
259
+ if (host.type !== 'item') {
260
+ const origin = [...hit.point]
261
+ if (!corrected.grab) {
262
+ origin[0]! -= offset.x
263
+ origin[2]! -= offset.z
264
+ }
265
+ hit.point = [
266
+ snapToGrid(origin[0]! + offset.x, dimensions[0]) - offset.x,
267
+ origin[1]!,
268
+ snapToGrid(origin[2]! + offset.z, dimensions[2]) - offset.z,
269
+ ]
270
+ if (stayingOnShelf) hit.normalWorldY = 1
271
+ }
272
+ const placement = resolveSurfacePlacement({
273
+ host,
274
+ childKind: node.type,
275
+ childId: node.id,
276
+ childFootprint: {
277
+ size: dimensions,
278
+ rotationY: localYaw,
279
+ rotation: Array.isArray(localRotation) ? localRotation : [0, localYaw, 0],
280
+ localBounds: center
281
+ ? {
282
+ min: center.map((v, i) => v - dimensions[i]! / 2) as [number, number, number],
283
+ max: center.map((v, i) => v + dimensions[i]! / 2) as [number, number, number],
284
+ }
285
+ : undefined,
286
+ },
287
+ hit: counterHit ?? hit,
288
+ origin: counterHit ? hit.point : undefined,
289
+ scene,
290
+ snapScalar: host.type !== 'item' ? undefined : snapToGrid,
291
+ checkFootprint: true,
292
+ onReject: (reason) => {
293
+ if (
294
+ live.parentId !== host.id &&
295
+ !counterHit &&
296
+ host.type !== 'item' &&
297
+ host.type !== 'shelf' &&
298
+ reason !== 'surface-cutout' &&
299
+ reason !== 'surface-occupied'
300
+ )
301
+ return
302
+ if (reason === 'surface-cutout' || reason === 'surface-occupied') {
303
+ ownership.claim(host.id, pointerEventOf(event))
304
+ pointer.hit(host.id, pointerEventOf(event))
305
+ event.stopPropagation()
306
+ }
307
+ feedback.reject(reason, pointerEventOf(event))
308
+ },
309
+ })
310
+ if (!placement) return null
311
+ valid = true
312
+ const childPose =
313
+ placement.childFrame === 'surface-local' ? placement.surfaceLocal : placement
314
+ if (!childPose) return null
315
+ const pose = {
316
+ position: [...childPose.position] as [number, number, number],
317
+ rotationY: childPose.rotationY,
318
+ worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
319
+ }
320
+ grab = corrected.grab
321
+ ownership.claim(host.id, pointerEventOf(event))
322
+ pointer.hit(host.id, pointerEventOf(event))
323
+ event.stopPropagation()
324
+ write(
325
+ host.id,
326
+ pose.position,
327
+ pose.rotationY,
328
+ placement.childFrame === 'surface-local' ? placement.surfaceLocal?.rotation : undefined,
329
+ placement.surfaceId,
330
+ )
331
+ return pose
332
+ },
333
+ blocksGrid(event: GridEvent, camera?: Camera) {
334
+ return (
335
+ session.hosted &&
336
+ (pointer.blocksGrid(pointerEventOf(event)) ||
337
+ Boolean(camera && cursorRayIntersectsShelf(liveNode().parentId, camera, event.position)))
338
+ )
339
+ },
340
+ leave(event: NodeEvent<AnyNode>, yaw: number) {
341
+ if (event.node.id !== liveNode().parentId) return null
342
+ if (event.node.type === 'shelf' || event.node.type === 'cabinet') return null
343
+ return session.detach(event.position, yaw)
344
+ },
345
+ detach(worldPosition: [number, number, number], yaw: number) {
346
+ if (!session.hosted) return null
347
+ const level = levelId()
348
+ if (!level) return null
349
+ pointer.clear()
350
+ feedback.clear()
351
+ valid = true
352
+ grab = null
353
+ const point = new Vector3(...worldPosition)
354
+ sceneRegistry.nodes.get(level)?.worldToLocal(point)
355
+ const rotationY = session.worldYaw(yaw) - parentWorldYaw(level)
356
+ const position: [number, number, number] = [snapToHalf(point.x), 0, snapToHalf(point.z)]
357
+ write(level, position, rotationY)
358
+ return { position, rotationY }
359
+ },
360
+ rotate(yaw: number) {
361
+ if (!session.hosted) return null
362
+ const live = liveNode() as typeof original
363
+ const position: [number, number, number] = [...live.position]
364
+ const host = live.parentId ? scene.get(live.parentId as AnyNodeId) : undefined
365
+ const storedRotation = Array.isArray(live.rotation)
366
+ ? ([live.rotation[0], yaw, live.rotation[2]] as [number, number, number])
367
+ : ([0, yaw, 0] as [number, number, number])
368
+ const local = surfaceFramePose(
369
+ live.parentId,
370
+ surfaceAttachmentId(live),
371
+ { position, rotation: storedRotation },
372
+ false,
373
+ )
374
+ if (host?.type === 'procedural-item') position.splice(0, 3, ...local.position)
375
+ if (host) {
376
+ const footprint = floorPlaced.footprint?.(live, { nodes: scene.nodes() })
377
+ const bounds:
378
+ | { size: [number, number, number]; center?: [number, number, number] }
379
+ | undefined =
380
+ capabilities?.dragBounds?.(live, scene.nodes()) ??
381
+ (footprint ? { size: footprint.dimensions } : undefined)
382
+ if (!bounds) return null
383
+ const center = bounds.center ?? [0, bounds.size[1] / 2, 0]
384
+ const localRotation = host.type === 'procedural-item' ? local.rotation : rotation(yaw)
385
+ const euler = new Euler(
386
+ ...(Array.isArray(localRotation) ? localRotation : ([0, yaw, 0] as const)),
387
+ )
388
+ const offset = new Vector3(...center).applyEuler(euler)
389
+ const localBounds = {
390
+ min: center.map((v, i) => v - bounds.size[i]! / 2) as [number, number, number],
391
+ max: center.map((v, i) => v + bounds.size[i]! / 2) as [number, number, number],
392
+ }
393
+ const bottom = Math.min(
394
+ ...boxCorners(localBounds.min, localBounds.max).map(
395
+ (point) => new Vector3(...point).applyEuler(euler).y,
396
+ ),
397
+ )
398
+ feedback.clear()
399
+ const placement = resolveSurfacePlacement({
400
+ host,
401
+ childKind: live.type,
402
+ childId: live.id,
403
+ childFootprint: {
404
+ size: bounds.size,
405
+ rotationY: Array.isArray(localRotation) ? localRotation[1] : localRotation,
406
+ rotation: Array.isArray(localRotation) ? localRotation : [0, yaw, 0],
407
+ localBounds,
408
+ },
409
+ hit: {
410
+ point:
411
+ host.type === 'item'
412
+ ? position
413
+ : [position[0] + offset.x, position[1] + bottom, position[2] + offset.z],
414
+ normalWorldY: 1,
415
+ },
416
+ origin: position,
417
+ scene,
418
+ onReject: (reason) => feedback.reject(reason),
419
+ })
420
+ valid = !!placement
421
+ if (!placement) {
422
+ // Null means free rotation to the caller, so return the unchanged stored pose.
423
+ return {
424
+ position: [...live.position] as [number, number, number],
425
+ rotationY: Array.isArray(live.rotation) ? live.rotation[1] : live.rotation,
426
+ }
427
+ }
428
+ if (host.type === 'procedural-item') {
429
+ const pose =
430
+ placement.childFrame === 'surface-local'
431
+ ? placement.surfaceLocal!
432
+ : { ...placement, rotation: local.rotation }
433
+ write(host.id, [...pose.position], pose.rotationY, pose.rotation, placement.surfaceId)
434
+ return {
435
+ position: [...pose.position] as [number, number, number],
436
+ rotationY: pose.rotationY,
437
+ }
438
+ }
439
+ position[1] = placement.position[1]
440
+ }
441
+ write(live.parentId, position, yaw)
442
+ return { position, rotationY: yaw }
443
+ },
444
+ restore() {
445
+ feedback.clear()
446
+ valid = true
447
+ pointer.clear()
448
+ grab = initialGrab()
449
+ if (!changed || !useScene.getState().nodes[node.id]) return
450
+ updateSurfaceNode(
451
+ node.id,
452
+ {
453
+ parentId: original.parentId,
454
+ position: original.position,
455
+ rotation: original.rotation,
456
+ supportSlabId: original.supportSlabId,
457
+ } as Partial<AnyNode>,
458
+ originalSurfaceId,
459
+ )
460
+ },
461
+ }
462
+ return session
463
+ }