@pascal-app/editor 0.9.1 → 0.9.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 +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -2,6 +2,7 @@ import { type AnyNodeId, type SolarPanelNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
4
  import { memo, useCallback, useState } from 'react'
5
+ import { SnapTargetIcon } from '../../../snap-target-badge'
5
6
  import useEditor from './../../../../../store/use-editor'
6
7
  import { InlineRenameInput } from './inline-rename-input'
7
8
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,13 +52,15 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
51
52
  expanded={false}
52
53
  hasChildren={false}
53
54
  icon={
54
- <Image
55
- alt=""
56
- className="object-contain opacity-60"
57
- height={14}
58
- src="/icons/roof.png"
59
- width={14}
60
- />
55
+ <SnapTargetIcon target="roof">
56
+ <Image
57
+ alt=""
58
+ className="object-contain opacity-60"
59
+ height={14}
60
+ src="/icons/roof.webp"
61
+ width={14}
62
+ />
63
+ </SnapTargetIcon>
61
64
  }
62
65
  isHovered={isHovered}
63
66
  isLast={isLast}
@@ -54,7 +54,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
54
54
  alt=""
55
55
  className="object-contain"
56
56
  height={14}
57
- src="/icons/spawn-point.png"
57
+ src="/icons/spawn-point.webp"
58
58
  width={14}
59
59
  />
60
60
  }
