@pascal-app/editor 0.6.0 → 0.8.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.
Files changed (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "0.6.0",
3
+ "version": "0.8.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.6.0",
15
- "@pascal-app/viewer": "^0.6.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",
@@ -41,23 +44,24 @@
41
44
  "clsx": "^2.1.1",
42
45
  "cmdk": "^1.1.1",
43
46
  "howler": "^2.2.4",
44
- "lucide-react": "^0.562.0",
47
+ "lucide-react": "^1.7.0",
45
48
  "mitt": "^3.0.1",
46
49
  "motion": "^12.34.3",
47
50
  "nanoid": "^5.1.6",
48
51
  "tailwind-merge": "^3.5.0",
49
52
  "zod": "^4.3.6",
50
- "zustand": "^5.0.11"
53
+ "zustand": "^5.0.11",
54
+ "three-mesh-bvh": "~0.9.8"
51
55
  },
52
56
  "devDependencies": {
53
- "@pascal-app/core": "^0.6.0",
54
- "@pascal-app/viewer": "^0.6.0",
57
+ "@pascal-app/core": "^0.7.0",
58
+ "@pascal-app/viewer": "^0.7.0",
55
59
  "@pascal/typescript-config": "*",
60
+ "@types/bun": "^1.3.0",
56
61
  "@types/howler": "^2.2.12",
57
- "@types/node": "^22.19.12",
58
62
  "@types/react": "19.2.2",
59
63
  "@types/react-dom": "19.2.2",
60
64
  "@types/three": "^0.184.0",
61
- "typescript": "5.9.3"
65
+ "typescript": "6.0.2"
62
66
  }
63
67
  }
@@ -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,6 +1,12 @@
1
1
  'use client'
2
2
 
3
- import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
3
+ import {
4
+ type CameraControlEvent,
5
+ type CameraControlFitSceneEvent,
6
+ emitter,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
4
10
  import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
5
11
  import { CameraControls, CameraControlsImpl } from '@react-three/drei'
6
12
  import { useThree } from '@react-three/fiber'
@@ -22,7 +28,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
22
28
  export const CustomCameraControls = () => {
23
29
  const controls = useRef<CameraControlsImpl>(null!)
24
30
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
25
- const walkthroughMode = useViewer((s) => s.walkthroughMode)
31
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
26
32
  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
27
33
  const selection = useViewer((s) => s.selection)
28
34
  const currentLevelId = selection.levelId
@@ -112,6 +118,49 @@ export const CustomCameraControls = () => {
112
118
  }
113
119
  }, [cameraMode, isPreviewMode])
114
120
 
