@pascal-app/editor 1.1.0 → 1.1.2

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 (262) hide show
  1. package/package.json +9 -15
  2. package/src/components/editor/action-menu-button.tsx +1 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
  4. package/src/components/editor/custom-camera-controls.tsx +11 -11
  5. package/src/components/editor/editor-layout-v2.tsx +1 -1
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +20 -1981
  8. package/src/components/editor/floating-action-menu.tsx +1 -1
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floor-region-controls.tsx +6 -2
  11. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  12. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  13. package/src/components/editor/floorplan-panel.tsx +26 -22
  14. package/src/components/editor/floorplan-wall-typing-keys.ts +1 -1
  15. package/src/components/editor/follow-pascal.test.tsx +1 -2
  16. package/src/components/editor/follow-pascal.tsx +1 -1
  17. package/src/components/editor/footprint-height-handle.tsx +1 -1
  18. package/src/components/editor/group-actions.ts +1 -2
  19. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  20. package/src/components/editor/group-move-3d.ts +1 -2
  21. package/src/components/editor/group-rotate-handle.tsx +1 -2
  22. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  23. package/src/components/editor/index.tsx +24 -14
  24. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  25. package/src/components/editor/node-arrow-handles.tsx +1 -1
  26. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  27. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  28. package/src/components/editor/room-controls.tsx +7 -4
  29. package/src/components/editor/room-handles-2d.tsx +2 -3
  30. package/src/components/editor/room-handles.tsx +1 -2
  31. package/src/components/editor/room-highlight.tsx +6 -105
  32. package/src/components/editor/room-labels.test.ts +8 -822
  33. package/src/components/editor/room-labels.tsx +40 -1671
  34. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  35. package/src/components/editor/selection-manager.tsx +5 -3
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  38. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  39. package/src/components/editor/views/plugins-view.tsx +1 -1
  40. package/src/components/editor/views/view-bar.tsx +9 -9
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +1 -1
  43. package/src/components/editor/wall-pull-grip.tsx +2 -2
  44. package/src/components/editor-2d/floorplan-group-move.tsx +2 -3
  45. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1 -1
  46. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -1
  47. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +1 -2
  48. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  49. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  50. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  51. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +1 -2
  52. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +8 -10
  53. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +25 -33
  54. package/src/components/systems/roof/roof-edit-system.tsx +4 -3
  55. package/src/components/tools/distribution/distribution-run-tool.tsx +1 -1
  56. package/src/components/tools/item/placement-history.test.tsx +1 -1
  57. package/src/components/tools/item/use-placement-coordinator.tsx +2 -3
  58. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +6 -2
  59. package/src/components/tools/registry/move-registry-node-tool.tsx +1 -2
  60. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  61. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  62. package/src/components/tools/shared/placement-box.tsx +1 -1
  63. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  64. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  65. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  66. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  67. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  68. package/src/components/ui/action-menu/index.tsx +3 -3
  69. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  70. package/src/components/ui/action-menu/placement.ts +0 -18
  71. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  72. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  73. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  74. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  75. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  76. package/src/components/ui/controls/select-control.tsx +1 -1
  77. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  78. package/src/components/ui/floating-level-selector.tsx +3 -3
  79. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  80. package/src/components/ui/helpers/helper-manager.test.tsx +1 -1
  81. package/src/components/ui/helpers/helper-manager.tsx +1 -1
  82. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  83. package/src/components/ui/node-icon-image.test.tsx +46 -0
  84. package/src/components/ui/node-icon-image.tsx +33 -1
  85. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  86. package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
  87. package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
  88. package/src/components/ui/panels/node-display.ts +1 -1
  89. package/src/components/ui/panels/panel-manager.tsx +1 -1
  90. package/src/components/ui/panels/panel-wrapper.tsx +1 -1
  91. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  92. package/src/components/ui/panels/room-construction-rows.tsx +6 -6
  93. package/src/components/ui/panels/room-name-options.ts +1 -5
  94. package/src/components/ui/panels/room-panel.tsx +6 -8
  95. package/src/components/ui/primitives/color-dot.tsx +2 -2
  96. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  97. package/src/components/ui/primitives/sidebar.tsx +3 -3
  98. package/src/components/ui/right-stack.tsx +1 -1
  99. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  100. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  101. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  102. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  103. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  105. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
  106. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  107. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  108. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
  109. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  110. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  111. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  112. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  113. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  114. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  115. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  116. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  117. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  118. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  119. package/src/components/viewer-overlay.tsx +1 -2
  120. package/src/hooks/use-auto-frame.ts +1 -2
  121. package/src/hooks/use-keyboard.ts +1 -2
  122. package/src/hooks/use-selected-room.ts +5 -7
  123. package/src/i18n/floor-footprint-name.ts +1 -1
  124. package/src/i18n/locales/en/messages.po +180 -182
  125. package/src/i18n/locales/es/messages.po +180 -182
  126. package/src/i18n/locales/fr/messages.po +180 -182
  127. package/src/i18n/locales/ja/messages.po +180 -182
  128. package/src/i18n/locales/zh-CN/messages.po +180 -182
  129. package/src/i18n/locales.test.ts +1 -1
  130. package/src/index.tsx +109 -131
  131. package/src/lib/below-grade.test.tsx +4 -4
  132. package/src/lib/camera-facing.test.ts +1 -1
  133. package/src/lib/camera-facing.ts +2 -3
  134. package/src/lib/editor-level-display.ts +1 -1
  135. package/src/lib/floor-footprints.test.ts +2 -2
  136. package/src/lib/floor-opening-draft.ts +1 -1
  137. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  138. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  139. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  140. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  141. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  142. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  144. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  145. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  146. package/src/lib/floorplan/geometry.ts +2 -112
  147. package/src/lib/floorplan/index.ts +0 -9
  148. package/src/lib/floorplan/items.ts +2 -1
  149. package/src/lib/floorplan/schedules.ts +6 -3
  150. package/src/lib/floorplan/sheet-export.ts +7 -3
  151. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  152. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  153. package/src/lib/follow-pascal-presentation.ts +1 -1
  154. package/src/lib/glb-export.ts +1 -1
  155. package/src/lib/material-paint.ts +1 -1
  156. package/src/lib/measurements.test.ts +2 -1
  157. package/src/lib/mezzanine-selection.test.ts +1 -1
  158. package/src/lib/paint-region-mode.ts +1 -1
  159. package/src/lib/point-ask/session.ts +1 -2
  160. package/src/lib/roof-duplication.test.ts +100 -0
  161. package/src/lib/roof-duplication.ts +30 -5
  162. package/src/lib/room-handle-drag.ts +2 -12
  163. package/src/lib/room-name-catalog.test.ts +2 -1
  164. package/src/lib/room-name-messages.test.ts +2 -1
  165. package/src/lib/room-name-messages.ts +1 -118
  166. package/src/lib/room-name-storage.test.ts +2 -1
  167. package/src/lib/room-selection-commands.ts +2 -2
  168. package/src/lib/room-selection.test.ts +6 -6
  169. package/src/lib/room-transform-session.ts +1 -2
  170. package/src/lib/room-zone-routing.ts +2 -2
  171. package/src/lib/selection-routing.test.ts +0 -28
  172. package/src/lib/selection-routing.ts +1 -40
  173. package/src/lib/sfx-bus.ts +8 -32
  174. package/src/lib/sfx-player.test.ts +72 -1
  175. package/src/lib/sfx-player.ts +49 -337
  176. package/src/lib/viewer-selection.test.ts +38 -10
  177. package/src/lib/wall-pull.test.ts +1 -1
  178. package/src/lib/wall-pull.ts +1 -27
  179. package/src/store/use-editor.tsx +17 -26
  180. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  181. package/src/store/use-wall-region-handles-selection.ts +1 -2
  182. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  183. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  184. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  185. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  186. package/src/components/editor/first-person/ground-holes.ts +0 -130
  187. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  188. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  189. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  190. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  191. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  192. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  193. package/src/components/editor/localized-html.tsx +0 -22
  194. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  195. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  196. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  197. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  198. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  199. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  200. package/src/components/editor-2d/svg-paths.ts +0 -124
  201. package/src/components/ui/action-menu/action-button.tsx +0 -89
  202. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  203. package/src/components/ui/primitives/button.tsx +0 -69
  204. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  205. package/src/components/ui/primitives/popover.tsx +0 -42
  206. package/src/components/ui/primitives/switch.tsx +0 -29
  207. package/src/components/ui/primitives/tooltip.tsx +0 -57
  208. package/src/components/ui/scene-loader.tsx +0 -73
  209. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  210. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  211. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  212. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  213. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  214. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  215. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  216. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  217. package/src/components/viewer/render-settings-menu.tsx +0 -294
  218. package/src/components/viewer/sky-control.tsx +0 -710
  219. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  220. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  221. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  222. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  223. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  224. package/src/components/viewer/viewer-rooms.tsx +0 -248
  225. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  226. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  227. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  228. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  229. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  230. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  231. package/src/components/viewer/viewer-stage.tsx +0 -221
  232. package/src/components/walkthrough-hud.tsx +0 -193
  233. package/src/hooks/use-mobile.ts +0 -19
  234. package/src/i18n/level-name.ts +0 -39
  235. package/src/i18n/scene-theme-name.ts +0 -16
  236. package/src/lib/below-grade.ts +0 -133
  237. package/src/lib/camera-pose.test.ts +0 -237
  238. package/src/lib/camera-pose.ts +0 -172
  239. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  240. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  241. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  242. package/src/lib/floorplan/marks.test.ts +0 -213
  243. package/src/lib/floorplan/marks.ts +0 -238
  244. package/src/lib/measurements.ts +0 -8
  245. package/src/lib/node-display-label.ts +0 -37
  246. package/src/lib/room-assembly-overlay.test.ts +0 -275
  247. package/src/lib/room-assembly-overlay.ts +0 -265
  248. package/src/lib/room-selection.ts +0 -417
  249. package/src/lib/scene-bounds.test.ts +0 -327
  250. package/src/lib/scene-bounds.ts +0 -233
  251. package/src/lib/typing-target.ts +0 -9
  252. package/src/lib/viewer-selection.ts +0 -595
  253. package/src/lib/zone-content.ts +0 -133
  254. package/src/store/camera-pose-store.test.ts +0 -29
  255. package/src/store/camera-pose-store.ts +0 -27
  256. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  257. package/src/store/navigation-sync-pose-store.ts +0 -27
  258. package/src/store/use-audio.test.ts +0 -146
  259. package/src/store/use-audio.tsx +0 -169
  260. package/src/store/use-first-person-hud.ts +0 -40
  261. package/src/store/use-follow-pascal.test.ts +0 -62
  262. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,248 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- type AnyNode,
