@pascal-app/editor 0.6.0 → 0.7.0
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 +9 -5
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +20 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +267 -36
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/tool-manager.tsx +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Pascal building editor component",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"check-types": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@pascal-app/core": "^0.
|
|
15
|
-
"@pascal-app/viewer": "^0.
|
|
14
|
+
"@pascal-app/core": "^0.7.0",
|
|
15
|
+
"@pascal-app/viewer": "^0.7.0",
|
|
16
16
|
"@react-three/drei": "^10",
|
|
17
17
|
"@react-three/fiber": "^9",
|
|
18
18
|
"next": ">=15",
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"three": "^0.184"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@dnd-kit/core": "^6.3.1",
|
|
25
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
26
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
24
27
|
"@iconify/react": "^6.0.2",
|
|
25
28
|
"@number-flow/react": "^0.5.14",
|
|
26
29
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
@@ -46,12 +49,13 @@
|
|
|
46
49
|
"motion": "^12.34.3",
|
|
47
50
|
"nanoid": "^5.1.6",
|
|
48
51
|
"tailwind-merge": "^3.5.0",
|
|
52
|
+
"three-mesh-bvh": "^0.9.8",
|
|
49
53
|
"zod": "^4.3.6",
|
|
50
54
|
"zustand": "^5.0.11"
|
|
51
55
|
},
|
|
52
56
|
"devDependencies": {
|
|
53
|
-
"@pascal-app/core": "^0.
|
|
54
|
-
"@pascal-app/viewer": "^0.
|
|
57
|
+
"@pascal-app/core": "^0.7.0",
|
|
58
|
+
"@pascal-app/viewer": "^0.7.0",
|
|
55
59
|
"@pascal/typescript-config": "*",
|
|
56
60
|
"@types/howler": "^2.2.12",
|
|
57
61
|
"@types/node": "^22.19.12",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { animate, motion, useMotionValue } from 'motion/react'
|
|
4
|
+
import {
|
|
5
|
+
forwardRef,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useRef,
|
|
11
|
+
} from 'react'
|
|
12
|
+
|
|
13
|
+
export type BottomSheetHandle = {
|
|
14
|
+
snapTo: (heightPx: number) => void
|
|
15
|
+
getHeight: () => number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface BottomSheetProps {
|
|
19
|
+
initialHeightPx: number
|
|
20
|
+
snapPointsPx: number[]
|
|
21
|
+
onCommit: (heightPx: number) => void
|
|
22
|
+
children: ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DRAG_THRESHOLD_PX = 6
|
|
26
|
+
|
|
27
|
+
export const BottomSheet = forwardRef<BottomSheetHandle, BottomSheetProps>(function BottomSheet(
|
|
28
|
+
{ initialHeightPx, snapPointsPx, onCommit, children },
|
|
29
|
+
ref,
|
|
30
|
+
) {
|
|
31
|
+
const height = useMotionValue(initialHeightPx)
|
|
32
|
+
const dragStartY = useRef<number | null>(null)
|
|
33
|
+
const dragStartHeight = useRef(0)
|
|
34
|
+
const hasDragged = useRef(false)
|
|
35
|
+
const animationRef = useRef<ReturnType<typeof animate> | null>(null)
|
|
36
|
+
|
|
37
|
+
const clamp = useCallback(
|
|
38
|
+
(px: number) => {
|
|
39
|
+
const min = Math.min(...snapPointsPx)
|
|
40
|
+
const max = Math.max(...snapPointsPx)
|
|
41
|
+
return Math.max(min, Math.min(max, px))
|
|
42
|
+
},
|
|
43
|
+
[snapPointsPx],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const nearestSnap = useCallback(
|
|
47
|
+
(px: number) => {
|
|
48
|
+
let best = snapPointsPx[0] ?? 0
|
|
49
|
+
let bestDist = Number.POSITIVE_INFINITY
|
|
50
|
+
for (const p of snapPointsPx) {
|
|
51
|
+
const d = Math.abs(p - px)
|
|
52
|
+
if (d < bestDist) {
|
|
53
|
+
bestDist = d
|
|
54
|
+
best = p
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return best
|
|
58
|
+
},
|
|
59
|
+
[snapPointsPx],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const animateTo = useCallback(
|
|
63
|
+
(targetPx: number) => {
|
|
64
|
+
animationRef.current?.stop()
|
|
65
|
+
const controls = animate(height, targetPx, {
|
|
66
|
+
type: 'spring',
|
|
67
|
+
stiffness: 320,
|
|
68
|
+
damping: 32,
|
|
69
|
+
mass: 0.8,
|
|
70
|
+
onComplete: () => {
|
|
71
|
+
onCommit(targetPx)
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
animationRef.current = controls
|
|
75
|
+
},
|
|
76
|
+
[height, onCommit],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
useImperativeHandle(
|
|
80
|
+
ref,
|
|
81
|
+
() => ({
|
|
82
|
+
snapTo: (px: number) => animateTo(clamp(px)),
|
|
83
|
+
getHeight: () => height.get(),
|
|
84
|
+
}),
|
|
85
|
+
[animateTo, clamp, height],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const handlePointerDown = useCallback(
|
|
89
|
+
(e: React.PointerEvent) => {
|
|
90
|
+
if (e.button !== 0 && e.pointerType === 'mouse') return
|
|
91
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
92
|
+
animationRef.current?.stop()
|
|
93
|
+
dragStartY.current = e.clientY
|
|
94
|
+
dragStartHeight.current = height.get()
|
|
95
|
+
hasDragged.current = false
|
|
96
|
+
},
|
|
97
|
+
[height],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const handlePointerMove = useCallback(
|
|
101
|
+
(e: React.PointerEvent) => {
|
|
102
|
+
if (dragStartY.current === null) return
|
|
103
|
+
const dy = e.clientY - dragStartY.current
|
|
104
|
+
if (!hasDragged.current && Math.abs(dy) < DRAG_THRESHOLD_PX) return
|
|
105
|
+
hasDragged.current = true
|
|
106
|
+
const next = clamp(dragStartHeight.current - dy)
|
|
107
|
+
height.set(next)
|
|
108
|
+
},
|
|
109
|
+
[clamp, height],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const endDrag = useCallback(
|
|
113
|
+
(e: React.PointerEvent) => {
|
|
114
|
+
if (dragStartY.current === null) return
|
|
115
|
+
dragStartY.current = null
|
|
116
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
117
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
118
|
+
}
|
|
119
|
+
if (!hasDragged.current) return
|
|
120
|
+
const target = nearestSnap(height.get())
|
|
121
|
+
animateTo(target)
|
|
122
|
+
},
|
|
123
|
+
[animateTo, height, nearestSnap],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
return () => {
|
|
128
|
+
animationRef.current?.stop()
|
|
129
|
+
}
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<motion.div
|
|
134
|
+
className="absolute right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-4px_16px_rgba(0,0,0,0.12)]"
|
|
135
|
+
style={{ height }}
|
|
136
|
+
>
|
|
137
|
+
<div
|
|
138
|
+
className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing"
|
|
139
|
+
onPointerCancel={endDrag}
|
|
140
|
+
onPointerDown={handlePointerDown}
|
|
141
|
+
onPointerMove={handlePointerMove}
|
|
142
|
+
onPointerUp={endDrag}
|
|
143
|
+
>
|
|
144
|
+
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
|
145
|
+
</div>
|
|
146
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
|
|
147
|
+
</motion.div>
|
|
148
|
+
)
|
|
149
|
+
})
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
type CameraControlEvent,
|
|
4
|
+
type CameraControlFitSceneEvent,
|
|
5
|
+
emitter,
|
|
6
|
+
sceneRegistry,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
|
5
10
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
|
6
11
|
import { useThree } from '@react-three/fiber'
|
|
7
12
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
@@ -22,7 +27,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
|
|
22
27
|
export const CustomCameraControls = () => {
|
|
23
28
|
const controls = useRef<CameraControlsImpl>(null!)
|
|
24
29
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
25
|
-
const
|
|
30
|
+
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
|
26
31
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
|
27
32
|
const selection = useViewer((s) => s.selection)
|
|
28
33
|
const currentLevelId = selection.levelId
|
|
@@ -112,6 +117,49 @@ export const CustomCameraControls = () => {
|
|
|
112
117
|
}
|
|
113
118
|
}, [cameraMode, isPreviewMode])
|
|
114
119
|
|
|
120
|
+
// Touch gestures (mobile / trackpad).
|
|
121
|
+
// - One finger drag → rotate by default (much easier on a phone), but
|
|
122
|
+
// falls back to NONE while the user is actively
|
|
123
|
+
// placing/moving something OR in box-select mode,
|
|
124
|
+
// so the editor's pointer handlers (place tool,
|
|
125
|
+
// drag-to-move endpoint, marquee selection drag)
|
|
126
|
+
// keep priority over the camera.
|
|
127
|
+
// In preview mode it's TOUCH_TRUCK (pan), matching
|
|
128
|
+
// preview's left = SCREEN_PAN.
|
|
129
|
+
// - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
|
|
130
|
+
// perspective, TOUCH_ZOOM_TRUCK for orthographic).
|
|
131
|
+
// - Three finger drag → rotate, so the camera is always orbitable even
|
|
132
|
+
// when one-finger is suppressed by an active
|
|
133
|
+
// editor action.
|
|
134
|
+
const tool = useEditor((s) => s.tool)
|
|
135
|
+
const mode = useEditor((s) => s.mode)
|
|
136
|
+
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
137
|
+
const movingNode = useEditor((s) => s.movingNode)
|
|
138
|
+
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
|
139
|
+
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
|
140
|
+
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
|
141
|
+
const isInteracting = Boolean(
|
|
142
|
+
tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
|
|
143
|
+
)
|
|
144
|
+
const touches = useMemo(() => {
|
|
145
|
+
const twoFingerAction =
|
|
146
|
+
cameraMode === 'orthographic'
|
|
147
|
+
? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
|
|
148
|
+
: CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
|
|
149
|
+
|
|
150
|
+
const oneFingerAction = isPreviewMode
|
|
151
|
+
? CameraControlsImpl.ACTION.TOUCH_TRUCK
|
|
152
|
+
: isInteracting
|
|
153
|
+
? CameraControlsImpl.ACTION.NONE
|
|
154
|
+
: CameraControlsImpl.ACTION.TOUCH_ROTATE
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
one: oneFingerAction,
|
|
158
|
+
two: twoFingerAction,
|
|
159
|
+
three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
|
|
160
|
+
}
|
|
161
|
+
}, [cameraMode, isPreviewMode, isInteracting])
|
|
162
|
+
|
|
115
163
|
useEffect(() => {
|
|
116
164
|
const keyState = {
|
|
117
165
|
shiftRight: false,
|
|
@@ -340,12 +388,30 @@ export const CustomCameraControls = () => {
|
|
|
340
388
|
focusNode(nodeId)
|
|
341
389
|
}
|
|
342
390
|
|
|
391
|
+
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
|
|
392
|
+
if (!controls.current || isPreviewMode) return
|
|
393
|
+
if (!bounds) {
|
|
394
|
+
// Restore default framing pose when no bounds were computed.
|
|
395
|
+
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
const [cx, cz] = bounds.center
|
|
399
|
+
const [w, d] = bounds.size
|
|
400
|
+
// Use the longer horizontal extent to size the orbit radius so the whole
|
|
401
|
+
// footprint sits in view regardless of aspect ratio.
|
|
402
|
+
const maxExtent = Math.max(w, d)
|
|
403
|
+
const distance = Math.max(maxExtent * 1.4, 15)
|
|
404
|
+
const height = Math.max(maxExtent * 0.8, 10)
|
|
405
|
+
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
|
|
406
|
+
}
|
|
407
|
+
|
|
343
408
|
emitter.on('camera-controls:capture', handleNodeCapture)
|
|
344
409
|
emitter.on('camera-controls:focus', handleNodeFocus)
|
|
345
410
|
emitter.on('camera-controls:view', handleNodeView)
|
|
346
411
|
emitter.on('camera-controls:top-view', handleTopView)
|
|
347
412
|
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
|
|
348
413
|
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
414
|
+
emitter.on('camera-controls:fit-scene', handleFitScene)
|
|
349
415
|
|
|
350
416
|
return () => {
|
|
351
417
|
emitter.off('camera-controls:capture', handleNodeCapture)
|
|
@@ -354,8 +420,9 @@ export const CustomCameraControls = () => {
|
|
|
354
420
|
emitter.off('camera-controls:top-view', handleTopView)
|
|
355
421
|
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
|
356
422
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
423
|
+
emitter.off('camera-controls:fit-scene', handleFitScene)
|
|
357
424
|
}
|
|
358
|
-
}, [focusNode])
|
|
425
|
+
}, [focusNode, isPreviewMode])
|
|
359
426
|
|
|
360
427
|
const onTransitionStart = useCallback(() => {
|
|
361
428
|
useViewer.getState().setCameraDragging(true)
|
|
@@ -365,8 +432,8 @@ export const CustomCameraControls = () => {
|
|
|
365
432
|
useViewer.getState().setCameraDragging(false)
|
|
366
433
|
}, [])
|
|
367
434
|
|
|
368
|
-
if (
|
|
369
|
-
return
|
|
435
|
+
if (isFirstPersonMode) {
|
|
436
|
+
return null
|
|
370
437
|
}
|
|
371
438
|
|
|
372
439
|
return (
|
|
@@ -382,6 +449,7 @@ export const CustomCameraControls = () => {
|
|
|
382
449
|
onTransitionStart={onTransitionStart}
|
|
383
450
|
ref={controls}
|
|
384
451
|
restThreshold={0.01}
|
|
452
|
+
touches={touches}
|
|
385
453
|
/>
|
|
386
454
|
)
|
|
387
455
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
5
|
+
import useEditor from '../../store/use-editor'
|
|
6
|
+
import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
|
|
7
|
+
import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
|
8
|
+
import { BottomSheet, type BottomSheetHandle } from './bottom-sheet'
|
|
9
|
+
|
|
10
|
+
const MIN_SNAP = 0
|
|
11
|
+
const MAX_SNAP = 1
|
|
12
|
+
const DEFAULT_SNAP = 0.5
|
|
13
|
+
// Viewer extends this many pixels behind the sheet's rounded top corners
|
|
14
|
+
// so the curve reveals viewer content underneath.
|
|
15
|
+
const SHEET_OVERLAP_PX = 16
|
|
16
|
+
// Sheet never collapses below the drag handle so the user can always grab it.
|
|
17
|
+
const SHEET_HANDLE_PX = 24
|
|
18
|
+
|
|
19
|
+
// Match the viewer's scene background colors (packages/viewer/src/components/viewer/index.tsx)
|
|
20
|
+
const VIEWER_BG_DARK = '#1f2433'
|
|
21
|
+
const VIEWER_BG_LIGHT = '#ffffff'
|
|
22
|
+
|
|
23
|
+
// Fixed set of intermediate snap heights (handle + middleH are added on top).
|
|
24
|
+
// Per-tab `mobileDefaultSnap` decides the OPENING height; this list bounds
|
|
25
|
+
// what the user can drag to.
|
|
26
|
+
const SNAP_RATIOS = [0.5, 0.66] as const
|
|
27
|
+
|
|
28
|
+
function getDefaultSnap(tab: SidebarTab | undefined): number {
|
|
29
|
+
const s = tab?.mobileDefaultSnap
|
|
30
|
+
if (typeof s !== 'number') return DEFAULT_SNAP
|
|
31
|
+
return Math.max(MIN_SNAP, Math.min(MAX_SNAP, s))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EditorLayoutMobileProps {
|
|
35
|
+
navbarSlot?: ReactNode
|
|
36
|
+
sidebarTabs?: SidebarTab[]
|
|
37
|
+
renderTabContent: (tabId: string) => ReactNode
|
|
38
|
+
sidebarOverlay?: ReactNode
|
|
39
|
+
viewerToolbarLeft?: ReactNode
|
|
40
|
+
viewerToolbarRight?: ReactNode
|
|
41
|
+
viewerContent: ReactNode
|
|
42
|
+
overlays?: ReactNode
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function EditorLayoutMobile({
|
|
46
|
+
navbarSlot,
|
|
47
|
+
sidebarTabs = [],
|
|
48
|
+
renderTabContent,
|
|
49
|
+
sidebarOverlay,
|
|
50
|
+
viewerToolbarLeft,
|
|
51
|
+
viewerToolbarRight,
|
|
52
|
+
viewerContent,
|
|
53
|
+
overlays,
|
|
54
|
+
}: EditorLayoutMobileProps) {
|
|
55
|
+
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
|
56
|
+
const activePanel = useEditor((s) => s.activeSidebarPanel)
|
|
57
|
+
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
|
|
58
|
+
const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight)
|
|
59
|
+
const theme = useViewer((s) => s.theme)
|
|
60
|
+
const viewerBg = theme === 'light' ? VIEWER_BG_LIGHT : VIEWER_BG_DARK
|
|
61
|
+
|
|
62
|
+
const middleRef = useRef<HTMLDivElement>(null)
|
|
63
|
+
const sheetRef = useRef<BottomSheetHandle>(null)
|
|
64
|
+
const [middleH, setMiddleH] = useState(0)
|
|
65
|
+
// Distance from the middle area's bottom edge to the viewport's bottom edge
|
|
66
|
+
// (i.e. the tab bar height incl. safe area). Needed to translate the panel
|
|
67
|
+
// sheet's viewport-relative height into middle-area coordinates.
|
|
68
|
+
const [middleBottomFromViewport, setMiddleBottomFromViewport] = useState(0)
|
|
69
|
+
const [committedSheetH, setCommittedSheetH] = useState(0)
|
|
70
|
+
|
|
71
|
+
const currentTab = sidebarTabs.find((t) => t.id === activePanel)
|
|
72
|
+
|
|
73
|
+
// Keep active panel valid
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (sidebarTabs.length > 0 && !sidebarTabs.some((t) => t.id === activePanel)) {
|
|
76
|
+
setActivePanel(sidebarTabs[0]!.id)
|
|
77
|
+
}
|
|
78
|
+
}, [sidebarTabs, activePanel, setActivePanel])
|
|
79
|
+
|
|
80
|
+
// Sync editor phase / mode with the active tab:
|
|
81
|
+
// - Entering Chat always drops to Select (chat is a composing context).
|
|
82
|
+
// - Entering Items snaps the editor into furnish-build (matches the
|
|
83
|
+
// desktop "Furnish" action which itself opens the Items panel).
|
|
84
|
+
// - Leaving Items while still furnishing exits the build mode.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const { phase, mode, setMode, setPhase } = useEditor.getState()
|
|
87
|
+
if (activePanel === 'ai' && mode === 'build') {
|
|
88
|
+
setMode('select')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (activePanel === 'items') {
|
|
92
|
+
if (phase !== 'furnish') setPhase('furnish')
|
|
93
|
+
if (mode !== 'build') setMode('build')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (phase === 'furnish' && mode === 'build') {
|
|
97
|
+
setMode('select')
|
|
98
|
+
}
|
|
99
|
+
}, [activePanel])
|
|
100
|
+
|
|
101
|
+
// Measure middle area height + its bottom offset from viewport bottom
|
|
102
|
+
useLayoutEffect(() => {
|
|
103
|
+
const el = middleRef.current
|
|
104
|
+
if (!el) return
|
|
105
|
+
const measure = () => {
|
|
106
|
+
const rect = el.getBoundingClientRect()
|
|
107
|
+
setMiddleH(rect.height)
|
|
108
|
+
setMiddleBottomFromViewport(Math.max(0, window.innerHeight - rect.bottom))
|
|
109
|
+
}
|
|
110
|
+
const ro = new ResizeObserver(measure)
|
|
111
|
+
ro.observe(el)
|
|
112
|
+
measure()
|
|
113
|
+
window.addEventListener('resize', measure)
|
|
114
|
+
return () => {
|
|
115
|
+
ro.disconnect()
|
|
116
|
+
window.removeEventListener('resize', measure)
|
|
117
|
+
}
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
// Initialise sheet to current tab default once we know the middle height
|
|
121
|
+
const didInit = useRef(false)
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (didInit.current || middleH <= 0) return
|
|
124
|
+
didInit.current = true
|
|
125
|
+
const targetPx = getDefaultSnap(currentTab) * middleH
|
|
126
|
+
setCommittedSheetH(targetPx)
|
|
127
|
+
sheetRef.current?.snapTo(targetPx)
|
|
128
|
+
}, [middleH, currentTab])
|
|
129
|
+
|
|
130
|
+
// When middle height changes (rotation / resize), keep sheet in proportion
|
|
131
|
+
const prevMiddleH = useRef(0)
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (middleH <= 0) return
|
|
134
|
+
if (prevMiddleH.current === 0) {
|
|
135
|
+
prevMiddleH.current = middleH
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (prevMiddleH.current === middleH) return
|
|
139
|
+
const ratio = committedSheetH / prevMiddleH.current
|
|
140
|
+
const nextPx = Math.max(SHEET_HANDLE_PX, Math.min(middleH, ratio * middleH))
|
|
141
|
+
prevMiddleH.current = middleH
|
|
142
|
+
setCommittedSheetH(nextPx)
|
|
143
|
+
sheetRef.current?.snapTo(nextPx)
|
|
144
|
+
}, [middleH, committedSheetH])
|
|
145
|
+
|
|
146
|
+
const handleTabPress = useCallback(
|
|
147
|
+
(id: string) => {
|
|
148
|
+
if (middleH <= 0) return
|
|
149
|
+
const tab = sidebarTabs.find((t) => t.id === id)
|
|
150
|
+
if (!tab) return
|
|
151
|
+
const defaultPx = getDefaultSnap(tab) * middleH
|
|
152
|
+
if (id !== activePanel) {
|
|
153
|
+
setActivePanel(id)
|
|
154
|
+
sheetRef.current?.snapTo(defaultPx)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// Same tab tapped — toggle
|
|
158
|
+
const current = sheetRef.current?.getHeight() ?? committedSheetH
|
|
159
|
+
const expandedThreshold = Math.max(SHEET_HANDLE_PX, defaultPx * 0.5)
|
|
160
|
+
if (current > expandedThreshold) {
|
|
161
|
+
sheetRef.current?.snapTo(SHEET_HANDLE_PX)
|
|
162
|
+
} else {
|
|
163
|
+
sheetRef.current?.snapTo(defaultPx)
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[sidebarTabs, activePanel, setActivePanel, middleH, committedSheetH],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const snapPointsPx = (() => {
|
|
170
|
+
if (middleH <= 0) return [SHEET_HANDLE_PX]
|
|
171
|
+
const intermediate = SNAP_RATIOS.map((r) => r * middleH)
|
|
172
|
+
return Array.from(new Set([SHEET_HANDLE_PX, ...intermediate, middleH])).sort((a, b) => a - b)
|
|
173
|
+
})()
|
|
174
|
+
|
|
175
|
+
// When the secondary panel sheet is open, it covers the tab bar + part of
|
|
176
|
+
// the middle area; translate its viewport height into middle-area units.
|
|
177
|
+
const panelPenetrationInMiddle = Math.max(0, panelSheetHeight - middleBottomFromViewport)
|
|
178
|
+
// The effective "sheet height" that the viewer sits above is the larger of
|
|
179
|
+
// the primary sidebar sheet and the secondary panel sheet's penetration.
|
|
180
|
+
const effectiveSheetH = Math.max(committedSheetH, panelPenetrationInMiddle)
|
|
181
|
+
|
|
182
|
+
// In capture mode the sheet and tab bar are hidden — the viewer should fill
|
|
183
|
+
// the entire middle area regardless of the stored sheet height.
|
|
184
|
+
// Otherwise, the viewer extends SHEET_OVERLAP_PX behind the sheet's rounded
|
|
185
|
+
// corners so the curve reveals viewer content underneath.
|
|
186
|
+
const baseViewerHeight = Math.max(0, middleH - effectiveSheetH)
|
|
187
|
+
const viewerHeight = isCaptureMode
|
|
188
|
+
? middleH
|
|
189
|
+
: baseViewerHeight === 0
|
|
190
|
+
? 0
|
|
191
|
+
: Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX)
|
|
192
|
+
|
|
193
|
+
// While the panel sheet is open, collapse the primary sheet to its handle so
|
|
194
|
+
// it doesn't peek above. Remember the previous height and restore it on close.
|
|
195
|
+
const sheetHeightBeforePanel = useRef<number | null>(null)
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (panelSheetHeight > 0) {
|
|
198
|
+
if (sheetHeightBeforePanel.current === null && committedSheetH > SHEET_HANDLE_PX) {
|
|
199
|
+
sheetHeightBeforePanel.current = committedSheetH
|
|
200
|
+
sheetRef.current?.snapTo(SHEET_HANDLE_PX)
|
|
201
|
+
}
|
|
202
|
+
} else if (sheetHeightBeforePanel.current !== null) {
|
|
203
|
+
const target = sheetHeightBeforePanel.current
|
|
204
|
+
sheetHeightBeforePanel.current = null
|
|
205
|
+
sheetRef.current?.snapTo(target)
|
|
206
|
+
}
|
|
207
|
+
}, [panelSheetHeight, committedSheetH])
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
|
211
|
+
{navbarSlot}
|
|
212
|
+
|
|
213
|
+
<div
|
|
214
|
+
className="relative flex min-h-0 flex-1"
|
|
215
|
+
ref={middleRef}
|
|
216
|
+
style={{ backgroundColor: viewerBg }}
|
|
217
|
+
>
|
|
218
|
+
{/* Viewer column: sized by committed sheet height */}
|
|
219
|
+
<div className="absolute inset-x-0 top-0 overflow-hidden" style={{ height: viewerHeight }}>
|
|
220
|
+
<div className="relative h-full w-full">
|
|
221
|
+
{(viewerToolbarLeft || viewerToolbarRight) && !isCaptureMode && (
|
|
222
|
+
<div className="pointer-events-none absolute top-3 right-3 left-3 z-20 flex items-center justify-between gap-2">
|
|
223
|
+
<div className="pointer-events-auto flex items-center gap-2">
|
|
224
|
+
{viewerToolbarLeft}
|
|
225
|
+
</div>
|
|
226
|
+
<div className="pointer-events-auto flex items-center gap-2">
|
|
227
|
+
{viewerToolbarRight}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
<div className="relative h-full w-full overflow-hidden">{viewerContent}</div>
|
|
232
|
+
{overlays && (
|
|
233
|
+
<div
|
|
234
|
+
className="pointer-events-none absolute inset-0 z-30"
|
|
235
|
+
style={{ transform: 'translateZ(0)' }}
|
|
236
|
+
>
|
|
237
|
+
{overlays}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{/* Bottom sheet: overlays the lower part of the middle area */}
|
|
244
|
+
{!isCaptureMode && sidebarTabs.length > 0 && (
|
|
245
|
+
<BottomSheet
|
|
246
|
+
initialHeightPx={SHEET_HANDLE_PX}
|
|
247
|
+
onCommit={setCommittedSheetH}
|
|
248
|
+
ref={sheetRef}
|
|
249
|
+
snapPointsPx={snapPointsPx}
|
|
250
|
+
>
|
|
251
|
+
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
252
|
+
{renderTabContent(activePanel)}
|
|
253
|
+
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
|
|
254
|
+
</div>
|
|
255
|
+
</BottomSheet>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{!isCaptureMode && sidebarTabs.length > 0 && (
|
|
260
|
+
<MobileTabBar activeTab={activePanel} onTabPress={handleTabPress} tabs={sidebarTabs} />
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { type ReactNode, useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
import { useIsMobile } from '../../hooks/use-mobile'
|
|
4
5
|
import useEditor from '../../store/use-editor'
|
|
6
|
+
|
|
5
7
|
import { useSidebarStore } from '../ui/primitives/sidebar'
|
|
6
8
|
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
|
|
9
|
+
import { EditorLayoutMobile } from './editor-layout-mobile'
|
|
7
10
|
|
|
8
11
|
const SIDEBAR_MIN_WIDTH = 300
|
|
9
12
|
const SIDEBAR_MAX_WIDTH = 800
|
|
@@ -202,6 +205,23 @@ export function EditorLayoutV2({
|
|
|
202
205
|
viewerContent,
|
|
203
206
|
overlays,
|
|
204
207
|
}: EditorLayoutV2Props) {
|
|
208
|
+
const isMobile = useIsMobile()
|
|
209
|
+
|
|
210
|
+
if (isMobile) {
|
|
211
|
+
return (
|
|
212
|
+
<EditorLayoutMobile
|
|
213
|
+
navbarSlot={navbarSlot}
|
|
214
|
+
overlays={overlays}
|
|
215
|
+
renderTabContent={renderTabContent}
|
|
216
|
+
sidebarOverlay={sidebarOverlay}
|
|
217
|
+
sidebarTabs={sidebarTabs}
|
|
218
|
+
viewerContent={viewerContent}
|
|
219
|
+
viewerToolbarLeft={viewerToolbarLeft}
|
|
220
|
+
viewerToolbarRight={viewerToolbarRight}
|
|
221
|
+
/>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
205
225
|
return (
|
|
206
226
|
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
|
207
227
|
{/* Top navbar */}
|