@pascal-app/editor 0.8.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 +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- 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 +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- 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 +5829 -12005
- 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 +178 -149
- 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 +433 -156
- 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 +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- 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-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- 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 +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- 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 +1 -1
- 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 +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- 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 -538
- 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 +56 -0
- 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 +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- 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 +4 -6
- 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 +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -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 +10 -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 +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- 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.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- 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 +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- 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 +320 -37
- 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/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- 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 -264
- 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 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- 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 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- 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 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- 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 -280
- 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 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -8,11 +8,12 @@ import {
|
|
|
8
8
|
} from '@pascal-app/core'
|
|
9
9
|
import { useViewer } from '@pascal-app/viewer'
|
|
10
10
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
11
|
+
import useEditor from '../../../store/use-editor'
|
|
11
12
|
import {
|
|
12
13
|
findWallSnapTarget,
|
|
14
|
+
getSegmentGridStep,
|
|
13
15
|
getWallAngleSnapStep,
|
|
14
|
-
|
|
15
|
-
isWallLongEnough,
|
|
16
|
+
isSegmentLongEnough,
|
|
16
17
|
snapPointTo45Degrees,
|
|
17
18
|
snapPointToGrid,
|
|
18
19
|
type WallPlanPoint,
|
|
@@ -20,6 +21,9 @@ import {
|
|
|
20
21
|
|
|
21
22
|
export type FencePlanPoint = WallPlanPoint
|
|
22
23
|
|
|
24
|
+
const FENCE_CORNER_SNAP_RADIUS = 0.28
|
|
25
|
+
const FENCE_SPAN_SNAP_RADIUS = 0.16
|
|
26
|
+
|
|
23
27
|
type SegmentNode = {
|
|
24
28
|
start: FencePlanPoint
|
|
25
29
|
end: FencePlanPoint
|
|
@@ -57,46 +61,68 @@ function findFenceSnapTarget(
|
|
|
57
61
|
fences: FenceNode[],
|
|
58
62
|
ignoreFenceIds: string[] = [],
|
|
59
63
|
): FencePlanPoint | null {
|
|
60
|
-
const
|
|
64
|
+
const cornerRadiusSquared = FENCE_CORNER_SNAP_RADIUS ** 2
|
|
65
|
+
const spanRadiusSquared = FENCE_SPAN_SNAP_RADIUS ** 2
|
|
61
66
|
const ignoredFenceIds = new Set(ignoreFenceIds)
|
|
62
|
-
let
|
|
63
|
-
let
|
|
67
|
+
let bestCornerTarget: FencePlanPoint | null = null
|
|
68
|
+
let bestCornerDistanceSquared = Number.POSITIVE_INFINITY
|
|
69
|
+
let bestSpanTarget: FencePlanPoint | null = null
|
|
70
|
+
let bestSpanDistanceSquared = Number.POSITIVE_INFINITY
|
|
64
71
|
|
|
65
72
|
for (const fence of fences) {
|
|
66
73
|
if (ignoredFenceIds.has(fence.id)) {
|
|
67
74
|
continue
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
const
|
|
77
|
+
for (const candidate of [fence.start, fence.end]) {
|
|
78
|
+
const candidateDistanceSquared = distanceSquared(point, candidate)
|
|
79
|
+
if (
|
|
80
|
+
candidateDistanceSquared > cornerRadiusSquared ||
|
|
81
|
+
candidateDistanceSquared >= bestCornerDistanceSquared
|
|
82
|
+
) {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
bestCornerTarget = candidate
|
|
87
|
+
bestCornerDistanceSquared = candidateDistanceSquared
|
|
88
|
+
}
|
|
89
|
+
|
|
71
90
|
if (isCurvedWall(fence)) {
|
|
72
91
|
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
|
|
73
|
-
for (let index =
|
|
92
|
+
for (let index = 1; index < sampleCount; index += 1) {
|
|
74
93
|
const frame = getWallCurveFrameAt(fence, index / sampleCount)
|
|
75
|
-
|
|
94
|
+
const candidate: FencePlanPoint = [frame.point.x, frame.point.y]
|
|
95
|
+
const candidateDistanceSquared = distanceSquared(point, candidate)
|
|
96
|
+
if (
|
|
97
|
+
candidateDistanceSquared > spanRadiusSquared ||
|
|
98
|
+
candidateDistanceSquared >= bestSpanDistanceSquared
|
|
99
|
+
) {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
bestSpanTarget = candidate
|
|
104
|
+
bestSpanDistanceSquared = candidateDistanceSquared
|
|
76
105
|
}
|
|
77
106
|
} else {
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
for (const candidate of candidates) {
|
|
107
|
+
const candidate = projectPointOntoSegment(point, fence)
|
|
82
108
|
if (!candidate) {
|
|
83
109
|
continue
|
|
84
110
|
}
|
|
85
111
|
|
|
86
112
|
const candidateDistanceSquared = distanceSquared(point, candidate)
|
|
87
113
|
if (
|
|
88
|
-
candidateDistanceSquared >
|
|
89
|
-
candidateDistanceSquared >=
|
|
114
|
+
candidateDistanceSquared > spanRadiusSquared ||
|
|
115
|
+
candidateDistanceSquared >= bestSpanDistanceSquared
|
|
90
116
|
) {
|
|
91
117
|
continue
|
|
92
118
|
}
|
|
93
119
|
|
|
94
|
-
|
|
95
|
-
|
|
120
|
+
bestSpanTarget = candidate
|
|
121
|
+
bestSpanDistanceSquared = candidateDistanceSquared
|
|
96
122
|
}
|
|
97
123
|
}
|
|
98
124
|
|
|
99
|
-
return
|
|
125
|
+
return bestCornerTarget ?? bestSpanTarget
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
export function snapFenceDraftPoint(args: {
|
|
@@ -106,14 +132,25 @@ export function snapFenceDraftPoint(args: {
|
|
|
106
132
|
start?: FencePlanPoint
|
|
107
133
|
angleSnap?: boolean
|
|
108
134
|
ignoreFenceIds?: string[]
|
|
135
|
+
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
|
|
136
|
+
step?: number
|
|
137
|
+
/**
|
|
138
|
+
* Optional grid-snap function. When provided, replaces the default
|
|
139
|
+
* local-axis snap — lets the 2D floor-plan keep snapping to the
|
|
140
|
+
* world XZ grid even when the building is rotated. Wall / fence
|
|
141
|
+
* endpoint snap precedence is preserved.
|
|
142
|
+
*/
|
|
143
|
+
gridSnap?: (point: FencePlanPoint) => FencePlanPoint
|
|
109
144
|
}): FencePlanPoint {
|
|
110
|
-
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
|
111
|
-
const gridStep =
|
|
145
|
+
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args
|
|
146
|
+
const gridStep = step ?? getSegmentGridStep()
|
|
112
147
|
const angleStep = getWallAngleSnapStep(gridStep)
|
|
113
148
|
const basePoint =
|
|
114
149
|
start && angleSnap
|
|
115
|
-
? snapPointTo45Degrees(start, point, gridStep, angleStep)
|
|
116
|
-
:
|
|
150
|
+
? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap)
|
|
151
|
+
: gridSnap
|
|
152
|
+
? gridSnap(point)
|
|
153
|
+
: snapPointToGrid(point, gridStep)
|
|
117
154
|
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
|
118
155
|
|
|
119
156
|
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
|
@@ -126,12 +163,17 @@ export function createFenceOnCurrentLevel(
|
|
|
126
163
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
127
164
|
const { createNode, nodes } = useScene.getState()
|
|
128
165
|
|
|
129
|
-
if (!(currentLevelId &&
|
|
166
|
+
if (!(currentLevelId && isSegmentLongEnough(start, end))) {
|
|
130
167
|
return null
|
|
131
168
|
}
|
|
132
169
|
|
|
133
170
|
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
|
|
171
|
+
// Build parameters seeded by a placed preset (height, style, post
|
|
172
|
+
// spacing, …) merge in first; `name`/`start`/`end` always win. The
|
|
173
|
+
// schema parse validates and drops anything unexpected.
|
|
174
|
+
const defaults = useEditor.getState().toolDefaults.fence ?? {}
|
|
134
175
|
const fence = FenceNode.parse({
|
|
176
|
+
...defaults,
|
|
135
177
|
name: `Fence ${fenceCount + 1}`,
|
|
136
178
|
start,
|
|
137
179
|
end,
|
|
@@ -1,112 +1,48 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
ColumnNode,
|
|
5
|
-
DoorNode,
|
|
6
|
-
FenceNode,
|
|
7
|
-
ItemNode,
|
|
8
|
-
RoofNode,
|
|
9
|
-
RoofSegmentNode,
|
|
10
|
-
SlabNode,
|
|
11
|
-
SpawnNode,
|
|
12
|
-
StairNode,
|
|
13
|
-
StairSegmentNode,
|
|
14
|
-
WallNode,
|
|
15
|
-
WindowNode,
|
|
16
|
-
} from '@pascal-app/core'
|
|
17
|
-
import { Vector3 } from 'three'
|
|
18
|
-
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
1
|
+
import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
|
|
2
|
+
import { nodeRegistry } from '@pascal-app/core'
|
|
3
|
+
import { Suspense } from 'react'
|
|
19
4
|
import useEditor from '../../../store/use-editor'
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import { MoveDoorTool } from '../door/move-door-tool'
|
|
24
|
-
import { MoveFenceTool } from '../fence/move-fence-tool'
|
|
25
|
-
import { MoveRoofTool } from '../roof/move-roof-tool'
|
|
26
|
-
import { MoveSlabTool } from '../slab/move-slab-tool'
|
|
27
|
-
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
|
28
|
-
import { MoveWallTool } from '../wall/move-wall-tool'
|
|
29
|
-
import { MoveWindowTool } from '../window/move-window-tool'
|
|
30
|
-
import type { PlacementState } from './placement-types'
|
|
31
|
-
import { useDraftNode } from './use-draft-node'
|
|
32
|
-
import { usePlacementCoordinator } from './use-placement-coordinator'
|
|
5
|
+
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
|
|
6
|
+
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
|
|
7
|
+
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
|
33
8
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
? (movingNode.metadata as Record<string, unknown>)
|
|
54
|
-
: {}
|
|
55
|
-
const isNew = !!meta.isNew
|
|
9
|
+
/**
|
|
10
|
+
* MoveTool dispatcher. Routes to (in order):
|
|
11
|
+
*
|
|
12
|
+
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
|
|
13
|
+
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
|
|
14
|
+
* …).
|
|
15
|
+
* 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
|
16
|
+
* via `getRegistryAffordanceTool`. Covers both generic movers
|
|
17
|
+
* (slab / ceiling / wall / fence / column / item / door / window) and
|
|
18
|
+
* the bespoke roof / roof-segment / stair / stair-segment / building
|
|
19
|
+
* movers ported into `@pascal-app/nodes`.
|
|
20
|
+
* 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
|
|
21
|
+
* mover hasn't been ported to a kind-owned affordance yet.
|
|
22
|
+
*/
|
|
23
|
+
export const MoveTool: React.FC<{
|
|
24
|
+
onNodeMoved?: (nodeId: AnyNodeId) => void
|
|
25
|
+
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
|
26
|
+
}> = ({ onNodeMoved }) => {
|
|
27
|
+
const movingNode = useEditor((state) => state.movingNode)
|
|
56
28
|
|
|
57
|
-
|
|
58
|
-
asset: movingNode.asset,
|
|
59
|
-
draftNode,
|
|
60
|
-
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
|
|
61
|
-
initialState: isNew
|
|
62
|
-
? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
|
|
63
|
-
: getInitialState(movingNode),
|
|
64
|
-
// Preserve the original item's scale so Y-position calculations use the correct height
|
|
65
|
-
defaultScale: isNew ? movingNode.scale : undefined,
|
|
66
|
-
initDraft: (gridPosition) => {
|
|
67
|
-
if (isNew) {
|
|
68
|
-
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
|
|
69
|
-
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
|
|
70
|
-
gridPosition.copy(new Vector3(...movingNode.position))
|
|
71
|
-
if (!movingNode.asset.attachTo) {
|
|
72
|
-
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
|
|
73
|
-
}
|
|
74
|
-
} else {
|
|
75
|
-
draftNode.adopt(movingNode)
|
|
76
|
-
gridPosition.copy(new Vector3(...movingNode.position))
|
|
77
|
-
}
|
|
78
|
-
},
|
|
79
|
-
onCommitted: () => {
|
|
80
|
-
sfxEmitter.emit('sfx:item-place')
|
|
81
|
-
useEditor.getState().setMovingNode(null)
|
|
82
|
-
return false
|
|
83
|
-
},
|
|
84
|
-
onCancel: () => {
|
|
85
|
-
draftNode.destroy()
|
|
86
|
-
useEditor.getState().setMovingNode(null)
|
|
87
|
-
},
|
|
88
|
-
})
|
|
29
|
+
if (!movingNode) return null
|
|
89
30
|
|
|
90
|
-
|
|
91
|
-
|
|
31
|
+
const def = nodeRegistry.get(movingNode.type)
|
|
32
|
+
if (def?.capabilities?.movable) {
|
|
33
|
+
return <MoveRegistryNodeTool node={movingNode} />
|
|
34
|
+
}
|
|
92
35
|
|
|
93
|
-
|
|
94
|
-
|
|
36
|
+
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
|
|
37
|
+
if (RegistryMove) {
|
|
38
|
+
return (
|
|
39
|
+
<Suspense fallback={null}>
|
|
40
|
+
<RegistryMove node={movingNode} />
|
|
41
|
+
</Suspense>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
95
44
|
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
|
100
|
-
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
101
|
-
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
|
102
|
-
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
|
103
|
-
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
|
104
|
-
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
|
105
|
-
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
106
|
-
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
107
|
-
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
108
|
-
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
|
|
109
|
-
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
|
110
|
-
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
|
111
|
-
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
|
45
|
+
if (movingNode.type === 'elevator')
|
|
46
|
+
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
|
47
|
+
return null
|
|
112
48
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { stripTransient } from './placement-math'
|
|
3
|
+
|
|
4
|
+
describe('stripTransient', () => {
|
|
5
|
+
test('removes placement-only metadata flags before commit', () => {
|
|
6
|
+
expect(stripTransient({ isNew: true, isTransient: true, label: 'copy' })).toEqual({
|
|
7
|
+
label: 'copy',
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AssetInput, isObject } from '@pascal-app/core'
|
|
2
|
+
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
|
|
2
3
|
import useEditor from '../../../store/use-editor'
|
|
3
4
|
|
|
4
5
|
function getGridSnapStep(): number {
|
|
@@ -110,11 +111,36 @@ export function isValidWallSideFace(normal: [number, number, number] | undefined
|
|
|
110
111
|
return Math.abs(normal[2]) > 0.7
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
/**
|
|
114
|
-
* Strip the `isTransient` flag from node metadata before committing.
|
|
115
|
-
*/
|
|
114
|
+
/** Strip placement-only metadata flags before committing a draft. */
|
|
116
115
|
export function stripTransient(meta: any): any {
|
|
117
116
|
if (!isObject(meta)) return meta
|
|
118
|
-
const {
|
|
119
|
-
|
|
117
|
+
const nextMeta = { ...(meta as Record<string, any>) }
|
|
118
|
+
delete nextMeta.isNew
|
|
119
|
+
delete nextMeta.isTransient
|
|
120
|
+
return nextMeta
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const _up = new Vector3(0, 1, 0)
|
|
124
|
+
const _normal = new Vector3()
|
|
125
|
+
const _quat = new Quaternion()
|
|
126
|
+
const _euler = new Euler()
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute euler rotation that tilts an item so its local +Y aligns with a
|
|
130
|
+
* roof surface normal. The normal is in the hit mesh's local space and is
|
|
131
|
+
* transformed to world space via the mesh's matrixWorld.
|
|
132
|
+
*/
|
|
133
|
+
export function calculateRoofRotation(
|
|
134
|
+
normal: [number, number, number] | undefined,
|
|
135
|
+
objectMatrixWorld: Matrix4,
|
|
136
|
+
): [number, number, number] {
|
|
137
|
+
if (!normal) return [0, 0, 0]
|
|
138
|
+
|
|
139
|
+
_normal.set(normal[0], normal[1], normal[2])
|
|
140
|
+
_normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize()
|
|
141
|
+
|
|
142
|
+
_quat.setFromUnitVectors(_up, _normal)
|
|
143
|
+
_euler.setFromQuaternion(_quat, 'XYZ')
|
|
144
|
+
|
|
145
|
+
return [_euler.x, _euler.y, _euler.z]
|
|
120
146
|
}
|
|
@@ -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,
|
|
@@ -587,6 +607,156 @@ export const itemSurfaceStrategy = {
|
|
|
587
607
|
},
|
|
588
608
|
}
|
|
589
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
|
+
|
|
590
760
|
// ============================================================================
|
|
591
761
|
// VALIDATION
|
|
592
762
|
// ============================================================================
|
|
@@ -603,6 +773,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
603
773
|
return ctx.state.surfaceItemId !== null
|
|
604
774
|
}
|
|
605
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
|
+
|
|
606
781
|
const attachTo = ctx.draftItem.asset.attachTo
|
|
607
782
|
|
|
608
783
|
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
|
|
@@ -635,7 +810,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
635
810
|
// Floor (no attachTo)
|
|
636
811
|
return validators.canPlaceOnFloor(
|
|
637
812
|
ctx.levelId,
|
|
638
|
-
[ctx.gridPosition.x,
|
|
813
|
+
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
639
814
|
alignedDims,
|
|
640
815
|
ctx.draftItem.rotation,
|
|
641
816
|
[ctx.draftItem.id],
|