@pascal-app/editor 0.4.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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@pascal-app/editor",
3
+ "version": "0.4.0",
4
+ "description": "Pascal building editor component",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.tsx",
8
+ "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
9
+ },
10
+ "scripts": {
11
+ "check-types": "tsc --noEmit"
12
+ },
13
+ "peerDependencies": {
14
+ "@pascal-app/core": "*",
15
+ "@pascal-app/viewer": "*",
16
+ "@react-three/drei": "^10",
17
+ "@react-three/fiber": "^9",
18
+ "next": ">=15",
19
+ "react": "^18 || ^19",
20
+ "react-dom": "^18 || ^19",
21
+ "three": "^0.183"
22
+ },
23
+ "dependencies": {
24
+ "@iconify/react": "^6.0.2",
25
+ "@number-flow/react": "^0.5.14",
26
+ "@radix-ui/react-alert-dialog": "^1.1.15",
27
+ "@radix-ui/react-context-menu": "^2.2.16",
28
+ "@radix-ui/react-dialog": "^1.1.15",
29
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
30
+ "@radix-ui/react-popover": "^1.1.15",
31
+ "@radix-ui/react-select": "^2.2.6",
32
+ "@radix-ui/react-separator": "^1.1.8",
33
+ "@radix-ui/react-slider": "^1.3.6",
34
+ "@radix-ui/react-slot": "^1.2.4",
35
+ "@radix-ui/react-switch": "^1.2.6",
36
+ "@radix-ui/react-tabs": "^1.1.13",
37
+ "@radix-ui/react-tooltip": "^1.2.8",
38
+ "@react-three/uikit-lucide": "^1.0.62",
39
+ "@visual-json/react": "latest",
40
+ "class-variance-authority": "^0.7.1",
41
+ "clsx": "^2.1.1",
42
+ "cmdk": "^1.1.1",
43
+ "howler": "^2.2.4",
44
+ "lucide-react": "^0.562.0",
45
+ "mitt": "^3.0.1",
46
+ "motion": "^12.34.3",
47
+ "nanoid": "^5.1.6",
48
+ "tailwind-merge": "^3.5.0",
49
+ "zod": "^4.3.6",
50
+ "zustand": "^5.0.11"
51
+ },
52
+ "devDependencies": {
53
+ "@pascal-app/core": "*",
54
+ "@pascal-app/viewer": "*",
55
+ "@pascal/typescript-config": "*",
56
+ "@types/howler": "^2.2.12",
57
+ "@types/react": "19.2.2",
58
+ "@types/react-dom": "19.2.2",
59
+ "@types/three": "^0.183.1",
60
+ "typescript": "5.9.3"
61
+ }
62
+ }
@@ -0,0 +1,387 @@
1
+ 'use client'
2
+
3
+ import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
5
+ import { CameraControls, CameraControlsImpl } from '@react-three/drei'
6
+ import { useThree } from '@react-three/fiber'
7
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
8
+ import { Box3, Vector3 } from 'three'
9
+ import { EDITOR_LAYER } from '../../lib/constants'
10
+ import useEditor from '../../store/use-editor'
11
+
12
+ const currentTarget = new Vector3()
13
+ const tempBox = new Box3()
14
+ const tempCenter = new Vector3()
15
+ const tempDelta = new Vector3()
16
+ const tempPosition = new Vector3()
17
+ const tempSize = new Vector3()
18
+ const tempTarget = new Vector3()
19
+ const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
20
+ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
21
+
22
+ export const CustomCameraControls = () => {
23
+ const controls = useRef<CameraControlsImpl>(null!)
24
+ const isPreviewMode = useEditor((s) => s.isPreviewMode)
25
+ const walkthroughMode = useViewer((s) => s.walkthroughMode)
26
+ const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
27
+ const selection = useViewer((s) => s.selection)
28
+ const currentLevelId = selection.levelId
29
+ const firstLoad = useRef(true)
30
+ const maxPolarAngle =
31
+ !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
32
+
33
+ const camera = useThree((state) => state.camera)
34
+ const raycaster = useThree((state) => state.raycaster)
35
+ useEffect(() => {
36
+ camera.layers.enable(EDITOR_LAYER)
37
+ raycaster.layers.enable(EDITOR_LAYER)
38
+ raycaster.layers.enable(ZONE_LAYER)
39
+ }, [camera, raycaster])
40
+
41
+ useEffect(() => {
42
+ if (isPreviewMode) return // Preview mode uses auto-navigate instead
43
+ let targetY = 0
44
+ if (currentLevelId) {
45
+ const levelMesh = sceneRegistry.nodes.get(currentLevelId)
46
+ if (levelMesh) {
47
+ targetY = levelMesh.position.y
48
+ }
49
+ }
50
+ if (!controls.current) return
51
+ if (firstLoad.current) {
52
+ firstLoad.current = false
53
+ controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
54
+ }
55
+ controls.current.getTarget(currentTarget)
56
+ controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
57
+ }, [currentLevelId, isPreviewMode])
58
+
59
+ useEffect(() => {
60
+ if (!controls.current) return
61
+
62
+ controls.current.maxPolarAngle = maxPolarAngle
63
+ controls.current.minPolarAngle = 0
64
+
65
+ if (controls.current.polarAngle > maxPolarAngle) {
66
+ controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
67
+ }
68
+ }, [maxPolarAngle])
69
+
70
+ const focusNode = useCallback(
71
+ (nodeId: string) => {
72
+ if (isPreviewMode || !controls.current) return
73
+
74
+ const object3D = sceneRegistry.nodes.get(nodeId)
75
+ if (!object3D) return
76
+
77
+ tempBox.setFromObject(object3D)
78
+ if (tempBox.isEmpty()) return
79
+
80
+ tempBox.getCenter(tempCenter)
81
+ controls.current.getPosition(tempPosition)
82
+ controls.current.getTarget(tempTarget)
83
+ tempDelta.copy(tempCenter).sub(tempTarget)
84
+
85
+ controls.current.setLookAt(
86
+ tempPosition.x + tempDelta.x,
87
+ tempPosition.y + tempDelta.y,
88
+ tempPosition.z + tempDelta.z,
89
+ tempCenter.x,
90
+ tempCenter.y,
91
+ tempCenter.z,
92
+ true,
93
+ )
94
+ },
95
+ [isPreviewMode],
96
+ )
97
+
98
+ // Configure mouse buttons based on control mode and camera mode
99
+ const cameraMode = useViewer((state) => state.cameraMode)
100
+ const mouseButtons = useMemo(() => {
101
+ // Use ZOOM for orthographic camera, DOLLY for perspective camera
102
+ const wheelAction =
103
+ cameraMode === 'orthographic'
104
+ ? CameraControlsImpl.ACTION.ZOOM
105
+ : CameraControlsImpl.ACTION.DOLLY
106
+
107
+ return {
108
+ left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,
109
+ middle: CameraControlsImpl.ACTION.SCREEN_PAN,
110
+ right: CameraControlsImpl.ACTION.ROTATE,
111
+ wheel: wheelAction,
112
+ }
113
+ }, [cameraMode, isPreviewMode])
114
+
115
+ useEffect(() => {
116
+ const keyState = {
117
+ shiftRight: false,
118
+ shiftLeft: false,
119
+ controlRight: false,
120
+ controlLeft: false,
121
+ space: false,
122
+ }
123
+
124
+ const updateConfig = () => {
125
+ if (!controls.current) return
126
+
127
+ const shift = keyState.shiftRight || keyState.shiftLeft
128
+ const control = keyState.controlRight || keyState.controlLeft
129
+ const space = keyState.space
130
+
131
+ const wheelAction =
132
+ cameraMode === 'orthographic'
133
+ ? CameraControlsImpl.ACTION.ZOOM
134
+ : CameraControlsImpl.ACTION.DOLLY
135
+ controls.current.mouseButtons.wheel = wheelAction
136
+ controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
137
+ controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
138
+ if (isPreviewMode) {
139
+ // In preview mode, left-click is always pan (viewer-style)
140
+ controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
141
+ } else if (space) {
142
+ controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
143
+ } else {
144
+ controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
145
+ }
146
+ }
147
+
148
+ const onKeyDown = (event: KeyboardEvent) => {
149
+ if (event.code === 'Space') {
150
+ keyState.space = true
151
+ document.body.style.cursor = 'grab'
152
+ }
153
+ if (event.code === 'ShiftRight') {
154
+ keyState.shiftRight = true
155
+ }
156
+ if (event.code === 'ShiftLeft') {
157
+ keyState.shiftLeft = true
158
+ }
159
+ if (event.code === 'ControlRight') {
160
+ keyState.controlRight = true
161
+ }
162
+ if (event.code === 'ControlLeft') {
163
+ keyState.controlLeft = true
164
+ }
165
+ updateConfig()
166
+ }
167
+
168
+ const onKeyUp = (event: KeyboardEvent) => {
169
+ if (event.code === 'Space') {
170
+ keyState.space = false
171
+ document.body.style.cursor = ''
172
+ }
173
+ if (event.code === 'ShiftRight') {
174
+ keyState.shiftRight = false
175
+ }
176
+ if (event.code === 'ShiftLeft') {
177
+ keyState.shiftLeft = false
178
+ }
179
+ if (event.code === 'ControlRight') {
180
+ keyState.controlRight = false
181
+ }
182
+ if (event.code === 'ControlLeft') {
183
+ keyState.controlLeft = false
184
+ }
185
+ updateConfig()
186
+ }
187
+
188
+ document.addEventListener('keydown', onKeyDown)
189
+ document.addEventListener('keyup', onKeyUp)
190
+ updateConfig()
191
+
192
+ return () => {
193
+ document.removeEventListener('keydown', onKeyDown)
194
+ document.removeEventListener('keyup', onKeyUp)
195
+ }
196
+ }, [cameraMode, isPreviewMode])
197
+
198
+ // Preview mode: auto-navigate camera to selected node (viewer behavior)
199
+ const previewTargetNodeId = isPreviewMode
200
+ ? (selection.zoneId ?? selection.levelId ?? selection.buildingId)
201
+ : null
202
+
203
+ useEffect(() => {
204
+ if (!(isPreviewMode && controls.current)) return
205
+
206
+ const nodes = useScene.getState().nodes
207
+ let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
208
+
209
+ if (!previewTargetNodeId) {
210
+ const site = Object.values(nodes).find((n) => n.type === 'site')
211
+ node = site || null
212
+ }
213
+ if (!node) return
214
+
215
+ // Check if node has a saved camera
216
+ if (node.camera) {
217
+ const { position, target } = node.camera
218
+ if (
219
+ position &&
220
+ target &&
221
+ position.length >= 3 &&
222
+ target.length >= 3 &&
223
+ position.every((v) => v !== null && v !== undefined) &&
224
+ target.every((v) => v !== null && v !== undefined)
225
+ ) {
226
+ requestAnimationFrame(() => {
227
+ if (!controls.current) return
228
+ controls.current.setLookAt(
229
+ position[0],
230
+ position[1],
231
+ position[2],
232
+ target[0],
233
+ target[1],
234
+ target[2],
235
+ true,
236
+ )
237
+ })
238
+ }
239
+ return
240
+ }
241
+
242
+ if (!previewTargetNodeId) return
243
+
244
+ // Calculate camera position from bounding box
245
+ const object3D = sceneRegistry.nodes.get(previewTargetNodeId)
246
+ if (!object3D) return
247
+
248
+ tempBox.setFromObject(object3D)
249
+ tempBox.getCenter(tempCenter)
250
+ tempBox.getSize(tempSize)
251
+
252
+ const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
253
+ const distance = Math.max(maxDim * 2, 15)
254
+
255
+ controls.current.setLookAt(
256
+ tempCenter.x + distance * 0.7,
257
+ tempCenter.y + distance * 0.5,
258
+ tempCenter.z + distance * 0.7,
259
+ tempCenter.x,
260
+ tempCenter.y,
261
+ tempCenter.z,
262
+ true,
263
+ )
264
+ }, [isPreviewMode, previewTargetNodeId])
265
+
266
+ useEffect(() => {
267
+ const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
268
+ if (!controls.current) return
269
+
270
+ const position = new Vector3()
271
+ const target = new Vector3()
272
+ controls.current.getPosition(position)
273
+ controls.current.getTarget(target)
274
+
275
+ const state = useScene.getState()
276
+
277
+ state.updateNode(nodeId, {
278
+ camera: {
279
+ position: [position.x, position.y, position.z],
280
+ target: [target.x, target.y, target.z],
281
+ mode: useViewer.getState().cameraMode,
282
+ },
283
+ })
284
+ }
285
+ const handleNodeView = ({ nodeId }: CameraControlEvent) => {
286
+ if (!controls.current) return
287
+
288
+ const node = useScene.getState().nodes[nodeId]
289
+ if (!node?.camera) return
290
+ const { position, target } = node.camera
291
+
292
+ controls.current.setLookAt(
293
+ position[0],
294
+ position[1],
295
+ position[2],
296
+ target[0],
297
+ target[1],
298
+ target[2],
299
+ true,
300
+ )
301
+ }
302
+
303
+ const handleTopView = () => {
304
+ if (!controls.current) return
305
+
306
+ const currentPolarAngle = controls.current.polarAngle
307
+
308
+ // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
309
+ // Otherwise, go to top view (0°)
310
+ const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
311
+
312
+ controls.current.rotatePolarTo(targetAngle, true)
313
+ }
314
+
315
+ const handleOrbitCW = () => {
316
+ if (!controls.current) return
317
+
318
+ const currentAzimuth = controls.current.azimuthAngle
319
+ const currentPolar = controls.current.polarAngle
320
+ // Round to nearest 90° increment, then rotate 90° clockwise
321
+ const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
322
+ const target = rounded - Math.PI / 2
323
+
324
+ controls.current.rotateTo(target, currentPolar, true)
325
+ }
326
+
327
+ const handleOrbitCCW = () => {
328
+ if (!controls.current) return
329
+
330
+ const currentAzimuth = controls.current.azimuthAngle
331
+ const currentPolar = controls.current.polarAngle
332
+ // Round to nearest 90° increment, then rotate 90° counter-clockwise
333
+ const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
334
+ const target = rounded + Math.PI / 2
335
+
336
+ controls.current.rotateTo(target, currentPolar, true)
337
+ }
338
+
339
+ const handleNodeFocus = ({ nodeId }: CameraControlEvent) => {
340
+ focusNode(nodeId)
341
+ }
342
+
343
+ emitter.on('camera-controls:capture', handleNodeCapture)
344
+ emitter.on('camera-controls:focus', handleNodeFocus)
345
+ emitter.on('camera-controls:view', handleNodeView)
346
+ emitter.on('camera-controls:top-view', handleTopView)
347
+ emitter.on('camera-controls:orbit-cw', handleOrbitCW)
348
+ emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
349
+
350
+ return () => {
351
+ emitter.off('camera-controls:capture', handleNodeCapture)
352
+ emitter.off('camera-controls:focus', handleNodeFocus)
353
+ emitter.off('camera-controls:view', handleNodeView)
354
+ emitter.off('camera-controls:top-view', handleTopView)
355
+ emitter.off('camera-controls:orbit-cw', handleOrbitCW)
356
+ emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
357
+ }
358
+ }, [focusNode])
359
+
360
+ const onTransitionStart = useCallback(() => {
361
+ useViewer.getState().setCameraDragging(true)
362
+ }, [])
363
+
364
+ const onRest = useCallback(() => {
365
+ useViewer.getState().setCameraDragging(false)
366
+ }, [])
367
+
368
+ if (walkthroughMode) {
369
+ return <WalkthroughControls />
370
+ }
371
+
372
+ return (
373
+ <CameraControls
374
+ makeDefault
375
+ maxDistance={100}
376
+ maxPolarAngle={maxPolarAngle}
377
+ minDistance={10}
378
+ minPolarAngle={0}
379
+ mouseButtons={mouseButtons}
380
+ onRest={onRest}
381
+ onSleep={onRest}
382
+ onTransitionStart={onTransitionStart}
383
+ ref={controls}
384
+ restThreshold={0.01}
385
+ />
386
+ )
387
+ }
@@ -0,0 +1,220 @@
1
+ 'use client'
2
+
3
+ import { type ReactNode, useCallback, useEffect, useRef } from 'react'
4
+ import useEditor from '../../store/use-editor'
5
+ import { useSidebarStore } from '../ui/primitives/sidebar'
6
+ import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
7
+
8
+ const SIDEBAR_MIN_WIDTH = 300
9
+ const SIDEBAR_MAX_WIDTH = 800
10
+ const SIDEBAR_COLLAPSE_THRESHOLD = 220
11
+
12
+ // ── Left column: resizable panel with tab bar ────────────────────────────────
13
+
14
+ function LeftColumn({
15
+ tabs,
16
+ renderTabContent,
17
+ sidebarOverlay,
18
+ }: {
19
+ tabs: SidebarTab[]
20
+ renderTabContent: (tabId: string) => ReactNode
21
+ sidebarOverlay?: ReactNode
22
+ }) {
23
+ const width = useSidebarStore((s) => s.width)
24
+ const isCollapsed = useSidebarStore((s) => s.isCollapsed)
25
+ const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
26
+ const setWidth = useSidebarStore((s) => s.setWidth)
27
+ const isDragging = useSidebarStore((s) => s.isDragging)
28
+ const setIsDragging = useSidebarStore((s) => s.setIsDragging)
29
+ const activePanel = useEditor((s) => s.activeSidebarPanel)
30
+ const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
31
+
32
+ const isResizing = useRef(false)
33
+ const isExpanding = useRef(false)
34
+
35
+ // Ensure active panel is a valid tab
36
+ useEffect(() => {
37
+ if (tabs.length > 0 && !tabs.some((t) => t.id === activePanel)) {
38
+ setActivePanel(tabs[0]!.id)
39
+ }
40
+ }, [tabs, activePanel, setActivePanel])
41
+
42
+ const handleResizerDown = useCallback(
43
+ (e: React.PointerEvent) => {
44
+ e.preventDefault()
45
+ isResizing.current = true
46
+ setIsDragging(true)
47
+ document.body.style.cursor = 'col-resize'
48
+ document.body.style.userSelect = 'none'
49
+ },
50
+ [setIsDragging],
51
+ )
52
+
53
+ const handleGrabDown = useCallback(
54
+ (e: React.PointerEvent) => {
55
+ e.preventDefault()
56
+ isExpanding.current = true
57
+ setIsDragging(true)
58
+ document.body.style.cursor = 'col-resize'
59
+ document.body.style.userSelect = 'none'
60
+ },
61
+ [setIsDragging],
62
+ )
63
+
64
+ useEffect(() => {
65
+ const handlePointerMove = (e: PointerEvent) => {
66
+ if (isResizing.current) {
67
+ const newWidth = e.clientX
68
+ if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
69
+ setIsCollapsed(true)
70
+ } else {
71
+ setIsCollapsed(false)
72
+ setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
73
+ }
74
+ } else if (isExpanding.current && e.clientX > 60) {
75
+ setIsCollapsed(false)
76
+ setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH)))
77
+ }
78
+ }
79
+ const handlePointerUp = () => {
80
+ isResizing.current = false
81
+ isExpanding.current = false
82
+ setIsDragging(false)
83
+ document.body.style.cursor = ''
84
+ document.body.style.userSelect = ''
85
+ }
86
+ window.addEventListener('pointermove', handlePointerMove)
87
+ window.addEventListener('pointerup', handlePointerUp)
88
+ return () => {
89
+ window.removeEventListener('pointermove', handlePointerMove)
90
+ window.removeEventListener('pointerup', handlePointerUp)
91
+ }
92
+ }, [setWidth, setIsCollapsed, setIsDragging])
93
+
94
+ if (isCollapsed) {
95
+ return (
96
+ <div
97
+ className="relative h-full w-2 flex-shrink-0 cursor-col-resize transition-colors hover:bg-primary/20"
98
+ onPointerDown={handleGrabDown}
99
+ title="Expand sidebar"
100
+ />
101
+ )
102
+ }
103
+
104
+ return (
105
+ <div
106
+ className="relative z-10 flex h-full flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground"
107
+ style={{
108
+ width,
109
+ transition: isDragging ? 'none' : 'width 150ms ease',
110
+ }}
111
+ >
112
+ <TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
113
+ <div className="relative flex flex-1 flex-col overflow-hidden">
114
+ {renderTabContent(activePanel)}
115
+ {sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
116
+ </div>
117
+
118
+ {/* Resize handle + hit area */}
119
+ <div
120
+ className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center"
121
+ onPointerDown={handleResizerDown}
122
+ >
123
+ <div className="h-8 w-1 rounded-full bg-neutral-500" />
124
+ </div>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ // ── Right column: viewer area with toolbar ───────────────────────────────────
130
+
131
+ function RightColumn({
132
+ toolbarLeft,
133
+ toolbarRight,
134
+ children,
135
+ overlays,
136
+ }: {
137
+ toolbarLeft?: ReactNode
138
+ toolbarRight?: ReactNode
139
+ children: ReactNode
140
+ overlays?: ReactNode
141
+ }) {
142
+ return (
143
+ <div
144
+ className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
145
+ style={{
146
+ borderTopLeftRadius: 16,
147
+ clipPath: 'inset(0 0 0 0 round 16px 0 0 0)',
148
+ boxShadow: '-4px -2px 16px rgba(0, 0, 0, 0.08), -1px 0 4px rgba(0, 0, 0, 0.04)',
149
+ }}
150
+ >
151
+ {/* Viewer toolbar */}
152
+ {(toolbarLeft || toolbarRight) && (
153
+ <div className="pointer-events-none absolute top-3 right-3 left-3 z-20 flex items-center justify-between gap-2">
154
+ <div className="pointer-events-auto flex items-center gap-2">{toolbarLeft}</div>
155
+ <div className="pointer-events-auto flex items-center gap-2">{toolbarRight}</div>
156
+ </div>
157
+ )}
158
+ {/* Canvas area */}
159
+ <div className="relative flex-1 overflow-hidden">{children}</div>
160
+ {/* Overlays scoped to the viewer column */}
161
+ {overlays && (
162
+ <div
163
+ className="pointer-events-none absolute inset-0 z-30"
164
+ style={{ transform: 'translateZ(0)' }}
165
+ >
166
+ {overlays}
167
+ </div>
168
+ )}
169
+ </div>
170
+ )
171
+ }
172
+
173
+ // ── Main v2 layout ───────────────────────────────────────────────────────────
174
+
175
+ export interface EditorLayoutV2Props {
176
+ navbarSlot?: ReactNode
177
+ sidebarTabs?: SidebarTab[]
178
+ renderTabContent: (tabId: string) => ReactNode
179
+ sidebarOverlay?: ReactNode
180
+ viewerToolbarLeft?: ReactNode
181
+ viewerToolbarRight?: ReactNode
182
+ viewerContent: ReactNode
183
+ overlays?: ReactNode
184
+ }
185
+
186
+ export function EditorLayoutV2({
187
+ navbarSlot,
188
+ sidebarTabs = [],
189
+ renderTabContent,
190
+ sidebarOverlay,
191
+ viewerToolbarLeft,
192
+ viewerToolbarRight,
193
+ viewerContent,
194
+ overlays,
195
+ }: EditorLayoutV2Props) {
196
+ return (
197
+ <div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
198
+ {/* Top navbar */}
199
+ {navbarSlot}
200
+
201
+ {/* Main content: left column + right column */}
202
+ <div className="flex min-h-0 flex-1">
203
+ {sidebarTabs.length > 0 && (
204
+ <LeftColumn
205
+ renderTabContent={renderTabContent}
206
+ sidebarOverlay={sidebarOverlay}
207
+ tabs={sidebarTabs}
208
+ />
209
+ )}
210
+ <RightColumn
211
+ overlays={overlays}
212
+ toolbarLeft={viewerToolbarLeft}
213
+ toolbarRight={viewerToolbarRight}
214
+ >
215
+ {viewerContent}
216
+ </RightColumn>
217
+ </div>
218
+ </div>
219
+ )
220
+ }