@pascal-app/editor 1.0.0-beta.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -117,6 +117,12 @@ interface TreeNodeProps {
117
117
  isLast?: boolean
118
118
  }
119
119
 
120
+ type TreeNodeComponent = React.ComponentType<{
121
+ depth: number
122
+ isLast?: boolean
123
+ nodeId: AnyNodeId
124
+ }>
125
+
120
126
  // Per-kind tree-node components keyed by `node.type`. Lookup replaces
121
127
  // the legacy switch — adding a kind to this map is now the only edit
122
128
  // needed in this file (the switch's `case '<kind>':` clauses were
@@ -124,10 +130,7 @@ interface TreeNodeProps {
124
130
  // outside the registry; future work moves these to a
125
131
  // `def.presentation`-driven generic tree-node and removes this map
126
132
  // entirely).
127
- const treeNodeByType: Record<
128
- string,
129
- React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
130
- > = {
133
+ const treeNodeByType: Record<string, TreeNodeComponent> = {
131
134
  building: BuildingTreeNode as React.ComponentType<{
132
135
  depth: number
133
136
  isLast?: boolean
@@ -136,6 +139,7 @@ const treeNodeByType: Record<
136
139
  cabinet: RegistryTreeNode,
137
140
  'cabinet-module': RegistryTreeNode,
138
141
  'box-vent': RegistryTreeNode,
142
+ 'block': RegistryTreeNode,
139
143
  ceiling: CeilingTreeNode,
140
144
  chimney: ChimneyTreeNode,
141
145
  dormer: DormerTreeNode,
@@ -169,6 +173,7 @@ const treeNodeByType: Record<
169
173
  'eyebrow-vent': RegistryTreeNode,
170
174
  skylight: RegistryTreeNode,
171
175
  roof: RoofTreeNode,
176
+ scan: RegistryTreeNode,
172
177
  stair: StairTreeNode,
173
178
  door: DoorTreeNode,
174
179
  window: WindowTreeNode,
@@ -180,6 +185,10 @@ const treeNodeByType: Record<
180
185
  item: ItemTreeNode,
181
186
  }
182
187
 
188
+ export function getTreeNodeComponent(nodeType: string): TreeNodeComponent {
189
+ return treeNodeByType[nodeType] ?? RegistryTreeNode
190
+ }
191
+
183
192
  export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
184
193
  // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean
185
194
  // selector so unrelated scene updates don't re-render every row.
@@ -191,8 +200,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
191
200
  const nodeType = useScene((state) => state.nodes[nodeId]?.type)
192
201
  if (shouldHide) return null
193
202
  if (!nodeType) return null
194
- const Component = treeNodeByType[nodeType]
195
- if (!Component) return null
203
+ const Component = getTreeNodeComponent(nodeType)
196
204
  return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
197
205
  })
198
206
 
@@ -86,6 +86,8 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
86
86
  <Tooltip key={tab.id}>
87
87
  <TooltipTrigger asChild>
88
88
  <button
89
+ aria-label={tab.label}
90
+ aria-pressed={showActive}
89
91
  className={cn(
90
92
  'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
91
93
  showActive
@@ -12,7 +12,12 @@ import {
12
12
  useSyncExternalStore,
13
13
  } from 'react'
14
14
  import useEditor from '../../../store/use-editor'
15
- import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
15
+ import {
16
+ editorHostPanelRegistry,
17
+ type EditorHostPanel,
18
+ managedPluginIds,
19
+ showsPluginManager,
20
+ } from '../../../lib/plugin-panels'
16
21
  import { ErrorBoundary } from '../primitives/error-boundary'
17
22
  import type { ExtraPanel } from './icon-rail'
18
23
  import { PluginsPanel } from './panels/plugins-panel'
@@ -100,6 +105,7 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
100
105
  )
101
106
  const workspaceMode = useEditor((s) => s.workspaceMode)
102
107
  const installedPlugins = useScene((s) => s.installedPlugins)
108
+ const readOnly = useScene((s) => s.readOnly)
103
109
  const hostIds = new Set(hostPanels?.map((p) => p.id))
104
110
 
105
111
  useEffect(() => {
@@ -126,7 +132,16 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
126
132
  pluginId: p.pluginId,
127
133
  }),
128
134
  )
135
+ // The manager tab is the one panel the editor contributes itself, so it is
136
+ // also the one that can be alone in the rail — see `showsPluginManager`.
129
137
  const manager =
130
- workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : []
138
+ !hostIds.has(pluginsManagerPanel.id) &&
139
+ showsPluginManager({
140
+ managedPluginCount: managedPluginIds(registered).length,
141
+ readOnly,
142
+ workspaceMode,
143
+ })
144
+ ? [pluginsManagerPanel]
145
+ : []
131
146
  return [...(hostPanels ?? []), ...fromRegistry, ...manager]
132
147
  }
