@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,428 @@
1
+ import { Icon } from '@iconify/react'
2
+ import type { ToolHint } from '@pascal-app/core'
3
+ import { Fragment, useSyncExternalStore } from 'react'
4
+ import {
5
+ CONTINUATION_PROFILES,
6
+ type ContinuationContext,
7
+ } from '../../../lib/continuation'
8
+ import type { ContextualShortcutHint } from '../../../lib/contextual-help'
9
+ import { hasActivePaintMaterial } from '../../../lib/material-paint'
10
+ import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope'
11
+ import { sfxEmitter } from '../../../lib/sfx-bus'
12
+ import {
13
+ cycleSnappingModeIn,
14
+ resolveSnapFlags,
15
+ type SnapContext,
16
+ } from '../../../lib/snapping-mode'
17
+ import { cn } from '../../../lib/utils'
18
+ import useEditor, { type GridSnapStep } from '../../../store/use-editor'
19
+ import useFenceCurveDraft from '../../../store/use-fence-curve-draft'
20
+ import { ShortcutToken } from '../primitives/shortcut-token'
21
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
22
+
23
+ // One muted container holds every row — passive key hints and interactive chips
24
+ // alike — so the HUD reads as a single panel, not a stack of floating pills. The
25
+ // background is near-opaque (`bg-background/95`) with a single backdrop blur so
26
+ // active rows stay readable over the 3D scene even while a modifier is held.
27
+ // A 2-track grid: column 1 sizes to `max-content` (the widest key across ALL
28
+ // rows), column 2 (`1fr`) is the label. Every row is a subgrid sharing those
29
+ // tracks, so labels align even when keys differ in width (⌘ vs Shift) or wrap to
30
+ // two lines. Near-opaque bg + single backdrop blur keeps active rows readable.
31
+ const CONTAINER_CLASS =
32
+ 'pointer-events-none fixed top-1/2 right-4 z-40 grid max-w-[260px] -translate-y-1/2 grid-cols-[max-content_1fr] gap-x-2.5 gap-y-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md'
33
+
34
+ const TOKEN_CLASS = 'h-5 px-1.5 text-[10px]'
35
+
36
+ // Each row spans both columns as its own subgrid, inheriting the container's
37
+ // tracks so its key/label cells land on the shared column lines.
38
+ const ROW_CLASS = 'col-span-2 grid grid-cols-subgrid'
39
+
40
+ // The key cell (column 1). `items-center` centres the token; the row's
41
+ // `items-start` keeps it on the label's first line when the label wraps.
42
+ const KEY_CELL_CLASS = 'flex items-center gap-1'
43
+
44
+ // Keys pressed together join with "+"; an entry that is itself an array is a
45
+ // group of alternatives and joins with "/" — so [['Cmd/Ctrl', 'Shift'],
46
+ // 'Left click'] reads "⌘ / ⇧ + click".
47
+ function ShortcutSequence({
48
+ active = false,
49
+ keys,
50
+ }: {
51
+ active?: boolean
52
+ keys: Array<string | string[]>
53
+ }) {
54
+ return (
55
+ <div className={KEY_CELL_CLASS}>
56
+ {keys.map((entry, index) => (
57
+ <Fragment key={`${String(entry)}-${index}`}>
58
+ {index > 0 ? (
59
+ <span className="font-medium text-[12px] text-muted-foreground/80 leading-none">
60
+ +
61
+ </span>
62
+ ) : null}
63
+ {Array.isArray(entry) ? (
64
+ entry.map((alternative, altIndex) => (
65
+ <Fragment key={`${alternative}-${altIndex}`}>
66
+ {altIndex > 0 ? (
67
+ <span className="text-[9px] text-muted-foreground/70">/</span>
68
+ ) : null}
69
+ <ShortcutToken
70
+ className={cn(TOKEN_CLASS, active && 'border-white bg-white text-black shadow-sm')}
71
+ value={alternative}
72
+ />
73
+ </Fragment>
74
+ ))
75
+ ) : (
76
+ <ShortcutToken
77
+ className={cn(TOKEN_CLASS, active && 'border-white bg-white text-black shadow-sm')}
78
+ value={entry}
79
+ />
80
+ )}
81
+ </Fragment>
82
+ ))}
83
+ </div>
84
+ )
85
+ }
86
+
87
+ // Shared single-line chip row (key cell + icon/label cell). Rendered either as a
88
+ // passive row (no `onClick`) or a clickable button. The outer container is
89
+ // `pointer-events-none`, so clickable chips opt back in.
90
+ function ChipRow({
91
+ ariaLabel,
92
+ disabled = false,
93
+ icon,
94
+ label,
95
+ onClick,
96
+ shortcut,
97
+ tooltip,
98
+ }: {
99
+ ariaLabel?: string
100
+ disabled?: boolean
101
+ icon?: string
102
+ label: string
103
+ onClick?: () => void
104
+ shortcut?: string
105
+ tooltip?: string
106
+ }) {
107
+ const body = (
108
+ <>
109
+ <span className={KEY_CELL_CLASS}>
110
+ {shortcut ? <ShortcutToken className={TOKEN_CLASS} value={shortcut} /> : null}
111
+ </span>
112
+ <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground text-xs">
113
+ {icon ? <Icon className="shrink-0" height={13} icon={icon} width={13} /> : null}
114
+ <span className="truncate">{label}</span>
115
+ </span>
116
+ </>
117
+ )
118
+
119
+ if (!onClick) {
120
+ return (
121
+ <div className={cn(ROW_CLASS, 'items-center', disabled && 'opacity-45 saturate-0')}>{body}</div>
122
+ )
123
+ }
124
+
125
+ const button = (
126
+ <button
127
+ aria-label={ariaLabel ?? label}
128
+ className={cn(
129
+ ROW_CLASS,
130
+ 'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
131
+ disabled && 'opacity-45 saturate-0',
132
+ )}
133
+ onClick={onClick}
134
+ type="button"
135
+ >
136
+ {body}
137
+ </button>
138
+ )
139
+
140
+ if (!tooltip) return button
141
+ return (
142
+ <Tooltip>
143
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
144
+ <TooltipContent side="left">{tooltip}</TooltipContent>
145
+ </Tooltip>
146
+ )
147
+ }
148
+
149
+ const SNAPPING_MODE_ICONS = {
150
+ grid: 'lucide:grid-2x2',
151
+ lines: 'lucide:magnet',
152
+ angles: 'lucide:triangle',
153
+ off: 'lucide:ban',
154
+ } as const
155
+
156
+ const SNAPPING_MODE_LABELS = {
157
+ grid: 'Grid',
158
+ lines: 'Lines',
159
+ angles: 'Angles',
160
+ off: 'Off',
161
+ } as const
162
+
163
+ const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
164
+
165
+ function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
166
+ const index = GRID_SNAP_STEPS.indexOf(step)
167
+ return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
168
+ }
169
+
170
+ // The active interaction's snapping controls, scoped to its context (wall / item
171
+ // / polygon) so each action shows only the modes that make sense for it.
172
+ function SnappingChips({ context }: { context: SnapContext }) {
173
+ const snappingMode = useEditor((s) => s.snappingModeByContext[context])
174
+ const setSnappingMode = useEditor((s) => s.setSnappingMode)
175
+ const gridSnapStep = useEditor((s) => s.gridSnapStep)
176
+ const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
177
+
178
+ const gridActive = resolveSnapFlags(snappingMode).grid
179
+
180
+ return (
181
+ <>
182
+ <ChipRow
183
+ ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
184
+ icon={SNAPPING_MODE_ICONS[snappingMode]}
185
+ label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
186
+ onClick={() => {
187
+ setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))
188
+ sfxEmitter.emit('sfx:grid-snap')
189
+ }}
190
+ shortcut="Shift"
191
+ tooltip="Snapping mode — click or press Shift to cycle"
192
+ />
193
+ {gridActive ? (
194
+ <ChipRow
195
+ ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
196
+ label={`Grid: ${gridSnapStep.toFixed(2)} m`}
197
+ onClick={() => {
198
+ setGridSnapStep(nextGridSnapStep(gridSnapStep))
199
+ sfxEmitter.emit('sfx:grid-snap')
200
+ }}
201
+ shortcut="Ctrl"
202
+ tooltip="Grid step — click or tap Ctrl to cycle"
203
+ />
204
+ ) : null}
205
+ </>
206
+ )
207
+ }
208
+
209
+ // A kind-owned live mode chip declared on a `ToolHint` (`hint.chip`) — the
210
+ // registry counterpart of the snapping / continuation chips above: shows the
211
+ // current value's label, and clicking the row (or the hint's key, handled by
212
+ // the tool itself) cycles it.
213
+ function ToolHintChipRow({ hint }: { hint: ToolHint & { chip: NonNullable<ToolHint['chip']> } }) {
214
+ const { chip } = hint
215
+ const value = useSyncExternalStore(chip.subscribe, chip.value, chip.value)
216
+ const label = chip.labels[value] ?? hint.label
217
+ return (
218
+ <ChipRow
219
+ ariaLabel={label}
220
+ icon={chip.icons?.[value]}
221
+ label={label}
222
+ onClick={chip.cycle}
223
+ shortcut={hint.key}
224
+ tooltip={chip.tooltip}
225
+ />
226
+ )
227
+ }
228
+
229
+ function ContinuationChip({ context }: { context: ContinuationContext }) {
230
+ const mode = useEditor((s) => s.getContinuation(context))
231
+ const cycleContinuation = useEditor((s) => s.cycleContinuation)
232
+ const profile = CONTINUATION_PROFILES[context]
233
+ const label = profile.labels[mode] ?? mode
234
+ const icon = profile.icons[mode] ?? 'lucide:repeat'
235
+
236
+ return (
237
+ <ChipRow
238
+ ariaLabel={`Continuation: ${label}`}
239
+ icon={icon}
240
+ label={label}
241
+ onClick={() => cycleContinuation(context)}
242
+ shortcut="C"
243
+ tooltip="Continuation — click or press C to cycle"
244
+ />
245
+ )
246
+ }
247
+
248
+ function FenceContinuationChips() {
249
+ const mode = useEditor((s) => s.getContinuation('fence'))
250
+ const setContinuation = useEditor((s) => s.setContinuation)
251
+ const curveStarted = useFenceCurveDraft((s) => s.pointCount > 0)
252
+
253
+ const isCurved = mode === 'curved'
254
+ const straightMode = isCurved ? 'continuous' : mode
255
+ const straightLabel = straightMode === 'single' ? 'Straight: Single' : 'Straight: Continuous'
256
+ const straightIcon = straightMode === 'single' ? 'lucide:minus' : 'lucide:waypoints'
257
+ const typeLabel = isCurved ? 'Type: Curved' : 'Type: Straight'
258
+ const typeIcon = isCurved ? 'lucide:spline' : 'lucide:minus'
259
+
260
+ return (
261
+ <>
262
+ <ChipRow
263
+ ariaLabel={`Fence type: ${isCurved ? 'Curved' : 'Straight'}`}
264
+ icon={typeIcon}
265
+ label={typeLabel}
266
+ onClick={() => setContinuation('fence', isCurved ? 'continuous' : 'curved')}
267
+ shortcut="T"
268
+ tooltip="Fence type — click or press T to switch between straight and curved"
269
+ />
270
+ <ChipRow
271
+ ariaLabel={`Fence continuation: ${straightLabel}`}
272
+ disabled={isCurved}
273
+ icon={straightIcon}
274
+ label={straightLabel}
275
+ onClick={
276
+ isCurved
277
+ ? undefined
278
+ : () => setContinuation('fence', straightMode === 'single' ? 'continuous' : 'single')
279
+ }
280
+ shortcut="C"
281
+ tooltip={
282
+ isCurved
283
+ ? 'Straight continuation is unavailable while curved fence type is active'
284
+ : 'Straight fence continuation — click or press C to toggle'
285
+ }
286
+ />
287
+ {/* Curved fences are committed by a closing gesture rather than per-click,
288
+ so the finish keys aren't discoverable on their own — surface them, but
289
+ only once the user has placed a point and a curve is actually in flight. */}
290
+ {isCurved && curveStarted ? (
291
+ <ChipRow
292
+ icon="lucide:circle-check"
293
+ label="Finish curve (or double-click)"
294
+ shortcut="Enter"
295
+ />
296
+ ) : null}
297
+ </>
298
+ )
299
+ }
300
+
301
+ const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
302
+ single: 'lucide:square',
303
+ object: 'lucide:box',
304
+ matching: 'lucide:copy',
305
+ room: 'lucide:scan',
306
+ }
307
+
308
+ // The painter's application-scope chip. Driven entirely by the hovered node's
309
+ // derived `paintHover` (scopes + labels), so it works for any kind without a
310
+ // per-target table.
311
+ function PaintScopeChip() {
312
+ // What the cursor is over (that's what the next click paints). `null` when not
313
+ // over a paintable surface — including an item with no slots.
314
+ const paintHover = useEditor((s) => s.paintHover)
315
+ const paintScope = useEditor((s) => s.paintScope)
316
+ const cyclePaintScope = useEditor((s) => s.cyclePaintScope)
317
+ const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
318
+ const paintEraser = useEditor((s) => s.paintEraser)
319
+
320
+ // Nothing to paint with yet (no material picked, not erasing) → the first step
321
+ // is choosing a material, so say that before anything about scope or hovering.
322
+ if (!(paintEraser || hasActivePaintMaterial(activePaintMaterial))) {
323
+ return <ChipRow icon="lucide:palette" label="Select a material to paint" />
324
+ }
325
+
326
+ // Not over anything paintable → guide the user to hover, still teaching Shift.
327
+ if (!paintHover) {
328
+ return (
329
+ <ChipRow icon="lucide:mouse-pointer-click" label="Hover a surface to paint" shortcut="Shift" />
330
+ )
331
+ }
332
+
333
+ const { scopes } = paintHover
334
+ // A scope carried over from another node (the mode is global) falls back to
335
+ // the narrowest for both display and — via the apply-time resolver — behaviour.
336
+ const effective: PaintScope = scopes.includes(paintScope) ? paintScope : 'single'
337
+
338
+ // Paintable but with no scope choice (roof, a one-slot node, …) → a passive
339
+ // row that still names the surface, so the user always sees what they'll paint.
340
+ if (scopes.length <= 1) {
341
+ return (
342
+ <ChipRow
343
+ icon={PAINT_SCOPE_ICONS[effective]}
344
+ label={`Paint: ${paintScopeLabel(effective, paintHover)}`}
345
+ />
346
+ )
347
+ }
348
+
349
+ return (
350
+ <ChipRow
351
+ ariaLabel={`Paint scope: ${paintScopeLabel(effective, paintHover)}`}
352
+ icon={PAINT_SCOPE_ICONS[effective]}
353
+ label={`Paint: ${paintScopeLabel(effective, paintHover)}`}
354
+ onClick={() => cyclePaintScope()}
355
+ shortcut="Shift"
356
+ tooltip="Paint scope — click or press Shift to cycle"
357
+ />
358
+ )
359
+ }
360
+
361
+ export function ContextualHelperPanel({
362
+ hints,
363
+ chipHints = [],
364
+ snapContext = null,
365
+ showPaintScope = false,
366
+ continuationContext = null,
367
+ }: {
368
+ hints: ContextualShortcutHint[]
369
+ // Kind-owned live mode chips (`ToolHint.chip`), rendered alongside the
370
+ // snapping / continuation chips.
371
+ chipHints?: ToolHint[]
372
+ // The active snapping context drives the snapping chips (which mode set). Null
373
+ // → no snapping chips for this interaction.
374
+ snapContext?: SnapContext | null
375
+ showPaintScope?: boolean
376
+ continuationContext?: ContinuationContext | null
377
+ }) {
378
+ if (
379
+ hints.length === 0 &&
380
+ chipHints.length === 0 &&
381
+ !snapContext &&
382
+ !showPaintScope &&
383
+ !continuationContext
384
+ )
385
+ return null
386
+
387
+ return (
388
+ <div className={CONTAINER_CLASS}>
389
+ {snapContext ? <SnappingChips context={snapContext} /> : null}
390
+ {continuationContext === 'fence' ? <FenceContinuationChips /> : null}
391
+ {continuationContext && continuationContext !== 'fence' ? (
392
+ <ContinuationChip context={continuationContext} />
393
+ ) : null}
394
+ {chipHints.map((hint) =>
395
+ hint.chip ? (
396
+ <ToolHintChipRow
397
+ hint={hint as ToolHint & { chip: NonNullable<ToolHint['chip']> }}
398
+ key={`${hint.key}:${hint.label}`}
399
+ />
400
+ ) : null,
401
+ )}
402
+ {showPaintScope ? <PaintScopeChip /> : null}
403
+ {hints.map((hint) => (
404
+ <div
405
+ className={cn(ROW_CLASS, 'items-start')}
406
+ key={`${hint.keys.join('+')}:${hint.label}`}
407
+ >
408
+ <ShortcutSequence active={hint.active} keys={hint.keys} />
409
+ <div className="min-w-0">
410
+ <div
411
+ className={cn(
412
+ 'text-xs leading-5',
413
+ hint.active ? 'font-medium text-white' : 'text-muted-foreground',
414
+ )}
415
+ >
416
+ {hint.label}
417
+ </div>
418
+ {hint.subtitle ? (
419
+ <div className="text-[10px] text-muted-foreground/70 leading-snug">
420
+ {hint.subtitle}
421
+ </div>
422
+ ) : null}
423
+ </div>
424
+ </div>
425
+ ))}
426
+ </div>
427
+ )
428
+ }