@pascal-app/editor 0.7.0 → 0.9.1
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.
- package/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -6,16 +6,20 @@ import type {
|
|
|
6
6
|
GridEvent,
|
|
7
7
|
ItemEvent,
|
|
8
8
|
ItemNode,
|
|
9
|
+
ShelfEvent,
|
|
10
|
+
ShelfNode,
|
|
9
11
|
WallEvent,
|
|
10
12
|
WallNode,
|
|
11
13
|
} from '@pascal-app/core'
|
|
12
14
|
import {
|
|
13
15
|
getScaledDimensions,
|
|
14
16
|
isLowProfileItemSurface,
|
|
17
|
+
nodeRegistry,
|
|
15
18
|
sceneRegistry,
|
|
16
19
|
useScene,
|
|
17
20
|
} from '@pascal-app/core'
|
|
18
21
|
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
|
22
|
+
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
|
|
19
23
|
import {
|
|
20
24
|
calculateCursorRotation,
|
|
21
25
|
calculateItemRotation,
|
|
@@ -96,16 +100,22 @@ export const floorStrategy = {
|
|
|
96
100
|
const [dimX, , dimZ] = dims
|
|
97
101
|
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
|
98
102
|
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
// Snap on the world XZ grid (the grid the editor renders) so the
|
|
104
|
+
// item edges land on the visible grid even when the active building
|
|
105
|
+
// is rotated; then project the world point back into building-local
|
|
106
|
+
// for storage. Without this, a rotated building drags placement off
|
|
107
|
+
// the world grid.
|
|
108
|
+
const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
|
109
|
+
const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
|
110
|
+
const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
|
|
111
|
+
const [x, z] = local
|
|
112
|
+
const y = ctx.gridPosition.y
|
|
103
113
|
|
|
104
114
|
return {
|
|
105
|
-
gridPosition: [x,
|
|
106
|
-
cursorPosition: [x,
|
|
107
|
-
cursorRotationY:
|
|
108
|
-
nodeUpdate: { position: [x,
|
|
115
|
+
gridPosition: [x, y, z],
|
|
116
|
+
cursorPosition: [x, y, z],
|
|
117
|
+
cursorRotationY: rotY,
|
|
118
|
+
nodeUpdate: { position: [x, y, z] },
|
|
109
119
|
stopPropagation: false,
|
|
110
120
|
dirtyNodeId: null,
|
|
111
121
|
}
|
|
@@ -123,7 +133,11 @@ export const floorStrategy = {
|
|
|
123
133
|
if (ctx.state.surface !== 'floor') return null
|
|
124
134
|
if (!(ctx.levelId && ctx.draftItem)) return null
|
|
125
135
|
|
|
126
|
-
const pos: [number, number, number] = [
|
|
136
|
+
const pos: [number, number, number] = [
|
|
137
|
+
ctx.gridPosition.x,
|
|
138
|
+
ctx.gridPosition.y,
|
|
139
|
+
ctx.gridPosition.z,
|
|
140
|
+
]
|
|
127
141
|
const valid = validators.canPlaceOnFloor(
|
|
128
142
|
ctx.levelId,
|
|
129
143
|
pos,
|
|
@@ -362,16 +376,19 @@ export const ceilingStrategy = {
|
|
|
362
376
|
// use the ceiling hit's local position rather than world position.
|
|
363
377
|
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
|
364
378
|
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
|
365
|
-
|
|
379
|
+
// Recessed fixtures seat flush with the ceiling plane (body rising into the
|
|
380
|
+
// void above); everything else hangs its full height below the ceiling.
|
|
381
|
+
const seatY = ctx.asset.recessed ? 0 : -itemHeight
|
|
382
|
+
const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
|
|
366
383
|
|
|
367
384
|
return {
|
|
368
385
|
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
|
369
386
|
nodeUpdate: {
|
|
370
|
-
position: [x,
|
|
387
|
+
position: [x, seatY, z],
|
|
371
388
|
parentId: event.node.id,
|
|
372
389
|
},
|
|
373
390
|
cursorRotationY: 0,
|
|
374
|
-
gridPosition: [x,
|
|
391
|
+
gridPosition: [x, seatY, z],
|
|
375
392
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
376
393
|
stopPropagation: true,
|
|
377
394
|
}
|
|
@@ -393,10 +410,13 @@ export const ceilingStrategy = {
|
|
|
393
410
|
|
|
394
411
|
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
|
395
412
|
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
|
396
|
-
|
|
413
|
+
// Recessed fixtures seat flush with the ceiling plane (body rising into the
|
|
414
|
+
// void above); everything else hangs its full height below the ceiling.
|
|
415
|
+
const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
|
|
416
|
+
const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
|
|
397
417
|
|
|
398
418
|
return {
|
|
399
|
-
gridPosition: [x,
|
|
419
|
+
gridPosition: [x, seatY, z],
|
|
400
420
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
401
421
|
cursorRotationY: 0,
|
|
402
422
|
nodeUpdate: null,
|
|
@@ -506,21 +526,22 @@ export const itemSurfaceStrategy = {
|
|
|
506
526
|
|
|
507
527
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
508
528
|
|
|
529
|
+
// Counter-rotate so the draft's world Y rotation stays continuous when
|
|
530
|
+
// the user drags onto a rotated surface item. The cursor wireframe
|
|
531
|
+
// already shows the user's intended world rotation; we just need to
|
|
532
|
+
// store the right local value relative to the new parent.
|
|
533
|
+
const surfaceQuat = new Quaternion()
|
|
534
|
+
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
535
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
536
|
+
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
|
537
|
+
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
538
|
+
|
|
509
539
|
return {
|
|
510
540
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
511
541
|
nodeUpdate: {
|
|
512
542
|
position: [x, y, z],
|
|
513
543
|
parentId: surfaceItem.id,
|
|
514
|
-
rotation: [
|
|
515
|
-
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
|
|
516
|
-
(() => {
|
|
517
|
-
const surfaceQuat = new Quaternion()
|
|
518
|
-
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
519
|
-
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
520
|
-
return ctx.currentCursorRotationY - surfaceWorldY
|
|
521
|
-
})(),
|
|
522
|
-
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
|
|
523
|
-
] as [number, number, number],
|
|
544
|
+
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
|
524
545
|
},
|
|
525
546
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
526
547
|
gridPosition: [x, y, z],
|
|
@@ -586,6 +607,156 @@ export const itemSurfaceStrategy = {
|
|
|
586
607
|
},
|
|
587
608
|
}
|
|
588
609
|
|
|
610
|
+
// ============================================================================
|
|
611
|
+
// SHELF SURFACE STRATEGY
|
|
612
|
+
// ============================================================================
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Resolve the row Y closest to the cursor's local Y. Reads candidate row
|
|
616
|
+
* positions from the kind's `capabilities.surfaces.custom` — the shelf
|
|
617
|
+
* declaration emits one `SurfacePoint` per board's top surface. The
|
|
618
|
+
* strategy stays kind-agnostic at this level: any future "multi-board"
|
|
619
|
+
* kind that declares `surfaces.custom` with upward normals gets the
|
|
620
|
+
* same hit behaviour for free.
|
|
621
|
+
*/
|
|
622
|
+
function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
|
|
623
|
+
const def = nodeRegistry.get('shelf')
|
|
624
|
+
const custom = def?.capabilities?.surfaces?.custom
|
|
625
|
+
if (!custom) return null
|
|
626
|
+
const candidates = custom(shelfNode as AnyNode)
|
|
627
|
+
if (candidates.length === 0) return null
|
|
628
|
+
let best = candidates[0]
|
|
629
|
+
let bestDist = Math.abs(best!.position[1] - localY)
|
|
630
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
631
|
+
const c = candidates[i]
|
|
632
|
+
if (!c) continue
|
|
633
|
+
const dist = Math.abs(c.position[1] - localY)
|
|
634
|
+
if (dist < bestDist) {
|
|
635
|
+
best = c
|
|
636
|
+
bestDist = dist
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return best?.position[1] ?? null
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export const shelfSurfaceStrategy = {
|
|
643
|
+
/**
|
|
644
|
+
* Handle shelf:enter — transition the draft onto the closest shelf
|
|
645
|
+
* row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
|
|
646
|
+
* surface heights from the shelf kind's `surfaces.custom` (one Y per
|
|
647
|
+
* board) instead of `asset.surface.height`. Picks the row whose
|
|
648
|
+
* surface Y is nearest the cursor's local Y so the user can target a
|
|
649
|
+
* specific row by hovering near it.
|
|
650
|
+
*/
|
|
651
|
+
enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
|
|
652
|
+
if (ctx.asset.attachTo) return null
|
|
653
|
+
const shelfNode = event.node as ShelfNode
|
|
654
|
+
|
|
655
|
+
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
|
|
656
|
+
return null
|
|
657
|
+
}
|
|
658
|
+
if (!isUpwardShelfSurfaceHit(event)) return null
|
|
659
|
+
|
|
660
|
+
// Size check: draft footprint must fit on the shelf board (width × depth).
|
|
661
|
+
const ourDims = ctx.draftItem
|
|
662
|
+
? getScaledDimensions(ctx.draftItem)
|
|
663
|
+
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
664
|
+
if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
|
|
665
|
+
|
|
666
|
+
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
|
|
667
|
+
if (!shelfMesh) return null
|
|
668
|
+
|
|
669
|
+
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
|
670
|
+
const localPos = shelfMesh.worldToLocal(worldPos)
|
|
671
|
+
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
|
672
|
+
if (rowY === null) return null
|
|
673
|
+
|
|
674
|
+
const x = snapToGrid(localPos.x, ourDims[0])
|
|
675
|
+
const z = snapToGrid(localPos.z, ourDims[2])
|
|
676
|
+
|
|
677
|
+
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
|
678
|
+
|
|
679
|
+
const surfaceQuat = new Quaternion()
|
|
680
|
+
shelfMesh.getWorldQuaternion(surfaceQuat)
|
|
681
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
682
|
+
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
|
683
|
+
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
|
|
687
|
+
nodeUpdate: {
|
|
688
|
+
position: [x, rowY, z],
|
|
689
|
+
parentId: shelfNode.id,
|
|
690
|
+
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
|
691
|
+
},
|
|
692
|
+
cursorRotationY: ctx.currentCursorRotationY,
|
|
693
|
+
gridPosition: [x, rowY, z],
|
|
694
|
+
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
695
|
+
stopPropagation: true,
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Handle shelf:move — re-derive the closest row each tick so the user
|
|
701
|
+
* can slide between rows without leaving the shelf.
|
|
702
|
+
*/
|
|
703
|
+
move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
|
|
704
|
+
if (ctx.state.surface !== 'shelf-surface') return null
|
|
705
|
+
if (!(ctx.state.shelfId && ctx.draftItem)) return null
|
|
706
|
+
if (event.node.id !== ctx.state.shelfId) return null
|
|
707
|
+
|
|
708
|
+
const shelfNode = event.node as ShelfNode
|
|
709
|
+
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
|
|
710
|
+
if (!shelfMesh) return null
|
|
711
|
+
|
|
712
|
+
const ourDims = getScaledDimensions(ctx.draftItem)
|
|
713
|
+
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
|
714
|
+
const localPos = shelfMesh.worldToLocal(worldPos)
|
|
715
|
+
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
|
716
|
+
if (rowY === null) return null
|
|
717
|
+
|
|
718
|
+
const x = snapToGrid(localPos.x, ourDims[0])
|
|
719
|
+
const z = snapToGrid(localPos.z, ourDims[2])
|
|
720
|
+
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
gridPosition: [x, rowY, z],
|
|
724
|
+
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
725
|
+
cursorRotationY: ctx.currentCursorRotationY,
|
|
726
|
+
nodeUpdate: { position: [x, rowY, z] },
|
|
727
|
+
stopPropagation: true,
|
|
728
|
+
dirtyNodeId: null,
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Handle shelf:click — commit placement on the active row.
|
|
734
|
+
*/
|
|
735
|
+
click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
|
|
736
|
+
if (ctx.state.surface !== 'shelf-surface') return null
|
|
737
|
+
if (!(ctx.draftItem && ctx.state.shelfId)) return null
|
|
738
|
+
if (event.node.id !== ctx.state.shelfId) return null
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
nodeUpdate: {
|
|
742
|
+
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
743
|
+
parentId: ctx.state.shelfId,
|
|
744
|
+
metadata: stripTransient(ctx.draftItem.metadata),
|
|
745
|
+
},
|
|
746
|
+
stopPropagation: true,
|
|
747
|
+
dirtyNodeId: null,
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
|
|
753
|
+
* for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
|
|
754
|
+
* via a tiny `ItemEvent`-shaped adapter — the function only reads
|
|
755
|
+
* `event.normal` + `event.object`. */
|
|
756
|
+
function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
|
|
757
|
+
return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
|
|
758
|
+
}
|
|
759
|
+
|
|
589
760
|
// ============================================================================
|
|
590
761
|
// VALIDATION
|
|
591
762
|
// ============================================================================
|
|
@@ -602,6 +773,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
602
773
|
return ctx.state.surfaceItemId !== null
|
|
603
774
|
}
|
|
604
775
|
|
|
776
|
+
// Shelf surface: same — size check already happened on enter
|
|
777
|
+
if (ctx.state.surface === 'shelf-surface') {
|
|
778
|
+
return ctx.state.shelfId !== null
|
|
779
|
+
}
|
|
780
|
+
|
|
605
781
|
const attachTo = ctx.draftItem.asset.attachTo
|
|
606
782
|
|
|
607
783
|
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
|
|
@@ -634,7 +810,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
634
810
|
// Floor (no attachTo)
|
|
635
811
|
return validators.canPlaceOnFloor(
|
|
636
812
|
ctx.levelId,
|
|
637
|
-
[ctx.gridPosition.x,
|
|
813
|
+
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
638
814
|
alignedDims,
|
|
639
815
|
ctx.draftItem.rotation,
|
|
640
816
|
[ctx.draftItem.id],
|
|
@@ -12,7 +12,7 @@ import type { Vector3 } from 'three'
|
|
|
12
12
|
// PLACEMENT STATE
|
|
13
13
|
// ============================================================================
|
|
14
14
|
|
|
15
|
-
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
|
|
15
|
+
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Tracks which surface the draft item is currently on.
|
|
@@ -23,6 +23,13 @@ export interface PlacementState {
|
|
|
23
23
|
wallId: string | null
|
|
24
24
|
ceilingId: string | null
|
|
25
25
|
surfaceItemId: string | null
|
|
26
|
+
/**
|
|
27
|
+
* Active shelf when `surface === 'shelf-surface'`. Items host on the
|
|
28
|
+
* shelf board closest to the cursor's local Y; the row index isn't
|
|
29
|
+
* stored separately because every move re-derives it from cursor
|
|
30
|
+
* position via `shelfRowSurfaceYs`.
|
|
31
|
+
*/
|
|
32
|
+
shelfId: string | null
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
// ============================================================================
|
|
@@ -58,6 +65,7 @@ export interface PlacementResult {
|
|
|
58
65
|
gridPosition: [number, number, number]
|
|
59
66
|
cursorPosition: [number, number, number]
|
|
60
67
|
cursorRotationY: number
|
|
68
|
+
cursorRotation?: [number, number, number]
|
|
61
69
|
nodeUpdate: Partial<ItemNode> | null
|
|
62
70
|
stopPropagation: boolean
|
|
63
71
|
dirtyNodeId: AnyNode['id'] | null
|
|
@@ -72,6 +80,7 @@ export interface TransitionResult {
|
|
|
72
80
|
gridPosition: [number, number, number]
|
|
73
81
|
cursorPosition: [number, number, number]
|
|
74
82
|
cursorRotationY: number
|
|
83
|
+
cursorRotation?: [number, number, number]
|
|
75
84
|
stopPropagation: boolean
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
130
130
|
useScene.getState().updateNode(draft.id, {
|
|
131
131
|
position: updateProps.position ?? draft.position,
|
|
132
132
|
rotation: updateProps.rotation ?? draft.rotation,
|
|
133
|
-
scale: updateProps.scale ?? draft.scale,
|
|
134
133
|
side: updateProps.side ?? draft.side,
|
|
135
134
|
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
|
136
135
|
parentId: parentId as string,
|
|
@@ -180,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle {
|
|
|
180
179
|
if (!draftRef.current) return
|
|
181
180
|
|
|
182
181
|
if (adoptedRef.current && originalStateRef.current) {
|
|
183
|
-
// Move mode: restore original state instead of deleting
|
|
182
|
+
// Move mode: restore original state instead of deleting — but only
|
|
183
|
+
// if no other system has already committed a new position for this
|
|
184
|
+
// node. The 2D `FloorplanRegistryMoveOverlay` commits via
|
|
185
|
+
// `useScene.updateNodes` before unmounting the legacy mover, and
|
|
186
|
+
// an unconditional restore here would wipe that commit. By
|
|
187
|
+
// comparing the live state to the snapshot we took in `adopt()`,
|
|
188
|
+
// we let an external committer's write stick.
|
|
184
189
|
const original = originalStateRef.current
|
|
185
190
|
const id = draftRef.current.id
|
|
191
|
+
const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
|
|
192
|
+
const livePosition = live?.position
|
|
193
|
+
const externallyMoved =
|
|
194
|
+
!!livePosition &&
|
|
195
|
+
(livePosition[0] !== original.position[0] ||
|
|
196
|
+
livePosition[1] !== original.position[1] ||
|
|
197
|
+
livePosition[2] !== original.position[2])
|
|
198
|
+
if (externallyMoved) {
|
|
199
|
+
draftRef.current = null
|
|
200
|
+
adoptedRef.current = false
|
|
201
|
+
originalStateRef.current = null
|
|
202
|
+
return
|
|
203
|
+
}
|
|
186
204
|
|
|
187
205
|
useScene.getState().updateNode(id, {
|
|
188
206
|
position: original.position,
|