@@ -98,7 +98,7 @@ export const StairTreeNode = memo(function StairTreeNode({
98
98
  expanded={expanded}
99
99
  hasChildren={segments.length > 0}
100
100
  icon={
101
- <Image alt="" className="object-contain" height={14} src="/icons/stairs.png" width={14} />
101
+ <Image alt="" className="object-contain" height={14} src="/icons/stairs.webp" width={14} />
102
102
  }
103
103
  isDropTarget={isValidDropTarget && isDropTarget}
104
104
  isHovered={isHovered || isDropTarget}
@@ -206,7 +206,7 @@ function StairSegmentTreeNode({
206
206
  alt=""
207
207
  className="object-contain opacity-60"
208
208
  height={14}
209
- src="/icons/stairs.png"
209
+ src="/icons/stairs.webp"
210
210
  width={14}
211
211
  />
212
212
  }
@@ -1,7 +1,10 @@
1
- import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
1
+ import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
2
3
  import { ChevronRight } from 'lucide-react'
3
4
  import { AnimatePresence, motion } from 'motion/react'
4
5
  import { forwardRef, memo, useEffect, useRef } from 'react'
6
+ import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
7
+ import useEditor from '../../../../../store/use-editor'
5
8
 
6
9
  export function handleTreeSelection(
7
10
  e: React.MouseEvent,
@@ -53,6 +56,31 @@ export function focusTreeNode(nodeId: AnyNodeId) {
53
56
  emitter.emit('camera-controls:focus', { nodeId })
54
57
  }
55
58
 
59
+ export function routeTreeSelectionToNode(node: AnyNode | null | undefined) {
60
+ const target = node ? resolveNodeSelectionTarget(node) : null
61
+ if (!target) return
62
+
63
+ const selectedIdsAfterClick = useViewer.getState().selection.selectedIds
64
+ const editor = useEditor.getState()
65
+ let didRoute = false
66
+
67
+ if (target.phase !== editor.phase) {
68
+ editor.setPhase(target.phase)
69
+ didRoute = true
70
+ }
71
+ if (
72
+ target.phase === 'structure' &&
73
+ target.structureLayer &&
74
+ target.structureLayer !== useEditor.getState().structureLayer
75
+ ) {
76
+ useEditor.getState().setStructureLayer(target.structureLayer)
77
+ didRoute = true
78
+ }
79
+ if (didRoute) {
80
+ useViewer.getState().setSelection({ selectedIds: selectedIdsAfterClick })
81
+ }
82
+ }
83
+
56
84
  import { cn } from '../../../../../lib/utils'
57
85
  import { BuildingTreeNode } from './building-tree-node'
58
86
  import { CeilingTreeNode } from './ceiling-tree-node'
@@ -98,10 +126,13 @@ const treeNodeByType: Record<
98
126
  isLast?: boolean
99
127
  nodeId: AnyNodeId
100
128
  }>,
129
+ cabinet: RegistryTreeNode,
130
+ 'cabinet-module': RegistryTreeNode,
101
131
  'box-vent': RegistryTreeNode,
102
132
  ceiling: CeilingTreeNode,
103
133
  chimney: ChimneyTreeNode,
104
134
  dormer: DormerTreeNode,
135
+ downspout: RegistryTreeNode,
105
136
  'solar-panel': SolarPanelTreeNode,
106
137
  column: ColumnTreeNode,
107
138
  elevator: ElevatorTreeNode,
@@ -124,10 +155,12 @@ const treeNodeByType: Record<
124
155
  wall: WallTreeNode,
125
156
  fence: FenceTreeNode,
126
157
  gutter: GutterTreeNode,
158
+ measurement: RegistryTreeNode,
127
159
  'ridge-vent': RegistryTreeNode,
128
160
  'turbine-vent': RegistryTreeNode,
129
161
  cupola: RegistryTreeNode,
130
162
  'eyebrow-vent': RegistryTreeNode,
163
+ skylight: RegistryTreeNode,
131
164
  roof: RoofTreeNode,
132
165
  stair: StairTreeNode,
133
166
  door: DoorTreeNode,
@@ -141,7 +174,15 @@ const treeNodeByType: Record<
141
174
  }
142
175
 
143
176
  export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
177
+ // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean
178
+ // selector so unrelated scene updates don't re-render every row.
179
+ const shouldHide = useScene((state) => {
180
+ const node = state.nodes[nodeId]
181
+ if (!node) return false
182
+ return nodeRegistry.get(node.type)?.tree?.hidden?.(node, state.nodes) ?? false
183
+ })
144
184
  const nodeType = useScene((state) => state.nodes[nodeId]?.type)
185
+ if (shouldHide) return null
145
186
  if (!nodeType) return null
146
187
  const Component = treeNodeByType[nodeType]
147
188
  if (!Component) return null
@@ -266,10 +307,10 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
266
307
  </motion.div>
267
308
  ) : null}
268
309
  </button>
269
- <div className="flex min-w-0 flex-1 items-center gap-1.5">
310
+ <div className="flex min-w-0 flex-1 items-center gap-2">
270
311
  <span
271
312
  className={cn(
272
- 'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
313
+ 'flex h-5 w-5 shrink-0 items-center justify-center transition-all duration-200',
273
314
  !isSelected && 'opacity-60 grayscale',
274
315
  )}
275
316
  >
@@ -0,0 +1,36 @@
1
+ import { type AnyNode, type AnyNodeId, nodeRegistry } from '@pascal-app/core'
2
+
3
+ /**
4
+ * Child ids the sidebar tree renders under a node: the kind's
5
+ * `def.tree.childIds` override when declared, otherwise the node's own
6
+ * `children`. Kind-agnostic — kinds that reshape their subtree (hidden
7
+ * derived nodes, flattened containers) do so via the registry hook.
8
+ */
9
+ export function resolveTreeChildIds(
10
+ nodeId: AnyNodeId,
11
+ nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>,
12
+ ): AnyNodeId[] {
13
+ const node = nodes[nodeId]
14
+ if (!node) return []
15
+ const childIds = nodeRegistry.get(node.type)?.tree?.childIds
16
+ if (childIds) return childIds(node, nodes)
17
+ const children = (node as { children?: unknown }).children
18
+ return Array.isArray(children) ? (children as AnyNodeId[]) : []
19
+ }
20
+
21
+ /**
22
+ * Whether `targetId` appears anywhere under `nodeId` in the *rendered*
23
+ * sidebar tree (i.e. walking registry-overridden child ids, not the raw
24
+ * scene graph). Drives auto-expansion when a descendant gets selected.
25
+ */
26
+ export function treeContainsDescendant(
27
+ nodeId: AnyNodeId,
28
+ targetId: AnyNodeId,
29
+ nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>,
30
+ ): boolean {
31
+ for (const childId of resolveTreeChildIds(nodeId, nodes)) {
32
+ if (childId === targetId) return true
33
+ if (treeContainsDescendant(childId, targetId, nodes)) return true
34
+ }
35
+ return false
36
+ }
@@ -79,7 +79,7 @@ export const WallTreeNode = memo(function WallTreeNode({
79
79
  expanded={expanded}
80
80
  hasChildren={children.length > 0}
81
81
  icon={
82
- <Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
82
+ <Image alt="" className="object-contain" height={14} src="/icons/wall.webp" width={14} />
83
83
  }
84
84
  isHovered={isHovered}
85
85
  isLast={isLast}
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, useScene } from '@pascal-app/core'
3
+ import { type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import Image from 'next/image'
6
6
  import { memo, useCallback, useState } from 'react'
7
+ import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
7
8
  import useEditor from './../../../../../store/use-editor'
8
9
  import { InlineRenameInput } from './inline-rename-input'
9
10
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -22,6 +23,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
22
23
  }: WindowTreeNodeProps) {
23
24
  const [isEditing, setIsEditing] = useState(false)
24
25
  const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
26
+ const node = useScene((s) => s.nodes[nodeId] as WindowNode | undefined)
25
27
  const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
26
28
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
27
29
  const setSelection = useViewer((state) => state.setSelection)
@@ -45,6 +47,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
45
47
 
46
48
  const handleStartEditing = useCallback(() => setIsEditing(true), [])
47
49
  const handleStopEditing = useCallback(() => setIsEditing(false), [])
50
+ const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
48
51
 
49
52
  return (
50
53
  <TreeNodeWrapper
@@ -53,7 +56,9 @@ export const WindowTreeNode = memo(function WindowTreeNode({
53
56
  expanded={false}
54
57
  hasChildren={false}
55
58
  icon={
56
- <Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
59
+ <SnapTargetIcon target={snapTarget}>
60
+ <Image alt="" className="object-contain" height={14} src="/icons/window.webp" width={14} />
61
+ </SnapTargetIcon>
57
62
  }
58
63
  isHovered={isHovered}
59
64
  isLast={isLast}
@@ -2,6 +2,7 @@ import { useScene, type ZoneNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { memo, useCallback, useState } from 'react'
4
4
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
5
+ import { formatAreaLabel } from './../../../../../lib/measurements'
5
6
  import { InlineRenameInput } from './inline-rename-input'
6
7
  import { focusTreeNode, TreeNodeWrapper } from './tree-node'
7
8
  import { TreeNodeActions } from './tree-node-actions'
@@ -26,6 +27,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
26
27
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
27
28
  const setSelection = useViewer((state) => state.setSelection)
28
29
  const setHoveredId = useViewer((state) => state.setHoveredId)
30
+ const unit = useViewer((state) => state.unit)
29
31
 
30
32
  const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
31
33
  const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
@@ -34,9 +36,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
34
36
  const handleStartEditing = useCallback(() => setIsEditing(true), [])
35
37
  const handleStopEditing = useCallback(() => setIsEditing(false), [])
36
38
 
37
- // Calculate approximate area from polygon
38
- const area = calculatePolygonArea(polygon).toFixed(1)
39
- const defaultName = `Zone (${area}m²)`
39
+ const defaultName = `Zone (${formatAreaLabel(calculatePolygonArea(polygon), unit)})`
40
40
 
41
41
  return (
42
42
  <TreeNodeWrapper
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import type { ReactNode } from 'react'
4
+ import { editorHostPanelRegistry } from '../../../lib/plugin-panels'
4
5
  import { triggerSFX } from './../../../lib/sfx-bus'
5
6
  import { cn } from './../../../lib/utils'
6
7
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
@@ -65,37 +66,50 @@ interface IconRailProps {
65
66
  * The label renders as a hover tooltip on the right.
66
67
  */
67
68
  export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
69
+ const pluginPanelIds = new Set(
70
+ editorHostPanelRegistry.getSnapshot().flatMap((panel) =>
71
+ panel.pluginId ? [panel.id] : [],
72
+ ),
73
+ )
74
+ const defaultTabs = tabs.filter((tab) => !pluginPanelIds.has(tab.id) && tab.id !== 'plugins')
75
+ const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
76
+
77
+ const renderTab = (tab: SidebarTab) => {
78
+ const showActive = activeTab === tab.id && !collapsed
79
+ return (
80
+ <Tooltip key={tab.id}>
81
+ <TooltipTrigger asChild>
82
+ <button
83
+ className={cn(
84
+ 'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
85
+ showActive
86
+ ? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
87
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
88
+ )}
89
+ onClick={() => {
90
+ triggerSFX('sfx:menu-click')
91
+ onIconClick(tab.id)
92
+ }}
93
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
94
+ type="button"
95
+ >
96
+ {tab.icon ?? tab.label.charAt(0)}
97
+ </button>
98
+ </TooltipTrigger>
99
+ <TooltipContent side="right">{tab.label}</TooltipContent>
100
+ </Tooltip>
101
+ )
102
+ }
103
+
68
104
  return (
69
105
  <TooltipProvider delayDuration={0} disableHoverableContent>
70
106
  <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
- })}
107
+ {defaultTabs.map(renderTab)}
108
+ {pluginTabs.length > 0 && (
109
+ <div className="mt-1 flex w-11 flex-col items-center gap-1 border-border/70 border-t pt-2">
110
+ {pluginTabs.map(renderTab)}
111
+ </div>
112
+ )}
99
113
  </div>
100
114
  </TooltipProvider>
101
115
  )
@@ -0,0 +1,132 @@
1
+ 'use client'
2
+
3
+ import { Icon } from '@iconify/react'
4
+ import { type IconRef, useScene } from '@pascal-app/core'
5
+ import { Plus } from 'lucide-react'
6
+ import {
7
+ type ComponentType,
8
+ lazy,
9
+ type ReactNode,
10
+ Suspense,
11
+ useEffect,
12
+ useSyncExternalStore,
13
+ } from 'react'
14
+ import useEditor from '../../../store/use-editor'
15
+ import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
16
+ import { ErrorBoundary } from '../primitives/error-boundary'
17
+ import type { ExtraPanel } from './icon-rail'
18
+ import { PluginsPanel } from './panels/plugins-panel'
19
+
20
+ const pluginsManagerPanel: ExtraPanel = {
21
+ id: 'plugins',
22
+ label: 'Plugins',
23
+ icon: <Plus className="h-5 w-5" />,
24
+ component: PluginsPanel,
25
+ }
26
+
27
+ /** Resolve a plugin's {@link IconRef} into a rail-sized React node. Mirrors the
28
+ * inspector's `renderIcon`, sized for the 24px icon-rail button. */
29
+ function renderIconRef(ref: IconRef): ReactNode {
30
+ if (ref.kind === 'url') {
31
+ return <img alt="" className="h-5 w-5 object-contain" src={ref.src} />
32
+ }
33
+ if (ref.kind === 'iconify') {
34
+ return <Icon height={20} icon={ref.name} width={20} />
35
+ }
36
+ if (ref.kind === 'svg') {
37
+ return (
38
+ <svg height={20} viewBox={ref.viewBox} width={20}>
39
+ <path d={ref.path} fill="currentColor" />
40
+ </svg>
41
+ )
42
+ }
43
+ const LazyIcon = lazy(ref.module)
44
+ return (
45
+ <Suspense fallback={null}>
46
+ <LazyIcon />
47
+ </Suspense>
48
+ )
49
+ }
50
+
51
+ function PluginPanelCrashed({ label }: { label: string }) {
52
+ return (
53
+ <div className="flex flex-col gap-2 p-4 text-sm">
54
+ <p className="font-medium text-sidebar-foreground">"{label}" plugin crashed</p>
55
+ <p className="text-sidebar-foreground/50 text-xs">
56
+ This panel hit an error and was unloaded for this session. The rest of the editor is
57
+ unaffected — reload to try again.
58
+ </p>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ // `React.lazy` must be called once per loader so the resolved component keeps a
64
+ // stable identity across renders (otherwise switching panels remounts the tree
65
+ // every time the sidebar re-renders). Cache the wrapped component by its loader.
66
+ const wrappedPanelCache = new WeakMap<EditorHostPanel['component'], ComponentType>()
67
+
68
+ function resolvePanelComponent(panel: EditorHostPanel): ComponentType {
69
+ const cached = wrappedPanelCache.get(panel.component)
70
+ if (cached) return cached
71
+ const Lazy = lazy(panel.component)
72
+ const Wrapped: ComponentType = () => (
73
+ <ErrorBoundary fallback={<PluginPanelCrashed label={panel.label} />}>
74
+ <Suspense fallback={<div className="p-4 text-sidebar-foreground/50 text-sm">Loading…</div>}>
75
+ <Lazy />
76
+ </Suspense>
77
+ </ErrorBoundary>
78
+ )
79
+ Wrapped.displayName = `PluginPanel(${panel.id})`
80
+ wrappedPanelCache.set(panel.component, Wrapped)
81
+ return Wrapped
82
+ }
83
+
84
+ /**
85
+ * Merge host-registered panels (from the observable {@link editorHostPanelRegistry})
86
+ * with the host's `extraPanels`, returning the combined list the icon rail and
87
+ * panel-content area render. Host panels keep their leading order and win on id
88
+ * collisions. Subscribes to the registry so a panel that registers after the
89
+ * first render makes the rail re-render.
90
+ *
91
+ * Panels are filtered by the current workspace: a panel surfaces only in the
92
+ * workspaces it declares (`EditorHostPanel.workspaces`, default `['edit']`), so an
93
+ * authoring panel like Nature doesn't ride into the studio rail.
94
+ */
95
+ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
96
+ const registered = useSyncExternalStore(
97
+ editorHostPanelRegistry.subscribe,
98
+ editorHostPanelRegistry.getSnapshot,
99
+ editorHostPanelRegistry.getSnapshot,
100
+ )
101
+ const workspaceMode = useEditor((s) => s.workspaceMode)
102
+ const installedPlugins = useScene((s) => s.installedPlugins)
103
+ const hostIds = new Set(hostPanels?.map((p) => p.id))
104
+
105
+ useEffect(() => {
106
+ const scene = useScene.getState()
107
+ if (scene.hasExplicitPluginInstallState) return
108
+ const defaults = editorHostPanelRegistry.getDefaultInstalledPluginIds()
109
+ if (defaults.every((pluginId) => scene.installedPlugins.includes(pluginId))) return
110
+ scene.setInstalledPlugins([...scene.installedPlugins, ...defaults], { explicit: false })
111
+ }, [registered])
112
+
113
+ const fromRegistry = registered
114
+ .filter(
115
+ (p) =>
116
+ !hostIds.has(p.id) &&
117
+ (p.workspaces ?? ['edit']).includes(workspaceMode) &&
118
+ (!p.pluginId || installedPlugins.includes(p.pluginId)),
119
+ )
120
+ .map(
121
+ (p): ExtraPanel => ({
122
+ id: p.id,
123
+ label: p.label,
124
+ icon: renderIconRef(p.icon),
125
+ component: resolvePanelComponent(p),
126
+ pluginId: p.pluginId,
127
+ }),
128
+ )
129
+ const manager =
130
+ workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : []
131
+ return [...(hostPanels ?? []), ...fromRegistry, ...manager]
132
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode } from '@pascal-app/core'
3
+ import { resolveAssetSnapTarget, resolveNodeSnapTarget } from './snap-target-badge'
4
+
5
+ describe('resolveAssetSnapTarget', () => {
6
+ test('maps wall-hosted catalog assets to a wall badge', () => {
7
+ expect(resolveAssetSnapTarget('wall')).toBe('wall')
8
+ expect(resolveAssetSnapTarget('wall-side')).toBe('wall')
9
+ })
10
+
11
+ test('maps ceiling-hosted catalog assets to a ceiling badge', () => {
12
+ expect(resolveAssetSnapTarget('ceiling')).toBe('ceiling')
13
+ })
14
+
15
+ test('does not badge floor assets', () => {
16
+ expect(resolveAssetSnapTarget(undefined)).toBeNull()
17
+ })
18
+ })
19
+
20
+ describe('resolveNodeSnapTarget', () => {
21
+ test('prefers roof attachment when a node is hosted by a roof segment', () => {
22
+ const node = {
23
+ id: 'window_1',
24
+ type: 'window',
25
+ roofSegmentId: 'roof-segment_1',
26
+ } as unknown as AnyNode
27
+
28
+ expect(resolveNodeSnapTarget(node)).toBe('roof')
29
+ })
30
+
31
+ test('badges gutter-hosted downspouts as roof accessories', () => {
32
+ const node = {
33
+ id: 'downspout_1',
34
+ type: 'downspout',
35
+ gutterId: 'gutter_1',
36
+ } as unknown as AnyNode
37
+
38
+ expect(resolveNodeSnapTarget(node)).toBe('roof')
39
+ })
40
+ })
@@ -0,0 +1,88 @@
1
+ import type { AnyNode, AssetInput } from '@pascal-app/core'
2
+ import type { ReactNode } from 'react'
3
+ import { cn } from '../../lib/utils'
4
+
5
+ export type SnapTarget = 'wall' | 'ceiling' | 'roof'
6
+ export type SnapTargetBadgeSize = 'tile' | 'tree'
7
+
8
+ const SNAP_TARGET_ICONS: Record<SnapTarget, string> = {
9
+ wall: '/icons/wall.webp',
10
+ ceiling: '/icons/ceiling.webp',
11
+ roof: '/icons/roof.webp',
12
+ }
13
+
14
+ const SNAP_TARGET_LABELS: Record<SnapTarget, string> = {
15
+ wall: 'Wall attachment',
16
+ ceiling: 'Ceiling attachment',
17
+ roof: 'Roof attachment',
18
+ }
19
+
20
+ const SNAP_TARGET_BADGE_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
21
+ tile: 'h-6 w-6 rounded-md',
22
+ tree: 'h-3.5 w-3.5 rounded-[3px]',
23
+ }
24
+
25
+ const SNAP_TARGET_ICON_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
26
+ tile: 'h-[18px] w-[18px]',
27
+ tree: 'h-2.5 w-2.5',
28
+ }
29
+
30
+ export function resolveAssetSnapTarget(attachTo: AssetInput['attachTo']): SnapTarget | null {
31
+ if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
32
+ if (attachTo === 'ceiling') return 'ceiling'
33
+ return null
34
+ }
35
+
36
+ export function resolveNodeSnapTarget(node: AnyNode | null | undefined): SnapTarget | null {
37
+ if (!node) return null
38
+ if ('roofSegmentId' in node && typeof node.roofSegmentId === 'string') return 'roof'
39
+ if (node.type === 'downspout') return 'roof'
40
+ if (node.type === 'door' || node.type === 'window') return 'wall'
41
+ if (node.type === 'item') return resolveAssetSnapTarget(node.asset?.attachTo)
42
+ return null
43
+ }
44
+
45
+ export function SnapTargetBadge({
46
+ className,
47
+ size = 'tile',
48
+ target,
49
+ }: {
50
+ className?: string
51
+ size?: SnapTargetBadgeSize
52
+ target: SnapTarget
53
+ }) {
54
+ return (
55
+ <span
56
+ className={cn(
57
+ 'flex items-center justify-center bg-black/65 ring-1 ring-white/20',
58
+ SNAP_TARGET_BADGE_SIZE_CLASSES[size],
59
+ className,
60
+ )}
61
+ >
62
+ <img
63
+ alt={SNAP_TARGET_LABELS[target]}
64
+ className={cn('object-contain', SNAP_TARGET_ICON_SIZE_CLASSES[size])}
65
+ src={SNAP_TARGET_ICONS[target]}
66
+ />
67
+ </span>
68
+ )
69
+ }
70
+
71
+ export function SnapTargetIcon({
72
+ children,
73
+ target,
74
+ }: {
75
+ children: ReactNode
76
+ target: SnapTarget
77
+ }) {
78
+ return (
79
+ <span className="relative inline-flex h-5 w-5 items-center justify-center">
80
+ {children}
81
+ <SnapTargetBadge
82
+ className="-right-1.5 -bottom-1.5 absolute"
83
+ size="tree"
84
+ target={target}
85
+ />
86
+ </span>
87
+ )
88
+ }