@@ -9,7 +9,7 @@ import {
9
9
  useScene,
10
10
  type ZoneNode,
11
11
  } from '@pascal-app/core'
12
- import { useViewer } from '@pascal-app/viewer'
12
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
13
13
  import { ArrowLeft, ChevronRight, Layers } from 'lucide-react'
14
14
  import Link from 'next/link'
15
15
  import type { ReactNode } from 'react'
@@ -76,6 +76,7 @@ export const ViewerSceneHeader = ({
76
76
 
77
77
  const handleLevelClick = (levelId: LevelNode['id']) => {
78
78
  // When switching levels, deselect zone and items
79
+ if (levelId !== selection.levelId) markPerfAction('level-switch', levelId)
79
80
  useViewer.getState().setSelection({ levelId })
80
81
  }
81
82
 
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
4
- import { useViewer } from '@pascal-app/viewer'
4
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
5
5
  import type { ReactNode } from 'react'
6
6
  import { useCallback, useEffect, useMemo, useState } from 'react'
7
7
  import { useShallow } from 'zustand/react/shallow'
@@ -134,6 +134,9 @@ export function ViewerStage({
134
134
  const chooseLevel = useCallback(
135
135
  (nextLevelId: string, notify = true) => {
136
136
  setInternalLevelId(nextLevelId)
137
+ if (notify && nextLevelId !== useViewer.getState().selection.levelId) {
138
+ markPerfAction('level-switch', nextLevelId)
139
+ }
137
140
  selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
138
141
  if (notify) onLevelChange?.(nextLevelId)
139
142
  },
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { flushSync } from 'react-dom'
4
+ import { requestWalkthroughPointerLock } from '../lib/walkthrough-pointer-lock'
4
5
  import useEditor from '../store/use-editor'
5
6
  import { ViewerControlsBar } from './viewer/viewer-controls-bar'
6
7
  import { ViewerSceneHeader } from './viewer/viewer-scene-header'
@@ -12,28 +13,6 @@ type ProjectOwner = {
12
13
  image: string | null
13
14
  }
14
15
 
15
- function requestWalkthroughPointerLock() {
16
- const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
17
- if (!canvas) return
18
-
19
- if (!canvas.hasAttribute('tabindex')) {
20
- canvas.tabIndex = -1
21
- }
22
- canvas.focus({ preventScroll: true })
23
-
24
- if (document.pointerLockElement === canvas) return
25
-
26
- try {
27
- // The request can also reject ASYNC (browser cooldown after a recent
28
- // unlock) — swallow it like the P-resume path; clicking the canvas
29
- // re-requests once the cooldown passes.
30
- const result = canvas.requestPointerLock?.() as Promise<void> | undefined
31
- if (result && typeof result.catch === 'function') result.catch(() => {})
32
- } catch {
33
- return
34
- }
35
- }
36
-
37
16
  interface ViewerOverlayProps {
38
17
  projectName?: string | null
39
18
  owner?: ProjectOwner | null
@@ -13,6 +13,9 @@ export const ViewerZoneSystem = () => {
13
13
  const { levelId, zoneId } = useViewer.getState().selection
14
14
  const structureLayer = useEditor.getState().structureLayer
15
15
  const nodes = useScene.getState().nodes
16
+ // Snapshot capture is a clean, camera-only surface — zone geometry and
17
+ // tags stay out of the framed shot (mirrors the editor ZoneSystem's gate).
18
+ const isCaptureMode = useEditor.getState().isCaptureMode
16
19
  // During any active interaction zone labels step back entirely (Sims-light).
17
20
  const zoneLabelsHidden =
18
21
  resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
@@ -31,7 +34,8 @@ export const ViewerZoneSystem = () => {
31
34
  // The editor ZoneSystem handles the selected zone's opacity animation.
32
35
  const isSelected = id === zoneId
33
36
  const shouldShowGeometry =
34
- (structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected
37
+ !isCaptureMode &&
38
+ ((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
35
39
  if (!obj.visible) obj.visible = true
36
40
  obj.traverse((child) => {
37
41
  if ((child as Mesh).isMesh) {
@@ -40,7 +44,7 @@ export const ViewerZoneSystem = () => {
40
44
  })
41
45
 
42
46
  // Labels: always visible on the current level (regardless of mode or zone selection)
43
- const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
47
+ const showLabel = !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
44
48
  const targetOpacity = showLabel ? '1' : '0'
45
49
  const labelEl = document.getElementById(`${id}-label`)
46
50
  if (labelEl && labelEl.style.opacity !== targetOpacity) {
@@ -17,6 +17,24 @@ export type WalkthroughHudProps = {
17
17
  children?: ReactNode
18
18
  }
19
19
 
20
+ /** The centered walkthrough pointer: a dot that grows into a green ring over
21
+ * an interactable (door / window / elevator). Also mounted by the snapshot
22
+ * capture overlay so walk / drone framing keeps the same E-to-open pointer. */
23
+ export function WalkthroughCrosshair({ interact }: { interact: WalkthroughInteract }) {
24
+ return (
25
+ <div className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
26
+ <div
27
+ className={cn(
28
+ 'rounded-full transition-all duration-150',
29
+ interact
30
+ ? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
31
+ : 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
32
+ )}
33
+ />
34
+ </div>
35
+ )
36
+ }
37
+
20
38
  export function WalkthroughHud({
21
39
  floorLabel,
22
40
  zoneLabel,
@@ -51,16 +69,7 @@ export function WalkthroughHud({
51
69
  {children}
52
70
  </div>
53
71
 
54
- <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
55
- <div
56
- className={cn(
57
- 'rounded-full transition-all duration-150',
58
- interact
59
- ? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
60
- : 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
61
- )}
62
- />
63
- </div>
72
+ <WalkthroughCrosshair interact={interact} />
64
73
 
65
74
  <div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center gap-2">
66
75
  {suspended ? (
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { createStoredNodeCountTracker, isSuspiciousNodeDrop } from './use-auto-save'
2
+ import {
3
+ createStoredNodeCountTracker,
4
+ decideExitFlush,
5
+ isSuspiciousNodeDrop,
6
+ } from './use-auto-save'
3
7
 
4
8
  describe('isSuspiciousNodeDrop', () => {
5
9
  test('blocks populated scenes from being flushed as empty skeletons', () => {
@@ -61,3 +65,71 @@ describe('createStoredNodeCountTracker', () => {
61
65
  expect(tracker.allowWrite(3)).toBe(true)
62
66
  })
63
67
  })
68
+
69
+ describe('decideExitFlush', () => {
70
+ test('reproduces the 2026-08-16 scene-wipe sequence and skips the flush', () => {
71
+ // The exact traced wipe (dev repro, scenes a4993ec9f1ab/1befee38f973 and
72
+ // the live sessions of 2026-08-18):
73
+ // 1. useAutoSave subscribes; store = initial empty state.
74
+ // 2. useHostPanels' mount effect writes default installedPlugins — a
75
+ // scene-store change BEFORE the Editor's load effect runs, so the
76
+ // session is marked dirty with zero user edits.
77
+ // 3. The load effect sets loading=true and calls unloadScene(); the
78
+ // tracker re-baselines to the transient 0-node state.
79
+ // 4. StrictMode's simulated unmount (prod: tab close / navigation)
80
+ // runs the effect cleanup -> flushOnExit with an EMPTY store.
81
+ // The flush must be skipped: the store content is transient, not data.
82
+ expect(
83
+ decideExitFlush({
84
+ isLoadingScene: true,
85
+ hasDirtyChanges: true,
86
+ storedNodeCount: 0,
87
+ currentNodeCount: 0,
88
+ }),
89
+ ).toBe('skip-loading')
90
+ })
91
+
92
+ test('never flushes while a load is in flight, whatever the counts say', () => {
93
+ expect(
94
+ decideExitFlush({
95
+ isLoadingScene: true,
96
+ hasDirtyChanges: true,
97
+ storedNodeCount: 74,
98
+ currentNodeCount: 74,
99
+ }),
100
+ ).toBe('skip-loading')
101
+ })
102
+
103
+ test('does nothing when there are no dirty changes', () => {
104
+ expect(
105
+ decideExitFlush({
106
+ isLoadingScene: false,
107
+ hasDirtyChanges: false,
108
+ storedNodeCount: 74,
109
+ currentNodeCount: 0,
110
+ }),
111
+ ).toBe('skip-clean')
112
+ })
113
+
114
+ test('blocks a populated-to-scaffold drop after hydration', () => {
115
+ expect(
116
+ decideExitFlush({
117
+ isLoadingScene: false,
118
+ hasDirtyChanges: true,
119
+ storedNodeCount: 74,
120
+ currentNodeCount: 0,
121
+ }),
122
+ ).toBe('blocked-suspicious')
123
+ })
124
+
125
+ test('flushes ordinary dirty sessions on exit', () => {
126
+ expect(
127
+ decideExitFlush({
128
+ isLoadingScene: false,
129
+ hasDirtyChanges: true,
130
+ storedNodeCount: 74,
131
+ currentNodeCount: 75,
132
+ }),
133
+ ).toBe('flush')
134
+ })
135
+ })
@@ -45,6 +45,35 @@ export function createStoredNodeCountTracker(initialNodeCount: number) {
45
45
  }
46
46
  }
47
47
 
48
+ export type ExitFlushDecision = 'skip-clean' | 'skip-loading' | 'blocked-suspicious' | 'flush'
49
+
50
+ /**
51
+ * Decides what the unload/unmount flush may do with the store's current
52
+ * content. Pure so the wipe scenarios stay unit-testable.
53
+ *
54
+ * `skip-loading` is the load-bearing branch: while a scene load is in flight
55
+ * the store passes through an intermediate `unloadScene()` state — zero nodes,
56
+ * zero roots — that is NOT user data. A flush fired in that window (StrictMode
57
+ * simulated unmount in dev, a quick tab close or navigation in prod) used to
58
+ * serialize that empty store and PUT it over the server copy, wiping the scene
59
+ * at v2. The dirty flag alone cannot protect here: document-level writes that
60
+ * land before hydration (e.g. the host-panel default `installedPlugins` sync)
61
+ * mark the session dirty without any user edit.
62
+ */
63
+ export function decideExitFlush(opts: {
64
+ isLoadingScene: boolean
65
+ hasDirtyChanges: boolean
66
+ storedNodeCount: number
67
+ currentNodeCount: number
68
+ }): ExitFlushDecision {
69
+ if (!opts.hasDirtyChanges) return 'skip-clean'
70
+ if (opts.isLoadingScene) return 'skip-loading'
71
+ if (isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)) {
72
+ return 'blocked-suspicious'
73
+ }
74
+ return 'flush'
75
+ }
76
+
48
77
  export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
49
78
 
50
79
  interface UseAutoSaveOptions {
@@ -65,10 +94,19 @@ export function useAutoSave({
65
94
  onDirty,
66
95
  onSaveStatusChange,
67
96
  isVersionPreviewMode = false,
68
- }: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject<boolean> } {
97
+ }: UseAutoSaveOptions): {
98
+ isLoadingSceneRef: MutableRefObject<boolean>
99
+ saveNow: () => void
100
+ } {
69
101
  const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
70
102
  const isSavingRef = useRef(false)
71
- const isLoadingSceneRef = useRef(false)
103
+ // Starts TRUE: the scene is "loading" from mount until the Editor's load
104
+ // effect completes its first hydration. The Editor's load effect runs
105
+ // several hooks AFTER this one (hook order), so store writes in that gap —
106
+ // e.g. `useHostPanels` syncing default `installedPlugins` on mount — must
107
+ // not mark the session dirty or arm a save: the store still holds the empty
108
+ // pre-hydration state, and flushing it wipes the scene server-side.
109
+ const isLoadingSceneRef = useRef(true)
72
110
  const pendingSaveRef = useRef(false)
73
111
  const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
74
112
  const hasDirtyChangesRef = useRef(false)
@@ -220,17 +258,31 @@ export function useAutoSave({
220
258
  // would otherwise drop the change entirely. `pagehide` fires in cases
221
259
  // (mobile Safari, bfcache) where `beforeunload` does not.
222
260
  function flushOnExit() {
223
- if (!hasDirtyChangesRef.current) return
224
261
  const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
225
262
  const currentNodeCount = Object.keys(nodes).length
226
263
  const previousNodeCount = storedNodeCount.count
227
- if (!storedNodeCount.allowWrite(currentNodeCount)) {
264
+ const decision = decideExitFlush({
265
+ isLoadingScene: isLoadingSceneRef.current,
266
+ hasDirtyChanges: hasDirtyChangesRef.current,
267
+ storedNodeCount: previousNodeCount,
268
+ currentNodeCount,
269
+ })
270
+ if (decision === 'skip-clean') return
271
+ if (decision === 'skip-loading') {
272
+ console.warn(
273
+ '[autosave] Skipped unload flush: a scene load is in flight, the store content is transient. Nothing user-authored is lost.',
274
+ )
275
+ return
276
+ }
277
+ if (decision === 'blocked-suspicious') {
228
278
  console.warn(
229
279
  `[autosave] Blocked unload flush: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
230
280
  )
231
281
  setSaveStatus('error')
232
282
  return
233
283
  }
284
+ // 'flush' — adopt the write as the new stored baseline.
285
+ storedNodeCount.allowWrite(currentNodeCount)
234
286
 
235
287
  hasDirtyChangesRef.current = false
236
288
  const sceneGraph = {
@@ -290,5 +342,24 @@ export function useAutoSave({
290
342
  setSaveStatus('saved')
291
343
  }, [isVersionPreviewMode, setSaveStatus])
292
344
 
293
- return { isLoadingSceneRef }
345
+ // Imperative flush for the save shortcut: drop the debounce and write now,
346
+ // through the same `executeSave` so the wipe guard and status callbacks stay
347
+ // in the loop. A write already in flight only arms the follow-up.
348
+ const saveNow = useCallback(() => {
349
+ if (isLoadingSceneRef.current) return
350
+
351
+ if (saveTimeoutRef.current) {
352
+ clearTimeout(saveTimeoutRef.current)
353
+ saveTimeoutRef.current = undefined
354
+ }
355
+
356
+ if (isSavingRef.current) {
357
+ pendingSaveRef.current = true
358
+ return
359
+ }
360
+
361
+ executeSaveRef.current?.()
362
+ }, [])
363
+
364
+ return { isLoadingSceneRef, saveNow }
294
365
  }
@@ -0,0 +1,122 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ type CeilingEvent,
4
+ CeilingNode,
5
+ emitter,
6
+ LevelNode,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
11
+ import { _roots, act, createRoot } from '@react-three/fiber'
12
+ import { createElement } from 'react'
13
+ import {
14
+ Mesh,
15
+ MeshBasicMaterial,
16
+ PerspectiveCamera,
17
+ PlaneGeometry,
18
+ type WebGLRenderer,
19
+ } from 'three'
20
+ import useEditor from '../store/use-editor'
21
+ import { useCeilingEvents } from './use-ceiling-events'
22
+
23
+ test('ceiling-item placement keeps move and commit hits while an unhovered ceiling is batched', async () => {
24
+ const previousViewer = useViewer.getState()
25
+ const previousEditor = useEditor.getState()
26
+ const previousScene = useScene.getState()
27
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
28
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
29
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
30
+ const canvas = Object.assign(new EventTarget(), {
31
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
32
+ }) as unknown as HTMLCanvasElement
33
+ const root = createRoot(canvas)
34
+ const camera = new PerspectiveCamera(60, 1, 0.1, 100)
35
+ camera.position.set(0, 1, 5)
36
+ camera.lookAt(0, 3, 0)
37
+ camera.updateMatrixWorld()
38
+ const level = LevelNode.parse({ id: 'level_ceiling_batch' })
39
+ const ceiling = CeilingNode.parse({
40
+ id: 'ceiling_placement_batch',
41
+ parentId: level.id,
42
+ polygon: [
43
+ [-5, -5],
44
+ [5, -5],
45
+ [5, 5],
46
+ [-5, 5],
47
+ ],
48
+ })
49
+ const surface = new Mesh(new PlaneGeometry(10, 10).rotateX(-Math.PI / 2), new MeshBasicMaterial())
50
+ surface.position.y = 3
51
+ surface.updateMatrixWorld(true)
52
+ const moves: CeilingEvent[] = []
53
+ const clicks: CeilingEvent[] = []
54
+ const onMove = (event: CeilingEvent) => moves.push(event)
55
+ const onClick = (event: CeilingEvent) => clicks.push(event)
56
+ emitter.on('ceiling:move', onMove)
57
+ emitter.on('ceiling:click', onClick)
58
+ function Placement() {
59
+ useCeilingEvents()
60
+ return null
61
+ }
62
+ const send = () => {
63
+ for (const type of ['pointermove', 'click']) {
64
+ canvas.dispatchEvent(Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 }))
65
+ }
66
+ }
67
+ try {
68
+ sceneRegistry.nodes.set(ceiling.id, surface)
69
+ sceneRegistry.byType.ceiling!.add(ceiling.id)
70
+ useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } })
71
+ useViewer.setState({
72
+ hoveredId: null,
73
+ cameraDragging: false,
74
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
75
+ })
76
+ useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
77
+ await root.configure({
78
+ gl: {
79
+ domElement: canvas,
80
+ render() {},
81
+ setSize() {},
82
+ setPixelRatio() {},
83
+ } as unknown as WebGLRenderer,
84
+ camera,
85
+ frameloop: 'never',
86
+ dpr: 1,
87
+ size: { width: 100, height: 100, top: 0, left: 0 },
88
+ })
89
+ await act(async () => {
90
+ root.render(createElement(Placement))
91
+ })
92
+ send()
93
+ hideFromScene(surface, 'batched')
94
+ send()
95
+ showInScene(surface, 'batched')
96
+ send()
97
+ expect(moves).toHaveLength(3)
98
+ expect(clicks).toHaveLength(3)
99
+ expect(moves[0]!.position[1]).toBeCloseTo(3)
100
+ for (const hit of [...moves, ...clicks]) {
101
+ expect(hit.node.id).toBe(ceiling.id)
102
+ expect(hit.position).toEqual(moves[0]!.position)
103
+ expect(hit.localPosition).toEqual(moves[0]!.localPosition)
104
+ }
105
+ expect(useViewer.getState().hoveredId).toBeNull()
106
+ } finally {
107
+ await act(async () => {
108
+ root.render(null)
109
+ })
110
+ _roots.delete(canvas)
111
+ emitter.off('ceiling:move', onMove)
112
+ emitter.off('ceiling:click', onClick)
113
+ sceneRegistry.nodes.delete(ceiling.id)
114
+ sceneRegistry.byType.ceiling!.delete(ceiling.id)
115
+ surface.geometry.dispose()
116
+ surface.material.dispose()
117
+ useScene.setState(previousScene)
118
+ useEditor.setState(previousEditor)
119
+ useViewer.setState(previousViewer)
120
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
121
+ }
122
+ })
@@ -14,6 +14,7 @@ import {
14
14
  type SpatialQuery,
15
15
  useScene,
16
16
  } from '@pascal-app/core'
17
+ import { beginPerfAction, cancelPerfAction, commitPerfAction } from '@pascal-app/viewer'
17
18
  import { useEffect, useRef } from 'react'
18
19
 
19
20
  const sceneApi = createSceneApi(useScene)
@@ -74,14 +75,26 @@ export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
74
75
  useEffect(() => {
75
76
  if (!args.active) return
76
77
 
78
+ const initial = argsRef.current.initial
79
+ const nodeType = initial.node?.type
80
+ const isEndpoint = initial.handleId === 'start' || initial.handleId === 'end'
81
+ const actionName = isEndpoint && nodeType ? `drag:${nodeType}-endpoint` : 'drag:move'
82
+
77
83
  const session = createDragSession<Ctx, Draft>(argsRef.current.action, sceneApi, {
78
84
  spatialQuery: argsRef.current.spatialQuery,
79
85
  childQuery: argsRef.current.childQuery,
80
- onCommit: () => argsRef.current.onCommit?.(),
81
- onCancel: () => argsRef.current.onCancel?.(),
86
+ onCommit: () => {
87
+ commitPerfAction()
88
+ argsRef.current.onCommit?.()
89
+ },
90
+ onCancel: () => {
91
+ cancelPerfAction()
92
+ argsRef.current.onCancel?.()
93
+ },
82
94
  })
83
95
 
84
- session.start(argsRef.current.initial)
96
+ beginPerfAction(actionName, initial.node?.id ?? nodeType ?? '')
97
+ session.start(initial)
85
98
 
86
99
  const activatedAt = Date.now()
87
100
  const graceMs = argsRef.current.activationGraceMs ?? 150
@@ -119,6 +132,7 @@ export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
119
132
  }
120
133
  // If the parent flipped `active` to false (or unmounted) while we were
121
134
  // still mid-drag, treat it as a cancel — no dangling history pause.
135
+ if (session.isActive()) cancelPerfAction()
122
136
  session.dispose()
123
137
  }
124
138
  }, [args.active])