@pascal-app/editor 1.0.0 → 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/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- 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 +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- 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/placement-strategies.ts +169 -169
- 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 +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- 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.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- 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/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- 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 +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- 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 +29 -10
- 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/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- 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 +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -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 +206 -0
- package/src/lib/glb-export.ts +171 -24
- 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/node-action-movement.ts +21 -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/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/surface-attachment.ts +77 -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/store/use-handle-group.ts +4 -0
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
CeilingEvent,
|
|
5
5
|
CeilingNode,
|
|
6
6
|
GridEvent,
|
|
7
|
-
ItemEvent,
|
|
8
7
|
ItemNode,
|
|
9
8
|
NodeEvent,
|
|
10
9
|
RoofEvent,
|
|
@@ -17,19 +16,20 @@ import type {
|
|
|
17
16
|
WallNode,
|
|
18
17
|
} from '@pascal-app/core'
|
|
19
18
|
import {
|
|
20
|
-
canHostOnTop,
|
|
21
19
|
clampRectToRoofWallFace,
|
|
20
|
+
createSceneApi,
|
|
22
21
|
getRoofSegmentWallFace,
|
|
23
22
|
getScaledDimensions,
|
|
24
|
-
isLowProfileItemSurface,
|
|
25
23
|
nodeRegistry,
|
|
24
|
+
resolveSurfacePlacement,
|
|
26
25
|
roofFacePointToSegment,
|
|
27
26
|
sceneRegistry,
|
|
28
27
|
useScene,
|
|
29
28
|
} from '@pascal-app/core'
|
|
30
|
-
import { Euler,
|
|
29
|
+
import { Euler, Quaternion, Vector3 } from 'three'
|
|
31
30
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
|
|
32
31
|
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
|
|
32
|
+
import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit'
|
|
33
33
|
import {
|
|
34
34
|
calculateItemRotation,
|
|
35
35
|
getGridAlignedDimensions,
|
|
@@ -49,42 +49,14 @@ import type {
|
|
|
49
49
|
} from './placement-types'
|
|
50
50
|
|
|
51
51
|
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
52
|
function isDescendantOfItem(
|
|
81
|
-
candidate:
|
|
82
|
-
|
|
53
|
+
candidate: AnyNode,
|
|
54
|
+
ancestorId: string,
|
|
83
55
|
nodes: Record<string, AnyNode>,
|
|
84
56
|
): boolean {
|
|
85
57
|
let parentId = candidate.parentId
|
|
86
58
|
while (parentId) {
|
|
87
|
-
if (parentId ===
|
|
59
|
+
if (parentId === ancestorId) return true
|
|
88
60
|
const parent = nodes[parentId as AnyNodeId]
|
|
89
61
|
parentId = parent?.parentId ?? null
|
|
90
62
|
}
|
|
@@ -881,65 +853,114 @@ export const ceilingStrategy = {
|
|
|
881
853
|
// ITEM SURFACE STRATEGY
|
|
882
854
|
// ============================================================================
|
|
883
855
|
|
|
856
|
+
function resolveCatalogItemSurfacePlacement(
|
|
857
|
+
host: AnyNode,
|
|
858
|
+
event: NodeEvent<AnyNode>,
|
|
859
|
+
dimensions: [number, number, number],
|
|
860
|
+
worldYaw: number,
|
|
861
|
+
onReject?: PlacementContext['onSurfaceReject'],
|
|
862
|
+
rawEvent = event,
|
|
863
|
+
) {
|
|
864
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
865
|
+
if (!mesh) return null
|
|
866
|
+
const hit = itemEventToSurfaceHit(host, event)
|
|
867
|
+
if (!hit) return null
|
|
868
|
+
const quaternion = mesh.getWorldQuaternion(new Quaternion())
|
|
869
|
+
const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
|
|
870
|
+
const placement = resolveSurfacePlacement({
|
|
871
|
+
host,
|
|
872
|
+
childKind: 'item',
|
|
873
|
+
childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
|
|
874
|
+
hit: host.type === 'procedural-item' ? (itemEventToSurfaceHit(host, rawEvent) ?? hit) : hit,
|
|
875
|
+
origin: host.type === 'procedural-item' ? hit.point : undefined,
|
|
876
|
+
scene: createSceneApi(useScene),
|
|
877
|
+
snapScalar: snapToGrid,
|
|
878
|
+
checkFootprint: true,
|
|
879
|
+
onReject,
|
|
880
|
+
})
|
|
881
|
+
if (!placement) return null
|
|
882
|
+
return {
|
|
883
|
+
position: [...placement.position] as [number, number, number],
|
|
884
|
+
rotationY: placement.rotationY,
|
|
885
|
+
surfaceId: placement.surfaceId,
|
|
886
|
+
worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export function validCatalogCounterPose(ctx: PlacementContext): boolean {
|
|
891
|
+
const hostId = ctx.state.surface === 'shelf-surface' ? ctx.state.shelfId : ctx.state.surfaceItemId
|
|
892
|
+
const host = hostId ? useScene.getState().nodes[hostId as AnyNodeId] : undefined
|
|
893
|
+
if (
|
|
894
|
+
host?.type !== 'cabinet' &&
|
|
895
|
+
host?.type !== 'shelf' &&
|
|
896
|
+
host?.type !== 'item' &&
|
|
897
|
+
host?.type !== 'procedural-item'
|
|
898
|
+
)
|
|
899
|
+
return true
|
|
900
|
+
if (!ctx.draftItem) return false
|
|
901
|
+
const placement = resolveSurfacePlacement({
|
|
902
|
+
host,
|
|
903
|
+
childKind: 'item',
|
|
904
|
+
childFootprint: {
|
|
905
|
+
size: getScaledDimensions(ctx.draftItem),
|
|
906
|
+
rotationY: ctx.draftItem.rotation[1],
|
|
907
|
+
rotation: ctx.draftItem.rotation,
|
|
908
|
+
},
|
|
909
|
+
hit: { point: ctx.gridPosition.toArray(), normalWorldY: 1 },
|
|
910
|
+
scene: createSceneApi(useScene),
|
|
911
|
+
onReject: ctx.onSurfaceReject,
|
|
912
|
+
})
|
|
913
|
+
return (
|
|
914
|
+
!!placement &&
|
|
915
|
+
(host.type !== 'cabinet' || Math.abs(placement.position[1] - ctx.gridPosition.y) < 1e-5)
|
|
916
|
+
)
|
|
917
|
+
}
|
|
918
|
+
|
|
884
919
|
export const itemSurfaceStrategy = {
|
|
885
920
|
/**
|
|
886
921
|
* Handle item:enter — transition from floor to an item surface.
|
|
887
922
|
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
|
|
888
923
|
*/
|
|
889
|
-
enter(ctx: PlacementContext, event:
|
|
924
|
+
enter(ctx: PlacementContext, event: NodeEvent<AnyNode>): TransitionResult | null {
|
|
890
925
|
// Only floor items can be placed on surfaces
|
|
891
926
|
if (ctx.asset.attachTo) return null
|
|
892
927
|
|
|
893
|
-
const surfaceItem = event.node
|
|
928
|
+
const surfaceItem = event.node
|
|
894
929
|
// Don't surface-place on the draft itself
|
|
895
930
|
if (surfaceItem.id === ctx.draftItem?.id) return null
|
|
896
931
|
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
|
|
897
932
|
return null
|
|
898
933
|
}
|
|
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
934
|
const ourDims = ctx.draftItem
|
|
904
935
|
? getScaledDimensions(ctx.draftItem)
|
|
905
936
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
const y = surfaceHeight
|
|
920
|
-
|
|
921
|
-
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
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
|
|
937
|
+
if (
|
|
938
|
+
ctx.draftItem &&
|
|
939
|
+
isDescendantOfItem(surfaceItem, ctx.draftItem.id, useScene.getState().nodes)
|
|
940
|
+
)
|
|
941
|
+
return null
|
|
942
|
+
const pose = resolveCatalogItemSurfacePlacement(
|
|
943
|
+
surfaceItem,
|
|
944
|
+
event,
|
|
945
|
+
ourDims,
|
|
946
|
+
ctx.currentCursorRotationY,
|
|
947
|
+
ctx.onSurfaceReject,
|
|
948
|
+
)
|
|
949
|
+
if (!pose) return null
|
|
931
950
|
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
932
951
|
|
|
933
952
|
return {
|
|
953
|
+
surfaceId: pose.surfaceId,
|
|
934
954
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
935
955
|
nodeUpdate: {
|
|
936
|
-
position:
|
|
956
|
+
position: pose.position,
|
|
937
957
|
parentId: surfaceItem.id,
|
|
938
|
-
|
|
958
|
+
supportSlabId: undefined,
|
|
959
|
+
rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
|
|
939
960
|
},
|
|
940
961
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
941
|
-
gridPosition:
|
|
942
|
-
cursorPosition:
|
|
962
|
+
gridPosition: pose.position,
|
|
963
|
+
cursorPosition: pose.worldPosition,
|
|
943
964
|
stopPropagation: true,
|
|
944
965
|
}
|
|
945
966
|
},
|
|
@@ -947,35 +968,31 @@ export const itemSurfaceStrategy = {
|
|
|
947
968
|
/**
|
|
948
969
|
* Handle item:move — update position while on an item surface.
|
|
949
970
|
*/
|
|
950
|
-
move(ctx: PlacementContext, event:
|
|
971
|
+
move(ctx: PlacementContext, event: NodeEvent<AnyNode>, rawEvent = event): PlacementResult | null {
|
|
951
972
|
if (ctx.state.surface !== 'item-surface') return null
|
|
952
973
|
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
|
953
974
|
if (event.node.id !== ctx.state.surfaceItemId) return null
|
|
954
975
|
|
|
955
976
|
const nodes = useScene.getState().nodes
|
|
956
|
-
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId]
|
|
977
|
+
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId]
|
|
957
978
|
if (!surfaceItem) return null
|
|
958
979
|
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const x = snapToGrid(localPos.x, ourDims[0])
|
|
969
|
-
const z = snapToGrid(localPos.z, ourDims[2])
|
|
970
|
-
const y = surfaceHeight
|
|
971
|
-
|
|
972
|
-
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
980
|
+
const pose = resolveCatalogItemSurfacePlacement(
|
|
981
|
+
surfaceItem,
|
|
982
|
+
event,
|
|
983
|
+
getScaledDimensions(ctx.draftItem),
|
|
984
|
+
ctx.currentCursorRotationY,
|
|
985
|
+
ctx.onSurfaceReject,
|
|
986
|
+
rawEvent,
|
|
987
|
+
)
|
|
988
|
+
if (!pose) return null
|
|
973
989
|
|
|
974
990
|
return {
|
|
975
|
-
|
|
976
|
-
|
|
991
|
+
surfaceId: pose.surfaceId,
|
|
992
|
+
gridPosition: pose.position,
|
|
993
|
+
cursorPosition: pose.worldPosition,
|
|
977
994
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
978
|
-
nodeUpdate: { position:
|
|
995
|
+
nodeUpdate: { position: pose.position },
|
|
979
996
|
stopPropagation: true,
|
|
980
997
|
dirtyNodeId: null,
|
|
981
998
|
}
|
|
@@ -984,10 +1001,11 @@ export const itemSurfaceStrategy = {
|
|
|
984
1001
|
/**
|
|
985
1002
|
* Handle item:click — commit placement on item surface.
|
|
986
1003
|
*/
|
|
987
|
-
click(ctx: PlacementContext, _event:
|
|
1004
|
+
click(ctx: PlacementContext, _event: NodeEvent<AnyNode>): CommitResult | null {
|
|
988
1005
|
if (ctx.state.surface !== 'item-surface') return null
|
|
989
1006
|
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
|
990
1007
|
if (_event.node.id !== ctx.state.surfaceItemId) return null
|
|
1008
|
+
if (!validCatalogCounterPose(ctx)) return null
|
|
991
1009
|
|
|
992
1010
|
return {
|
|
993
1011
|
nodeUpdate: {
|
|
@@ -1005,32 +1023,40 @@ export const itemSurfaceStrategy = {
|
|
|
1005
1023
|
// SHELF SURFACE STRATEGY
|
|
1006
1024
|
// ============================================================================
|
|
1007
1025
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1026
|
+
function resolveShelfSurfacePlacement(
|
|
1027
|
+
host: ShelfNode,
|
|
1028
|
+
event: ShelfEvent,
|
|
1029
|
+
dimensions: [number, number, number],
|
|
1030
|
+
worldYaw: number,
|
|
1031
|
+
entering: boolean,
|
|
1032
|
+
onReject?: PlacementContext['onSurfaceReject'],
|
|
1033
|
+
) {
|
|
1034
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
1035
|
+
if (!mesh) return null
|
|
1036
|
+
const local = mesh.worldToLocal(new Vector3(...event.position))
|
|
1037
|
+
const quaternion = mesh.getWorldQuaternion(new Quaternion())
|
|
1038
|
+
const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
|
|
1039
|
+
const placement = resolveSurfacePlacement({
|
|
1040
|
+
host,
|
|
1041
|
+
childKind: 'item',
|
|
1042
|
+
childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
|
|
1043
|
+
hit: {
|
|
1044
|
+
point: local.toArray(),
|
|
1045
|
+
// Existing shelf movement elects rows even over side faces or board gaps;
|
|
1046
|
+
// only entering requires an upward hit.
|
|
1047
|
+
normalWorldY: entering ? surfaceWorldNormalY(event.normal, event.object.matrixWorld) : 1,
|
|
1048
|
+
},
|
|
1049
|
+
scene: createSceneApi(useScene),
|
|
1050
|
+
snapScalar: snapToGrid,
|
|
1051
|
+
onReject,
|
|
1052
|
+
checkFootprint: true,
|
|
1053
|
+
})
|
|
1054
|
+
if (!placement) return null
|
|
1055
|
+
return {
|
|
1056
|
+
position: [...placement.position] as [number, number, number],
|
|
1057
|
+
rotationY: placement.rotationY,
|
|
1058
|
+
worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
|
|
1032
1059
|
}
|
|
1033
|
-
return best?.position[1] ?? null
|
|
1034
1060
|
}
|
|
1035
1061
|
|
|
1036
1062
|
export const shelfSurfaceStrategy = {
|
|
@@ -1049,43 +1075,30 @@ export const shelfSurfaceStrategy = {
|
|
|
1049
1075
|
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
|
|
1050
1076
|
return null
|
|
1051
1077
|
}
|
|
1052
|
-
if (!isUpwardShelfSurfaceHit(event)) return null
|
|
1053
|
-
|
|
1054
|
-
// Size check: draft footprint must fit on the shelf board (width × depth).
|
|
1055
1078
|
const ourDims = ctx.draftItem
|
|
1056
1079
|
? getScaledDimensions(ctx.draftItem)
|
|
1057
1080
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
if (
|
|
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
|
|
1081
|
+
const pose = resolveShelfSurfacePlacement(
|
|
1082
|
+
shelfNode,
|
|
1083
|
+
event,
|
|
1084
|
+
ourDims,
|
|
1085
|
+
ctx.currentCursorRotationY,
|
|
1086
|
+
true,
|
|
1087
|
+
ctx.onSurfaceReject,
|
|
1088
|
+
)
|
|
1089
|
+
if (!pose) return null
|
|
1077
1090
|
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
1078
1091
|
|
|
1079
1092
|
return {
|
|
1080
1093
|
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
|
|
1081
1094
|
nodeUpdate: {
|
|
1082
|
-
position:
|
|
1095
|
+
position: pose.position,
|
|
1083
1096
|
parentId: shelfNode.id,
|
|
1084
|
-
rotation: [draftRotation[0],
|
|
1097
|
+
rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
|
|
1085
1098
|
},
|
|
1086
1099
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
1087
|
-
gridPosition:
|
|
1088
|
-
cursorPosition:
|
|
1100
|
+
gridPosition: pose.position,
|
|
1101
|
+
cursorPosition: pose.worldPosition,
|
|
1089
1102
|
stopPropagation: true,
|
|
1090
1103
|
}
|
|
1091
1104
|
},
|
|
@@ -1099,25 +1112,21 @@ export const shelfSurfaceStrategy = {
|
|
|
1099
1112
|
if (!(ctx.state.shelfId && ctx.draftItem)) return null
|
|
1100
1113
|
if (event.node.id !== ctx.state.shelfId) return null
|
|
1101
1114
|
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
if (
|
|
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))
|
|
1115
|
+
const pose = resolveShelfSurfacePlacement(
|
|
1116
|
+
event.node as ShelfNode,
|
|
1117
|
+
event,
|
|
1118
|
+
getScaledDimensions(ctx.draftItem),
|
|
1119
|
+
ctx.currentCursorRotationY,
|
|
1120
|
+
false,
|
|
1121
|
+
ctx.onSurfaceReject,
|
|
1122
|
+
)
|
|
1123
|
+
if (!pose) return null
|
|
1115
1124
|
|
|
1116
1125
|
return {
|
|
1117
|
-
gridPosition:
|
|
1118
|
-
cursorPosition:
|
|
1126
|
+
gridPosition: pose.position,
|
|
1127
|
+
cursorPosition: pose.worldPosition,
|
|
1119
1128
|
cursorRotationY: ctx.currentCursorRotationY,
|
|
1120
|
-
nodeUpdate: { position:
|
|
1129
|
+
nodeUpdate: { position: pose.position },
|
|
1121
1130
|
stopPropagation: true,
|
|
1122
1131
|
dirtyNodeId: null,
|
|
1123
1132
|
}
|
|
@@ -1130,6 +1139,7 @@ export const shelfSurfaceStrategy = {
|
|
|
1130
1139
|
if (ctx.state.surface !== 'shelf-surface') return null
|
|
1131
1140
|
if (!(ctx.draftItem && ctx.state.shelfId)) return null
|
|
1132
1141
|
if (event.node.id !== ctx.state.shelfId) return null
|
|
1142
|
+
if (!validCatalogCounterPose(ctx)) return null
|
|
1133
1143
|
|
|
1134
1144
|
return {
|
|
1135
1145
|
nodeUpdate: {
|
|
@@ -1143,14 +1153,6 @@ export const shelfSurfaceStrategy = {
|
|
|
1143
1153
|
},
|
|
1144
1154
|
}
|
|
1145
1155
|
|
|
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
1156
|
// ============================================================================
|
|
1155
1157
|
// VALIDATION
|
|
1156
1158
|
// ============================================================================
|
|
@@ -1162,14 +1164,12 @@ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
|
|
|
1162
1164
|
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
|
1163
1165
|
if (!(ctx.levelId && ctx.draftItem)) return false
|
|
1164
1166
|
|
|
1165
|
-
// Item surface: valid if we entered (size check was in enter)
|
|
1166
1167
|
if (ctx.state.surface === 'item-surface') {
|
|
1167
|
-
return ctx.state.surfaceItemId !== null
|
|
1168
|
+
return ctx.state.surfaceItemId !== null && validCatalogCounterPose(ctx)
|
|
1168
1169
|
}
|
|
1169
1170
|
|
|
1170
|
-
// Shelf surface: same — size check already happened on enter
|
|
1171
1171
|
if (ctx.state.surface === 'shelf-surface') {
|
|
1172
|
-
return ctx.state.shelfId !== null
|
|
1172
|
+
return ctx.state.shelfId !== null && validCatalogCounterPose(ctx)
|
|
1173
1173
|
}
|
|
1174
1174
|
|
|
1175
1175
|
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]
|