@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
@@ -0,0 +1,212 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
6
+ import {
7
+ createQuickMeasurementPointerScheduler,
8
+ resolveQuickMeasurementReport,
9
+ } from '../../lib/quick-measurement'
10
+ import {
11
+ activateQuickMeasurementHudSource,
12
+ clearQuickMeasurementHudSource,
13
+ publishQuickMeasurementHudSource,
14
+ } from '../../store/use-quick-measurement-hud'
15
+ import { useFloorplanRender } from './floorplan-render-context'
16
+
17
+ type FloorplanQuickMeasureHit = {
18
+ nodeId: string
19
+ point: { x: number; y: number }
20
+ }
21
+
22
+ function nodeIdFromElement(element: Element | null): string | null {
23
+ const entry = element?.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
24
+ return entry?.dataset.nodeId ?? null
25
+ }
26
+
27
+ function nodeIdAtPointer(svg: SVGSVGElement, event: PointerEvent): string | null {
28
+ const direct = event.target instanceof Element ? nodeIdFromElement(event.target) : null
29
+ if (direct) return direct
30
+ for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
31
+ if (!svg.contains(element)) continue
32
+ const nodeId = nodeIdFromElement(element)
33
+ if (nodeId) return nodeId
34
+ }
35
+ return null
36
+ }
37
+
38
+ function pointAtPointer(
39
+ group: SVGGElement,
40
+ event: PointerEvent,
41
+ ): FloorplanQuickMeasureHit['point'] | null {
42
+ const matrix = group.getScreenCTM()
43
+ if (!matrix) return null
44
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
45
+ return { x: point.x, y: point.y }
46
+ }
47
+
48
+ function FloorplanSmartMarker({
49
+ hit,
50
+ pinned,
51
+ unitsPerPixel,
52
+ markerRef,
53
+ }: {
54
+ hit?: FloorplanQuickMeasureHit
55
+ pinned: boolean
56
+ unitsPerPixel: number
57
+ markerRef?: RefObject<SVGGElement | null>
58
+ }) {
59
+ const localRef = useRef<SVGGElement>(null)
60
+ const ref = markerRef ?? localRef
61
+
62
+ useLayoutEffect(() => {
63
+ if (!hit) ref.current?.setAttribute('display', 'none')
64
+ }, [hit, ref])
65
+
66
+ return (
67
+ <g ref={ref} transform={hit ? `translate(${hit.point.x} ${hit.point.y})` : undefined}>
68
+ <circle
69
+ cx={0}
70
+ cy={0}
71
+ fill="none"
72
+ r={(pinned ? 11 : 8) * unitsPerPixel}
73
+ stroke={pinned ? '#0e7490' : '#0891b2'}
74
+ strokeWidth={pinned ? 2.5 : 2}
75
+ vectorEffect="non-scaling-stroke"
76
+ />
77
+ {pinned ? <circle cx={0} cy={0} fill="#0e7490" r={2.75 * unitsPerPixel} /> : null}
78
+ </g>
79
+ )
80
+ }
81
+
82
+ function showFloorplanSmartMarker(
83
+ marker: SVGGElement | null,
84
+ point: FloorplanQuickMeasureHit['point'],
85
+ ) {
86
+ if (!marker) return
87
+ marker.setAttribute('transform', `translate(${point.x} ${point.y})`)
88
+ marker.removeAttribute('display')
89
+ }
90
+
91
+ function hideFloorplanSmartMarker(marker: SVGGElement | null) {
92
+ marker?.setAttribute('display', 'none')
93
+ }
94
+
95
+ export function FloorplanQuickMeasureLayer() {
96
+ const groupRef = useRef<SVGGElement>(null)
97
+ const hoverRef = useRef<FloorplanQuickMeasureHit | null>(null)
98
+ const hoverNodeIdRef = useRef<string | null>(null)
99
+ const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
100
+ const candidateHasReportRef = useRef(false)
101
+ const hoverMarkerRef = useRef<SVGGElement>(null)
102
+ const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
103
+ const [pinned, setPinned] = useState<FloorplanQuickMeasureHit | null>(null)
104
+ const nodes = useScene((state) => state.nodes)
105
+ const candidateNodesRef = useRef(nodes)
106
+ const levelId = useViewer((state) => state.selection.levelId)
107
+ const levelRef = useRef(levelId)
108
+ const renderContext = useFloorplanRender()
109
+ const hoverReport = useMemo(
110
+ () => resolveQuickMeasurementReport(hoverNodeId, nodes),
111
+ [hoverNodeId, nodes],
112
+ )
113
+ const pinnedReport = useMemo(
114
+ () => resolveQuickMeasurementReport(pinned?.nodeId ?? null, nodes),
115
+ [pinned?.nodeId, nodes],
116
+ )
117
+
118
+ useEffect(() => {
119
+ if (levelRef.current === levelId) return
120
+ levelRef.current = levelId
121
+ hoverRef.current = null
122
+ hoverNodeIdRef.current = null
123
+ candidateNodeIdRef.current = undefined
124
+ candidateHasReportRef.current = false
125
+ hideFloorplanSmartMarker(hoverMarkerRef.current)
126
+ setHoverNodeId(null)
127
+ setPinned(null)
128
+ }, [levelId])
129
+
130
+ useEffect(() => {
131
+ const group = groupRef.current
132
+ const svg = groupRef.current?.ownerSVGElement
133
+ if (!(group && svg)) return
134
+ const updateHover = (next: FloorplanQuickMeasureHit | null) => {
135
+ hoverRef.current = next
136
+ if (next) showFloorplanSmartMarker(hoverMarkerRef.current, next.point)
137
+ else hideFloorplanSmartMarker(hoverMarkerRef.current)
138
+ const nextNodeId = next?.nodeId ?? null
139
+ if (nextNodeId === hoverNodeIdRef.current) return
140
+ hoverNodeIdRef.current = nextNodeId
141
+ setHoverNodeId(nextNodeId)
142
+ }
143
+ const processPointerMove = (event: PointerEvent) => {
144
+ activateQuickMeasurementHudSource('2d')
145
+ const candidateNodeId = nodeIdAtPointer(svg, event)
146
+ const sceneNodes = useScene.getState().nodes
147
+ if (candidateNodesRef.current !== sceneNodes) {
148
+ candidateNodesRef.current = sceneNodes
149
+ candidateNodeIdRef.current = undefined
150
+ }
151
+ if (candidateNodeId !== candidateNodeIdRef.current) {
152
+ candidateNodeIdRef.current = candidateNodeId
153
+ candidateHasReportRef.current = Boolean(
154
+ resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
155
+ )
156
+ }
157
+ const point = candidateHasReportRef.current ? pointAtPointer(group, event) : null
158
+ updateHover(candidateNodeId && point ? { nodeId: candidateNodeId, point } : null)
159
+ }
160
+ const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
161
+ const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
162
+ const clear = () => {
163
+ pointerScheduler.clear()
164
+ updateHover(null)
165
+ }
166
+ const onClick = (event: MouseEvent) => {
167
+ const next = hoverRef.current
168
+ if (!(next && event.button === 0)) return
169
+ event.preventDefault()
170
+ event.stopImmediatePropagation()
171
+ activateQuickMeasurementHudSource('2d')
172
+ setPinned(next)
173
+ }
174
+
175
+ svg.addEventListener('pointermove', onPointerMove, true)
176
+ svg.addEventListener('pointerleave', clear)
177
+ svg.addEventListener('click', onClick, true)
178
+ return () => {
179
+ svg.removeEventListener('pointermove', onPointerMove, true)
180
+ svg.removeEventListener('pointerleave', clear)
181
+ svg.removeEventListener('click', onClick, true)
182
+ pointerScheduler.clear()
183
+ }
184
+ }, [])
185
+
186
+ const unitsPerPixel = Math.max(renderContext?.unitsPerPixel ?? 0.01, 1e-6)
187
+ const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
188
+ const report = hoverReport ?? pinnedReport
189
+ const lensState =
190
+ pinnedReport && activeHit?.nodeId === pinned?.nodeId ? ('pinned' as const) : ('live' as const)
191
+
192
+ useEffect(() => {
193
+ publishQuickMeasurementHudSource('2d', report ? { lensState, report } : null)
194
+ }, [lensState, report])
195
+
196
+ useEffect(() => () => clearQuickMeasurementHudSource('2d'), [])
197
+
198
+ return (
199
+ <g pointerEvents="none" ref={groupRef}>
200
+ {pinned && pinnedReport ? (
201
+ <FloorplanSmartMarker hit={pinned} pinned unitsPerPixel={unitsPerPixel} />
202
+ ) : null}
203
+ <FloorplanSmartMarker
204
+ markerRef={hoverMarkerRef}
205
+ pinned={false}
206
+ unitsPerPixel={unitsPerPixel}
207
+ />
208
+ </g>
209
+ )
210
+ }
211
+
212
+ export default FloorplanQuickMeasureLayer
@@ -4,8 +4,11 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type CeilingNode,
7
+ createSceneApi,
7
8
  getWallMidpointHandlePoint,
