@pascal-app/editor 1.0.0-beta.5 → 1.0.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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +78 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +63 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +44 -21
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +103 -10
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +5 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +455 -0
- package/src/lib/portable-export.ts +1042 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +38 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -6,14 +6,20 @@ import {
|
|
|
6
6
|
type CeilingEvent,
|
|
7
7
|
collectAlignmentAnchors,
|
|
8
8
|
emitter,
|
|
9
|
+
findLevelAncestorId,
|
|
9
10
|
type GridEvent,
|
|
10
11
|
getScaledDimensions,
|
|
11
12
|
type ItemEvent,
|
|
12
13
|
movingFootprintAnchors,
|
|
14
|
+
type NodeEvent,
|
|
15
|
+
nodeRegistry,
|
|
13
16
|
type RoofEvent,
|
|
17
|
+
resolveFrozenFloorPlacementPatch,
|
|
14
18
|
resolveLevelId,
|
|
15
19
|
type ShelfEvent,
|
|
20
|
+
type SurfaceRejectReason,
|
|
16
21
|
sceneRegistry,
|
|
22
|
+
useLiveNodeOverrides,
|
|
17
23
|
useLiveTransforms,
|
|
18
24
|
useScene,
|
|
19
25
|
useSpatialQuery,
|
|
@@ -25,16 +31,13 @@ import { Html } from '@react-three/drei'
|
|
|
25
31
|
import { useFrame, useThree } from '@react-three/fiber'
|
|
26
32
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
27
33
|
import {
|
|
28
|
-
Box3,
|
|
29
34
|
Euler,
|
|
30
35
|
type Group,
|
|
31
36
|
type LineSegments,
|
|
32
|
-
Matrix4,
|
|
33
37
|
type Mesh,
|
|
34
38
|
type Object3D,
|
|
35
39
|
PlaneGeometry,
|
|
36
40
|
Quaternion,
|
|
37
|
-
Ray,
|
|
38
41
|
Vector3,
|
|
39
42
|
} from 'three'
|
|
40
43
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
@@ -45,16 +48,29 @@ import {
|
|
|
45
48
|
} from '../../../lib/active-placement-surface'
|
|
46
49
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
47
50
|
import { formatLinearMeasurement } from '../../../lib/measurements'
|
|
51
|
+
import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
|
|
48
52
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
49
|
-
|
|
53
|
+
import {
|
|
54
|
+
surfaceAttachmentId,
|
|
55
|
+
surfaceFramePose,
|
|
56
|
+
updateSurfaceNode,
|
|
57
|
+
} from '../../../lib/surface-attachment'
|
|
50
58
|
import {
|
|
51
59
|
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
|
52
60
|
resolveAlignmentForActiveBuilding,
|
|
53
61
|
} from '../../../lib/world-grid-snap'
|
|
54
62
|
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
|
55
|
-
import useEditor, {
|
|
56
|
-
|
|
63
|
+
import useEditor, {
|
|
64
|
+
isAlignmentGuideActive,
|
|
65
|
+
isGridSnapActive,
|
|
66
|
+
isMagneticSnapActive,
|
|
67
|
+
} from '../../../store/use-editor'
|
|
57
68
|
import useFacingPose from '../../../store/use-facing-pose'
|
|
69
|
+
import usePlacementPreview from '../../../store/use-placement-preview'
|
|
70
|
+
import {
|
|
71
|
+
createItemSurfaceGridDispatch,
|
|
72
|
+
createItemSurfacePointerArbitration,
|
|
73
|
+
} from '../registry/item-surface-move'
|
|
58
74
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
59
75
|
import {
|
|
60
76
|
createLineGeometry,
|
|
@@ -63,9 +79,19 @@ import {
|
|
|
63
79
|
updateLineGeometry,
|
|
64
80
|
} from '../shared/placement-box-geometry'
|
|
65
81
|
import {
|
|
82
|
+
type PointerSupportSurface,
|
|
66
83
|
resolvePointerSupportElevation,
|
|
67
84
|
resolvePointerSupportSurface,
|
|
68
85
|
} from '../shared/pointer-support-cap'
|
|
86
|
+
import { createShelfStickiness } from '../shared/shelf-stickiness'
|
|
87
|
+
import { createSurfaceRejectionFeedback } from '../shared/surface-rejection'
|
|
88
|
+
import { shouldCreateFloorDraft } from './draft-creation'
|
|
89
|
+
import { commitFaceHostClick, resolveFaceHostPreviewCommit } from './face-host-commit'
|
|
90
|
+
import {
|
|
91
|
+
applyFaceHostPreviewPose,
|
|
92
|
+
resolveFaceHostSwitch,
|
|
93
|
+
shouldDetachFaceHostOnLeave,
|
|
94
|
+
} from './face-host-preview'
|
|
69
95
|
import {
|
|
70
96
|
getDetachedAttachmentPreviewLift,
|
|
71
97
|
getGridAlignedDimensions,
|
|
@@ -77,12 +103,14 @@ import {
|
|
|
77
103
|
import {
|
|
78
104
|
ceilingStrategy,
|
|
79
105
|
checkCanPlace,
|
|
106
|
+
faceHostStrategy,
|
|
80
107
|
floorStrategy,
|
|
81
108
|
itemSurfaceStrategy,
|
|
82
109
|
roofWallStrategy,
|
|
83
110
|
shelfSurfaceStrategy,
|
|
84
111
|
wallStrategy,
|
|
85
112
|
} from './placement-strategies'
|
|
113
|
+
import { resolveItemPlacementSurfaceNormal } from './placement-surface'
|
|
86
114
|
import type { PlacementState, TransitionResult } from './placement-types'
|
|
87
115
|
import type { DraftNodeHandle } from './use-draft-node'
|
|
88
116
|
|
|
@@ -178,6 +206,27 @@ function getGridAlignedPreviewNode(item: ItemNode): ItemNode {
|
|
|
178
206
|
}
|
|
179
207
|
}
|
|
180
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Building-local Y of the storey the floor-path ghost belongs to.
|
|
211
|
+
*
|
|
212
|
+
* The cursor group is mounted inside ToolManager's building-local group, which
|
|
213
|
+
* carries no per-floor elevation, while every floor-path position (grid
|
|
214
|
+
* position, `getFloorVisualPosition`) is LEVEL-local — so on an upper storey the
|
|
215
|
+
* wireframe and its dimension labels render a floor too low. The wall / ceiling
|
|
216
|
+
* / item-surface paths don't need this: they convert a world hit through
|
|
217
|
+
* `worldToBuildingLocal`, which already carries the storey.
|
|
218
|
+
*
|
|
219
|
+
* Read off the level mesh (same source as `LevelOffsetGroup`) rather than the
|
|
220
|
+
* stored elevation so the ghost also follows the exploded-view lerp.
|
|
221
|
+
*/
|
|
222
|
+
function getPlacementLevelY(draft: ItemNode | null | undefined): number {
|
|
223
|
+
const levelId =
|
|
224
|
+
(draft ? findLevelAncestorId(draft.id, useScene.getState().nodes) : null) ??
|
|
225
|
+
useViewer.getState().selection.levelId
|
|
226
|
+
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null
|
|
227
|
+
return levelMesh ? levelMesh.position.y : 0
|
|
228
|
+
}
|
|
229
|
+
|
|
181
230
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
|
182
231
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
|
183
232
|
const edgeMaterial = new LineBasicNodeMaterial({
|
|
@@ -280,6 +329,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
280
329
|
// by the MAX overlapping slab and a deck above the aimed-at floor
|
|
281
330
|
// captures the item (and the grid-plane feedback makes it blink).
|
|
282
331
|
const pointerSupportCapRef = useRef<number | null>(null)
|
|
332
|
+
const pointerSupportSurfaceRef = useRef<PointerSupportSurface | null>(null)
|
|
333
|
+
const frozenSupportSlabIdRef = useRef<string | undefined>(undefined)
|
|
283
334
|
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
|
284
335
|
|
|
285
336
|
// Live camera ref — the shelf-stickiness test reconstructs the cursor world
|
|
@@ -427,17 +478,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
427
478
|
): [number, number, number] => {
|
|
428
479
|
const draft = draftNode.current
|
|
429
480
|
if (!(draft && !asset?.attachTo)) return position
|
|
430
|
-
const previewNode = getGridAlignedPreviewNode({
|
|
481
|
+
const previewNode = getGridAlignedPreviewNode({
|
|
482
|
+
...draft,
|
|
483
|
+
...nodeUpdate,
|
|
484
|
+
...(pointerSupportSurfaceRef.current?.sourceNodeId
|
|
485
|
+
? { supportSlabId: frozenSupportSlabIdRef.current }
|
|
486
|
+
: {}),
|
|
487
|
+
} as ItemNode)
|
|
431
488
|
return getFloorStackPreviewPosition({
|
|
432
489
|
node: previewNode,
|
|
433
490
|
position,
|
|
434
491
|
rotation: previewNode.rotation,
|
|
435
|
-
maxElevation:
|
|
492
|
+
maxElevation: pointerSupportSurfaceRef.current?.sourceNodeId
|
|
493
|
+
? null
|
|
494
|
+
: pointerSupportCapRef.current,
|
|
436
495
|
})
|
|
437
496
|
},
|
|
438
497
|
[asset?.attachTo, draftNode],
|
|
439
498
|
)
|
|
440
499
|
|
|
500
|
+
// Disable raycasting on the live draft mesh (and restore it when the draft
|
|
501
|
+
// changes or goes away) so the cursor ray passes through the item being
|
|
502
|
+
// moved and lands on the surface beneath it.
|
|
503
|
+
const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
|
|
504
|
+
if (raycastDisabledMeshRef.current !== mesh) {
|
|
505
|
+
// New draft root (or cleared): restore the prior mesh and reset tracking.
|
|
506
|
+
for (const restore of restoreRaycastsRef.current) restore()
|
|
507
|
+
restoreRaycastsRef.current = []
|
|
508
|
+
raycastDisabledChildrenRef.current = new WeakSet()
|
|
509
|
+
raycastDisabledMeshRef.current = mesh
|
|
510
|
+
}
|
|
511
|
+
if (!mesh) return
|
|
512
|
+
// Item models can mount descendants asynchronously. Re-walk the root so
|
|
513
|
+
// newly mounted meshes cannot intercept the next pointer event.
|
|
514
|
+
mesh.traverse((child) => {
|
|
515
|
+
if (raycastDisabledChildrenRef.current.has(child)) return
|
|
516
|
+
raycastDisabledChildrenRef.current.add(child)
|
|
517
|
+
const original = child.raycast
|
|
518
|
+
child.raycast = () => {}
|
|
519
|
+
restoreRaycastsRef.current.push(() => {
|
|
520
|
+
child.raycast = original
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
}, [])
|
|
524
|
+
|
|
441
525
|
useEffect(() => {
|
|
442
526
|
if (!asset) return
|
|
443
527
|
useScene.temporal.getState().pause()
|
|
@@ -452,6 +536,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
452
536
|
// layer's frame.
|
|
453
537
|
let alignmentCandidates: AlignmentAnchor[] | null = null
|
|
454
538
|
let floorDragAnchor: [number, number] | null = null
|
|
539
|
+
let pendingFaceHostId: string | null = null
|
|
455
540
|
|
|
456
541
|
// Reset placement state
|
|
457
542
|
placementState.current = configRef.current.initialState ?? {
|
|
@@ -465,17 +550,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
465
550
|
// No pointer surface known yet — fall back to the uncapped election
|
|
466
551
|
// (an adopted move draft keeps its persisted host until the first move).
|
|
467
552
|
pointerSupportCapRef.current = null
|
|
553
|
+
pointerSupportSurfaceRef.current = null
|
|
554
|
+
frozenSupportSlabIdRef.current = undefined
|
|
468
555
|
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
|
469
556
|
gridPosition.current.y = 0
|
|
470
557
|
if (cursorGroupRef.current) {
|
|
471
|
-
cursorGroupRef.current.position.y =
|
|
558
|
+
cursorGroupRef.current.position.y = getPlacementLevelY(draftNode.current)
|
|
472
559
|
}
|
|
473
560
|
}
|
|
474
561
|
|
|
562
|
+
let lastSurfaceEvent: NodeEvent<AnyNode> | null = null
|
|
563
|
+
const feedback = createSurfaceRejectionFeedback()
|
|
564
|
+
feedback.clear()
|
|
565
|
+
|
|
475
566
|
// ---- Helpers ----
|
|
476
567
|
|
|
477
568
|
const getContext = () => ({
|
|
478
569
|
asset,
|
|
570
|
+
onSurfaceReject: (reason: SurfaceRejectReason) => feedback.reject(reason),
|
|
479
571
|
levelId: useViewer.getState().selection.levelId,
|
|
480
572
|
draftItem: draftNode.current,
|
|
481
573
|
gridPosition: gridPosition.current,
|
|
@@ -493,6 +585,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
493
585
|
}
|
|
494
586
|
: validators
|
|
495
587
|
|
|
588
|
+
const disableDraftRaycastNow = () => {
|
|
589
|
+
const draft = draftNode.current
|
|
590
|
+
if (!draft) return
|
|
591
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
592
|
+
if (mesh) reconcileDraftRaycast(mesh)
|
|
593
|
+
}
|
|
594
|
+
|
|
496
595
|
const finishCommittedPlacement = (
|
|
497
596
|
committedId: string | null,
|
|
498
597
|
wasAdopted: boolean,
|
|
@@ -517,14 +616,94 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
517
616
|
useEditor.getState().setMode('select')
|
|
518
617
|
}
|
|
519
618
|
|
|
619
|
+
const commitDraft = (
|
|
620
|
+
nodeUpdate: Partial<ItemNode>,
|
|
621
|
+
options?: {
|
|
622
|
+
supportElevationCap?: number | null
|
|
623
|
+
preferredSupportSlabId?: string | null
|
|
624
|
+
pinSupport?: boolean
|
|
625
|
+
},
|
|
626
|
+
) => {
|
|
627
|
+
const draftId = draftNode.current?.id ?? null
|
|
628
|
+
const wasAdopted = draftNode.isAdopted
|
|
629
|
+
const finalId = draftNode.commit(nodeUpdate, options)
|
|
630
|
+
if (draftId) {
|
|
631
|
+
useLiveTransforms.getState().clear(draftId)
|
|
632
|
+
useLiveNodeOverrides.getState().clearFields(draftId, faceHostClearFields(draftNode.current))
|
|
633
|
+
}
|
|
634
|
+
return { committedId: finalId ?? draftId, wasAdopted }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const faceHostClearFields = (draft: ItemNode | null | undefined): Array<keyof ItemNode> => {
|
|
638
|
+
if (!draft?.parentId) return ['position', 'rotation']
|
|
639
|
+
const host = useScene.getState().nodes[draft.parentId as AnyNodeId]
|
|
640
|
+
return host
|
|
641
|
+
? [...(nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields ?? [])]
|
|
642
|
+
: ['position', 'rotation']
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const currentFaceHostId = (draft: ItemNode | null | undefined): string | null => {
|
|
646
|
+
if (!draft?.parentId) return null
|
|
647
|
+
const host = useScene.getState().nodes[draft.parentId as AnyNodeId]
|
|
648
|
+
return host
|
|
649
|
+
? (nodeRegistry.get(host.type)?.capabilities.faceHost?.currentFaceId(draft) ?? null)
|
|
650
|
+
: null
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const surfacePointer = createItemSurfacePointerArbitration()
|
|
520
654
|
const revalidate = (): boolean => {
|
|
521
|
-
const
|
|
655
|
+
const fits = checkCanPlace(getContext(), validators)
|
|
656
|
+
if (
|
|
657
|
+
!fits &&
|
|
658
|
+
lastSurfaceEvent &&
|
|
659
|
+
(feedback.reason === 'footprint-outside-surface' ||
|
|
660
|
+
feedback.reason === 'footprint-exceeds-host' ||
|
|
661
|
+
feedback.reason === 'no-surface') &&
|
|
662
|
+
(placementState.current.surface === 'item-surface' ||
|
|
663
|
+
placementState.current.surface === 'shelf-surface')
|
|
664
|
+
) {
|
|
665
|
+
const hostId =
|
|
666
|
+
placementState.current.surface === 'shelf-surface'
|
|
667
|
+
? placementState.current.shelfId
|
|
668
|
+
: placementState.current.surfaceItemId
|
|
669
|
+
const mesh = hostId ? sceneRegistry.nodes.get(hostId) : undefined
|
|
670
|
+
if (mesh) {
|
|
671
|
+
const position = mesh.localToWorld(gridPosition.current.clone()).toArray()
|
|
672
|
+
feedback.clear()
|
|
673
|
+
detachItemSurfaceToFloor({ ...lastSurfaceEvent, position } as ItemEvent)
|
|
674
|
+
return revalidate()
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const placeable = !feedback.reason && (altFreeRef.current || fits)
|
|
522
678
|
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
|
523
679
|
edgeMaterial.color.setHex(color)
|
|
524
680
|
basePlaneMaterial.color.setHex(color)
|
|
525
681
|
return placeable
|
|
526
682
|
}
|
|
527
683
|
|
|
684
|
+
const surfaceContext = (event: NodeEvent<AnyNode>) => {
|
|
685
|
+
lastSurfaceEvent = event
|
|
686
|
+
return {
|
|
687
|
+
...getContext(),
|
|
688
|
+
onSurfaceReject: (reason: SurfaceRejectReason) => {
|
|
689
|
+
if (
|
|
690
|
+
(reason === 'footprint-outside-surface' ||
|
|
691
|
+
reason === 'footprint-exceeds-host' ||
|
|
692
|
+
reason === 'no-surface') &&
|
|
693
|
+
(placementState.current.surface === 'item-surface' ||
|
|
694
|
+
placementState.current.surface === 'shelf-surface')
|
|
695
|
+
) {
|
|
696
|
+
feedback.clear()
|
|
697
|
+
detachItemSurfaceToFloor(event as ItemEvent)
|
|
698
|
+
surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
feedback.reject(reason, event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
702
|
+
revalidate()
|
|
703
|
+
},
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
528
707
|
// Tool visuals are rendered inside the building-local ToolManager group, so all cursor
|
|
529
708
|
// positions must be in building-local space. Wall/ceiling/item-surface strategies return
|
|
530
709
|
// world-space cursor positions (from their event.position); convert them here.
|
|
@@ -540,7 +719,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
540
719
|
return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
|
541
720
|
}
|
|
542
721
|
|
|
543
|
-
const
|
|
722
|
+
const worldRotationToBuildingLocal = (
|
|
723
|
+
rotation: [number, number, number],
|
|
724
|
+
): [number, number, number] => {
|
|
725
|
+
const worldQuaternion = new Quaternion().setFromEuler(new Euler(...rotation))
|
|
726
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
727
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
728
|
+
if (buildingMesh) {
|
|
729
|
+
const buildingWorldQuaternion = new Quaternion()
|
|
730
|
+
buildingMesh.getWorldQuaternion(buildingWorldQuaternion)
|
|
731
|
+
worldQuaternion.premultiply(buildingWorldQuaternion.invert())
|
|
732
|
+
}
|
|
733
|
+
const localRotation = new Euler().setFromQuaternion(worldQuaternion, 'XYZ')
|
|
734
|
+
return [localRotation.x, localRotation.y, localRotation.z]
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const { tick: tickMovementSfx } = createMovementSfxTick()
|
|
738
|
+
const tickSurfaceMovementSfx = (position: [number, number, number]) => {
|
|
739
|
+
const point = worldToBuildingLocal(...position)
|
|
740
|
+
tickMovementSfx({
|
|
741
|
+
coords: [point.x, point.z],
|
|
742
|
+
gridSnapActive: isGridSnapActive(),
|
|
743
|
+
gridStep: useEditor.getState().gridSnapStep,
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const applyTransition = (result: TransitionResult, event?: NodeEvent<AnyNode>) => {
|
|
748
|
+
if (event) {
|
|
749
|
+
surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
750
|
+
tickSurfaceMovementSfx(result.cursorPosition)
|
|
751
|
+
}
|
|
752
|
+
feedback.clear()
|
|
544
753
|
// Alignment guides are floor-only; clear them when the cursor moves
|
|
545
754
|
// onto a wall / ceiling / item surface (only those paths call this).
|
|
546
755
|
useAlignmentGuides.getState().clear()
|
|
@@ -555,7 +764,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
555
764
|
if (cursorGroupRef.current) {
|
|
556
765
|
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
557
766
|
if (result.cursorRotation) {
|
|
558
|
-
cursorGroupRef.current.rotation.set(
|
|
767
|
+
cursorGroupRef.current.rotation.set(
|
|
768
|
+
...worldRotationToBuildingLocal(result.cursorRotation),
|
|
769
|
+
)
|
|
559
770
|
} else {
|
|
560
771
|
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
|
561
772
|
}
|
|
@@ -575,7 +786,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
575
786
|
if (cursorGroupRef.current) {
|
|
576
787
|
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
|
577
788
|
if (result.cursorRotation) {
|
|
578
|
-
cursorGroupRef.current.rotation.set(
|
|
789
|
+
cursorGroupRef.current.rotation.set(
|
|
790
|
+
...worldRotationToBuildingLocal(result.cursorRotation),
|
|
791
|
+
)
|
|
579
792
|
} else {
|
|
580
793
|
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
|
581
794
|
}
|
|
@@ -599,7 +812,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
599
812
|
if (draft) {
|
|
600
813
|
Object.assign(draft, result.nodeUpdate)
|
|
601
814
|
// One-time setup: put node in the right parent so it renders correctly
|
|
602
|
-
|
|
815
|
+
if (result.surfaceId !== undefined)
|
|
816
|
+
draftNode.updateSurface(result.nodeUpdate, result.surfaceId)
|
|
817
|
+
else updateSurfaceNode(draft.id, result.nodeUpdate)
|
|
818
|
+
disableDraftRaycastNow()
|
|
603
819
|
}
|
|
604
820
|
|
|
605
821
|
const previewBounds = expandBoundsToGrid(
|
|
@@ -617,6 +833,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
617
833
|
|
|
618
834
|
// ---- Init draft ----
|
|
619
835
|
configRef.current.initDraft(gridPosition.current)
|
|
836
|
+
if (draftNode.current && surfaceAttachmentId(draftNode.current))
|
|
837
|
+
gridPosition.current.set(...draftNode.current.position)
|
|
620
838
|
const preserveDragOffset = configRef.current.preserveDragOffset === true
|
|
621
839
|
// The host the item was grabbed from + its pre-drag host-local position.
|
|
622
840
|
// Each surface's grab anchor preserves the grab offset only on THAT host,
|
|
@@ -775,7 +993,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
775
993
|
}
|
|
776
994
|
}
|
|
777
995
|
} else if (cursorGroupRef.current) {
|
|
996
|
+
// No registered mesh yet (a just-created draft renders next tick), so
|
|
997
|
+
// fall back to the level-local grid position lifted onto its storey.
|
|
778
998
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
|
999
|
+
cursorGroupRef.current.position.y += getPlacementLevelY(draftNode.current)
|
|
779
1000
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
|
780
1001
|
}
|
|
781
1002
|
}
|
|
@@ -800,6 +1021,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
800
1021
|
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
|
|
801
1022
|
}
|
|
802
1023
|
const onReleaseCommit = () => {
|
|
1024
|
+
gridDispatch.flush()
|
|
803
1025
|
if (!releaseCommit) return
|
|
804
1026
|
const commit = releaseCommit
|
|
805
1027
|
releaseCommit = null
|
|
@@ -809,56 +1031,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
809
1031
|
|
|
810
1032
|
// ---- Floor Handlers ----
|
|
811
1033
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
// Scratch objects reused by the stickiness test (runs per grid:move).
|
|
815
|
-
const stickyRay = new Ray()
|
|
816
|
-
const stickyBox = new Box3()
|
|
817
|
-
const stickyMat = new Matrix4()
|
|
818
|
-
const stickyCamPos = new Vector3()
|
|
819
|
-
|
|
820
|
-
// True while the cursor ray still points at the active shelf's volume.
|
|
821
|
-
// Used to keep an item hosted on a shelf "sticky": from an angled camera
|
|
822
|
-
// the cursor ray slips off the shelf's thin boards / through its gaps and
|
|
823
|
-
// lands on the floor *behind* the shelf, which would otherwise thrash the
|
|
824
|
-
// placement between the shelf row and the floor on every micro-move. We
|
|
825
|
-
// reconstruct the world ray (camera → grid hit point) and test it against
|
|
826
|
-
// the shelf's bounding box — so a ray that passes *through* the shelf but
|
|
827
|
-
// lands behind it still counts as "on the shelf". Only a ray that misses
|
|
828
|
-
// the shelf box entirely means the user genuinely moved off it. A simple
|
|
829
|
-
// footprint test on the floor hit point can't distinguish those.
|
|
830
|
-
const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
|
|
831
|
-
const shelfId = placementState.current.shelfId
|
|
832
|
-
if (!shelfId) return false
|
|
833
|
-
const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
|
|
834
|
-
const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
|
|
835
|
-
| { width?: number; depth?: number; height?: number }
|
|
836
|
-
| undefined
|
|
837
|
-
if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
|
|
838
|
-
|
|
839
|
-
cameraRef.current.getWorldPosition(stickyCamPos)
|
|
840
|
-
stickyRay.origin.copy(stickyCamPos)
|
|
841
|
-
stickyRay.direction
|
|
842
|
-
.set(
|
|
843
|
-
gridWorldPoint[0] - stickyCamPos.x,
|
|
844
|
-
gridWorldPoint[1] - stickyCamPos.y,
|
|
845
|
-
gridWorldPoint[2] - stickyCamPos.z,
|
|
846
|
-
)
|
|
847
|
-
.normalize()
|
|
848
|
-
|
|
849
|
-
// Into shelf-local space, then test the shelf's local AABB (origin at the
|
|
850
|
-
// base: y ∈ [0, height]) with a small margin.
|
|
851
|
-
stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
|
|
852
|
-
const m = 0.08
|
|
853
|
-
stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
|
|
854
|
-
stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
|
|
855
|
-
return stickyRay.intersectsBox(stickyBox)
|
|
856
|
-
}
|
|
1034
|
+
const cursorRayIntersectsShelf = createShelfStickiness()
|
|
857
1035
|
|
|
858
1036
|
const onGridMove = (event: GridEvent) => {
|
|
1037
|
+
if (surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent)) return
|
|
859
1038
|
releaseCommit = () => onGridClick(event)
|
|
860
1039
|
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
|
861
|
-
if (
|
|
1040
|
+
if (
|
|
1041
|
+
shouldCreateFloorDraft(draftNode.current, asset.attachTo, placementState.current.surface)
|
|
1042
|
+
) {
|
|
862
1043
|
configRef.current.initDraft(gridPosition.current)
|
|
863
1044
|
}
|
|
864
1045
|
|
|
@@ -875,6 +1056,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
875
1056
|
// a drag over a deck-above-a-floor hop between the two surfaces).
|
|
876
1057
|
const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
|
|
877
1058
|
pointerSupportCapRef.current = pointed?.elevation ?? null
|
|
1059
|
+
pointerSupportSurfaceRef.current = pointed
|
|
878
1060
|
const surfaceEvent: GridEvent =
|
|
879
1061
|
pointed?.worldPoint && pointed.localPoint
|
|
880
1062
|
? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint }
|
|
@@ -885,13 +1067,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
885
1067
|
// off a board / through a gap and hit the floor behind). Detach to the
|
|
886
1068
|
// floor only once the ray misses the shelf entirely — without this the
|
|
887
1069
|
// item oscillates between the shelf row and the floor on every micro-move.
|
|
1070
|
+
const counterId =
|
|
1071
|
+
placementState.current.surface === 'item-surface'
|
|
1072
|
+
? placementState.current.surfaceItemId
|
|
1073
|
+
: null
|
|
1074
|
+
if (counterId && useScene.getState().nodes[counterId as AnyNodeId]?.type === 'cabinet') {
|
|
1075
|
+
if (
|
|
1076
|
+
surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent) ||
|
|
1077
|
+
cursorRayIntersectsShelf(counterId, cameraRef.current, event.position)
|
|
1078
|
+
)
|
|
1079
|
+
return
|
|
1080
|
+
detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
|
|
1081
|
+
}
|
|
888
1082
|
if (placementState.current.surface === 'shelf-surface') {
|
|
889
|
-
if (
|
|
1083
|
+
if (
|
|
1084
|
+
cursorRayIntersectsShelf(
|
|
1085
|
+
placementState.current.shelfId,
|
|
1086
|
+
cameraRef.current,
|
|
1087
|
+
event.position,
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
return
|
|
890
1091
|
// Land at the pointed surface's plan point — the raw grid hit is
|
|
891
1092
|
// still skewed by the plane riding at the shelf-surface height.
|
|
892
1093
|
detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
|
|
893
1094
|
}
|
|
894
1095
|
|
|
1096
|
+
if (placementState.current.surface === 'floor')
|
|
1097
|
+
feedback.grid(event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
895
1098
|
const floorEvent = applyFloorGrabOffset(surfaceEvent)
|
|
896
1099
|
|
|
897
1100
|
lastRawPos.current.set(
|
|
@@ -946,27 +1149,53 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
946
1149
|
useAlignmentGuides.getState().clear()
|
|
947
1150
|
}
|
|
948
1151
|
|
|
949
|
-
|
|
1152
|
+
// `result.gridPosition[1]` is the LIVE `gridPosition.current.y` — seeded
|
|
1153
|
+
// from the draft's authored Y by `initDraft` (so a block-face / raised
|
|
1154
|
+
// construction-plane item keeps its height) and zeroed by
|
|
1155
|
+
// `detachItemSurfaceToFloor` / `faceHostStrategy.leave` when the item
|
|
1156
|
+
// comes back down. Freezing it at drag start instead left an item taken
|
|
1157
|
+
// off a shelf floating at the shelf's height.
|
|
1158
|
+
let gridPos: [number, number, number] = [
|
|
950
1159
|
result.gridPosition[0] + alignX,
|
|
951
1160
|
result.gridPosition[1],
|
|
952
1161
|
result.gridPosition[2] + alignZ,
|
|
953
1162
|
]
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1163
|
+
frozenSupportSlabIdRef.current = undefined
|
|
1164
|
+
if (draft && pointed?.sourceNodeId) {
|
|
1165
|
+
const effectiveNode = {
|
|
1166
|
+
...draft,
|
|
1167
|
+
position: gridPos,
|
|
1168
|
+
parentId: useViewer.getState().selection.levelId ?? draft.parentId,
|
|
1169
|
+
} as ItemNode
|
|
1170
|
+
const frozenPatch = resolveFrozenFloorPlacementPatch(
|
|
1171
|
+
effectiveNode,
|
|
1172
|
+
useScene.getState().nodes,
|
|
1173
|
+
{
|
|
1174
|
+
position: gridPos,
|
|
1175
|
+
rotation: effectiveNode.rotation,
|
|
1176
|
+
elevation: pointed.elevation,
|
|
1177
|
+
preferredSlabId: pointed.supportSlabId,
|
|
1178
|
+
},
|
|
1179
|
+
)
|
|
1180
|
+
gridPos = frozenPatch.position
|
|
1181
|
+
frozenSupportSlabIdRef.current = frozenPatch.supportSlabId
|
|
961
1182
|
}
|
|
962
1183
|
|
|
963
|
-
|
|
1184
|
+
tickMovementSfx({
|
|
1185
|
+
coords: [gridPos[0], gridPos[2]],
|
|
1186
|
+
gridSnapActive: isGridSnapActive(),
|
|
1187
|
+
gridStep: useEditor.getState().gridSnapStep,
|
|
1188
|
+
})
|
|
964
1189
|
gridPosition.current.set(...gridPos)
|
|
965
1190
|
const cursorPosition = getFloorVisualPosition(gridPos)
|
|
966
1191
|
if (!draft && asset.attachTo) {
|
|
967
1192
|
cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
|
|
968
1193
|
}
|
|
969
|
-
cursorGroupRef.current.position.set(
|
|
1194
|
+
cursorGroupRef.current.position.set(
|
|
1195
|
+
cursorPosition[0],
|
|
1196
|
+
cursorPosition[1] + getPlacementLevelY(draft),
|
|
1197
|
+
cursorPosition[2],
|
|
1198
|
+
)
|
|
970
1199
|
// Floor items only rotate on Y; keep the preview box (and the live
|
|
971
1200
|
// transform the 2D floorplan mirrors) aligned with the draft's
|
|
972
1201
|
// rotation. Without this the box stays at its seed rotation until a
|
|
@@ -989,6 +1218,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
989
1218
|
}
|
|
990
1219
|
|
|
991
1220
|
const onGridClick = (event: GridEvent) => {
|
|
1221
|
+
gridDispatch.flush()
|
|
1222
|
+
if (feedback.reason) return
|
|
992
1223
|
// Drop alignment guides on click — the move commits (guides done) or
|
|
993
1224
|
// placement re-arms (the next move republishes them).
|
|
994
1225
|
useAlignmentGuides.getState().clear()
|
|
@@ -1002,20 +1233,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1002
1233
|
0,
|
|
1003
1234
|
]
|
|
1004
1235
|
|
|
1005
|
-
// Clear live transform before commit
|
|
1006
|
-
if (draftNode.current) {
|
|
1007
|
-
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
const committedId = draftNode.current?.id ?? null
|
|
1011
|
-
const wasAdopted = draftNode.isAdopted
|
|
1012
1236
|
// Carry the pointer surface cap into the commit so the persisted
|
|
1013
1237
|
// supportSlabId reproduces the capped election (elects the aimed-at
|
|
1014
1238
|
// lower slab — or the ground — instead of a deck hanging above).
|
|
1015
|
-
const
|
|
1239
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate, {
|
|
1016
1240
|
supportElevationCap: pointerSupportCapRef.current,
|
|
1241
|
+
preferredSupportSlabId: pointerSupportSurfaceRef.current?.supportSlabId,
|
|
1242
|
+
pinSupport: pointerSupportSurfaceRef.current?.sourceNodeId != null,
|
|
1017
1243
|
})
|
|
1018
|
-
finishCommittedPlacement(
|
|
1244
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1019
1245
|
draftNode.create(
|
|
1020
1246
|
gridPosition.current,
|
|
1021
1247
|
asset,
|
|
@@ -1055,7 +1281,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1055
1281
|
ensureDraft(result)
|
|
1056
1282
|
} else if (result.nodeUpdate.parentId) {
|
|
1057
1283
|
// Existing draft (move mode): reparent to new wall
|
|
1058
|
-
|
|
1284
|
+
updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
|
|
1059
1285
|
if (result.stateUpdate.wallId) {
|
|
1060
1286
|
useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
|
|
1061
1287
|
}
|
|
@@ -1082,7 +1308,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1082
1308
|
event.stopPropagation()
|
|
1083
1309
|
applyTransition(enterResult)
|
|
1084
1310
|
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
|
1085
|
-
|
|
1311
|
+
updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
|
|
1086
1312
|
if (enterResult.stateUpdate.wallId) {
|
|
1087
1313
|
useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
|
|
1088
1314
|
}
|
|
@@ -1208,18 +1434,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1208
1434
|
if (!result) return
|
|
1209
1435
|
|
|
1210
1436
|
event.stopPropagation()
|
|
1211
|
-
|
|
1212
|
-
if (draftNode.current) {
|
|
1213
|
-
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1214
|
-
}
|
|
1215
|
-
const committedId = draftNode.current?.id ?? null
|
|
1216
|
-
const wasAdopted = draftNode.isAdopted
|
|
1217
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1437
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
1218
1438
|
if (result.dirtyNodeId) {
|
|
1219
1439
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
1220
1440
|
}
|
|
1221
1441
|
|
|
1222
|
-
finishCommittedPlacement(
|
|
1442
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1223
1443
|
const nodes = useScene.getState().nodes
|
|
1224
1444
|
const enterResult = wallStrategy.enter(
|
|
1225
1445
|
getContext(),
|
|
@@ -1285,7 +1505,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1285
1505
|
ensureDraft(result)
|
|
1286
1506
|
} else if (result.nodeUpdate.parentId) {
|
|
1287
1507
|
// Existing draft (move mode): reparent to the segment
|
|
1288
|
-
|
|
1508
|
+
updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
|
|
1289
1509
|
}
|
|
1290
1510
|
return true
|
|
1291
1511
|
}
|
|
@@ -1368,14 +1588,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1368
1588
|
if (!result) return
|
|
1369
1589
|
|
|
1370
1590
|
event.stopPropagation()
|
|
1371
|
-
|
|
1372
|
-
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1373
|
-
}
|
|
1374
|
-
const committedId = draftNode.current?.id ?? null
|
|
1375
|
-
const wasAdopted = draftNode.isAdopted
|
|
1376
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1591
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
1377
1592
|
|
|
1378
|
-
finishCommittedPlacement(
|
|
1593
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1379
1594
|
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
|
1380
1595
|
if (enterResult) {
|
|
1381
1596
|
applyTransition(enterResult)
|
|
@@ -1408,10 +1623,124 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1408
1623
|
}
|
|
1409
1624
|
}
|
|
1410
1625
|
|
|
1626
|
+
// ---- Face Host Handlers ----
|
|
1627
|
+
|
|
1628
|
+
const enterFaceHost = (event: NodeEvent): boolean => {
|
|
1629
|
+
const result = faceHostStrategy.enter(getContext(), event)
|
|
1630
|
+
if (!result) return false
|
|
1631
|
+
pendingFaceHostId = null
|
|
1632
|
+
event.stopPropagation()
|
|
1633
|
+
applyTransition(result)
|
|
1634
|
+
if (!draftNode.current) {
|
|
1635
|
+
ensureDraft(result)
|
|
1636
|
+
} else if (result.nodeUpdate.parentId) {
|
|
1637
|
+
updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
|
|
1638
|
+
disableDraftRaycastNow()
|
|
1639
|
+
}
|
|
1640
|
+
if (draftNode.current) useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1641
|
+
return true
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const onFaceHostEnter = (event: NodeEvent) => {
|
|
1645
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1646
|
+
enterFaceHost(event)
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const onFaceHostMove = (event: NodeEvent) => {
|
|
1650
|
+
has3DPointerDrivenMoveRef.current = true
|
|
1651
|
+
if (!cursorGroupRef.current) return
|
|
1652
|
+
const ctx = getContext()
|
|
1653
|
+
if (ctx.state.surface !== 'block-face' || !draftNode.current) {
|
|
1654
|
+
if (enterFaceHost(event)) releaseCommit = () => onFaceHostClick(event)
|
|
1655
|
+
return
|
|
1656
|
+
}
|
|
1657
|
+
const result = faceHostStrategy.move(ctx, event)
|
|
1658
|
+
if (!result) {
|
|
1659
|
+
event.stopPropagation()
|
|
1660
|
+
return
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
event.stopPropagation()
|
|
1664
|
+
const draft = draftNode.current
|
|
1665
|
+
const nextFaceId = result.hostFaceId
|
|
1666
|
+
const faceSwitch = resolveFaceHostSwitch(
|
|
1667
|
+
currentFaceHostId(draft),
|
|
1668
|
+
nextFaceId,
|
|
1669
|
+
pendingFaceHostId,
|
|
1670
|
+
)
|
|
1671
|
+
pendingFaceHostId = faceSwitch.pendingFaceId
|
|
1672
|
+
if (!faceSwitch.accept) return
|
|
1673
|
+
releaseCommit = () => onFaceHostClick(event)
|
|
1674
|
+
|
|
1675
|
+
const posChanged =
|
|
1676
|
+
gridPosition.current.x !== result.gridPosition[0] ||
|
|
1677
|
+
gridPosition.current.y !== result.gridPosition[1] ||
|
|
1678
|
+
gridPosition.current.z !== result.gridPosition[2]
|
|
1679
|
+
if (posChanged) sfxEmitter.emit('sfx:grid-snap')
|
|
1680
|
+
gridPosition.current.set(...result.gridPosition)
|
|
1681
|
+
const cursor = worldToBuildingLocal(...result.cursorPosition)
|
|
1682
|
+
cursorGroupRef.current.position.copy(cursor)
|
|
1683
|
+
if (result.cursorRotation) {
|
|
1684
|
+
cursorGroupRef.current.rotation.set(...worldRotationToBuildingLocal(result.cursorRotation))
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
if (draft && result.nodeUpdate) {
|
|
1688
|
+
Object.assign(draft, result.nodeUpdate)
|
|
1689
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1690
|
+
const rotation = result.nodeUpdate.rotation ?? draft.rotation
|
|
1691
|
+
if (mesh) applyFaceHostPreviewPose(mesh, result.gridPosition, rotation)
|
|
1692
|
+
useLiveNodeOverrides.getState().set(draft.id, {
|
|
1693
|
+
position: result.gridPosition,
|
|
1694
|
+
rotation,
|
|
1695
|
+
...result.nodeUpdate,
|
|
1696
|
+
})
|
|
1697
|
+
}
|
|
1698
|
+
revalidate()
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const onFaceHostClick = (event: NodeEvent) => {
|
|
1702
|
+
const outcome = commitFaceHostClick({
|
|
1703
|
+
commitDraft,
|
|
1704
|
+
enterFaceHost,
|
|
1705
|
+
event,
|
|
1706
|
+
getContext,
|
|
1707
|
+
})
|
|
1708
|
+
if (!outcome) return
|
|
1709
|
+
const { committedId, wasAdopted } = outcome
|
|
1710
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1711
|
+
const enterResult = faceHostStrategy.enter(getContext(), event)
|
|
1712
|
+
if (enterResult) applyTransition(enterResult)
|
|
1713
|
+
else revalidate()
|
|
1714
|
+
})
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const onFaceHostLeave = (event: NodeEvent) => {
|
|
1718
|
+
pendingFaceHostId = null
|
|
1719
|
+
if (!shouldDetachFaceHostOnLeave(asset.attachTo)) {
|
|
1720
|
+
event.stopPropagation()
|
|
1721
|
+
return
|
|
1722
|
+
}
|
|
1723
|
+
const result = faceHostStrategy.leave(getContext())
|
|
1724
|
+
if (!result) return
|
|
1725
|
+
event.stopPropagation()
|
|
1726
|
+
const draft = draftNode.current
|
|
1727
|
+
if (draft) {
|
|
1728
|
+
useLiveNodeOverrides.getState().clearFields(draft.id, faceHostClearFields(draft))
|
|
1729
|
+
}
|
|
1730
|
+
if (draftNode.isAdopted) {
|
|
1731
|
+
applyTransition(result)
|
|
1732
|
+
if (draft) useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
|
1733
|
+
} else {
|
|
1734
|
+
draftNode.destroy()
|
|
1735
|
+
Object.assign(placementState.current, result.stateUpdate)
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1411
1739
|
// ---- Item Surface Handlers ----
|
|
1412
1740
|
|
|
1413
|
-
const detachItemSurfaceToFloor = (event:
|
|
1741
|
+
const detachItemSurfaceToFloor = (event: NodeEvent<AnyNode>) => {
|
|
1414
1742
|
hostSurfaceDragAnchor = null
|
|
1743
|
+
surfacePointer.clear()
|
|
1415
1744
|
// Landing back on the floor: refresh the pointer surface cap from
|
|
1416
1745
|
// this event's world hit so the first floor position already targets
|
|
1417
1746
|
// the aimed-at surface (not a deck above it).
|
|
@@ -1449,7 +1778,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1449
1778
|
levelId ? { parentId: levelId } : undefined,
|
|
1450
1779
|
)
|
|
1451
1780
|
if (cursorGroupRef.current) {
|
|
1452
|
-
cursorGroupRef.current.position.set(
|
|
1781
|
+
cursorGroupRef.current.position.set(
|
|
1782
|
+
floorVisualPosition[0],
|
|
1783
|
+
floorVisualPosition[1] + getPlacementLevelY(draftNode.current),
|
|
1784
|
+
floorVisualPosition[2],
|
|
1785
|
+
)
|
|
1453
1786
|
}
|
|
1454
1787
|
|
|
1455
1788
|
const draft = draftNode.current
|
|
@@ -1480,7 +1813,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1480
1813
|
// parent-must-be-a-level guard and the item + grid stopped following
|
|
1481
1814
|
// slab elevations for the rest of the drag).
|
|
1482
1815
|
if (levelId) draft.parentId = levelId
|
|
1483
|
-
|
|
1816
|
+
updateSurfaceNode(draft.id, {
|
|
1484
1817
|
parentId: useViewer.getState().selection.levelId as string,
|
|
1485
1818
|
position: floorPos,
|
|
1486
1819
|
rotation,
|
|
@@ -1490,29 +1823,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1490
1823
|
revalidate()
|
|
1491
1824
|
}
|
|
1492
1825
|
|
|
1493
|
-
const onItemEnter = (event:
|
|
1826
|
+
const onItemEnter = (event: NodeEvent<AnyNode>) => {
|
|
1827
|
+
if (event.node.type === 'cabinet' && placementState.current.surfaceItemId === event.node.id) {
|
|
1828
|
+
onItemMove(event)
|
|
1829
|
+
return
|
|
1830
|
+
}
|
|
1831
|
+
if (event.node.type === 'cabinet') {
|
|
1832
|
+
lastRawPos.current.set(...event.position)
|
|
1833
|
+
surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
1834
|
+
}
|
|
1494
1835
|
if (event.node.id === draftNode.current?.id) return
|
|
1495
1836
|
has3DPointerDrivenMoveRef.current = true
|
|
1496
|
-
const result = itemSurfaceStrategy.enter(
|
|
1837
|
+
const result = itemSurfaceStrategy.enter(surfaceContext(event), event)
|
|
1497
1838
|
if (!result) return
|
|
1839
|
+
feedback.clear()
|
|
1498
1840
|
|
|
1499
1841
|
event.stopPropagation()
|
|
1500
|
-
applyTransition(result)
|
|
1842
|
+
applyTransition(result, event)
|
|
1501
1843
|
|
|
1502
1844
|
if (!draftNode.current) {
|
|
1503
1845
|
ensureDraft(result)
|
|
1504
1846
|
} else if (result.nodeUpdate.parentId) {
|
|
1505
1847
|
// Existing draft (move mode): reparent to surface item
|
|
1506
|
-
|
|
1848
|
+
draftNode.updateSurface(result.nodeUpdate, result.surfaceId ?? null)
|
|
1507
1849
|
}
|
|
1508
1850
|
}
|
|
1509
1851
|
|
|
1510
|
-
const onItemMove = (event:
|
|
1852
|
+
const onItemMove = (event: NodeEvent<AnyNode>) => {
|
|
1511
1853
|
if (event.node.id === draftNode.current?.id) return
|
|
1512
1854
|
releaseCommit = () => onItemClick(event)
|
|
1513
1855
|
has3DPointerDrivenMoveRef.current = true
|
|
1514
1856
|
if (!cursorGroupRef.current) return
|
|
1515
|
-
const ctx =
|
|
1857
|
+
const ctx = surfaceContext(event)
|
|
1858
|
+
if (event.node.type === 'cabinet') {
|
|
1859
|
+
lastRawPos.current.set(...event.position)
|
|
1860
|
+
surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
1861
|
+
}
|
|
1516
1862
|
|
|
1517
1863
|
if (ctx.state.surface !== 'item-surface') {
|
|
1518
1864
|
// Try entering surface mode
|
|
@@ -1520,9 +1866,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1520
1866
|
if (!enterResult) return
|
|
1521
1867
|
|
|
1522
1868
|
event.stopPropagation()
|
|
1523
|
-
applyTransition(enterResult)
|
|
1869
|
+
applyTransition(enterResult, event)
|
|
1524
1870
|
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
|
1525
|
-
|
|
1871
|
+
draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
|
|
1526
1872
|
}
|
|
1527
1873
|
return
|
|
1528
1874
|
}
|
|
@@ -1535,18 +1881,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1535
1881
|
|
|
1536
1882
|
event.stopPropagation()
|
|
1537
1883
|
if (enterResult) {
|
|
1538
|
-
applyTransition(enterResult)
|
|
1884
|
+
applyTransition(enterResult, event)
|
|
1539
1885
|
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
|
1540
|
-
|
|
1886
|
+
draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
|
|
1541
1887
|
}
|
|
1542
|
-
} else {
|
|
1888
|
+
} else if (!feedback.reason) {
|
|
1543
1889
|
detachItemSurfaceToFloor(event)
|
|
1544
1890
|
}
|
|
1545
1891
|
return
|
|
1546
1892
|
}
|
|
1547
1893
|
|
|
1548
1894
|
if (!draftNode.current) {
|
|
1549
|
-
const enterResult = itemSurfaceStrategy.enter(
|
|
1895
|
+
const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
|
|
1550
1896
|
if (!enterResult) return
|
|
1551
1897
|
event.stopPropagation()
|
|
1552
1898
|
ensureDraft(enterResult)
|
|
@@ -1563,11 +1909,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1563
1909
|
itemMoveEvent.position[1],
|
|
1564
1910
|
itemMoveEvent.position[2],
|
|
1565
1911
|
)
|
|
1566
|
-
const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
|
|
1567
|
-
if (!result)
|
|
1912
|
+
const result = itemSurfaceStrategy.move(ctx, itemMoveEvent, event)
|
|
1913
|
+
if (!result) {
|
|
1914
|
+
revalidate()
|
|
1915
|
+
return
|
|
1916
|
+
}
|
|
1917
|
+
feedback.clear()
|
|
1568
1918
|
|
|
1569
1919
|
event.stopPropagation()
|
|
1570
1920
|
|
|
1921
|
+
surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
1922
|
+
tickSurfaceMovementSfx(result.cursorPosition)
|
|
1571
1923
|
gridPosition.current.set(...result.gridPosition)
|
|
1572
1924
|
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
1573
1925
|
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
@@ -1576,8 +1928,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1576
1928
|
const draft = draftNode.current
|
|
1577
1929
|
if (draft) {
|
|
1578
1930
|
draft.position = result.gridPosition
|
|
1931
|
+
if (event.node.type === 'procedural-item')
|
|
1932
|
+
draftNode.updateSurface(result.nodeUpdate ?? {}, result.surfaceId ?? null)
|
|
1579
1933
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1580
|
-
if (mesh)
|
|
1934
|
+
if (mesh)
|
|
1935
|
+
mesh.position.set(
|
|
1936
|
+
...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
|
|
1937
|
+
)
|
|
1581
1938
|
|
|
1582
1939
|
// Publish live transform for 2D floorplan
|
|
1583
1940
|
useLiveTransforms.getState().set(draft.id, {
|
|
@@ -1589,7 +1946,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1589
1946
|
revalidate()
|
|
1590
1947
|
}
|
|
1591
1948
|
|
|
1592
|
-
const onItemLeave = (event:
|
|
1949
|
+
const onItemLeave = (event: NodeEvent<AnyNode>) => {
|
|
1950
|
+
if (event.node.type === 'cabinet') return
|
|
1593
1951
|
if (event.node.id === draftNode.current?.id) return
|
|
1594
1952
|
if (placementState.current.surface !== 'item-surface') return
|
|
1595
1953
|
|
|
@@ -1603,7 +1961,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1603
1961
|
detachItemSurfaceToFloor(event)
|
|
1604
1962
|
}
|
|
1605
1963
|
|
|
1606
|
-
const onItemClick = (event:
|
|
1964
|
+
const onItemClick = (event: NodeEvent<AnyNode>) => {
|
|
1965
|
+
gridDispatch.flush()
|
|
1966
|
+
if (feedback.reason) return
|
|
1607
1967
|
// Click on the draft item itself. R3F dispatches click events to
|
|
1608
1968
|
// the closest intersected mesh only — when the draft is hovering
|
|
1609
1969
|
// on a host (shelf / table / etc.) the draft's mesh is *above*
|
|
@@ -1612,7 +1972,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1612
1972
|
// self-click as a commit on the active shelf so the user doesn't
|
|
1613
1973
|
// have to aim around the cursor preview to drop the item.
|
|
1614
1974
|
if (event.node.id === draftNode.current?.id) {
|
|
1615
|
-
const ctx =
|
|
1975
|
+
const ctx = surfaceContext(event)
|
|
1976
|
+
if (ctx.state.surface === 'block-face') {
|
|
1977
|
+
const result = resolveFaceHostPreviewCommit(ctx)
|
|
1978
|
+
if (result) {
|
|
1979
|
+
event.stopPropagation()
|
|
1980
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
1981
|
+
finishCommittedPlacement(committedId, wasAdopted, revalidate)
|
|
1982
|
+
return
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1616
1985
|
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
|
|
1617
1986
|
const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
|
|
1618
1987
|
if (shelfNode && shelfNode.type === 'shelf') {
|
|
@@ -1620,13 +1989,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1620
1989
|
const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
|
|
1621
1990
|
if (result) {
|
|
1622
1991
|
event.stopPropagation()
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
}
|
|
1626
|
-
const committedId = draftNode.current?.id ?? null
|
|
1627
|
-
const wasAdopted = draftNode.isAdopted
|
|
1628
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1629
|
-
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
1992
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
1993
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1630
1994
|
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
|
|
1631
1995
|
if (enterResult) {
|
|
1632
1996
|
applyTransition(enterResult)
|
|
@@ -1643,18 +2007,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1643
2007
|
// the host's own click event is blocked by the cursor preview.
|
|
1644
2008
|
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
|
|
1645
2009
|
const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
|
|
1646
|
-
if (
|
|
2010
|
+
if (
|
|
2011
|
+
hostNode &&
|
|
2012
|
+
(hostNode.type === 'item' ||
|
|
2013
|
+
hostNode.type === 'cabinet' ||
|
|
2014
|
+
hostNode.type === 'procedural-item')
|
|
2015
|
+
) {
|
|
1647
2016
|
const synthetic = { ...event, node: hostNode } as ItemEvent
|
|
1648
2017
|
const result = itemSurfaceStrategy.click(ctx, synthetic)
|
|
1649
2018
|
if (result) {
|
|
1650
2019
|
event.stopPropagation()
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1654
|
-
const committedId = draftNode.current?.id ?? null
|
|
1655
|
-
const wasAdopted = draftNode.isAdopted
|
|
1656
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1657
|
-
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
2020
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
2021
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1658
2022
|
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
|
|
1659
2023
|
if (enterResult) {
|
|
1660
2024
|
applyTransition(enterResult)
|
|
@@ -1679,16 +2043,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1679
2043
|
const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators())
|
|
1680
2044
|
if (result) {
|
|
1681
2045
|
event.stopPropagation()
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
}
|
|
1685
|
-
const committedId = draftNode.current?.id ?? null
|
|
1686
|
-
const wasAdopted = draftNode.isAdopted
|
|
1687
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
1688
|
-
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
|
|
2046
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
2047
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1689
2048
|
const nodes = useScene.getState().nodes
|
|
1690
2049
|
const enterResult = ceilingStrategy.enter(
|
|
1691
|
-
|
|
2050
|
+
surfaceContext(event),
|
|
1692
2051
|
synthetic,
|
|
1693
2052
|
resolveLevelId,
|
|
1694
2053
|
nodes,
|
|
@@ -1706,21 +2065,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1706
2065
|
return
|
|
1707
2066
|
}
|
|
1708
2067
|
|
|
1709
|
-
const result = itemSurfaceStrategy.click(
|
|
2068
|
+
const result = itemSurfaceStrategy.click(surfaceContext(event), event)
|
|
1710
2069
|
if (!result) return
|
|
1711
2070
|
|
|
1712
2071
|
event.stopPropagation()
|
|
1713
|
-
|
|
1714
|
-
if (draftNode.current) {
|
|
1715
|
-
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1716
|
-
}
|
|
1717
|
-
const committedId = draftNode.current?.id ?? null
|
|
1718
|
-
const wasAdopted = draftNode.isAdopted
|
|
1719
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
2072
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
1720
2073
|
|
|
1721
|
-
finishCommittedPlacement(
|
|
2074
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1722
2075
|
// Try to set up next draft on the same surface
|
|
1723
|
-
const enterResult = itemSurfaceStrategy.enter(
|
|
2076
|
+
const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
|
|
1724
2077
|
if (enterResult) {
|
|
1725
2078
|
applyTransition(enterResult)
|
|
1726
2079
|
} else {
|
|
@@ -1744,7 +2097,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1744
2097
|
ensureDraft(result)
|
|
1745
2098
|
} else if (result.nodeUpdate.parentId) {
|
|
1746
2099
|
// Existing draft (move mode): reparent to new ceiling
|
|
1747
|
-
|
|
2100
|
+
updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
|
|
1748
2101
|
if (result.stateUpdate.ceilingId) {
|
|
1749
2102
|
useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
|
|
1750
2103
|
}
|
|
@@ -1841,15 +2194,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1841
2194
|
if (!result) return
|
|
1842
2195
|
|
|
1843
2196
|
event.stopPropagation()
|
|
1844
|
-
|
|
1845
|
-
if (draftNode.current) {
|
|
1846
|
-
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1847
|
-
}
|
|
1848
|
-
const committedId = draftNode.current?.id ?? null
|
|
1849
|
-
const wasAdopted = draftNode.isAdopted
|
|
1850
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
2197
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
1851
2198
|
|
|
1852
|
-
finishCommittedPlacement(
|
|
2199
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
1853
2200
|
const nodes = useScene.getState().nodes
|
|
1854
2201
|
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
1855
2202
|
if (enterResult) {
|
|
@@ -1900,16 +2247,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1900
2247
|
|
|
1901
2248
|
const onShelfEnter = (event: ShelfEvent) => {
|
|
1902
2249
|
has3DPointerDrivenMoveRef.current = true
|
|
1903
|
-
const result = shelfSurfaceStrategy.enter(
|
|
2250
|
+
const result = shelfSurfaceStrategy.enter(surfaceContext(event), event)
|
|
1904
2251
|
if (!result) return
|
|
1905
2252
|
|
|
1906
2253
|
event.stopPropagation()
|
|
1907
|
-
applyTransition(result)
|
|
2254
|
+
applyTransition(result, event)
|
|
1908
2255
|
|
|
1909
2256
|
if (!draftNode.current) {
|
|
1910
2257
|
ensureDraft(result)
|
|
1911
2258
|
} else if (result.nodeUpdate.parentId) {
|
|
1912
|
-
|
|
2259
|
+
updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
|
|
1913
2260
|
}
|
|
1914
2261
|
}
|
|
1915
2262
|
|
|
@@ -1919,7 +2266,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1919
2266
|
// A shelf event can fire before the cursor group mounts or after
|
|
1920
2267
|
// teardown, leaving the ref null; bail before dereferencing it below.
|
|
1921
2268
|
if (!cursorGroupRef.current) return
|
|
1922
|
-
const ctx =
|
|
2269
|
+
const ctx = surfaceContext(event)
|
|
1923
2270
|
if (ctx.state.surface !== 'shelf-surface') {
|
|
1924
2271
|
// Cursor entered via a move event without an enter — try
|
|
1925
2272
|
// transitioning in so the user doesn't need to mouse out + back
|
|
@@ -1927,11 +2274,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1927
2274
|
const enterResult = shelfSurfaceStrategy.enter(ctx, event)
|
|
1928
2275
|
if (!enterResult) return
|
|
1929
2276
|
event.stopPropagation()
|
|
1930
|
-
applyTransition(enterResult)
|
|
2277
|
+
applyTransition(enterResult, event)
|
|
1931
2278
|
if (!draftNode.current) {
|
|
1932
2279
|
ensureDraft(enterResult)
|
|
1933
2280
|
} else if (enterResult.nodeUpdate.parentId) {
|
|
1934
|
-
|
|
2281
|
+
updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
|
|
1935
2282
|
}
|
|
1936
2283
|
return
|
|
1937
2284
|
}
|
|
@@ -1943,8 +2290,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1943
2290
|
const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
|
|
1944
2291
|
if (!result) return
|
|
1945
2292
|
|
|
2293
|
+
feedback.clear()
|
|
1946
2294
|
event.stopPropagation()
|
|
1947
2295
|
|
|
2296
|
+
surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
|
|
2297
|
+
tickSurfaceMovementSfx(result.cursorPosition)
|
|
1948
2298
|
gridPosition.current.set(...result.gridPosition)
|
|
1949
2299
|
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
1950
2300
|
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
@@ -1954,7 +2304,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1954
2304
|
if (draft) {
|
|
1955
2305
|
draft.position = result.gridPosition
|
|
1956
2306
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
1957
|
-
if (mesh)
|
|
2307
|
+
if (mesh)
|
|
2308
|
+
mesh.position.set(
|
|
2309
|
+
...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
|
|
2310
|
+
)
|
|
1958
2311
|
useLiveTransforms.getState().set(draft.id, {
|
|
1959
2312
|
position: result.cursorPosition,
|
|
1960
2313
|
rotation: result.cursorRotationY,
|
|
@@ -1978,19 +2331,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1978
2331
|
}
|
|
1979
2332
|
|
|
1980
2333
|
const onShelfClick = (event: ShelfEvent) => {
|
|
1981
|
-
|
|
2334
|
+
gridDispatch.flush()
|
|
2335
|
+
if (feedback.reason) return
|
|
2336
|
+
const result = shelfSurfaceStrategy.click(surfaceContext(event), event)
|
|
1982
2337
|
if (!result) return
|
|
1983
2338
|
|
|
1984
2339
|
event.stopPropagation()
|
|
1985
|
-
|
|
1986
|
-
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
1987
|
-
}
|
|
1988
|
-
const committedId = draftNode.current?.id ?? null
|
|
1989
|
-
const wasAdopted = draftNode.isAdopted
|
|
1990
|
-
const finalId = draftNode.commit(result.nodeUpdate)
|
|
2340
|
+
const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
|
|
1991
2341
|
|
|
1992
|
-
finishCommittedPlacement(
|
|
1993
|
-
const enterResult = shelfSurfaceStrategy.enter(
|
|
2342
|
+
finishCommittedPlacement(committedId, wasAdopted, () => {
|
|
2343
|
+
const enterResult = shelfSurfaceStrategy.enter(surfaceContext(event), event)
|
|
1994
2344
|
if (enterResult) {
|
|
1995
2345
|
applyTransition(enterResult)
|
|
1996
2346
|
} else {
|
|
@@ -2018,7 +2368,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2018
2368
|
|
|
2019
2369
|
// Roof-wall drafts live flat in the host face frame (yaw 0) —
|
|
2020
2370
|
// manual rotation would skew them off the wall plane.
|
|
2021
|
-
if (
|
|
2371
|
+
if (
|
|
2372
|
+
placementState.current.surface === 'roof-wall' ||
|
|
2373
|
+
placementState.current.surface === 'block-face'
|
|
2374
|
+
)
|
|
2375
|
+
return
|
|
2022
2376
|
|
|
2023
2377
|
let rotationDir: 1 | -1 | 0 = 0
|
|
2024
2378
|
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
|
@@ -2034,9 +2388,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2034
2388
|
const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
|
|
2035
2389
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
2036
2390
|
|
|
2037
|
-
//
|
|
2391
|
+
// Rotate the building-local cursor by the same delta as the host-local
|
|
2392
|
+
// draft. This preserves the host's composed yaw for items resting on a
|
|
2393
|
+
// table or shelf while still matching the draft exactly on the floor.
|
|
2038
2394
|
if (cursorGroupRef.current) {
|
|
2039
|
-
cursorGroupRef.current.rotation.y
|
|
2395
|
+
cursorGroupRef.current.rotation.y += newRotationY - currentRotation[1]
|
|
2040
2396
|
}
|
|
2041
2397
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
2042
2398
|
if (mesh) mesh.rotation.y = newRotationY
|
|
@@ -2053,8 +2409,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2053
2409
|
draft.position = [x, gridPosition.current.y, z]
|
|
2054
2410
|
if (cursorGroupRef.current) {
|
|
2055
2411
|
if (surface === 'floor') {
|
|
2412
|
+
const visual = getFloorVisualPosition([x, gridPosition.current.y, z])
|
|
2056
2413
|
cursorGroupRef.current.position.set(
|
|
2057
|
-
|
|
2414
|
+
visual[0],
|
|
2415
|
+
visual[1] + getPlacementLevelY(draft),
|
|
2416
|
+
visual[2],
|
|
2058
2417
|
)
|
|
2059
2418
|
} else {
|
|
2060
2419
|
cursorGroupRef.current.position.x = x
|
|
@@ -2100,27 +2459,63 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2100
2459
|
}
|
|
2101
2460
|
}
|
|
2102
2461
|
|
|
2103
|
-
//
|
|
2462
|
+
// Keep both preview renderers on the rotated draft. The item renderer
|
|
2463
|
+
// consumes live node overrides, while the floor-plan renderer consumes
|
|
2464
|
+
// the live transform and placement-preview snapshot.
|
|
2465
|
+
const storedSurfacePose = surfaceFramePose(
|
|
2466
|
+
draft.parentId,
|
|
2467
|
+
surfaceAttachmentId(draft),
|
|
2468
|
+
draft,
|
|
2469
|
+
true,
|
|
2470
|
+
)
|
|
2471
|
+
if (surfaceAttachmentId(draft)) {
|
|
2472
|
+
draftNode.updateSurface(
|
|
2473
|
+
{ position: draft.position, rotation: draft.rotation },
|
|
2474
|
+
surfaceAttachmentId(draft),
|
|
2475
|
+
)
|
|
2476
|
+
mesh?.position.set(...storedSurfacePose.position)
|
|
2477
|
+
mesh?.rotation.set(...storedSurfacePose.rotation)
|
|
2478
|
+
}
|
|
2479
|
+
useLiveNodeOverrides.getState().set(draft.id, { rotation: storedSurfacePose.rotation })
|
|
2104
2480
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2481
|
+
const livePosition: [number, number, number] =
|
|
2482
|
+
surface === 'floor'
|
|
2483
|
+
? [draft.position[0], draft.position[1], draft.position[2]]
|
|
2484
|
+
: cursorGroupRef.current
|
|
2485
|
+
? [
|
|
2486
|
+
cursorGroupRef.current.position.x,
|
|
2487
|
+
cursorGroupRef.current.position.y,
|
|
2488
|
+
cursorGroupRef.current.position.z,
|
|
2489
|
+
]
|
|
2490
|
+
: [draft.position[0], draft.position[1], draft.position[2]]
|
|
2491
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
2492
|
+
...currentLive,
|
|
2493
|
+
position: livePosition,
|
|
2494
|
+
rotation: cursorGroupRef.current?.rotation.y ?? newRotationY,
|
|
2495
|
+
})
|
|
2496
|
+
|
|
2497
|
+
const placementPreview = usePlacementPreview.getState()
|
|
2498
|
+
if (placementPreview.node?.id === draft.id) {
|
|
2499
|
+
const parentNode = draft.parentId
|
|
2500
|
+
? (useScene.getState().nodes[draft.parentId as AnyNodeId] ?? null)
|
|
2501
|
+
: null
|
|
2502
|
+
placementPreview.set(
|
|
2503
|
+
{
|
|
2504
|
+
...draft,
|
|
2505
|
+
position: [...draft.position],
|
|
2506
|
+
rotation: [...draft.rotation],
|
|
2507
|
+
},
|
|
2508
|
+
parentNode,
|
|
2509
|
+
)
|
|
2121
2510
|
}
|
|
2122
2511
|
|
|
2123
|
-
|
|
2512
|
+
if (feedback.reason && lastSurfaceEvent) {
|
|
2513
|
+
if (lastSurfaceEvent.node.type === 'shelf') onShelfMove(lastSurfaceEvent as ShelfEvent)
|
|
2514
|
+
else onItemMove(lastSurfaceEvent as NodeEvent<AnyNode>)
|
|
2515
|
+
} else {
|
|
2516
|
+
feedback.clear()
|
|
2517
|
+
revalidate()
|
|
2518
|
+
}
|
|
2124
2519
|
}
|
|
2125
2520
|
}
|
|
2126
2521
|
|
|
@@ -2214,7 +2609,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2214
2609
|
|
|
2215
2610
|
// ---- Subscribe ----
|
|
2216
2611
|
|
|
2217
|
-
|
|
2612
|
+
const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
|
|
2613
|
+
emitter.on('grid:move', gridDispatch.schedule)
|
|
2218
2614
|
emitter.on('grid:click', onGridClick)
|
|
2219
2615
|
emitter.on('item:enter', onItemEnter)
|
|
2220
2616
|
emitter.on('item:move', onItemMove)
|
|
@@ -2228,10 +2624,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2228
2624
|
emitter.on('roof:move', onRoofWallMove)
|
|
2229
2625
|
emitter.on('roof:click', onRoofWallClick)
|
|
2230
2626
|
emitter.on('roof:leave', onRoofWallLeave)
|
|
2627
|
+
emitter.on('node:enter', onFaceHostEnter)
|
|
2628
|
+
emitter.on('node:move', onFaceHostMove)
|
|
2629
|
+
emitter.on('node:click', onFaceHostClick)
|
|
2630
|
+
emitter.on('node:leave', onFaceHostLeave)
|
|
2231
2631
|
emitter.on('ceiling:enter', onCeilingEnter)
|
|
2232
2632
|
emitter.on('ceiling:move', onCeilingMove)
|
|
2233
2633
|
emitter.on('ceiling:click', onCeilingClick)
|
|
2234
2634
|
emitter.on('ceiling:leave', onCeilingLeave)
|
|
2635
|
+
emitter.on('cabinet:enter', onItemEnter)
|
|
2636
|
+
emitter.on('procedural-item:enter' as never, onItemEnter)
|
|
2637
|
+
emitter.on('cabinet:move', onItemMove)
|
|
2638
|
+
emitter.on('procedural-item:move' as never, onItemMove)
|
|
2639
|
+
emitter.on('cabinet:click', onItemClick)
|
|
2640
|
+
emitter.on('procedural-item:click' as never, onItemClick)
|
|
2641
|
+
emitter.on('cabinet:leave', onItemLeave)
|
|
2642
|
+
emitter.on('procedural-item:leave' as never, onItemLeave)
|
|
2235
2643
|
emitter.on('shelf:enter', onShelfEnter)
|
|
2236
2644
|
emitter.on('shelf:move', onShelfMove)
|
|
2237
2645
|
emitter.on('shelf:click', onShelfClick)
|
|
@@ -2250,25 +2658,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2250
2658
|
if (placementState.current.surface !== 'floor') return
|
|
2251
2659
|
onGridClick(event as unknown as GridEvent)
|
|
2252
2660
|
}
|
|
2253
|
-
emitter.on('
|
|
2254
|
-
emitter.on('item:click', commitFloorOnSurfaceClick as never)
|
|
2255
|
-
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2256
|
-
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
|
|
2257
|
-
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
|
|
2661
|
+
emitter.on('node:click', commitFloorOnSurfaceClick as never)
|
|
2258
2662
|
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
|
2259
2663
|
|
|
2260
2664
|
return () => {
|
|
2261
2665
|
tearingDown = true
|
|
2666
|
+
feedback.clear()
|
|
2262
2667
|
if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
|
|
2263
2668
|
unsubDraftWatch()
|
|
2264
2669
|
useAlignmentGuides.getState().clear()
|
|
2265
|
-
// Clear live
|
|
2670
|
+
// Clear every live preview channel before restoring or deleting the draft.
|
|
2266
2671
|
if (draftNode.current) {
|
|
2267
2672
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
2673
|
+
useLiveNodeOverrides.getState().clearFields(draftNode.current.id, ['rotation'])
|
|
2268
2674
|
}
|
|
2269
2675
|
draftNode.destroy()
|
|
2270
2676
|
useScene.temporal.getState().resume()
|
|
2271
|
-
|
|
2677
|
+
gridDispatch.cancel()
|
|
2678
|
+
emitter.off('grid:move', gridDispatch.schedule)
|
|
2272
2679
|
emitter.off('grid:click', onGridClick)
|
|
2273
2680
|
emitter.off('item:enter', onItemEnter)
|
|
2274
2681
|
emitter.off('item:move', onItemMove)
|
|
@@ -2282,19 +2689,27 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2282
2689
|
emitter.off('roof:move', onRoofWallMove)
|
|
2283
2690
|
emitter.off('roof:click', onRoofWallClick)
|
|
2284
2691
|
emitter.off('roof:leave', onRoofWallLeave)
|
|
2692
|
+
emitter.off('node:enter', onFaceHostEnter)
|
|
2693
|
+
emitter.off('node:move', onFaceHostMove)
|
|
2694
|
+
emitter.off('node:click', onFaceHostClick)
|
|
2695
|
+
emitter.off('node:leave', onFaceHostLeave)
|
|
2285
2696
|
emitter.off('ceiling:enter', onCeilingEnter)
|
|
2286
2697
|
emitter.off('ceiling:move', onCeilingMove)
|
|
2287
2698
|
emitter.off('ceiling:click', onCeilingClick)
|
|
2288
2699
|
emitter.off('ceiling:leave', onCeilingLeave)
|
|
2700
|
+
emitter.off('cabinet:enter', onItemEnter)
|
|
2701
|
+
emitter.off('procedural-item:enter' as never, onItemEnter)
|
|
2702
|
+
emitter.off('cabinet:move', onItemMove)
|
|
2703
|
+
emitter.off('procedural-item:move' as never, onItemMove)
|
|
2704
|
+
emitter.off('cabinet:click', onItemClick)
|
|
2705
|
+
emitter.off('procedural-item:click' as never, onItemClick)
|
|
2706
|
+
emitter.off('cabinet:leave', onItemLeave)
|
|
2707
|
+
emitter.off('procedural-item:leave' as never, onItemLeave)
|
|
2289
2708
|
emitter.off('shelf:enter', onShelfEnter)
|
|
2290
2709
|
emitter.off('shelf:move', onShelfMove)
|
|
2291
2710
|
emitter.off('shelf:click', onShelfClick)
|
|
2292
2711
|
emitter.off('shelf:leave', onShelfLeave)
|
|
2293
|
-
emitter.off('
|
|
2294
|
-
emitter.off('item:click', commitFloorOnSurfaceClick as never)
|
|
2295
|
-
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
|
|
2296
|
-
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
|
|
2297
|
-
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
|
|
2712
|
+
emitter.off('node:click', commitFloorOnSurfaceClick as never)
|
|
2298
2713
|
emitter.off('tool:cancel', onCancel)
|
|
2299
2714
|
window.removeEventListener('keydown', onKeyDown)
|
|
2300
2715
|
window.removeEventListener('keyup', onKeyUp)
|
|
@@ -2312,6 +2727,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2312
2727
|
gridSnapStep,
|
|
2313
2728
|
updateDimensionGuides,
|
|
2314
2729
|
updatePreviewGeometry,
|
|
2730
|
+
reconcileDraftRaycast,
|
|
2315
2731
|
])
|
|
2316
2732
|
|
|
2317
2733
|
// Refresh wireframe when the grid step changes mid-placement so the green/red
|
|
@@ -2343,39 +2759,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2343
2759
|
const draftParent = draft.parentId
|
|
2344
2760
|
? useScene.getState().nodes[draft.parentId as AnyNodeId]
|
|
2345
2761
|
: undefined
|
|
2346
|
-
if (
|
|
2762
|
+
if (
|
|
2763
|
+
draftParent?.type === 'item' ||
|
|
2764
|
+
draftParent?.type === 'shelf' ||
|
|
2765
|
+
draftParent?.type === 'cabinet' ||
|
|
2766
|
+
draftParent?.type === 'procedural-item' ||
|
|
2767
|
+
(draftParent &&
|
|
2768
|
+
nodeRegistry.get(draftParent.type)?.capabilities.faceHost?.currentFaceId(draft))
|
|
2769
|
+
)
|
|
2770
|
+
return
|
|
2347
2771
|
draft.parentId = viewerLevelId
|
|
2348
2772
|
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
|
2349
2773
|
}, [viewerLevelId, draftNode, asset])
|
|
2350
2774
|
|
|
2351
|
-
// Disable raycasting on the live draft mesh (and restore it when the draft
|
|
2352
|
-
// changes or goes away) so the cursor ray passes through the item being
|
|
2353
|
-
// moved and lands on the surface beneath it.
|
|
2354
|
-
const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
|
|
2355
|
-
if (raycastDisabledMeshRef.current !== mesh) {
|
|
2356
|
-
// New draft root (or cleared): restore the prior mesh and reset tracking.
|
|
2357
|
-
for (const restore of restoreRaycastsRef.current) restore()
|
|
2358
|
-
restoreRaycastsRef.current = []
|
|
2359
|
-
raycastDisabledChildrenRef.current = new WeakSet()
|
|
2360
|
-
raycastDisabledMeshRef.current = mesh
|
|
2361
|
-
}
|
|
2362
|
-
if (!mesh) return
|
|
2363
|
-
// Disable any descendant not handled yet. Item drafts are GLB models whose
|
|
2364
|
-
// child meshes mount asynchronously (Suspense), so a one-shot traverse
|
|
2365
|
-
// misses them — those late children keep intercepting the ray and corrupt
|
|
2366
|
-
// the shelf-row hit the moment the item moves onto a row. Re-walking each
|
|
2367
|
-
// frame is cheap: the WeakSet makes it idempotent, so only new children pay.
|
|
2368
|
-
mesh.traverse((child) => {
|
|
2369
|
-
if (raycastDisabledChildrenRef.current.has(child)) return
|
|
2370
|
-
raycastDisabledChildrenRef.current.add(child)
|
|
2371
|
-
const original = child.raycast
|
|
2372
|
-
child.raycast = () => {}
|
|
2373
|
-
restoreRaycastsRef.current.push(() => {
|
|
2374
|
-
child.raycast = original
|
|
2375
|
-
})
|
|
2376
|
-
})
|
|
2377
|
-
}, [])
|
|
2378
|
-
|
|
2379
2775
|
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
|
2380
2776
|
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
|
2381
2777
|
|
|
@@ -2387,8 +2783,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2387
2783
|
// moving existing node has no draft here, so the grid reads that case straight
|
|
2388
2784
|
// off the node's mesh. Cleared when idle.
|
|
2389
2785
|
const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
|
|
2786
|
+
const surfaceWorldPointRef = useRef(new Vector3())
|
|
2390
2787
|
const facingForwardRef = useRef(new Vector3(0, 0, 1))
|
|
2391
2788
|
const facingQuatRef = useRef(new Quaternion())
|
|
2789
|
+
const ghostSurfaceQuatRef = useRef(new Quaternion())
|
|
2392
2790
|
useFrame(() => {
|
|
2393
2791
|
const ghost = cursorGroupRef.current
|
|
2394
2792
|
if (!(asset && ghost)) {
|
|
@@ -2403,7 +2801,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2403
2801
|
// the item's forward on the floor, and the triangle rides at the ghost's Y.
|
|
2404
2802
|
let facingYaw = ghost.rotation.y
|
|
2405
2803
|
let facingY = ghost.position.y
|
|
2406
|
-
if (surf === 'wall' || surf === 'roof-wall') {
|
|
2804
|
+
if (surf === 'wall' || surf === 'roof-wall' || surf === 'block-face') {
|
|
2407
2805
|
// Wall/roof-segment faces: the cursor group's yaw is the symmetric
|
|
2408
2806
|
// wireframe yaw (π off the real facing for a wall, and a different frame
|
|
2409
2807
|
// for a roof face), so derive the item's TRUE outward facing from the
|
|
@@ -2411,21 +2809,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2411
2809
|
// surface. This keeps BOTH the grid normal and the triangle correct for
|
|
2412
2810
|
// wall and roof-segment hosts alike, rather than the old quaternion read
|
|
2413
2811
|
// that pointed the wrong way.
|
|
2414
|
-
const mesh =
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2812
|
+
const mesh =
|
|
2813
|
+
surf === 'block-face' || !draftNode.current
|
|
2814
|
+
? null
|
|
2815
|
+
: sceneRegistry.nodes.get(draftNode.current.id)
|
|
2816
|
+
ghost.getWorldQuaternion(ghostSurfaceQuatRef.current)
|
|
2817
|
+
const hostedQuaternion = mesh ? mesh.getWorldQuaternion(facingQuatRef.current) : null
|
|
2818
|
+
resolveItemPlacementSurfaceNormal(
|
|
2819
|
+
surf,
|
|
2820
|
+
ghostSurfaceQuatRef.current,
|
|
2821
|
+
hostedQuaternion,
|
|
2822
|
+
n,
|
|
2823
|
+
asset.attachTo,
|
|
2824
|
+
)
|
|
2825
|
+
const fwd = facingForwardRef.current.copy(n)
|
|
2826
|
+
if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
|
|
2421
2827
|
// The forward triangle is a floor aid; drop it to the building-local floor
|
|
2422
|
-
// under the
|
|
2423
|
-
facingY =
|
|
2424
|
-
n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
|
|
2828
|
+
// under the hosted plane — the storey's floor, not world ground.
|
|
2829
|
+
facingY = getPlacementLevelY(draftNode.current)
|
|
2425
2830
|
} else {
|
|
2426
|
-
|
|
2831
|
+
ghost.getWorldQuaternion(ghostSurfaceQuatRef.current)
|
|
2832
|
+
resolveItemPlacementSurfaceNormal(surf, ghostSurfaceQuatRef.current, null, n)
|
|
2427
2833
|
}
|
|
2428
|
-
publishPlacementSurface(
|
|
2834
|
+
// `publishPlacementSurface` is a WORLD-space contract (the grid reads it in
|
|
2835
|
+
// world space), but the ghost lives in the building-local tool group.
|
|
2836
|
+
publishPlacementSurface(ghost.getWorldPosition(surfaceWorldPointRef.current), n)
|
|
2429
2837
|
|
|
2430
2838
|
if (shape.depth > 0) {
|
|
2431
2839
|
useFacingPose.getState().set({
|
|
@@ -2493,8 +2901,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2493
2901
|
gridPosition.current.z,
|
|
2494
2902
|
])
|
|
2495
2903
|
mesh.position.y = visualPosition[1]
|
|
2496
|
-
cursorGroupRef.current.position.y =
|
|
2497
|
-
|
|
2904
|
+
cursorGroupRef.current.position.y =
|
|
2905
|
+
visualPosition[1] + getPlacementLevelY(draftNode.current)
|
|
2906
|
+
}
|
|
2907
|
+
} else if (placementState.current.surface === 'block-face') {
|
|
2908
|
+
const rotation = draftNode.current.rotation
|
|
2909
|
+
applyFaceHostPreviewPose(
|
|
2910
|
+
mesh,
|
|
2911
|
+
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
|
2912
|
+
rotation,
|
|
2913
|
+
)
|
|
2498
2914
|
}
|
|
2499
2915
|
})
|
|
2500
2916
|
|