@pascal-app/editor 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- 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 +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- 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 +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- 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/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- 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/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- 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/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- 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/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -4,8 +4,6 @@ import type {
|
|
|
4
4
|
CeilingEvent,
|
|
5
5
|
CeilingNode,
|
|
6
6
|
GridEvent,
|
|
7
|
-
ItemEvent,
|
|
8
|
-
ItemNode,
|
|
9
7
|
NodeEvent,
|
|
10
8
|
RoofEvent,
|
|
11
9
|
RoofNode,
|
|
@@ -17,19 +15,23 @@ import type {
|
|
|
17
15
|
WallNode,
|
|
18
16
|
} from '@pascal-app/core'
|
|
19
17
|
import {
|
|
20
|
-
canHostOnTop,
|
|
21
18
|
clampRectToRoofWallFace,
|
|
19
|
+
clearFaceHostItemFields,
|
|
20
|
+
createSceneApi,
|
|
22
21
|
getRoofSegmentWallFace,
|
|
23
22
|
getScaledDimensions,
|
|
24
|
-
isLowProfileItemSurface,
|
|
25
23
|
nodeRegistry,
|
|
24
|
+
resolveSurfacePlacement,
|
|
26
25
|
roofFacePointToSegment,
|
|
27
26
|
sceneRegistry,
|
|
27
|
+
snapLocalXZInWorld,
|
|
28
28
|
useScene,
|
|
29
|
+
wouldCreateHostingCycle,
|
|
29
30
|
} from '@pascal-app/core'
|
|
30
|
-
import { Euler,
|
|
31
|
+
import { Euler, Quaternion, Vector3 } from 'three'
|
|
31
32
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
|
|
32
|
-
import {
|
|
33
|
+
import { getActiveBuildingPose } from '../../../lib/world-grid-snap'
|
|
34
|
+
import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit'
|
|
33
35
|
import {
|
|
34
36
|
calculateItemRotation,
|
|
35
37
|
getGridAlignedDimensions,
|
|
@@ -49,48 +51,6 @@ import type {
|
|
|
49
51
|
} from './placement-types'
|
|
50
52
|
|
|
51
53
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
52
|
-
const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
|
|
53
|
-
|
|
54
|
-
function getWorldNormalY(event: ItemEvent): number | null {
|
|
55
|
-
if (!event.normal) return null
|
|
56
|
-
|
|
57
|
-
const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
|
|
58
|
-
normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
|
|
59
|
-
return normal.y
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
|
|
63
|
-
const normalY = getWorldNormalY(event)
|
|
64
|
-
return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
|
|
68
|
-
if (!canHostOnTop(surfaceItem)) return null
|
|
69
|
-
if (isLowProfileItemSurface(surfaceItem)) return null
|
|
70
|
-
if (!isUpwardItemSurfaceHit(event)) return null
|
|
71
|
-
|
|
72
|
-
if (surfaceItem.asset.surface) {
|
|
73
|
-
return surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (!Number.isFinite(localPos.y)) return null
|
|
77
|
-
return localPos.y
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function isDescendantOfItem(
|
|
81
|
-
candidate: ItemNode,
|
|
82
|
-
ancestor: ItemNode,
|
|
83
|
-
nodes: Record<string, AnyNode>,
|
|
84
|
-
): boolean {
|
|
85
|
-
let parentId = candidate.parentId
|
|
86
|
-
while (parentId) {
|
|
87
|
-
if (parentId === ancestor.id) return true
|
|
88
|
-
const parent = nodes[parentId as AnyNodeId]
|
|
89
|
-
parentId = parent?.parentId ?? null
|
|
90
|
-
}
|
|
91
|
-
return false
|
|
92
|
-
}
|
|
93
|
-
|
|
94
54
|
// ============================================================================
|
|
95
55
|
// FLOOR STRATEGY
|
|
96
56
|
// ============================================================================
|
|
@@ -117,11 +77,12 @@ export const floorStrategy = {
|
|
|
117
77
|
// the world grid.
|
|
118
78
|
// Snapping is governed by the active mode (snapToGrid returns raw in Off /
|
|
119
79
|
// non-grid modes); Alt is force-place only and never bypasses snapping here.
|
|
120
|
-
const [
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
80
|
+
const dimensions = [swapDims ? dimZ : dimX, swapDims ? dimX : dimZ] as const
|
|
81
|
+
const [x, z] = snapLocalXZInWorld(
|
|
82
|
+
[event.localPosition[0], event.localPosition[2]],
|
|
83
|
+
getActiveBuildingPose(),
|
|
84
|
+
(value, axis) => snapToGrid(value, dimensions[axis]),
|
|
85
|
+
)
|
|
125
86
|
const y = ctx.gridPosition.y
|
|
126
87
|
|
|
127
88
|
return {
|
|
@@ -240,6 +201,12 @@ export const wallStrategy = {
|
|
|
240
201
|
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
|
241
202
|
if (!isValidWallSideFace(event.normal)) return null
|
|
242
203
|
|
|
204
|
+
if (
|
|
205
|
+
ctx.draftItem &&
|
|
206
|
+
wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
|
|
207
|
+
)
|
|
208
|
+
return null
|
|
209
|
+
|
|
243
210
|
// Level guard
|
|
244
211
|
const wallLevelId = resolveLevelId(event.node, nodes)
|
|
245
212
|
if (ctx.levelId !== wallLevelId) return null
|
|
@@ -444,6 +411,11 @@ function resolveRoofWallTarget(
|
|
|
444
411
|
|
|
445
412
|
const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
|
|
446
413
|
if (!hit) return null
|
|
414
|
+
if (
|
|
415
|
+
ctx.draftItem &&
|
|
416
|
+
wouldCreateHostingCycle(ctx.draftItem.id, hit.segment, createSceneApi(useScene))
|
|
417
|
+
)
|
|
418
|
+
return null
|
|
447
419
|
|
|
448
420
|
const rawDims = ctx.draftItem
|
|
449
421
|
? getScaledDimensions(ctx.draftItem)
|
|
@@ -633,6 +605,11 @@ export const roofWallStrategy = {
|
|
|
633
605
|
// ============================================================================
|
|
634
606
|
|
|
635
607
|
function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
|
|
608
|
+
if (
|
|
609
|
+
ctx.draftItem &&
|
|
610
|
+
wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
|
|
611
|
+
)
|
|
612
|
+
return null
|
|
636
613
|
const faceHost = nodeRegistry.get(event.node.type)?.capabilities.faceHost
|
|
637
614
|
if (!faceHost) return null
|
|
638
615
|
const rawDimensions = ctx.draftItem
|
|
@@ -652,18 +629,6 @@ function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
|
|
|
652
629
|
})
|
|
653
630
|
}
|
|
654
631
|
|
|
655
|
-
function clearFaceHostItemFields(ctx: PlacementContext): Partial<ItemNode> {
|
|
656
|
-
const host = ctx.state.blockId ? useScene.getState().nodes[ctx.state.blockId] : undefined
|
|
657
|
-
const clearFields = host
|
|
658
|
-
? nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields
|
|
659
|
-
: undefined
|
|
660
|
-
const patch: Partial<ItemNode> = {}
|
|
661
|
-
for (const field of clearFields ?? []) {
|
|
662
|
-
;(patch as Record<string, unknown>)[field] = undefined
|
|
663
|
-
}
|
|
664
|
-
return patch
|
|
665
|
-
}
|
|
666
|
-
|
|
667
632
|
export const faceHostStrategy = {
|
|
668
633
|
enter(ctx: PlacementContext, event: NodeEvent): TransitionResult | null {
|
|
669
634
|
const target = resolveFaceHostTarget(ctx, event)
|
|
@@ -723,7 +688,9 @@ export const faceHostStrategy = {
|
|
|
723
688
|
return {
|
|
724
689
|
stateUpdate: { surface: 'floor', blockId: null },
|
|
725
690
|
nodeUpdate: {
|
|
726
|
-
...clearFaceHostItemFields(
|
|
691
|
+
...clearFaceHostItemFields(
|
|
692
|
+
ctx.state.blockId ? useScene.getState().nodes[ctx.state.blockId] : undefined,
|
|
693
|
+
),
|
|
727
694
|
position: floorPosition,
|
|
728
695
|
parentId: ctx.levelId,
|
|
729
696
|
rotation: [0, ctx.currentCursorRotationY, 0],
|
|
@@ -753,6 +720,12 @@ export const ceilingStrategy = {
|
|
|
753
720
|
): TransitionResult | null {
|
|
754
721
|
if (ctx.asset.attachTo !== 'ceiling') return null
|
|
755
722
|
|
|
723
|
+
if (
|
|
724
|
+
ctx.draftItem &&
|
|
725
|
+
wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
|
|
726
|
+
)
|
|
727
|
+
return null
|
|
728
|
+
|
|
756
729
|
// Level guard
|
|
757
730
|
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
|
758
731
|
if (ctx.levelId !== ceilingLevelId) return null
|
|
@@ -881,65 +854,119 @@ export const ceilingStrategy = {
|
|
|
881
854
|
// ITEM SURFACE STRATEGY
|
|
882
855
|
// ============================================================================
|
|
883
856
|
|
|
857
|
+
function resolveCatalogItemSurfacePlacement(
|
|
858
|
+
host: AnyNode,
|
|
859
|
+
event: NodeEvent<AnyNode>,
|
|
860
|
+
dimensions: [number, number, number],
|
|
861
|
+
worldYaw: number,
|
|
862
|
+
onReject?: PlacementContext['onSurfaceReject'],
|
|
863
|
+
rawEvent = event,
|
|
864
|
+
childId?: string,
|
|
865
|
+
childRotation?: [number, number, number],
|
|
866
|
+
) {
|
|
867
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
868
|
+
if (!mesh) return null
|
|
869
|
+
const hit = itemEventToSurfaceHit(host, event)
|
|
870
|
+
if (!hit) return null
|
|
871
|
+
const quaternion = mesh.getWorldQuaternion(new Quaternion())
|
|
872
|
+
const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
|
|
873
|
+
const placement = resolveSurfacePlacement({
|
|
874
|
+
host,
|
|
875
|
+
childKind: 'item',
|
|
876
|
+
childId,
|
|
877
|
+
childFootprint: {
|
|
878
|
+
size: dimensions,
|
|
879
|
+
rotationY: worldYaw - hostYaw,
|
|
880
|
+
rotation: [childRotation?.[0] ?? 0, worldYaw - hostYaw, childRotation?.[2] ?? 0],
|
|
881
|
+
},
|
|
882
|
+
hit: host.type === 'procedural-item' ? (itemEventToSurfaceHit(host, rawEvent) ?? hit) : hit,
|
|
883
|
+
origin: host.type === 'procedural-item' ? hit.point : undefined,
|
|
884
|
+
scene: createSceneApi(useScene),
|
|
885
|
+
snapScalar: snapToGrid,
|
|
886
|
+
checkFootprint: true,
|
|
887
|
+
onReject,
|
|
888
|
+
})
|
|
889
|
+
if (!placement) return null
|
|
890
|
+
return {
|
|
891
|
+
position: [...placement.position] as [number, number, number],
|
|
892
|
+
rotationY: placement.rotationY,
|
|
893
|
+
surfaceId: placement.surfaceId,
|
|
894
|
+
worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export function validCatalogCounterPose(ctx: PlacementContext): boolean {
|
|
899
|
+
const hostId = ctx.state.surface === 'shelf-surface' ? ctx.state.shelfId : ctx.state.surfaceItemId
|
|
900
|
+
const host = hostId ? useScene.getState().nodes[hostId as AnyNodeId] : undefined
|
|
901
|
+
if (!host) return true
|
|
902
|
+
if (!ctx.draftItem) return false
|
|
903
|
+
const placement = resolveSurfacePlacement({
|
|
904
|
+
host,
|
|
905
|
+
childKind: 'item',
|
|
906
|
+
childId: ctx.draftItem.id,
|
|
907
|
+
childFootprint: {
|
|
908
|
+
size: getScaledDimensions(ctx.draftItem),
|
|
909
|
+
rotationY: ctx.draftItem.rotation[1],
|
|
910
|
+
rotation: ctx.draftItem.rotation,
|
|
911
|
+
},
|
|
912
|
+
hit: { point: ctx.gridPosition.toArray(), normalWorldY: 1 },
|
|
913
|
+
scene: createSceneApi(useScene),
|
|
914
|
+
onReject: ctx.onSurfaceReject,
|
|
915
|
+
})
|
|
916
|
+
return (
|
|
917
|
+
!!placement &&
|
|
918
|
+
(host.type !== 'cabinet' || Math.abs(placement.position[1] - ctx.gridPosition.y) < 1e-5)
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
884
922
|
export const itemSurfaceStrategy = {
|
|
885
923
|
/**
|
|
886
924
|
* Handle item:enter — transition from floor to an item surface.
|
|
887
925
|
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
|
|
888
926
|
*/
|
|
889
|
-
enter(ctx: PlacementContext, event:
|
|
927
|
+
enter(ctx: PlacementContext, event: NodeEvent<AnyNode>): TransitionResult | null {
|
|
890
928
|
// Only floor items can be placed on surfaces
|
|
891
929
|
if (ctx.asset.attachTo) return null
|
|
892
930
|
|
|
893
|
-
const surfaceItem = event.node
|
|
931
|
+
const surfaceItem = event.node
|
|
894
932
|
// Don't surface-place on the draft itself
|
|
895
933
|
if (surfaceItem.id === ctx.draftItem?.id) return null
|
|
896
934
|
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
|
|
897
935
|
return null
|
|
898
936
|
}
|
|
899
|
-
const nodes = useScene.getState().nodes
|
|
900
|
-
if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
|
|
901
|
-
|
|
902
|
-
// Size check: our footprint must fit on surface item's footprint
|
|
903
937
|
const ourDims = ctx.draftItem
|
|
904
938
|
? getScaledDimensions(ctx.draftItem)
|
|
905
939
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
// Counter-rotate so the draft's world Y rotation stays continuous when
|
|
924
|
-
// the user drags onto a rotated surface item. The cursor wireframe
|
|
925
|
-
// already shows the user's intended world rotation; we just need to
|
|
926
|
-
// store the right local value relative to the new parent.
|
|
927
|
-
const surfaceQuat = new Quaternion()
|
|
928
|
-
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
929
|
-
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
930
|
-
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
|
940
|
+
if (
|
|
941
|
+
ctx.draftItem &&
|
|
942
|
+
wouldCreateHostingCycle(ctx.draftItem.id, surfaceItem, createSceneApi(useScene))
|
|
943
|
+
)
|
|
944
|
+
return null
|
|
945
|
+
const pose = resolveCatalogItemSurfacePlacement(
|
|
946
|
+
surfaceItem,
|
|
947
|
+
event,
|
|
948
|
+
ourDims,
|
|
949
|
+
ctx.currentCursorRotationY,
|
|
950
|
+
ctx.onSurfaceReject,
|
|
951
|
+
event,
|
|
952
|
+
ctx.draftItem?.id,
|
|
953
|
+
ctx.draftItem?.rotation,
|
|
954
|
+
)
|
|
955
|
+
if (!pose) return null
|
|
931
956
|
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
932
957
|
|
|
933
958
|
return {
|
|
959
|
+
surfaceId: pose.surfaceId,
|
|
934
960
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
935
961
|
nodeUpdate: {
|
|
936
|
-
position:
|
|
962
|
+
position: pose.position,
|
|
937
963
|
parentId: surfaceItem.id,
|
|
938
|
-
|
|
964
|
+
supportSlabId: undefined,
|
|
965
|
+
rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
|
|
939
966
|
},
|
|
940
967
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
941
|
-
gridPosition:
|
|
942
|
-
cursorPosition:
|
|
968
|
+
gridPosition: pose.position,
|
|
969
|
+
cursorPosition: pose.worldPosition,
|
|
943
970
|
stopPropagation: true,
|
|
944
971
|
}
|
|
945
972
|
},
|
|
@@ -947,35 +974,33 @@ export const itemSurfaceStrategy = {
|
|
|
947
974
|
/**
|
|
948
975
|
* Handle item:move — update position while on an item surface.
|
|
949
976
|
*/
|
|
950
|
-
move(ctx: PlacementContext, event:
|
|
977
|
+
move(ctx: PlacementContext, event: NodeEvent<AnyNode>, rawEvent = event): PlacementResult | null {
|
|
951
978
|
if (ctx.state.surface !== 'item-surface') return null
|
|
952
979
|
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
|
953
980
|
if (event.node.id !== ctx.state.surfaceItemId) return null
|
|
954
981
|
|
|
955
982
|
const nodes = useScene.getState().nodes
|
|
956
|
-
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId]
|
|
983
|
+
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId]
|
|
957
984
|
if (!surfaceItem) return null
|
|
958
985
|
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const y = surfaceHeight
|
|
971
|
-
|
|
972
|
-
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
986
|
+
const pose = resolveCatalogItemSurfacePlacement(
|
|
987
|
+
surfaceItem,
|
|
988
|
+
event,
|
|
989
|
+
getScaledDimensions(ctx.draftItem),
|
|
990
|
+
ctx.currentCursorRotationY,
|
|
991
|
+
ctx.onSurfaceReject,
|
|
992
|
+
rawEvent,
|
|
993
|
+
ctx.draftItem.id,
|
|
994
|
+
ctx.draftItem.rotation,
|
|
995
|
+
)
|
|
996
|
+
if (!pose) return null
|
|
973
997
|
|
|
974
998
|
return {
|
|
975
|
-
|
|
976
|
-
|
|
999
|
+
surfaceId: pose.surfaceId,
|
|
1000
|
+
gridPosition: pose.position,
|
|
1001
|
+
cursorPosition: pose.worldPosition,
|
|
977
1002
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
978
|
-
nodeUpdate: { position:
|
|
1003
|
+
nodeUpdate: { position: pose.position },
|
|
979
1004
|
stopPropagation: true,
|
|
980
1005
|
dirtyNodeId: null,
|
|
981
1006
|
}
|
|
@@ -984,10 +1009,11 @@ export const itemSurfaceStrategy = {
|
|
|
984
1009
|
/**
|
|
985
1010
|
* Handle item:click — commit placement on item surface.
|
|
986
1011
|
*/
|
|
987
|
-
click(ctx: PlacementContext, _event:
|
|
1012
|
+
click(ctx: PlacementContext, _event: NodeEvent<AnyNode>): CommitResult | null {
|
|
988
1013
|
if (ctx.state.surface !== 'item-surface') return null
|
|
989
1014
|
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
|
990
1015
|
if (_event.node.id !== ctx.state.surfaceItemId) return null
|
|
1016
|
+
if (!validCatalogCounterPose(ctx)) return null
|
|
991
1017
|
|
|
992
1018
|
return {
|
|
993
1019
|
nodeUpdate: {
|
|
@@ -1005,32 +1031,42 @@ export const itemSurfaceStrategy = {
|
|
|
1005
1031
|
// SHELF SURFACE STRATEGY
|
|
1006
1032
|
// ============================================================================
|
|
1007
1033
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1034
|
+
function resolveShelfSurfacePlacement(
|
|
1035
|
+
host: ShelfNode,
|
|
1036
|
+
event: ShelfEvent,
|
|
1037
|
+
dimensions: [number, number, number],
|
|
1038
|
+
worldYaw: number,
|
|
1039
|
+
entering: boolean,
|
|
1040
|
+
onReject?: PlacementContext['onSurfaceReject'],
|
|
1041
|
+
childId?: string,
|
|
1042
|
+
) {
|
|
1043
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
1044
|
+
if (!mesh) return null
|
|
1045
|
+
const local = mesh.worldToLocal(new Vector3(...event.position))
|
|
1046
|
+
const quaternion = mesh.getWorldQuaternion(new Quaternion())
|
|
1047
|
+
const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
|
|
1048
|
+
const placement = resolveSurfacePlacement({
|
|
1049
|
+
host,
|
|
1050
|
+
childKind: 'item',
|
|
1051
|
+
childId,
|
|
1052
|
+
childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
|
|
1053
|
+
hit: {
|
|
1054
|
+
point: local.toArray(),
|
|
1055
|
+
// Existing shelf movement elects rows even over side faces or board gaps;
|
|
1056
|
+
// only entering requires an upward hit.
|
|
1057
|
+
normalWorldY: entering ? surfaceWorldNormalY(event.normal, event.object.matrixWorld) : 1,
|
|
1058
|
+
},
|
|
1059
|
+
scene: createSceneApi(useScene),
|
|
1060
|
+
snapScalar: snapToGrid,
|
|
1061
|
+
onReject,
|
|
1062
|
+
checkFootprint: true,
|
|
1063
|
+
})
|
|
1064
|
+
if (!placement) return null
|
|
1065
|
+
return {
|
|
1066
|
+
position: [...placement.position] as [number, number, number],
|
|
1067
|
+
rotationY: placement.rotationY,
|
|
1068
|
+
worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
|
|
1032
1069
|
}
|
|
1033
|
-
return best?.position[1] ?? null
|
|
1034
1070
|
}
|
|
1035
1071
|
|
|
1036
1072
|
export const shelfSurfaceStrategy = {
|
|
@@ -1049,43 +1085,31 @@ export const shelfSurfaceStrategy = {
|
|
|
1049
1085
|
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
|
|
1050
1086
|
return null
|
|
1051
1087
|
}
|
|
1052
|
-
if (!isUpwardShelfSurfaceHit(event)) return null
|
|
1053
|
-
|
|
1054
|
-
// Size check: draft footprint must fit on the shelf board (width × depth).
|
|
1055
1088
|
const ourDims = ctx.draftItem
|
|
1056
1089
|
? getScaledDimensions(ctx.draftItem)
|
|
1057
1090
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
const x = snapToGrid(localPos.x, ourDims[0])
|
|
1069
|
-
const z = snapToGrid(localPos.z, ourDims[2])
|
|
1070
|
-
|
|
1071
|
-
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
|
1072
|
-
|
|
1073
|
-
const surfaceQuat = new Quaternion()
|
|
1074
|
-
shelfMesh.getWorldQuaternion(surfaceQuat)
|
|
1075
|
-
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
1076
|
-
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
|
1091
|
+
const pose = resolveShelfSurfacePlacement(
|
|
1092
|
+
shelfNode,
|
|
1093
|
+
event,
|
|
1094
|
+
ourDims,
|
|
1095
|
+
ctx.currentCursorRotationY,
|
|
1096
|
+
true,
|
|
1097
|
+
ctx.onSurfaceReject,
|
|
1098
|
+
ctx.draftItem?.id,
|
|
1099
|
+
)
|
|
1100
|
+
if (!pose) return null
|
|
1077
1101
|
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
1078
1102
|
|
|
1079
1103
|
return {
|
|
1080
1104
|
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
|
|
1081
1105
|
nodeUpdate: {
|
|
1082
|
-
position:
|
|
1106
|
+
position: pose.position,
|
|
1083
1107
|
parentId: shelfNode.id,
|
|
1084
|
-
rotation: [draftRotation[0],
|
|
1108
|
+
rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
|
|
1085
1109
|
},
|
|
1086
1110
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
1087
|
-
gridPosition:
|
|
1088
|
-
cursorPosition:
|
|
1111
|
+
gridPosition: pose.position,
|
|
1112
|
+
cursorPosition: pose.worldPosition,
|
|
1089
1113
|
stopPropagation: true,
|
|
1090
1114
|
}
|
|
1091
1115
|
},
|
|
@@ -1099,25 +1123,22 @@ export const shelfSurfaceStrategy = {
|
|
|
1099
1123
|
if (!(ctx.state.shelfId && ctx.draftItem)) return null
|
|
1100
1124
|
if (event.node.id !== ctx.state.shelfId) return null
|
|
1101
1125
|
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
const x = snapToGrid(localPos.x, ourDims[0])
|
|
1113
|
-
const z = snapToGrid(localPos.z, ourDims[2])
|
|
1114
|
-
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
|
1126
|
+
const pose = resolveShelfSurfacePlacement(
|
|
1127
|
+
event.node as ShelfNode,
|
|
1128
|
+
event,
|
|
1129
|
+
getScaledDimensions(ctx.draftItem),
|
|
1130
|
+
ctx.currentCursorRotationY,
|
|
1131
|
+
false,
|
|
1132
|
+
ctx.onSurfaceReject,
|
|
1133
|
+
ctx.draftItem.id,
|
|
1134
|
+
)
|
|
1135
|
+
if (!pose) return null
|
|
1115
1136
|
|
|
1116
1137
|
return {
|
|
1117
|
-
gridPosition:
|
|
1118
|
-
cursorPosition:
|
|
1138
|
+
gridPosition: pose.position,
|
|
1139
|
+
cursorPosition: pose.worldPosition,
|
|
1119
1140
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
1120
|
-
nodeUpdate: { position:
|
|
1141
|
+
nodeUpdate: { position: pose.position },
|
|
1121
1142
|
stopPropagation: true,
|
|
1122
1143
|
dirtyNodeId: null,
|
|
1123
1144
|
}
|
|
@@ -1130,6 +1151,7 @@ export const shelfSurfaceStrategy = {
|
|
|
1130
1151
|
if (ctx.state.surface !== 'shelf-surface') return null
|
|
1131
1152
|
if (!(ctx.draftItem && ctx.state.shelfId)) return null
|
|
1132
1153
|
if (event.node.id !== ctx.state.shelfId) return null
|
|
1154
|
+
if (!validCatalogCounterPose(ctx)) return null
|
|
1133
1155
|
|
|
1134
1156
|
return {
|
|
1135
1157
|
nodeUpdate: {
|
|
@@ -1143,14 +1165,6 @@ export const shelfSurfaceStrategy = {
|
|
|
1143
1165
|
},
|
|
1144
1166
|
}
|
|
1145
1167
|
|
|
1146
|
-
/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
|
|
1147
|
-
* for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
|
|
1148
|
-
* via a tiny `ItemEvent`-shaped adapter — the function only reads
|
|
1149
|
-
* `event.normal` + `event.object`. */
|
|
1150
|
-
function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
|
|
1151
|
-
return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
1168
|
// ============================================================================
|
|
1155
1169
|
// VALIDATION
|
|
1156
1170
|
// ============================================================================
|
|
@@ -1162,14 +1176,12 @@ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
|
|
|
1162
1176
|
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
|
1163
1177
|
if (!(ctx.levelId && ctx.draftItem)) return false
|
|
1164
1178
|
|
|
1165
|
-
// Item surface: valid if we entered (size check was in enter)
|
|
1166
1179
|
if (ctx.state.surface === 'item-surface') {
|
|
1167
|
-
return ctx.state.surfaceItemId !== null
|
|
1180
|
+
return ctx.state.surfaceItemId !== null && validCatalogCounterPose(ctx)
|
|
1168
1181
|
}
|
|
1169
1182
|
|
|
1170
|
-
// Shelf surface: same — size check already happened on enter
|
|
1171
1183
|
if (ctx.state.surface === 'shelf-surface') {
|
|
1172
|
-
return ctx.state.shelfId !== null
|
|
1184
|
+
return ctx.state.shelfId !== null && validCatalogCounterPose(ctx)
|
|
1173
1185
|
}
|
|
1174
1186
|
|
|
1175
1187
|
const attachTo = ctx.draftItem.asset.attachTo
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
CeilingNode,
|
|
6
6
|
ItemNode,
|
|
7
7
|
LevelNode,
|
|
8
|
+
SurfaceRejectReason,
|
|
8
9
|
WallNode,
|
|
9
10
|
} from '@pascal-app/core'
|
|
10
11
|
import type { Vector3 } from 'three'
|
|
@@ -56,6 +57,7 @@ export interface PlacementState {
|
|
|
56
57
|
* Read-only snapshot passed to every strategy call.
|
|
57
58
|
*/
|
|
58
59
|
export interface PlacementContext {
|
|
60
|
+
onSurfaceReject?: (reason: SurfaceRejectReason) => void
|
|
59
61
|
asset: AssetInput
|
|
60
62
|
levelId: LevelNode['id'] | null
|
|
61
63
|
draftItem: ItemNode | null
|
|
@@ -78,6 +80,7 @@ export interface PlacementContext {
|
|
|
78
80
|
* Returned by strategy move handlers.
|
|
79
81
|
*/
|
|
80
82
|
export interface PlacementResult {
|
|
83
|
+
surfaceId?: string | null
|
|
81
84
|
gridPosition: [number, number, number]
|
|
82
85
|
cursorPosition: [number, number, number]
|
|
83
86
|
cursorRotationY: number
|
|
@@ -92,6 +95,7 @@ export interface PlacementResult {
|
|
|
92
95
|
* Returned by enter/leave handlers (surface transitions).
|
|
93
96
|
*/
|
|
94
97
|
export interface TransitionResult {
|
|
98
|
+
surfaceId?: string | null
|
|
95
99
|
stateUpdate: Partial<PlacementState>
|
|
96
100
|
nodeUpdate: Partial<ItemNode>
|
|
97
101
|
gridPosition: [number, number, number]
|