121
+ // Touch gestures (mobile / trackpad).
122
+ // - One finger drag → rotate by default (much easier on a phone), but
123
+ // falls back to NONE while the user is actively
124
+ // placing/moving something OR in box-select mode,
125
+ // so the editor's pointer handlers (place tool,
126
+ // drag-to-move endpoint, marquee selection drag)
127
+ // keep priority over the camera.
128
+ // In preview mode it's TOUCH_TRUCK (pan), matching
129
+ // preview's left = SCREEN_PAN.
130
+ // - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
131
+ // perspective, TOUCH_ZOOM_TRUCK for orthographic).
132
+ // - Three finger drag → rotate, so the camera is always orbitable even
133
+ // when one-finger is suppressed by an active
134
+ // editor action.
135
+ const tool = useEditor((s) => s.tool)
136
+ const mode = useEditor((s) => s.mode)
137
+ const selectionTool = useEditor((s) => s.floorplanSelectionTool)
138
+ const movingNode = useEditor((s) => s.movingNode)
139
+ const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
140
+ const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
141
+ const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
142
+ const isInteracting = Boolean(
143
+ tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
144
+ )
145
+ const touches = useMemo(() => {
146
+ const twoFingerAction =
147
+ cameraMode === 'orthographic'
148
+ ? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
149
+ : CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
150
+
151
+ const oneFingerAction = isPreviewMode
152
+ ? CameraControlsImpl.ACTION.TOUCH_TRUCK
153
+ : isInteracting
154
+ ? CameraControlsImpl.ACTION.NONE
155
+ : CameraControlsImpl.ACTION.TOUCH_ROTATE
156
+
157
+ return {
158
+ one: oneFingerAction,
159
+ two: twoFingerAction,
160
+ three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
161
+ }
162
+ }, [cameraMode, isPreviewMode, isInteracting])
163
+
115
164
  useEffect(() => {
116
165
  const keyState = {
117
166
  shiftRight: false,
@@ -340,12 +389,30 @@ export const CustomCameraControls = () => {
340
389
  focusNode(nodeId)
341
390
  }
342
391
 
392
+ const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
393
+ if (!controls.current || isPreviewMode) return
394
+ if (!bounds) {
395
+ // Restore default framing pose when no bounds were computed.
396
+ controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
397
+ return
398
+ }
399
+ const [cx, cz] = bounds.center
400
+ const [w, d] = bounds.size
401
+ // Use the longer horizontal extent to size the orbit radius so the whole
402
+ // footprint sits in view regardless of aspect ratio.
403
+ const maxExtent = Math.max(w, d)
404
+ const distance = Math.max(maxExtent * 1.4, 15)
405
+ const height = Math.max(maxExtent * 0.8, 10)
406
+ controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
407
+ }
408
+
343
409
  emitter.on('camera-controls:capture', handleNodeCapture)
344
410
  emitter.on('camera-controls:focus', handleNodeFocus)
345
411
  emitter.on('camera-controls:view', handleNodeView)
346
412
  emitter.on('camera-controls:top-view', handleTopView)
347
413
  emitter.on('camera-controls:orbit-cw', handleOrbitCW)
348
414
  emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
415
+ emitter.on('camera-controls:fit-scene', handleFitScene)
349
416
 
350
417
  return () => {
351
418
  emitter.off('camera-controls:capture', handleNodeCapture)
@@ -354,8 +421,9 @@ export const CustomCameraControls = () => {
354
421
  emitter.off('camera-controls:top-view', handleTopView)
355
422
  emitter.off('camera-controls:orbit-cw', handleOrbitCW)
356
423
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
424
+ emitter.off('camera-controls:fit-scene', handleFitScene)
357
425
  }
358
- }, [focusNode])
426
+ }, [focusNode, isPreviewMode])
359
427
 
360
428
  const onTransitionStart = useCallback(() => {
361
429
  useViewer.getState().setCameraDragging(true)
@@ -365,8 +433,8 @@ export const CustomCameraControls = () => {
365
433
  useViewer.getState().setCameraDragging(false)
366
434
  }, [])
367
435
 
368
- if (walkthroughMode) {
369
- return <WalkthroughControls />
436
+ if (isFirstPersonMode) {
437
+ return null
370
438
  }
371
439
 
372
440
  return (
@@ -382,6 +450,7 @@ export const CustomCameraControls = () => {
382
450
  onTransitionStart={onTransitionStart}
383
451
  ref={controls}
384
452
  restThreshold={0.01}
453
+ touches={touches}
385
454
  />
386
455
  )
387
456
  }
@@ -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,24 @@ export function EditorLayoutV2({
202
205
  viewerContent,
203
206
  overlays,
204
207
  }: EditorLayoutV2Props) {
208
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
209
+ const isMobile = useIsMobile()
210
+
211
+ if (isMobile) {
212
+ return (
213
+ <EditorLayoutMobile
214
+ navbarSlot={navbarSlot}
215
+ overlays={overlays}
216
+ renderTabContent={renderTabContent}
217
+ sidebarOverlay={sidebarOverlay}
218
+ sidebarTabs={sidebarTabs}
219
+ viewerContent={viewerContent}
220
+ viewerToolbarLeft={viewerToolbarLeft}
221
+ viewerToolbarRight={viewerToolbarRight}
222
+ />
223
+ )
224
+ }
225
+
205
226
  return (
206
227
  <div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
207
228
  {/* Top navbar */}
@@ -209,7 +230,7 @@ export function EditorLayoutV2({
209
230
 
210
231
  {/* Main content: left column + right column */}
211
232
  <div className="flex min-h-0 flex-1">
212
- {sidebarTabs.length > 0 && (
233
+ {!isCaptureMode && sidebarTabs.length > 0 && (
213
234
  <LeftColumn
214
235
  renderTabContent={renderTabContent}
215
236
  sidebarOverlay={sidebarOverlay}
@@ -218,8 +239,8 @@ export function EditorLayoutV2({
218
239
  )}
219
240
  <RightColumn
220
241
  overlays={overlays}
221
- toolbarLeft={viewerToolbarLeft}
222
- toolbarRight={viewerToolbarRight}
242
+ toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
243
+ toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
223
244
  >
224
245
  {viewerContent}
225
246
  </RightColumn>