@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,13 +1,17 @@
1
1
  'use client'
2
2
 
3
3
  import type { ReactNode } from 'react'
4
+ import { triggerSFX } from './../../../lib/sfx-bus'
4
5
  import { cn } from './../../../lib/utils'
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
5
7
 
6
8
  export type SidebarTab = {
7
9
  id: string
8
10
  label: string
9
11
  mobileDefaultSnap?: number
10
12
  mobileIcon?: ReactNode
13
+ /** Desktop icon shown in the vertical rail (v2 layout). */
14
+ icon?: ReactNode
11
15
  }
12
16
 
13
17
  interface TabBarProps {
@@ -30,7 +34,11 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
30
34
  : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
31
35
  )}
32
36
  key={tab.id}
33
- onClick={() => onTabChange(tab.id)}
37
+ onClick={() => {
38
+ triggerSFX('sfx:menu-click')
39
+ onTabChange(tab.id)
40
+ }}
41
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
34
42
  type="button"
35
43
  >
36
44
  {tab.label}
@@ -40,3 +48,55 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
40
48
  </div>
41
49
  )
42
50
  }
51
+
52
+ interface IconRailProps {
53
+ tabs: SidebarTab[]
54
+ /** Highlighted tab. Stays highlighted while the panel is collapsed. */
55
+ activeTab: string
56
+ /** True when the panel beside the rail is collapsed. */
57
+ collapsed: boolean
58
+ /** Clicking a rail icon: switch tab, or toggle the panel (see layout). */
59
+ onIconClick: (id: string) => void
60
+ }
61
+
62
+ /**
63
+ * Vertical icon rail for the v2 left column. Always visible (even when the
64
+ * panel is collapsed) so the user can reopen the panel by clicking an icon.
65
+ * The label renders as a hover tooltip on the right.
66
+ */
67
+ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
68
+ return (
69
+ <TooltipProvider delayDuration={0} disableHoverableContent>
70
+ <div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
71
+ {tabs.map((tab) => {
72
+ // Only show the active highlight while the panel is open. When
73
+ // collapsed nothing is "open", so every icon reads as unselected.
74
+ const showActive = activeTab === tab.id && !collapsed
75
+ return (
76
+ <Tooltip key={tab.id}>
77
+ <TooltipTrigger asChild>
78
+ <button
79
+ className={cn(
80
+ 'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
81
+ showActive
82
+ ? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
83
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
84
+ )}
85
+ onClick={() => {
86
+ triggerSFX('sfx:menu-click')
87
+ onIconClick(tab.id)
88
+ }}
89
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
90
+ type="button"
91
+ >
92
+ {tab.icon ?? tab.label.charAt(0)}
93
+ </button>
94
+ </TooltipTrigger>
95
+ <TooltipContent side="right">{tab.label}</TooltipContent>
96
+ </Tooltip>
97
+ )
98
+ })}
99
+ </div>
100
+ </TooltipProvider>
101
+ )
102
+ }
@@ -10,13 +10,35 @@ import {
10
10
  useScene,
11
11
  type ZoneNode,
12
12
  } from '@pascal-app/core'
13
- import { useViewer } from '@pascal-app/viewer'
14
- import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
15
- import { motion } from 'motion/react'
13
+ import {
14
+ CLAY_PALETTE,
15
+ type EdgeMode,
16
+ getSceneTheme,
17
+ SCENE_THEMES,
18
+ useViewer,
19
+ } from '@pascal-app/viewer'
20
+ import {
21
+ ArrowLeft,
22
+ Box,
23
+ Camera,
24
+ Check,
25
+ ChevronRight,
26
+ Diamond,
27
+ Layers,
28
+ PenLine,
29
+ Sparkles,
30
+ } from 'lucide-react'
16
31
  import Link from 'next/link'
17
32
  import { useShallow } from 'zustand/react/shallow'
33
+ import { getLevelDisplayName } from '../lib/level-name'
18
34
  import { cn } from '../lib/utils'
19
35
  import { ActionButton } from './ui/action-menu/action-button'
36
+ import {
37
+ DropdownMenu,
38
+ DropdownMenuContent,
39
+ DropdownMenuItem,
40
+ DropdownMenuTrigger,
41
+ } from './ui/primitives/dropdown-menu'
20
42
  import { TooltipProvider } from './ui/primitives/tooltip'
21
43
 