9
+ type NodeQuickAction,
8
10
  nodeRegistry,
11
+ runAsSingleSceneHistoryStep,
9
12
  type SlabNode,
10
13
  useLiveNodeOverrides,
11
14
  useScene,
@@ -14,9 +17,90 @@ import {
14
17
  import { useViewer } from '@pascal-app/viewer'
15
18
  import { useEffect, useState } from 'react'
16
19
  import { createPortal } from 'react-dom'
20
+ import { useShallow } from 'zustand/react/shallow'
21
+ import {
22
+ createFreshPlacementSubtree,
23
+ duplicatesAsFreshSubtree,
24
+ } from '../../lib/fresh-planar-placement'
17
25
  import { sfxEmitter } from '../../lib/sfx-bus'
18
26
  import useEditor from '../../store/use-editor'
27
+ import { useMovingNode } from '../../store/use-interaction-scope'
19
28
  import { NodeActionMenu } from '../editor/node-action-menu'
29
+ import { IconRefGlyph } from '../ui/icon-ref'
30
+
31
+ function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
32
+ return (
33
+ <svg
34
+ aria-hidden="true"
35
+ className="h-3.5 w-3.5"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ strokeLinecap="round"
39
+ strokeLinejoin="round"
40
+ strokeWidth="1.7"
41
+ viewBox="0 0 24 24"
42
+ >
43
+ <rect x="8" y="7" width="9" height="10" rx="1.75" />
44
+ <path d="M12.5 7v10" />
45
+ {direction === 'left' ? (
46
+ <>
47
+ <path d="M6.5 12H2.75" />
48
+ <path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
49
+ </>
50
+ ) : (
51
+ <>
52
+ <path d="M17.5 12h3.75" />
53
+ <path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
54
+ </>
55
+ )}
56
+ </svg>
57
+ )
58
+ }
59
+
60
+ // Builtin string tokens map to the generic glyphs above; kind-owned marks
61
+ // arrive as IconRef objects and render through the shared IconRefGlyph.
62
+ // Mirrors the 3D floating-action-menu (2D ↔ 3D parity).
63
+ function QuickActionIcon({ action }: { action: NodeQuickAction }) {
64
+ const icon = action.icon
65
+ if (!icon) return null
66
+ if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
67
+ switch (icon) {
68
+ case 'add-left':
69
+ return <SideAddGlyph direction="left" />
70
+ case 'add-right':
71
+ return <SideAddGlyph direction="right" />
72
+ default:
73
+ return null
74
+ }
75
+ }
76
+
77
+ function collectQuickActionNodes(
78
+ nodes: Record<AnyNodeId, AnyNode>,
79
+ selectedId: string | null,
80
+ ): Record<AnyNodeId, AnyNode> | null {
81
+ if (!selectedId) return null
82
+ const selected = nodes[selectedId as AnyNodeId]
83
+ if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
84
+
85
+ const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
86
+ const add = (id: string | null | undefined) => {
87
+ if (!id) return
88
+ const node = nodes[id as AnyNodeId]
89
+ if (node) collected[node.id as AnyNodeId] = node
90
+ }
91
+ const addChildren = (node: AnyNode | undefined) => {
92
+ for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
93
+ add(childId)
94
+ }
95
+ }
96
+
97
+ add(selected.parentId ?? null)
98
+ addChildren(selected)
99
+ const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
100
+ addChildren(parent)
101
+
102
+ return collected
103
+ }
20
104
 