5
- buildUnitReport,
6
- polygonInteriorPoint,
7
- roomFloorElevation,
8
- sceneRegistry,
9
- useLocale,
10
- usePascalI18n,
11
- useScene,
12
- type ZoneNode,
13
- } from '@pascal-app/core'
14
- import { SelectionCallout, useViewer } from '@pascal-app/viewer'
15
- import { useCallback, useEffect, useMemo } from 'react'
16
- import { Vector3 } from 'three'
17
- import { useShallow } from 'zustand/react/shallow'
18
- import { resolveRoomAssemblyHeights } from '../../lib/room-assembly-overlay'
19
- import { describeZoneOutline, type RoomSelectionRecord } from '../../lib/room-selection'
20
- import {
21
- selectViewerRoom,
22
- type ViewerSource,
23
- viewerRooms,
24
- zoneLabel,
25
- } from '../../lib/viewer-selection'
26
- import { RoomAssemblyMesh } from '../editor/room-highlight'
27
- import {
28
- formatRoomArea,
29
- RoomLabelAnchor3D,
30
- type RoomLabelEntry,
31
- RoomPillChip,
32
- RoomPillDetail,
33
- RoomPillsLayer3D,
34
- roomLabelEntries,
35
- roomLabelsVisible,
36
- useFadePresence,
37
- } from '../editor/room-labels'
38
-
39
- /**
40
- * Rooms on every viewer surface, as the editor shows them: the editor's pills
41
- * on the current floor while nothing but the floor is selected, and the
42
- * editor's room highlight on the selected or hovered room (or a focused
43
- * unit's rooms). Zones draw no tinted volume here, and their meshes are not
44
- * mounted at all, so they never catch a click.
45
- */
46
- export function ViewerRooms({ source }: { source: ViewerSource }) {
47
- const i18n = usePascalI18n()
48
- useEffect(() => {
49
- const before = useViewer.getState().showZones
50
- useViewer.getState().setShowZones(false)
51
- return () => useViewer.getState().setShowZones(before)
52
- }, [])
53
- const { levelId, zoneId, selectedIds } = useViewer(
54
- useShallow((s) => ({
55
- levelId: s.selection.levelId,
56
- zoneId: s.selection.zoneId,
57
- selectedIds: s.selection.selectedIds,
58
- })),
59
- )
60
- const focusedUnitId = useViewer((s) => s.focusedUnitId)
61
- const hoveredId = useViewer((s) => s.hoveredId)
62
- const walkthrough = useViewer((s) => s.walkthroughMode)
63
- const { nodes } = source
64
-
65
- const pillsShown =
66
- !!levelId &&
67
- !walkthrough &&
68
- roomLabelsVisible({
69
- phase: 'building',
70
- mode: 'select',
71
- scopeIdle: true,
72
- room: null,
73
- zoneId,
74
- focusedUnitId,
75
- isCaptureMode: false,
76
- isThumbnailCapture: false,
77
- selectedTypes: selectedIds.map((id) => nodes[id]?.type),
78
- })
79
-
80
- // The layer stays mounted through its fade-out, like the editor's.
81
- const pillsPresent = useFadePresence(pillsShown)
82
-
83
- const rooms = useMemo(() => (levelId ? viewerRooms(levelId, nodes) : []), [levelId, nodes])
84
- const entries = useMemo(() => {
85
- if (!levelId) return []
86
- const zones = Object.values(nodes).filter(
87
- (node): node is ZoneNode =>
88
- node.type === 'zone' && node.parentId === levelId && node.visible !== false,
89
- )
90
- return roomLabelEntries(
91
- rooms.filter((room) => nodes[room.zoneId]?.visible !== false),
92
- zones,
93
- )
94
- }, [levelId, nodes, rooms])
95
-
96
- const highlighted = useMemo(() => {
97
- if (walkthrough) return []
98
- const ids = new Set<string>()
99
- if (zoneId) ids.add(zoneId)
100
- if (hoveredId && nodes[hoveredId]?.type === 'zone') ids.add(hoveredId)
101
- const unit = focusedUnitId ? nodes[focusedUnitId] : undefined
102
- if (unit?.type === 'unit') {
103
- for (const member of buildUnitReport(unit, nodes).members)
104
- if (member.levelId === levelId) ids.add(member.zoneId)
105
- }
106
- return [...ids].flatMap((id): RoomSelectionRecord[] => {
107
- const zone = nodes[id]
108
- if (zone?.type !== 'zone' || !zone.parentId) return []
109
- const detected = viewerRooms(zone.parentId, nodes).find((room) => room.zoneId === id)
110
- return [detected ?? describeZoneOutline(zone.parentId, zone)]
111
- })
112
- }, [focusedUnitId, hoveredId, levelId, nodes, walkthrough, zoneId])
113
-
114
- return (
115
- <>
116
- {pillsPresent && levelId ? (
117
- <RoomPillsLayer3D
118
- entries={entries}
119
- // A level of its own: nothing open or placed on one floor carries to the next.
120
- key={levelId}
121
- levelObject={() => source.objectFor(levelId)}
122
- renderDetail={(entry) => {
123
- const zone = nodes[entry.zoneId]
124
- if (zone?.type !== 'zone') return null
125
- return (
126
- <RoomPillDetail
127
- areaM2={entry.area}
128
- color={zone.color}
129
- name={zoneLabel(zone, i18n)}
130
- view="3d"
131
- zoneId={zone.id}
132
- />
133
- )
134
- }}
135
- renderLabel={(entry, anchors, elements) => {
136
- const zone = nodes[entry.zoneId]
137
- if (zone?.type !== 'zone') return null
138
- return (
139
- <RoomLabelAnchor3D
140
- anchors={anchors}
141
- entry={entry}
142
- floorY={roomFloorElevation(nodes as Record<string, AnyNode>, entry.zoneId)}
143
- >
144
- <RoomPillChip
145
- areaM2={entry.area}
146
- color={zone.color}
147
- elements={elements}
148
- name={zoneLabel(zone, i18n)}
149
- onClick={() => selectViewerRoom(zone.id, source)}
150
- onHover={() => useViewer.getState().setHoveredId(zone.id)}
151
- onLeave={() => {
152
- if (useViewer.getState().hoveredId === zone.id)
153
- useViewer.getState().setHoveredId(null)
154
- }}
155
- view="3d"
156
- zoneId={zone.id}
157
- />
158
- </RoomLabelAnchor3D>
159
- )
160
- }}
161
- shown={pillsShown}
162
- />
163
- ) : null}
164
- {highlighted.map((room) => (
165
- <ViewerRoomHighlight key={room.zoneId} room={room} source={source} />
166
- ))}
167
- {zoneId && levelId && !walkthrough && selectedIds.length === 0
168
- ? (() => {
169
- const entry = entries.find((candidate) => candidate.zoneId === zoneId)
170
- const zone = nodes[zoneId]
171
- const level = source.objectFor(levelId)
172
- return entry && zone?.type === 'zone' && level ? (
173
- <ViewerRoomCallout
174
- entry={entry}
175
- floorY={roomFloorElevation(nodes as Record<string, AnyNode>, zoneId)}
176
- key={zoneId}
177
- level={level}
178
- name={zoneLabel(zone, i18n)}
179
- />
180
- ) : null
181
- })()
182
- : null}
183
- </>
184
- )
185
- }
186
-
187
- /** The selected room's callout: its name and area at a point inside it, and the close button. */
188
- function ViewerRoomCallout({
189
- entry,
190
- floorY,
191
- level,
192
- name,
193
- }: {
194
- entry: RoomLabelEntry
195
- floorY: number
196
- level: NonNullable<ReturnType<ViewerSource['objectFor']>>
197
- name: string
198
- }) {
199
- const unit = useViewer((s) => s.unit)
200
- const locale = useLocale()
201
- const anchor = useMemo(() => {
202
- const [x, z] = polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes })
203
- return new Vector3(x, floorY + 0.05, z)
204
- }, [entry.polygon, entry.holes, floorY])
205
- const dismiss = useCallback(() => {
206
- const { selection, setSelection } = useViewer.getState()
207
- if (selection.zoneId === entry.zoneId) setSelection({ zoneId: null })
208
- }, [entry.zoneId])
209
- return (
210
- <SelectionCallout
211
- actions={[]}
212
- anchor={anchor}
213
- object={level}
214
- onDismiss={dismiss}
215
- subtitle={formatRoomArea(entry.area, unit, locale)}
216
- title={name}
217
- />
218
- )
219
- }
220
-
221
- const parametricObject = (id: string) => sceneRegistry.nodes.get(id)
222
-
223
- /** `ViewerRooms` over the parametric scene store. */
224
- export function ParametricViewerRooms() {
225
- const nodes = useScene((s) => s.nodes)
226
- const source = useMemo<ViewerSource>(() => ({ nodes, objectFor: parametricObject }), [nodes])
227
- return <ViewerRooms source={source} />
228
- }
229
-
230
- function ViewerRoomHighlight({
231
- room,
232
- source,
233
- }: {
234
- room: RoomSelectionRecord
235
- source: ViewerSource
236
- }) {
237
- const heights = useMemo(
238
- () => resolveRoomAssemblyHeights(room, source.nodes as Record<string, AnyNode>),
239
- [room, source.nodes],
240
- )
241
- return (
242
- <RoomAssemblyMesh
243
- heights={heights}
244
- levelObject={() => source.objectFor(room.key.levelId)}
245
- room={room}
246
- />
247
- )
248
- }
@@ -1,286 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- type LevelNode,
5
- type PascalI18n,
6
- sceneRegistry,
7
- useLocale,
8
- usePascalI18n,
9
- useScene,
10
- } from '@pascal-app/core'
11
- import { markPerfAction, useViewer } from '@pascal-app/viewer'
12
- import { ArrowLeft, ChevronRight, Layers } from 'lucide-react'
13
- import Link from 'next/link'
14
- import { type ReactNode, useMemo } from 'react'
15
- import { localizedLevelName } from '../../i18n/level-name'
16
- import { cn } from '../../lib/utils'
17
- import {
18
- applyViewerClick,
19
- frameViewerNode,
20
- roomOfObject,
21
- selectViewerBuilding,
22
- selectViewerLevel,
23
- selectViewerRoom,
24
- selectViewerUnit,
25
- type ViewerCrumb,
26
- type ViewerSidebarSection,
27
- type ViewerSource,
28
- viewerBreadcrumb,
29
- viewerSidebarSections,
30
- } from '../../lib/viewer-selection'
31
- import { formatRoomArea } from '../editor/room-labels'
32
-
33
- export type ViewerSceneHeaderProps = {
34
- projectName?: string | null
35
- owner?: { username?: string | null } | null
36
- onBack?: () => void
37
- /** Fallback destination when no `onBack` handler is supplied. Must already be
38
- * sanitized by the caller. `null` shows no back arrow (an embedded viewer with
39
- * nowhere to go back to). */
40
- backHref?: string | null
41
- /** Extra row under the project info (e.g. like and duplicate actions). */
42
- stats?: ReactNode
43
- /** The scene the header navigates; the parametric scene store by default. */
44
- source?: ViewerSource
45
- }
46
-
47
- function sectionTitle(kind: ViewerSidebarSection['kind'], i18n: PascalI18n): string {
48
- if (kind === 'units') return i18n._({ id: 'viewer.header.units', message: 'Units' })
49
- if (kind === 'rooms') return i18n._({ id: 'viewer.header.rooms', message: 'Rooms' })
50
- return i18n._({ id: 'viewer.header.zones', message: 'Zones' })
51
- }
52
-
53
- const parametricObject = (id: string) => sceneRegistry.nodes.get(id)
54
-
55
- /**
56
- * The navigation of every viewer surface: the project card, the bar (building
57
- * › floor › room › element) and the floor list with, under the current floor,
58
- * its units, rooms and zones. A row or a crumb selects and frames; the canvas
59
- * selects in place (`lib/viewer-selection.ts`).
60
- */
61
- export const ViewerSceneHeader = ({
62
- projectName,
63
- owner,
64
- onBack,
65
- backHref = '/',
66
- stats,
67
- source: sourceProp,
68
- }: ViewerSceneHeaderProps) => {
69
- const i18n = usePascalI18n()
70
- const catalogVersion = i18n.getVersion()
71
- const locale = useLocale()
72
- const sceneNodes = useScene((s) => s.nodes)
73
- const source = useMemo<ViewerSource>(
74
- () => sourceProp ?? { nodes: sceneNodes, objectFor: parametricObject },
75
- [sourceProp, sceneNodes],
76
- )
77
- const { nodes } = source
78
- const selection = useViewer((s) => s.selection)
79
- const focusedUnitId = useViewer((s) => s.focusedUnitId)
80
- const unit = useViewer((s) => s.unit)
81
-
82
- const crumbs = useMemo(
83
- () =>
84
- viewerBreadcrumb(selection, focusedUnitId, nodes, i18n, (nodeId, levelId) =>
85
- roomOfObject(nodeId, levelId, source),
86
- ),
87
- [selection, focusedUnitId, nodes, source, i18n, locale, catalogVersion],
88
- )
89
- const buildingId = crumbs.find((crumb) => crumb.kind === 'building')?.id ?? null
90
- // Highest first so the list reads top-down like a building section.
91
- const levels = useMemo(() => {
92
- const building = buildingId ? nodes[buildingId] : undefined
93
- if (building?.type !== 'building') return []
94
- return building.children
95
- .map((id) => nodes[id])
96
- .filter((node): node is LevelNode => node?.type === 'level')
97
- .sort((a, b) => b.level - a.level)
98
- }, [buildingId, nodes])
99
- const sections = useMemo(
100
- () => (selection.levelId ? viewerSidebarSections(selection.levelId, nodes, i18n) : []),
101
- [selection.levelId, nodes, i18n, locale, catalogVersion],
102
- )
103
-
104
- const handleCrumb = (crumb: ViewerCrumb) => {
105
- switch (crumb.kind) {
106
- case 'building':
107
- return selectViewerBuilding(source)
108
- case 'level':
109
- return selectViewerLevel(crumb.id, source)
110
- case 'unit':
111
- return selectViewerUnit(crumb.id, source)
112
- case 'room':
113
- return selectViewerRoom(crumb.id, source)
114
- case 'node':
115
- applyViewerClick({ nodeId: crumb.id, point: null }, nodes)
116
- return frameViewerNode(crumb.id, source)
117
- }
118
- }
119
-
120
- return (
121
- <div className="dark absolute top-4 left-4 z-20 flex max-h-[calc(100%-7rem)] flex-col gap-3 text-foreground">
122
- <div className="corner-smooth pointer-events-auto flex min-w-[200px] max-w-[min(28rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out">
123
- {/* Project info + back */}
124
- <div className="flex items-center gap-3 px-3 py-2.5">
125
- {onBack ? (
126
- <button
127
- aria-label={i18n._({ id: 'viewer.header.back', message: 'Back' })}
128
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
129
- onClick={onBack}
130
- type="button"
131
- >
132
- <ArrowLeft className="h-4 w-4 text-muted-foreground" />
133
- </button>
134
- ) : backHref === null ? null : (
135
- <Link
136
- aria-label={i18n._({ id: 'viewer.header.back', message: 'Back' })}
137
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-white/10"
138
- href={backHref}
139
- prefetch={false}
140
- >
141
- <ArrowLeft className="h-4 w-4 text-muted-foreground" />
142
- </Link>
143
- )}
144
- <div className="min-w-0 flex-1">
145
- <div className="truncate font-medium text-foreground text-sm">
146
- {projectName || i18n._({ id: 'viewer.header.untitled', message: 'Untitled' })}
147
- </div>
148
- {owner?.username && (
149
- <Link
150
- className="text-muted-foreground text-xs transition-colors hover:text-foreground"
151
- href={`/u/${owner.username}`}
152
- >
153
- @{owner.username}
154
- </Link>
155
- )}
156
- </div>
157
- </div>
158
-
159
- {stats && (
160
- <div className="flex items-center gap-1 border-border/40 border-t px-3 py-2">{stats}</div>
161
- )}
162
-
163
- {crumbs.length > 0 && (
164
- <nav
165
- aria-label={i18n._({ id: 'viewer.header.selection', message: 'Selection' })}
166
- className="flex flex-wrap items-center gap-x-1.5 gap-y-1 border-border/40 border-t px-3 py-2 text-xs"
167
- data-testid="viewer-breadcrumb"
168
- >
169
- {crumbs.map((crumb, index) => {
170
- const isLast = index === crumbs.length - 1
171
- return (
172
- <span
173
- className="flex min-w-0 items-center gap-1.5"
174
- key={`${crumb.kind}:${crumb.id}`}
175
- >
176
- {index > 0 && <ChevronRight className="h-3 w-3 text-muted-foreground/50" />}
177
- <button
178
- className={cn(
179
- 'truncate transition-colors',
180
- isLast
181
- ? 'font-medium text-foreground'
182
- : 'text-muted-foreground hover:text-foreground',
183
- )}
184
- data-crumb={crumb.kind}
185
- onClick={() => handleCrumb(crumb)}
186
- type="button"
187
- >
188
- {crumb.label}
189
- </button>
190
- </span>
191
- )
192
- })}
193
- </nav>
194
- )}
195
- </div>
196
-
197
- {levels.length > 0 && (
198
- <div className="corner-smooth pointer-events-auto flex min-h-0 w-56 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out">
199
- <span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
200
- {i18n._({ id: 'viewer.header.levels', message: 'Levels' })}
201
- </span>
202
- <div className="flex min-h-0 flex-col overflow-y-auto">
203
- {levels.map((lvl) => {
204
- const isSelected = lvl.id === selection.levelId
205
- return (
206
- <div key={lvl.id}>
207
- <button
208
- className={cn(
209
- 'group/row relative flex h-8 w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 text-sm transition-all duration-200',
210
- isSelected
211
- ? 'border-r-3 border-r-white bg-accent/50 text-foreground'
212
- : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
213
- )}
214
- onClick={() => {
215
- if (!isSelected) markPerfAction('level-switch', lvl.id)
216
- selectViewerLevel(lvl.id, source)
217
- }}
218
- type="button"
219
- >
220
- <div className="flex min-w-0 flex-1 items-center gap-2">
221
- <span
222
- className={cn(
223
- 'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
224
- !isSelected && 'opacity-60 grayscale',
225
- )}
226
- >
227
- <Layers className="h-3.5 w-3.5" />
228
- </span>
229
- <div className="min-w-0 flex-1 truncate text-left">
230
- {localizedLevelName(lvl, i18n, locale)}
231
- </div>
232
- </div>
233
- </button>
234
- {isSelected &&
235
- sections.map((section) => (
236
- <div
237
- className="border-border/50 border-b pb-1"
238
- data-viewer-section={section.kind}
239
- key={section.kind}
240
- >
241
- <span className="block px-3 pt-2 pb-1 pl-9 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
242
- {sectionTitle(section.kind, i18n)}
243
- </span>
244
- {section.rows.map((row) => {
245
- const active =
246
- section.kind === 'units'
247
- ? row.id === focusedUnitId
248
- : row.id === selection.zoneId
249
- return (
250
- <button
251
- className={cn(
252
- 'flex h-7 w-full cursor-pointer select-none items-center gap-2 pr-3 pl-9 text-left text-sm transition-colors',
253
- active
254
- ? 'bg-accent/50 text-foreground'
255
- : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
256
- )}
257
- key={row.id}
258
- onClick={() =>
259
- section.kind === 'units'
260
- ? selectViewerUnit(row.id, source)
261
- : selectViewerRoom(row.id, source)
262
- }
263
- type="button"
264
- >
265
- <span
266
- className="h-2 w-2 shrink-0 rounded-full"
267
- style={{ backgroundColor: row.color }}
268
- />
269
- <span className="min-w-0 flex-1 truncate">{row.label}</span>
270
- <span className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
271
- {formatRoomArea(row.areaM2, unit, locale)}
272
- </span>
273
- </button>
274
- )
275
- })}
276
- </div>
277
- ))}
278
- </div>
279
- )
280
- })}
281
- </div>
282
- </div>
283
- )}
284
- </div>
285
- )
286
- }
@@ -1,124 +0,0 @@
1
- 'use client'
2
-
3
- import { type AnyNodeId, emitter, type NodeEvent, sceneRegistry, useScene } from '@pascal-app/core'
4
- import { useViewer } from '@pascal-app/viewer'
5
- import { useThree } from '@react-three/fiber'
6
- import { useEffect } from 'react'
7
- import { selectionModifiersFromEvent } from '../../lib/selection-routing'
8
- import {
9
- applyViewerClick,
10
- applyViewerHover,
11
- viewerPickFromNodeEvent,
12
- } from '../../lib/viewer-selection'
13
-
14
- /**
15
- * Selection for a parametric viewer surface (the published viewer's fallback,
16
- * its embed, the editor's Preview): free-form and room-first, through the same
17
- * rules as the baked viewer. Mount it with `<Viewer selectionManager="custom">`.
18
- */
19
- export function ViewerSelectionManager() {
20
- const gl = useThree((state) => state.gl)
21
-
22
- useEffect(() => {
23
- let clickHandled = false
24
- let hoveredFrom: string | null = null
25
- const pendingClicks = new Set<number>()
26
-
27
- const onMove = (event: NodeEvent) => {
28
- if (useViewer.getState().walkthroughMode) return
29
- const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
30
- if (!pick) return
31
- event.stopPropagation()
32
- hoveredFrom = event.node.id
33
- applyViewerHover(pick, useScene.getState().nodes)
34
- }
35
- const onLeave = (event: NodeEvent) => {
36
- if (event.node.id !== hoveredFrom) return
37
- hoveredFrom = null
38
- useViewer.getState().setHoveredId(null)
39
- }
40
- const onClick = (event: NodeEvent) => {
41
- if (useViewer.getState().walkthroughMode) return
42
- const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
43
- if (!pick) return
44
- event.stopPropagation()
45
- clickHandled = true
46
- applyViewerClick(
47
- pick,
48
- useScene.getState().nodes,
49
- selectionModifiersFromEvent(event.nativeEvent),
50
- )
51
- }
52
- // A click on nothing selectable clears the room and the element.
53
- const onCanvasClick = (event: MouseEvent) => {
54
- const viewer = useViewer.getState()
55
- if (event.button !== 0 || viewer.walkthroughMode) return
56
- if (viewer.cameraDragging || viewer.inputDragging) return
57
- const frame = requestAnimationFrame(() => {
58
- pendingClicks.delete(frame)
59
- if (useViewer.getState().walkthroughMode) return
60
- if (clickHandled) {
61
- clickHandled = false
62
- return
63
- }
64
- applyViewerClick(null, useScene.getState().nodes)
65
- })
66
- pendingClicks.add(frame)
67
- }
68
-
69
- emitter.on('node:move', onMove)
70
- emitter.on('node:enter', onMove)
71
- emitter.on('node:leave', onLeave)
72
- emitter.on('node:click', onClick)
73
- const canvas = gl.domElement
74
- canvas.addEventListener('click', onCanvasClick)
75
- return () => {
76
- emitter.off('node:move', onMove)
77
- emitter.off('node:enter', onMove)
78
- emitter.off('node:leave', onLeave)
79
- emitter.off('node:click', onClick)
80
- canvas.removeEventListener('click', onCanvasClick)
81
- for (const frame of pendingClicks) cancelAnimationFrame(frame)
82
- useViewer.getState().setHoveredId(null)
83
- }
84
- }, [gl])
85
-
86
- return <ViewerOutlineSync />
87
- }
88
-
89
- /** The selected elements and the hovered one, outlined (rooms highlight instead). */
90
- function ViewerOutlineSync() {
91
- const selectedIds = useViewer((s) => s.selection.selectedIds)
92
- const externalSelectedIds = useViewer((s) => s.externalSelectedIds)
93
- const hoveredId = useViewer((s) => s.hoveredId)
94
- const outliner = useViewer((s) => s.outliner)
95
- const geometryRevision = useViewer((s) => s.geometryRevision)
96
- const walkthrough = useViewer((s) => s.walkthroughMode)
97
- const nodes = useScene((s) => s.nodes)
98
-
99
- useEffect(() => {
100
- void geometryRevision
101
- outliner.selectedObjects.length = 0
102
- outliner.hoveredObjects.length = 0
103
- if (walkthrough) return
104
- for (const id of new Set([...selectedIds, ...externalSelectedIds])) {
105
- const object = sceneRegistry.nodes.get(id)
106
- if (object) outliner.selectedObjects.push(object)
107
- }
108
- const hovered = hoveredId ? nodes[hoveredId as AnyNodeId] : undefined
109
- if (hovered && hovered.type !== 'zone') {
110
- const object = sceneRegistry.nodes.get(hovered.id)
111
- if (object) outliner.hoveredObjects.push(object)
112
- }
113
- }, [selectedIds, externalSelectedIds, hoveredId, outliner, nodes, geometryRevision, walkthrough])
114
-
115
- useEffect(
116
- () => () => {
117
- outliner.selectedObjects.length = 0
118
- outliner.hoveredObjects.length = 0
119
- },
120
- [outliner],
121
- )
122
-
123
- return null
124
- }
@@ -1,38 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import {
3
- normalizeViewerStageModes,
4
- resolveMobileViewerStageMode,
5
- resolveViewerStageMode,
6
- viewerStageIncludes3D,
7
- } from './viewer-stage-modes'
8
-
9
- describe('viewer stage modes', () => {
10
- test('supports every ordered combination without duplicates', () => {
11
- expect(normalizeViewerStageModes(['split', '3d', 'split'])).toEqual(['3d', 'split'])
12
- expect(normalizeViewerStageModes(['2d'])).toEqual(['2d'])
13
- expect(normalizeViewerStageModes([])).toEqual(['3d'])
14
- })
15
-
16
- test('reuses normalized combinations across inline prop arrays', () => {
17
- expect(normalizeViewerStageModes(['split', '3d'])).toBe(
18
- normalizeViewerStageModes(['3d', 'split']),
19
- )
20
- })
21
-
22
- test('falls back to the first enabled mode', () => {
23
- expect(resolveViewerStageMode('split', ['3d', '2d'])).toBe('3d')
24
- expect(resolveViewerStageMode(undefined, ['2d', 'split'])).toBe('2d')
25
- })
26
-
27
- test('uses an enabled single-pane mode on mobile but preserves split-only embeds', () => {
28
- expect(resolveMobileViewerStageMode('split', ['3d', 'split'])).toBe('3d')
29
- expect(resolveMobileViewerStageMode('split', ['2d', 'split'])).toBe('2d')
30
- expect(resolveMobileViewerStageMode('split', ['split'])).toBe('split')
31
- })
32
-
33
- test('does not require a GPU canvas for a 2D-only embed', () => {
34
- expect(viewerStageIncludes3D(['2d'])).toBe(false)
35
- expect(viewerStageIncludes3D(['split'])).toBe(true)
36
- expect(viewerStageIncludes3D(['3d', '2d'])).toBe(true)
37
- })
38
- })