@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. 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}
@@ -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
+ })