@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useViewer } from '@pascal-app/viewer'
3
+ import { getSceneTheme, useViewer } from '@pascal-app/viewer'
4
4
  import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
5
5
  import useEditor from '../../store/use-editor'
6
6
  import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
@@ -56,8 +56,8 @@ export function EditorLayoutMobile({
56
56
  const activePanel = useEditor((s) => s.activeSidebarPanel)
57
57
  const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
58
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
59
+ const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark')
60
+ const viewerBg = isDark ? VIEWER_BG_DARK : VIEWER_BG_LIGHT
61
61
 
62
62
  const middleRef = useRef<HTMLDivElement>(null)
63
63
  const sheetRef = useRef<BottomSheetHandle>(null)
@@ -5,12 +5,14 @@ import { useIsMobile } from '../../hooks/use-mobile'
5
5
  import useEditor from '../../store/use-editor'
6
6
 
7
7
  import { useSidebarStore } from '../ui/primitives/sidebar'
8
- import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
8
+ import { IconRail, type SidebarTab } from '../ui/sidebar/tab-bar'
9
9
  import { EditorLayoutMobile } from './editor-layout-mobile'
10
10
 
11
11
  const SIDEBAR_MIN_WIDTH = 300
12
12
  const SIDEBAR_MAX_WIDTH = 800
13
13
  const SIDEBAR_COLLAPSE_THRESHOLD = 220
14
+ // Matches the `w-14` rail in <IconRail>; the resize math is relative to it.
15
+ const RAIL_WIDTH = 56
14
16
 
15
17
  // ── Left column: resizable panel with tab bar ────────────────────────────────
16
18
 
@@ -33,7 +35,6 @@ function LeftColumn({
33
35
  const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
34
36
 
35
37
  const isResizing = useRef(false)
36
- const isExpanding = useRef(false)
37
38
 
38
39
  // Ensure active panel is a valid tab
39
40
  useEffect(() => {
@@ -62,35 +63,40 @@ function LeftColumn({
62
63
  [setIsDragging],
63
64
  )
64
65
 
65
- const handleGrabDown = useCallback(
66
- (e: React.PointerEvent) => {
67
- e.preventDefault()
68
- isExpanding.current = true
69
- setIsDragging(true)
70
- document.body.style.cursor = 'col-resize'
71
- document.body.style.userSelect = 'none'
66
+ // Rail click: reopen a collapsed panel, collapse when re-clicking the open
67
+ // tab, otherwise switch tabs. Reopening clamps below-min persisted widths
68
+ // up to the minimum so the panel always returns to a usable size.
69
+ const handleRailClick = useCallback(
70
+ (id: string) => {
71
+ if (isCollapsed) {
72
+ setIsCollapsed(false)
73
+ if (width < SIDEBAR_MIN_WIDTH) setWidth(SIDEBAR_MIN_WIDTH)
74
+ setActivePanel(id)
75
+ return
76
+ }
77
+ if (id === activePanel) {
78
+ setIsCollapsed(true)
79
+ return
80
+ }
81
+ setActivePanel(id)
72
82
  },
73
- [setIsDragging],
83
+ [isCollapsed, width, activePanel, setIsCollapsed, setWidth, setActivePanel],
74
84
  )
75
85
 
76
86
  useEffect(() => {
77
87
  const handlePointerMove = (e: PointerEvent) => {
78
- if (isResizing.current) {
79
- const newWidth = e.clientX
80
- if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
81
- setIsCollapsed(true)
82
- } else {
83
- setIsCollapsed(false)
84
- setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
85
- }
86
- } else if (isExpanding.current && e.clientX > 60) {
88
+ if (!isResizing.current) return
89
+ // Rail occupies the leftmost 48px; the panel starts after it.
90
+ const newWidth = e.clientX - RAIL_WIDTH
91
+ if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
92
+ setIsCollapsed(true)
93
+ } else {
87
94
  setIsCollapsed(false)
88
- setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH)))
95
+ setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
89
96
  }
90
97
  }
91
98
  const handlePointerUp = () => {
92
99
  isResizing.current = false
93
- isExpanding.current = false
94
100
  setIsDragging(false)
95
101
  document.body.style.cursor = ''
96
102
  document.body.style.userSelect = ''
@@ -103,37 +109,36 @@ function LeftColumn({
103
109
  }
104
110
  }, [setWidth, setIsCollapsed, setIsDragging])
105
111
 
106
- if (isCollapsed) {
107
- return (
108
- <div
109
- className="relative h-full w-2 flex-shrink-0 cursor-col-resize transition-colors hover:bg-primary/20"
110
- onPointerDown={handleGrabDown}
111
- title="Expand sidebar"
112
- />
113
- )
114
- }
115
-
116
112
  return (
117
- <div
118
- className="relative z-10 flex h-full flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground"
119
- style={{
120
- width,
121
- transition: isDragging ? 'none' : 'width 150ms ease',
122
- }}
123
- >
124
- <TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
125
- <div className="relative flex flex-1 flex-col overflow-hidden">
126
- {renderTabContent(activePanel)}
127
- {sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
128
- </div>
113
+ <div className="relative z-10 flex h-full flex-shrink-0 bg-sidebar text-sidebar-foreground">
114
+ <IconRail
115
+ activeTab={activePanel}
116
+ collapsed={isCollapsed}
117
+ onIconClick={handleRailClick}
118
+ tabs={tabs}
119
+ />
120
+ {!isCollapsed && (
121
+ <div
122
+ className="relative flex h-full flex-col"
123
+ style={{
124
+ width,
125
+ transition: isDragging ? 'none' : 'width 150ms ease',
126
+ }}
127
+ >
128
+ <div className="relative flex flex-1 flex-col overflow-hidden">
129
+ {renderTabContent(activePanel)}
130
+ {sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
131
+ </div>
129
132
 
130
- {/* Resize handle + hit area */}
131
- <div
132
- className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center"
133
- onPointerDown={handleResizerDown}
134
- >
135
- <div className="h-8 w-1 rounded-full bg-neutral-500" />
136
- </div>
133
+ {/* Resize handle + hit area */}
134
+ <div
135
+ className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center"
136
+ onPointerDown={handleResizerDown}
137
+ >
138
+ <div className="h-8 w-1 rounded-full bg-neutral-500" />
139
+ </div>
140
+ </div>
141
+ )}
137
142
  </div>
138
143
  )
139
144
  }
@@ -169,10 +174,12 @@ function RightColumn({
169
174
  )}
170
175
  {/* Canvas area */}
171
176
  <div className="relative flex-1 overflow-hidden">{children}</div>
172
- {/* Overlays scoped to the viewer column */}
177
+ {/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the
178
+ draggable region the floating inspector clamps itself to. */}
173
179
  {overlays && (
174
180
  <div
175
181
  className="pointer-events-none absolute inset-0 z-30"
182
+ data-viewer-bounds
176
183
  style={{ transform: 'translateZ(0)' }}
177
184
  >
178
185
  {overlays}
@@ -205,6 +212,7 @@ export function EditorLayoutV2({
205
212
  viewerContent,
206
213
  overlays,
207
214
  }: EditorLayoutV2Props) {
215
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
208
216
  const isMobile = useIsMobile()
209
217
 
210
218
  if (isMobile) {
@@ -229,7 +237,7 @@ export function EditorLayoutV2({
229
237
 
230
238
  {/* Main content: left column + right column */}
231
239
  <div className="flex min-h-0 flex-1">
232
- {sidebarTabs.length > 0 && (
240
+ {!isCaptureMode && sidebarTabs.length > 0 && (
233
241
  <LeftColumn
234
242
  renderTabContent={renderTabContent}
235
243
  sidebarOverlay={sidebarOverlay}
@@ -238,8 +246,8 @@ export function EditorLayoutV2({
238
246
  )}
239
247
  <RightColumn
240
248
  overlays={overlays}
241
- toolbarLeft={viewerToolbarLeft}
242
- toolbarRight={viewerToolbarRight}
249
+ toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
250
+ toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
243
251
  >
244
252
  {viewerContent}
245
253
  </RightColumn>
@@ -3,6 +3,7 @@
3
3
  import { useViewer } from '@pascal-app/viewer'
4
4
  import { useThree } from '@react-three/fiber'
5
5
  import { useEffect } from 'react'
6
+ import type { Mesh, Object3D } from 'three'
6
7
  import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
7
8
  import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
8
9
  import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
@@ -21,10 +22,11 @@ export function ExportManager() {
21
22
  }
22
23
 
23
24
  const date = new Date().toISOString().split('T')[0]
25
+ const exportScene = prepareSceneForExport(sceneGroup)
24
26
 
25
27
  if (format === 'stl') {
26
28
  const exporter = new STLExporter()
27
- const result = exporter.parse(sceneGroup, { binary: true })
29
+ const result = exporter.parse(exportScene, { binary: true })
28
30
  const blob = new Blob([result], { type: 'model/stl' })
29
31
  downloadBlob(blob, `model_${date}.stl`)
30
32
  return
@@ -32,7 +34,7 @@ export function ExportManager() {
32
34
 
33
35
  if (format === 'obj') {
34
36
  const exporter = new OBJExporter()
35
- const result = exporter.parse(sceneGroup)
37
+ const result = exporter.parse(exportScene)
36
38
  const blob = new Blob([result], { type: 'model/obj' })
37
39
  downloadBlob(blob, `model_${date}.obj`)
38
40
  return
@@ -43,7 +45,7 @@ export function ExportManager() {
43
45
 
44
46
  return new Promise<void>((resolve, reject) => {
45
47
  exporter.parse(
46
- sceneGroup,
48
+ exportScene,
47
49
  (gltf) => {
48
50
  const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
49
51
  downloadBlob(blob, `model_${date}.glb`)
@@ -68,6 +70,33 @@ export function ExportManager() {
68
70
  return null
69
71
  }
70
72
 
73
+ function prepareSceneForExport(source: Object3D) {
74
+ const clone = source.clone(true)
75
+ const meshesToRemove: Mesh[] = []
76
+
77
+ clone.traverse((object) => {
78
+ if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object)
79
+ })
80
+
81
+ for (const mesh of meshesToRemove) {
82
+ mesh.removeFromParent()
83
+ }
84
+
85
+ return clone
86
+ }
87
+
88
+ function isMeshWithInvalidGeometry(object: Object3D): object is Mesh {
89
+ if (!isMesh(object)) return false
90
+
91
+ // Three exporters can crash when a Mesh has no readable position attribute.
92
+ const position = object.geometry?.getAttribute('position')
93
+ return !position || position.count === 0
94
+ }
95
+
96
+ function isMesh(object: Object3D): object is Mesh {
97
+ return (object as Mesh).isMesh === true
98
+ }
99
+
71
100
  function downloadBlob(blob: Blob, filename: string) {
72
101
  const url = URL.createObjectURL(blob)
73
102
  const link = document.createElement('a')
@@ -0,0 +1,149 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ CeilingNode,
6
+ ColumnNode,
7
+ ElevatorNode,
8
+ LevelNode,
9
+ nodeRegistry,
10
+ registerNode,
11
+ ShelfNode,
12
+ SiteNode,
13
+ sceneRegistry,
14
+ useScene,
15
+ } from '@pascal-app/core'
16
+ import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
17
+ import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'
18
+
19
+ function registerColliderDefinition(
20
+ kind: AnyNode['type'],
21
+ schema: AnyNodeDefinition['schema'],
22
+ category: AnyNodeDefinition['category'],
23
+ surfaceRole?: AnyNodeDefinition['surfaceRole'],
24
+ ) {
25
+ registerNode({
26
+ kind,
27
+ schema,
28
+ schemaVersion: 1,
29
+ category,
30
+ surfaceRole,
31
+ capabilities: {},
32
+ } as AnyNodeDefinition)
33
+ }
34
+
35
+ function mountNode(
36
+ node: AnyNode,
37
+ box: [number, number, number],
38
+ position: [number, number, number],
39
+ ) {
40
+ const group = new Group()
41
+ const mesh = new Mesh(new BoxGeometry(box[0], box[1], box[2]), new MeshBasicMaterial())
42
+ mesh.position.set(position[0], position[1], position[2])
43
+ group.add(mesh)
44
+ group.updateMatrixWorld(true)
45
+ sceneRegistry.nodes.set(node.id, group)
46
+ sceneRegistry.byType[node.type]!.add(node.id)
47
+ }
48
+
49
+ function mountRegistryGroup(node: AnyNode) {
50
+ const group = new Group()
51
+ group.updateMatrixWorld(true)
52
+ sceneRegistry.nodes.set(node.id, group)
53
+ sceneRegistry.byType[node.type]!.add(node.id)
54
+ }
55
+
56
+ function setSceneNodes(nodes: AnyNode[]) {
57
+ useScene.setState({
58
+ nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
59
+ rootNodeIds: nodes.map((node) => node.id),
60
+ } as never)
61
+ }
62
+
63
+ describe('buildFirstPersonColliderWorldFromRegistry', () => {
64
+ afterEach(() => {
65
+ sceneRegistry.clear()
66
+ nodeRegistry._reset()
67
+ useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
68
+ })
69
+
70
+ test('includes structure and furnish nodes discovered through the node registry', () => {
71
+ registerColliderDefinition('column', ColumnNode, 'structure')
72
+ registerColliderDefinition('shelf', ShelfNode, 'furnish')
73
+
74
+ const column = ColumnNode.parse({ id: 'column_test' })
75
+ const shelf = ShelfNode.parse({ id: 'shelf_test', position: [3, 0, 0] })
76
+ setSceneNodes([column, shelf])
77
+ mountNode(column, [1, 2, 1], [0, 1, 0])
78
+ mountNode(shelf, [2, 1, 1], [3, 0.5, 0])
79
+
80
+ const world = buildFirstPersonColliderWorldFromRegistry()
81
+
82
+ expect(world).not.toBeNull()
83
+ expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
84
+ expect(world?.bounds?.max.x).toBeCloseTo(4)
85
+ world?.dispose()
86
+ })
87
+
88
+ test('excludes ceiling surfaces so the walkthrough player passes through them', () => {
89
+ registerColliderDefinition('column', ColumnNode, 'structure')
90
+ registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling')
91
+
92
+ const column = ColumnNode.parse({ id: 'column_test' })
93
+ const ceiling = CeilingNode.parse({ id: 'ceiling_test', polygon: [] })
94
+ setSceneNodes([column, ceiling])
95
+ mountNode(column, [1, 2, 1], [0, 1, 0])
96
+ // A wide ceiling at head height — if it were collected, bounds would span ±5.
97
+ mountNode(ceiling, [10, 0.1, 10], [0, 2.5, 0])
98
+
99
+ const world = buildFirstPersonColliderWorldFromRegistry()
100
+
101
+ expect(world).not.toBeNull()
102
+ // Bounds reflect only the 1×1 column; the ceiling contributed no geometry.
103
+ expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
104
+ expect(world?.bounds?.max.x).toBeCloseTo(0.5)
105
+ world?.dispose()
106
+ })
107
+
108
+ test('leaves elevators to their dedicated dynamic collider meshes', () => {
109
+ registerColliderDefinition('elevator', ElevatorNode, 'structure')
110
+
111
+ const elevator = ElevatorNode.parse({ id: 'elevator_test' })
112
+ setSceneNodes([elevator])
113
+ mountNode(elevator, [2, 3, 2], [0, 1.5, 0])
114
+
115
+ const world = buildFirstPersonColliderWorldFromRegistry()
116
+
117
+ expect(world).toBeNull()
118
+ })
119
+
120
+ test('adds a fallback floor for a visible level with no slab', () => {
121
+ const level = LevelNode.parse({ id: 'level_test', level: 0 })
122
+ setSceneNodes([level])
123
+ mountRegistryGroup(level)
124
+
125
+ const world = buildFirstPersonColliderWorldFromRegistry()
126
+
127
+ expect(world).not.toBeNull()
128
+ expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
129
+ expect(world?.bounds?.max.y).toBeCloseTo(0)
130
+ world?.dispose()
131
+ })
132
+
133
+ test('adds a site ground collider so a spawn on bare ground has a floor', () => {
134
+ const site = SiteNode.parse({ id: 'site_test' })
135
+ setSceneNodes([site])
136
+ mountRegistryGroup(site)
137
+
138
+ const world = buildFirstPersonColliderWorldFromRegistry()
139
+
140
+ expect(world).not.toBeNull()
141
+ // Ground slab sits just below the site ground plane (y = 0).
142
+ expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
143
+ expect(world?.bounds?.max.y).toBeCloseTo(0)
144
+ // Default site footprint falls back to the 30 m minimum size.
145
+ expect(world?.bounds?.min.x).toBeCloseTo(-15)
146
+ expect(world?.bounds?.max.x).toBeCloseTo(15)
147
+ world?.dispose()
148
+ })
149
+ })