21
105
  /**
22
106
  * Floating Move / Duplicate / Delete buttons that appear above the
@@ -45,9 +129,21 @@ import { NodeActionMenu } from '../editor/node-action-menu'
45
129
  * Hidden while in a move state (so we don't show buttons over a ghost).
46
130
  */
47
131
  export function FloorplanRegistryActionMenu() {
48
- const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
49
- const movingNode = useEditor((s) => s.movingNode)
132
+ // Sole selection only a multi-selection gets the group menu
133
+ // (`FloorplanGroupActionMenu`), whose actions target the whole selection.
134
+ const selectedId = useViewer((s) =>
135
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
136
+ ) as AnyNodeId | undefined
137
+ const movingNode = useMovingNode()
50
138
  const setMovingNode = useEditor((s) => s.setMovingNode)
139
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
140
+ // Gate on floorplan hover so this 2D menu never coexists with the 3D
141
+ // FloatingActionMenu in split view — that menu hides while the floorplan
142
+ // is hovered, so this one must only show then. Mirrors the legacy
143
+ // FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a
144
+ // duct) shows two Duplicate buttons whenever the pointer is outside the
145
+ // 2D panel.
146
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
51
147
 
52
148
  const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
53
149
 
@@ -56,8 +152,12 @@ export function FloorplanRegistryActionMenu() {
56
152
  const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
57
153
  const def = selectedKind ? nodeRegistry.get(selectedKind) : null
58
154
  const isRegistryKind = !!def
59
- const isVisible = isRegistryKind && !movingNode
155
+ const isVisible =
156
+ isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
60
157
  const isWall = selectedKind === 'wall'
158
+ const quickActionNodes = useScene(
159
+ useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
160
+ )
61
161
 
62
162
  useEffect(() => {
63
163
  if (!(isVisible && selectedId)) {
@@ -117,6 +217,14 @@ export function FloorplanRegistryActionMenu() {
117
217
  const node = useScene.getState().nodes[selectedId]
118
218
  if (!node) return null
119
219
 
220
+ const quickActions =
221
+ quickActionNodes && nodeRegistry.get(node.type)?.quickActions
222
+ ? (nodeRegistry.get(node.type)?.quickActions?.({
223
+ node: node as never,
224
+ nodes: quickActionNodes,
225
+ }) ?? [])
226
+ : []
227
+
120
228
  // Move button is enabled when any of:
121
229
  // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
122
230
  // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
@@ -134,6 +242,11 @@ export function FloorplanRegistryActionMenu() {
134
242
  const handleMove = () => {
135
243
  sfxEmitter.emit('sfx:item-pick')
136
244
  setMovingNode(node as never)
245
+ // 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
246
+ // Mark the origin (after `setMovingNode`, which resets it to null) so
247
+ // `ToolManager` keeps the 3D affordance mover from also adopting the node
248
+ // and reverting it on unmount. Mirrors the orange move-dot path.
249
+ setMovingNodeOrigin('2d')
137
250
  // Match the legacy 3D `floating-action-menu`: clear selection so
138
251
  // selection-gated affordances unmount during the drag. Specifically
139
252
  // the slab / ceiling boundary editor (`ToolManager` shows it when
@@ -185,12 +298,29 @@ export function FloorplanRegistryActionMenu() {
185
298
  if (!node.parentId) return
186
299
  sfxEmitter.emit('sfx:item-pick')
187
300
  useScene.temporal.getState().pause()
301
+ if (duplicatesAsFreshSubtree(node as AnyNode)) {
302
+ const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
303
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
304
+ if (draft) {
305
+ setMovingNode(draft as never)
306
+ setMovingNodeOrigin('2d')
307
+ useScene.temporal.getState().resume()
308
+ return
309
+ }
310
+ useScene.temporal.getState().resume()
311
+ return
312
+ }
188
313
  const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
189
314
  delete (cloned as { id?: AnyNodeId }).id
190
315
  const prevMeta =
191
316
  cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
192
317
  ? (cloned.metadata as Record<string, unknown>)
193
318
  : {}
319
+ // Mark fresh + hand to the placement cursor so the copy follows the
320
+ // pointer and only lands on the next click — same gesture for every
321
+ // kind. Polyline runs (duct / pipe / lineset) ride the same path:
322
+ // `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
323
+ // no longer need the old "offset + drop already-placed" special case.
194
324
  cloned.metadata = { ...prevMeta, isNew: true }
195
325
  const parsed = def.schema.parse(cloned) as AnyNode
196
326
  useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
@@ -204,9 +334,20 @@ export function FloorplanRegistryActionMenu() {
204
334
  useViewer.getState().setSelection({ selectedIds: [] })
205
335
  }
206
336
 
337
+ const handleQuickAction = (action: NodeQuickAction) => {
338
+ if (action.disabled) return
339
+ const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
340
+ const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
341
+ if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
342
+ if (result?.selectedIds) {
343
+ const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
344
+ sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
345
+ }
346
+ }
347
+
207
348
  return createPortal(
208
349
  <div
209
- className="pointer-events-none fixed z-30"
350
+ className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
210
351
  style={{
211
352
  left: position.left,
212
353
  top: position.top,
@@ -221,6 +362,30 @@ export function FloorplanRegistryActionMenu() {
221
362
  onPointerDown={(event) => event.stopPropagation()}
222
363
  onPointerUp={(event) => event.stopPropagation()}
223
364
  />
365
+ {quickActions.length > 0 ? (
366
+ <div
367
+ className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
368
+ onPointerDown={(event) => event.stopPropagation()}
369
+ onPointerUp={(event) => event.stopPropagation()}
370
+ >
371
+ {quickActions.map((action) => (
372
+ <button
373
+ aria-label={action.title ?? action.label}
374
+ className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
375
+ disabled={action.disabled}
376
+ key={action.id}
377
+ onClick={() => handleQuickAction(action)}
378
+ title={action.title ?? action.label}
379
+ type="button"
380
+ >
381
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
382
+ <QuickActionIcon action={action} />
383
+ </span>
384
+ <span className="whitespace-nowrap leading-none">{action.label}</span>
385
+ </button>
386
+ ))}
387
+ </div>
388
+ ) : null}
224
389
  </div>,
225
390
  document.body,
226
391
  )