22
44
  type ProjectOwner = {
@@ -60,6 +82,143 @@ const wallModeConfig = {
60
82
  },
61
83
  }
62
84
 
85
+ const SHADING_OPTIONS = [
86
+ { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
87
+ { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
88
+ ] as const
89
+
90
+ function RenderModeMenu() {
91
+ const shading = useViewer((s) => s.shading)
92
+ const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
93
+ const ActiveIcon = active.icon
94
+ return (
95
+ <DropdownMenu>
96
+ <DropdownMenuTrigger asChild>
97
+ <ActionButton
98
+ className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
99
+ label={`Render: ${active.name}`}
100
+ size="icon"
101
+ tooltipSide="top"
102
+ variant="ghost"
103
+ >
104
+ <ActiveIcon className="h-6 w-6" />
105
+ </ActionButton>
106
+ </DropdownMenuTrigger>
107
+ <DropdownMenuContent align="center" className="min-w-56" side="top">
108
+ {SHADING_OPTIONS.map((option) => {
109
+ const OptionIcon = option.icon
110
+ return (
111
+ <DropdownMenuItem
112
+ key={option.id}
113
+ onSelect={() => useViewer.getState().setShading(option.id)}
114
+ >
115
+ <OptionIcon />
116
+ <div className="flex flex-col">
117
+ <span className="text-foreground">{option.name}</span>
118
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
119
+ </div>
120
+ {shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
121
+ </DropdownMenuItem>
122
+ )
123
+ })}
124
+ </DropdownMenuContent>
125
+ </DropdownMenu>
126
+ )
127
+ }
128
+
129
+ function SceneThemeMenu() {
130
+ const sceneTheme = useViewer((s) => s.sceneTheme)
131
+ const active = getSceneTheme(sceneTheme)
132
+ return (
133
+ <DropdownMenu>
134
+ <DropdownMenuTrigger asChild>
135
+ <ActionButton
136
+ className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
137
+ label={`Theme: ${active.name}`}
138
+ size="icon"
139
+ tooltipSide="top"
140
+ variant="ghost"
141
+ >
142
+ <Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
143
+ </ActionButton>
144
+ </DropdownMenuTrigger>
145
+ <DropdownMenuContent align="center" className="min-w-48" side="top">
146
+ {SCENE_THEMES.map((sceneThemeOption) => {
147
+ const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
148
+ (role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
149
+ )
150
+ return (
151
+ <DropdownMenuItem
152
+ key={sceneThemeOption.id}
153
+ onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
154
+ >
155
+ <span
156
+ className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
157
+ style={{ backgroundColor: sceneThemeOption.background }}
158
+ >
159
+ {swatches.map((color, index) => (
160
+ <span
161
+ key={`${sceneThemeOption.id}-${index}`}
162
+ style={{ backgroundColor: color }}
163
+ />
164
+ ))}
165
+ </span>
166
+ <span className="text-foreground">{sceneThemeOption.name}</span>
167
+ {sceneTheme === sceneThemeOption.id ? (
168
+ <Check className="ml-auto text-foreground" />
169
+ ) : null}
170
+ </DropdownMenuItem>
171
+ )
172
+ })}
173
+ </DropdownMenuContent>
174
+ </DropdownMenu>
175
+ )
176
+ }
177
+
178
+ const EDGE_OPTIONS = [
179
+ { id: 'off', name: 'Off', detail: 'No edge lines' },
180
+ { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
181
+ { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
182
+ ] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
183
+
184
+ function EdgesMenu() {
185
+ const edges = useViewer((s) => s.edges)
186
+ const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
187
+ return (
188
+ <DropdownMenu>
189
+ <DropdownMenuTrigger asChild>
190
+ <ActionButton
191
+ className={
192
+ edges === 'off'
193
+ ? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
194
+ : 'bg-white/10 text-foreground'
195
+ }
196
+ label={`Edges: ${active.name}`}
197
+ size="icon"
198
+ tooltipSide="top"
199
+ variant="ghost"
200
+ >
201
+ <PenLine className="h-6 w-6" />
202
+ </ActionButton>
203
+ </DropdownMenuTrigger>
204
+ <DropdownMenuContent align="center" className="min-w-56" side="top">
205
+ {EDGE_OPTIONS.map((option) => (
206
+ <DropdownMenuItem
207
+ key={option.id}
208
+ onSelect={() => useViewer.getState().setEdges(option.id)}
209
+ >
210
+ <div className="flex flex-col">
211
+ <span className="text-foreground">{option.name}</span>
212
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
213
+ </div>
214
+ {edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
215
+ </DropdownMenuItem>
216
+ ))}
217
+ </DropdownMenuContent>
218
+ </DropdownMenu>
219
+ )
220
+ }
221
+
63
222
  const getNodeName = (node: AnyNode): string => {
64
223
  if ('name' in node && node.name) return node.name
65
224
  if (node.type === 'wall') return 'Wall'
@@ -93,7 +252,6 @@ export const ViewerOverlay = ({
93
252
  const cameraMode = useViewer((s) => s.cameraMode)
94
253
  const levelMode = useViewer((s) => s.levelMode)
95
254
  const wallMode = useViewer((s) => s.wallMode)
96
- const theme = useViewer((s) => s.theme)
97
255
 
98
256
  // Subscribe only to the specific nodes we read so that creating an unrelated
99
257
  // node elsewhere in the scene doesn't re-render this overlay.
@@ -206,7 +364,7 @@ export const ViewerOverlay = ({
206
364
  className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
207
365
  onClick={() => handleBreadcrumbClick('level')}
208
366
  >
209
- {level.name || `Level ${level.level}`}
367
+ {getLevelDisplayName(level)}
210
368
  </button>
211
369
  </>
212
370
  )}
@@ -280,53 +438,6 @@ export const ViewerOverlay = ({
280
438
  <div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
281
439
  <TooltipProvider delayDuration={0}>
282
440
  <div className="pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
283
- {/* Theme Toggle */}
284
- <button
285
- aria-label="Toggle theme"
286
- className="flex h-[36px] shrink-0 cursor-pointer items-center rounded-full border border-border/50 bg-accent/50 p-1"
287
- onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
288
- type="button"
289
- >
290
- <div className="relative flex">
291
- {/* Sliding Background */}
292
- <motion.div
293
- animate={{
294
- x: theme === 'light' ? '100%' : '0%',
295
- }}
296
- className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-white/20"
297
- initial={false}
298
- style={{ width: '50%' }}
299
- transition={{
300
- type: 'spring',
301
- stiffness: 500,
302
- damping: 35,
303
- }}
304
- />
305
-
306
- {/* Dark Mode Icon */}
307
- <div
308
- className={cn(
309
- 'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
310
- theme === 'dark' ? 'text-foreground' : 'text-muted-foreground',
311
- )}
312
- >
313
- <Moon className="h-4 w-4" />
314
- </div>
315
-
316
- {/* Light Mode Icon */}
317
- <div
318
- className={cn(
319
- 'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
320
- theme === 'light' ? 'text-foreground' : 'text-muted-foreground',
321
- )}
322
- >
323
- <Sun className="h-4 w-4" />
324
- </div>
325
- </div>
326
- </button>
327
-
328
- <div className="mx-1 h-5 w-px bg-border/40" />
329
-
330
441
  {/* Scans and Guides Visibility */}
331
442
  {canShowScans && (
332
443
  <ActionButton
@@ -392,6 +503,12 @@ export const ViewerOverlay = ({
392
503
  <Camera className="h-6 w-6" />
393
504
  </ActionButton>
394
505
 
506
+ <RenderModeMenu />
507
+
508
+ <SceneThemeMenu />
509
+
510
+ <EdgesMenu />
511
+
395
512
  {/* Level Mode */}
396
513
  <ActionButton
397
514
  className={cn(
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
12
12
  const structureLayer = useEditor.getState().structureLayer
13
13
  const nodes = useScene.getState().nodes
14
14
 
15
- sceneRegistry.byType.zone.forEach((id) => {
15
+ sceneRegistry.byType.zone!.forEach((id) => {
16
16
  const obj = sceneRegistry.nodes.get(id)
17
17
  if (!obj) return
18
18
 
@@ -9,7 +9,7 @@ const AUTOSAVE_DEBOUNCE_MS = 1000
9
9
  export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
10
10
 
11
11
  interface UseAutoSaveOptions {
12
- onSave?: (scene: SceneGraph) => Promise<void>
12
+ onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
13
13
  onDirty?: () => void
14
14
  onSaveStatusChange?: (status: SaveStatus) => void
15
15
  isVersionPreviewMode?: boolean
@@ -148,23 +148,30 @@ export function useAutoSave({
148
148
  }, AUTOSAVE_DEBOUNCE_MS)
149
149
  })
150
150
 
151
+ // Flush any unsaved change while the page is going away. The network
152
+ // save MUST set `keepalive` — a normal fetch is cancelled by the browser
153
+ // the moment the page unloads, so a quick refresh right after an edit
154
+ // would otherwise drop the change entirely. `pagehide` fires in cases
155
+ // (mobile Safari, bfcache) where `beforeunload` does not.
151
156
  function flushOnExit() {
152
157
  if (!hasDirtyChangesRef.current) return
158
+ hasDirtyChangesRef.current = false
153
159
  const { nodes, rootNodeIds } = useScene.getState()
154
160
  const sceneGraph = { nodes, rootNodeIds } as SceneGraph
155
161
  if (onSaveRef.current) {
156
- onSaveRef.current(sceneGraph).catch(() => {})
162
+ onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
157
163
  } else {
158
164
  saveSceneToLocalStorage(sceneGraph)
159
165
  }
160
- hasDirtyChangesRef.current = false
161
166
  }
162
167
 
163
168
  window.addEventListener('beforeunload', flushOnExit)
169
+ window.addEventListener('pagehide', flushOnExit)
164
170
 
165
171
  return () => {
166
172
  executeSaveRef.current = null
167
173
  window.removeEventListener('beforeunload', flushOnExit)
174
+ window.removeEventListener('pagehide', flushOnExit)
168
175
  if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
169
176
  flushOnExit()
170
177
  unsubscribe()
@@ -0,0 +1,175 @@
1
+ import {
2
+ type AnyNodeId,
3
+ type CeilingEvent,
4
+ type CeilingNode,
5
+ emitter,
6
+ resolveLevelId,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useThree } from '@react-three/fiber'
12
+ import { useEffect, useRef } from 'react'
13
+ import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
14
+ import useEditor from '../store/use-editor'
15
+
16
+ const UP = new Vector3(0, 1, 0)
17
+
18
+ /** Ray-casting point-in-polygon on ceiling-local [x, z] tuples. */
19
+ function pointInPolygon(x: number, z: number, polygon: Array<[number, number]>): boolean {
20
+ let inside = false
21
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
22
+ const a = polygon[i]!
23
+ const b = polygon[j]!
24
+ const intersects =
25
+ a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
26
+ if (intersects) inside = !inside
27
+ }
28
+ return inside
29
+ }
30
+
31
+ /**
32
+ * Reliable pointer events for placing ceiling-attached items.
33
+ *
34
+ * Mirrors {@link useGridEvents} (the floor path): rather than relying on the
35
+ * thin, single-sided `ceiling-grid` overlay mesh to be hit by R3F's raycaster —
36
+ * which misses from the "wrong" camera side, near polygon edges/holes, or while
37
+ * the overlay is mid-reveal, dropping the commit click even though the green box
38
+ * still shows — it intersects a math plane at each ceiling's height (double-sided,
39
+ * so it hits from above or below) and point-in-polygon tests the hit. Emits
40
+ * `ceiling:enter/move/leave/click` so both the placement coordinator and the 2D
41
+ * floor-plan item preview keep working.
42
+ *
43
+ * Active only while a ceiling-attached item is being placed or moved. The
44
+ * `ceiling-grid` mesh no longer emits these events (see `CeilingRenderer`), so
45
+ * this is the single, reliable source.
46
+ */
47
+ export function useCeilingEvents() {
48
+ const { camera, gl } = useThree()
49
+ const raycaster = useRef(new Raycaster())
50
+ const pointer = useRef(new Vector2())
51
+ const plane = useRef(new Plane(UP.clone(), 0))
52
+ const worldHit = useRef(new Vector3())
53
+ const meshWorld = useRef(new Vector3())
54
+ const hoveredRef = useRef<string | null>(null)
55
+
56
+ useEffect(() => {
57
+ const canvas = gl.domElement
58
+
59
+ const isActive = (): boolean => {
60
+ const ed = useEditor.getState()
61
+ if (ed.selectedItem?.attachTo === 'ceiling') return true
62
+ const moving = ed.movingNode
63
+ return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
64
+ }
65
+
66
+ type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
67
+
68
+ // The ceiling on the active level whose plane the cursor ray crosses inside
69
+ // its polygon, nearest the camera.
70
+ const pick = (nativeEvent: PointerEvent | MouseEvent): Hit | null => {
71
+ const activeLevelId = useViewer.getState().selection.levelId
72
+ if (!activeLevelId) return null
73
+
74
+ const rect = canvas.getBoundingClientRect()
75
+ pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
76
+ pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
77
+ raycaster.current.setFromCamera(pointer.current, camera)
78
+
79
+ const nodes = useScene.getState().nodes
80
+ const ceilingIds = sceneRegistry.byType.ceiling
81
+ if (!ceilingIds) return null
82
+
83
+ let best: Hit | null = null
84
+ let bestDist = Number.POSITIVE_INFINITY
85
+
86
+ for (const id of ceilingIds) {
87
+ const node = nodes[id as AnyNodeId] as CeilingNode | undefined
88
+ if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue
89
+ if (resolveLevelId(node, nodes) !== activeLevelId) continue
90
+ const mesh = sceneRegistry.nodes.get(id)
91
+ if (!mesh) continue
92
+
93
+ mesh.getWorldPosition(meshWorld.current)
94
+ plane.current.set(UP, -meshWorld.current.y)
95
+ if (!raycaster.current.ray.intersectPlane(plane.current, worldHit.current)) continue
96
+
97
+ const local = mesh.worldToLocal(worldHit.current.clone())
98
+ if (!pointInPolygon(local.x, local.z, node.polygon)) continue
99
+
100
+ const dist = raycaster.current.ray.origin.distanceToSquared(worldHit.current)
101
+ if (dist < bestDist) {
102
+ bestDist = dist
103
+ best = { node, mesh, world: worldHit.current.clone(), local }
104
+ }
105
+ }
106
+ return best
107
+ }
108
+
109
+ const buildEvent = (hit: Hit, nativeEvent: PointerEvent | MouseEvent): CeilingEvent => ({
110
+ node: hit.node,
111
+ position: [hit.world.x, hit.world.y, hit.world.z],
112
+ localPosition: [hit.local.x, hit.local.y, hit.local.z],
113
+ normal: [0, 1, 0],
114
+ object: hit.mesh,
115
+ stopPropagation: () => {},
116
+ nativeEvent: nativeEvent as never,
117
+ })
118
+
119
+ const emitLeave = (nativeEvent: PointerEvent | MouseEvent) => {
120
+ const id = hoveredRef.current
121
+ if (!id) return
122
+ hoveredRef.current = null
123
+ const node = useScene.getState().nodes[id as AnyNodeId] as CeilingNode | undefined
124
+ const mesh = sceneRegistry.nodes.get(id)
125
+ if (!node || !mesh) return
126
+ emitter.emit(
127
+ 'ceiling:leave',
128
+ buildEvent(
129
+ { node, mesh, world: meshWorld.current.clone(), local: new Vector3() },
130
+ nativeEvent,
131
+ ),
132
+ )
133
+ }
134
+
135
+ const onMove = (e: PointerEvent) => {
136
+ if (!isActive()) {
137
+ emitLeave(e)
138
+ return
139
+ }
140
+ const hit = pick(e)
141
+ if (!hit) {
142
+ emitLeave(e)
143
+ return
144
+ }
145
+ const ev = buildEvent(hit, e)
146
+ if (hoveredRef.current !== hit.node.id) {
147
+ if (hoveredRef.current) emitLeave(e)
148
+ hoveredRef.current = hit.node.id
149
+ emitter.emit('ceiling:enter', ev)
150
+ }
151
+ emitter.emit('ceiling:move', ev)
152
+ }
153
+
154
+ const onClick = (e: MouseEvent) => {
155
+ if (useViewer.getState().cameraDragging) return
156
+ if (e.button !== 0) return
157
+ if (!isActive()) return
158
+ const hit = pick(e)
159
+ if (!hit) return
160
+ emitter.emit('ceiling:click', buildEvent(hit, e))
161
+ }
162
+
163
+ const onPointerLeave = (e: PointerEvent) => emitLeave(e)
164
+
165
+ canvas.addEventListener('pointermove', onMove)
166
+ canvas.addEventListener('click', onClick)
167
+ canvas.addEventListener('pointerleave', onPointerLeave)
168
+
169
+ return () => {
170
+ canvas.removeEventListener('pointermove', onMove)
171
+ canvas.removeEventListener('click', onClick)
172
+ canvas.removeEventListener('pointerleave', onPointerLeave)
173
+ }
174
+ }, [camera, gl])
175
+ }