@pascal-app/editor 0.8.0 → 0.9.1

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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -5,16 +5,24 @@ import {
5
5
  type AnyNodeId,
6
6
  type CeilingNode,
7
7
  ColumnNode,
8
+ DEFAULT_WALL_HEIGHT,
8
9
  DoorNode,
10
+ ElevatorNode,
9
11
  FenceNode,
10
12
  generateId,
13
+ getWallCurveLength,
14
+ getWallThickness,
11
15
  ItemNode,
16
+ isRegistryMovable,
17
+ isRegistrySelectable,
18
+ nodeRegistry,
12
19
  RoofSegmentNode,
13
20
  type SlabNode,
14
21
  SpawnNode,
15
22
  StairNode,
16
23
  StairSegmentNode,
17
24
  sceneRegistry,
25
+ useLiveNodeOverrides,
18
26
  useScene,
19
27
  WallNode,
20
28
  WindowNode,
@@ -22,19 +30,20 @@ import {
22
30
  import { useViewer } from '@pascal-app/viewer'
23
31
  import { Html } from '@react-three/drei'
24
32
  import { useFrame } from '@react-three/fiber'
25
- import { Move } from 'lucide-react'
26
- import { useCallback, useEffect, useRef, useState } from 'react'
33
+ import { useCallback, useRef } from 'react'
27
34
  import * as THREE from 'three'
28
35
  import { duplicateRoofSubtree } from '../../lib/roof-duplication'
29
- import { sfxEmitter } from '../../lib/sfx-bus'
36
+ import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
30
37
  import { duplicateStairSubtree } from '../../lib/stair-duplication'
31
38
  import useEditor from '../../store/use-editor'
39
+ import { formatMeasurement, MeasurementPill } from './measurement-pill'
32
40
  import { NodeActionMenu } from './node-action-menu'
33
41
 
34
42
  const ALLOWED_TYPES = [
35
43
  'item',
36
44
  'door',
37
45
  'window',
46
+ 'elevator',
38
47
  'roof',
39
48
  'roof-segment',
40
49
  'stair',
@@ -49,6 +58,85 @@ const ALLOWED_TYPES = [
49
58
  const DELETE_ONLY_TYPES: string[] = []
50
59
  const HOLE_TYPES = ['slab', 'ceiling']
51
60
 
61
+ // Menu scales with camera zoom so it feels anchored to the object, but is
62
+ // clamped on both ends so it stays readable when zoomed way out and doesn't
63
+ // dominate the screen when zoomed in close. Reference values are picked so
64
+ // scale = 1 lands near the editor's default framing.
65
+ const MIN_MENU_SCALE = 0.5
66
+ // Cap at 1 so zooming in doesn't grow the menu past its default pixel size —
67
+ // only zoom-out shrinks it (down to MIN_MENU_SCALE).
68
+ const MAX_MENU_SCALE = 1
69
+ const REF_ORTHO_ZOOM = 20
70
+ const REF_CAMERA_DISTANCE = 12
71
+
72
+ // World-space Y distance from a node's bbox top to the floating menu anchor.
73
+ // Per-type because in-world chrome above the node (height-resize arrows,
74
+ // measurement labels) varies in vertical reach.
75
+ // `EXTRA_MENU_LIFT` is a uniform global nudge — easier to tune one
76
+ // constant than to bump every per-type entry below.
77
+ const EXTRA_MENU_LIFT = 0.35
78
+ const MENU_Y_OFFSET_DEFAULT = 0.3
79
+ const MENU_Y_OFFSETS: Record<string, number> = {
80
+ wall: 0.5,
81
+ door: 0.6,
82
+ window: 0.6,
83
+ column: 0.6,
84
+ // Fence: still clears the height-resize arrow (sits at fence.height +
85
+ // 0.45) plus the chevron's visual size, but kept low so the menu sits
86
+ // close to the fence rather than floating well above it.
87
+ fence: 0.7,
88
+ // Elevator: clears the cab-height arrow which sits above the SHAFT
89
+ // top (resolved through level entries), so the menu floats above it.
90
+ elevator: 0.9,
91
+ stair: 0.2,
92
+ 'stair-stair': 1.1,
93
+ 'stair-landing': 0.9,
94
+ // Slab: clears the height arrow that sits at elevation + 0.22 plus the
95
+ // chevron's own visual reach, so the menu floats just above it.
96
+ slab: 0.7,
97
+ // Ceiling: clears the upward height arrow that sits ~0.22 above the
98
+ // ceiling plane, plus extra headroom so the menu doesn't crowd the
99
+ // chevron at any zoom level.
100
+ ceiling: 1.0,
101
+ // Shelf: clears the height arrow that sits at shelf.height + 0.22
102
+ // plus the chevron's visual reach.
103
+ shelf: 0.6,
104
+ }
105
+
106
+ function getMenuYOffset(node: AnyNode | null): number {
107
+ if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT
108
+ if (node.type === 'stair-segment') {
109
+ return (MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
110
+ }
111
+ return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
112
+ }
113
+
114
+ // Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
115
+ // pill reads sensibly before an explicit height / thickness is set.
116
+ const FENCE_DEFAULT_HEIGHT = 1.8
117
+ const FENCE_DEFAULT_THICKNESS = 0.08
118
+
119
+ // Dimensions for the height-drag pill. Walls and fences both carry
120
+ // start/end/curveOffset, so getWallCurveLength covers length for either.
121
+ function getHeightPillDimensions(node: WallNode | FenceNode): {
122
+ height: number
123
+ length: number
124
+ thickness: number
125
+ } {
126
+ if (node.type === 'wall') {
127
+ return {
128
+ height: node.height ?? DEFAULT_WALL_HEIGHT,
129
+ length: getWallCurveLength(node),
130
+ thickness: getWallThickness(node),
131
+ }
132
+ }
133
+ return {
134
+ height: node.height ?? FENCE_DEFAULT_HEIGHT,
135
+ length: getWallCurveLength(node),
136
+ thickness: node.thickness ?? FENCE_DEFAULT_THICKNESS,
137
+ }
138
+ }
139
+
52
140
  export function FloatingActionMenu() {
53
141
  const selectedIds = useViewer((s) => s.selection.selectedIds)
54
142
  const updateNode = useScene((s) => s.updateNode)
@@ -58,17 +146,35 @@ export function FloatingActionMenu() {
58
146
  const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
59
147
  const curvingFence = useEditor((s) => s.curvingFence)
60
148
  const setMovingNode = useEditor((s) => s.setMovingNode)
61
- const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
62
- const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
63
149
  const setCurvingWall = useEditor((s) => s.setCurvingWall)
64
150
  const setCurvingFence = useEditor((s) => s.setCurvingFence)
65
151
  const setSelection = useViewer((s) => s.setSelection)
66
152
  const setEditingHole = useEditor((s) => s.setEditingHole)
153
+ const unit = useViewer((s) => s.unit)
154
+ // Drives the height-drag dimension pill below the menu. `activeHandleDrag`
155
+ // flips only at drag start / end, so subscribing here is cheap — the live
156
+ // height value is written imperatively in the useFrame below.
157
+ const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
67
158
 
68
159
  const groupRef = useRef<THREE.Group>(null)
69
- const startEndpointGroupRef = useRef<THREE.Group>(null)
70
- const endEndpointGroupRef = useRef<THREE.Group>(null)
71
- const [altPressed, setAltPressed] = useState(false)
160
+ const menuScaleRef = useRef<HTMLDivElement>(null)
161
+ const pillHeightRef = useRef<HTMLSpanElement>(null)
162
+
163
+ // Cached world anchor. The anchor is derived from `Box3.setFromObject`,
164
+ // which traverses the selected object's children — so a node with a
165
+ // continuously-animating child (the spinning turbine-vent head) makes the
166
+ // AABB wobble a few millimetres every frame, and the menu drifts even with
167
+ // a still camera. We instead recompute the box only when the selection,
168
+ // the object's world transform, or its geometry actually changes, and
169
+ // reuse the cached anchor otherwise. (Also removes a per-frame
170
+ // `setFromObject` for every selection.)
171
+ const anchorRef = useRef(new THREE.Vector3())
172
+ const hasAnchorRef = useRef(false)
173
+ const lastMatrixRef = useRef(new THREE.Matrix4())
174
+ const lastAnchorKeyRef = useRef<{ id: string | null; node: AnyNode | null }>({
175
+ id: null,
176
+ node: null,
177
+ })
72
178
 
73
179
  // Only show for single selection of specific types
74
180
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
@@ -76,7 +182,23 @@ export function FloatingActionMenu() {
76
182
  // Subscribe just to the selected node so unrelated scene updates do not
77
183
  // re-render this menu.
78
184
  const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
79
- const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
185
+ // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
186
+ // NodeDefinition with `capabilities.selectable`) get the floating menu
187
+ // by default too. Phase 4 collapses these into a single registry check.
188
+ const isValidType = node
189
+ ? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
190
+ : false
191
+
192
+ // Height-drag pill: shown just above the menu only while the selected
193
+ // wall/fence height arrow is being dragged. Length + thickness are fixed
194
+ // during a height drag, so they're computed here; the live height value
195
+ // is updated imperatively in the useFrame (same pattern as the scale).
196
+ const pillNode = node?.type === 'wall' || node?.type === 'fence' ? node : null
197
+ const isHeightDragPill =
198
+ pillNode !== null &&
199
+ activeHandleDrag?.nodeId === selectedId &&
200
+ activeHandleDrag?.label === 'height'
201
+ const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null
80
202
 
81
203
  // Boolean selector, only re-renders when curving availability actually flips.
82
204
  const canCurveSelectedWall = useScene((s) => {
@@ -95,111 +217,73 @@ export function FloatingActionMenu() {
95
217
  })
96
218
  })
97
219
 
98
- useEffect(() => {
99
- const handleKeyDown = (event: KeyboardEvent) => {
100
- if (event.key === 'Alt') {
101
- setAltPressed(true)
102
- }
103
- }
104
-
105
- const handleKeyUp = (event: KeyboardEvent) => {
106
- if (event.key === 'Alt') {
107
- setAltPressed(false)
108
- }
109
- }
220
+ useFrame((state) => {
221
+ if (!(selectedId && isValidType && groupRef.current)) return
110
222
 
111
- const handleBlur = () => {
112
- setAltPressed(false)
223
+ // Scale the HTML menu with camera zoom (ortho) or inverse distance
224
+ // (perspective) so it feels anchored to the world, clamped on both ends
225
+ // so it stays readable at extreme zoom-out and doesn't fill the screen
226
+ // when zoomed in close.
227
+ if (menuScaleRef.current) {
228
+ const raw =
229
+ state.camera instanceof THREE.OrthographicCamera
230
+ ? state.camera.zoom / REF_ORTHO_ZOOM
231
+ : REF_CAMERA_DISTANCE /
232
+ Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
233
+ const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
234
+ menuScaleRef.current.style.transform = `scale(${scale})`
113
235
  }
114
236
 
115
- window.addEventListener('keydown', handleKeyDown)
116
- window.addEventListener('keyup', handleKeyUp)
117
- window.addEventListener('blur', handleBlur)
118
-
119
- return () => {
120
- window.removeEventListener('keydown', handleKeyDown)
121
- window.removeEventListener('keyup', handleKeyUp)
122
- window.removeEventListener('blur', handleBlur)
237
+ // Live height readout for the drag pill. The dragged height lands in
238
+ // `useLiveNodeOverrides` (not the scene store) each frame, so read it
239
+ // imperatively here instead of forcing a per-frame React re-render.
240
+ if (
241
+ pillHeightRef.current &&
242
+ (node?.type === 'wall' || node?.type === 'fence') &&
243
+ activeHandleDrag?.nodeId === selectedId &&
244
+ activeHandleDrag?.label === 'height'
245
+ ) {
246
+ const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
247
+ | { height?: number }
248
+ | undefined
249
+ const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT
250
+ const liveHeight = override?.height ?? node.height ?? fallbackHeight
251
+ pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
123
252
  }
124
- }, [])
125
-
126
- useFrame(() => {
127
- if (!(selectedId && isValidType && groupRef.current)) return
128
253
 
129
254
  const obj = sceneRegistry.nodes.get(selectedId)
130
255
  if (obj) {
131
- // Calculate bounding box in world space
132
- const box = new THREE.Box3().setFromObject(obj)
133
- if (!box.isEmpty()) {
134
- const center = box.getCenter(new THREE.Vector3())
135
- // Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
136
- const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type)
137
- const yOffset = isStructural ? 0.8 : 0.3
138
- groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
139
- }
256
+ // Recompute the anchor only when the object genuinely changes —
257
+ // reselected, moved (its own world matrix changed), or resized
258
+ // (a fresh store node on commit, or a live override / handle drag
259
+ // mid-resize). A spinning child changes the head's matrix, not the
260
+ // registered group's, so it never triggers a recompute the menu
261
+ // holds still.
262
+ const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
263
+ const dragActive = activeHandleDrag?.nodeId === selectedId
264
+ const selectionChanged =
265
+ lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
266
+ const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
140
267
 
141
- if (node?.type === 'wall' || node?.type === 'fence') {
142
- const segment = node as WallNode | FenceNode
143
- const endpointYOffset = 0.35
144
- const startWorld =
145
- node.type === 'wall'
146
- ? obj.localToWorld(new THREE.Vector3(0, 0, 0))
147
- : obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
148
- const endWorld =
149
- node.type === 'wall'
150
- ? obj.localToWorld(
151
- new THREE.Vector3(
152
- Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
153
- 0,
154
- 0,
155
- ),
156
- )
157
- : obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
158
-
159
- if (startEndpointGroupRef.current) {
160
- startEndpointGroupRef.current.position.set(
161
- startWorld.x,
162
- startWorld.y + endpointYOffset,
163
- startWorld.z,
164
- )
165
- }
166
- if (endEndpointGroupRef.current) {
167
- endEndpointGroupRef.current.position.set(
168
- endWorld.x,
169
- endWorld.y + endpointYOffset,
170
- endWorld.z,
171
- )
268
+ if (selectionChanged || matrixChanged || overrideActive || dragActive) {
269
+ const box = new THREE.Box3().setFromObject(obj)
270
+ if (!box.isEmpty()) {
271
+ const center = box.getCenter(new THREE.Vector3())
272
+ // Position above the object. Per-type offsets clear each kind's
273
+ // in-world chrome (height-resize arrows, measurement labels).
274
+ anchorRef.current.set(center.x, box.max.y + getMenuYOffset(node), center.z)
275
+ hasAnchorRef.current = true
172
276
  }
277
+ lastMatrixRef.current.copy(obj.matrixWorld)
278
+ lastAnchorKeyRef.current = { id: selectedId, node }
279
+ }
280
+
281
+ if (hasAnchorRef.current) {
282
+ groupRef.current.position.copy(anchorRef.current)
173
283
  }
174
284
  }
175
285
  })
176
286
 
177
- const handleMove = useCallback(
178
- (e: React.MouseEvent) => {
179
- e.stopPropagation()
180
- if (!node) return
181
- sfxEmitter.emit('sfx:item-pick')
182
- if (
183
- node.type === 'item' ||
184
- node.type === 'window' ||
185
- node.type === 'door' ||
186
- node.type === 'wall' ||
187
- node.type === 'fence' ||
188
- node.type === 'column' ||
189
- node.type === 'slab' ||
190
- node.type === 'ceiling' ||
191
- node.type === 'spawn' ||
192
- node.type === 'roof' ||
193
- node.type === 'roof-segment' ||
194
- node.type === 'stair' ||
195
- node.type === 'stair-segment'
196
- ) {
197
- setMovingNode(node as any)
198
- }
199
- setSelection({ selectedIds: [] })
200
- },
201
- [node, setMovingNode, setSelection],
202
- )
203
287
  const handleCurve = useCallback(
204
288
  (e: React.MouseEvent) => {
205
289
  e.stopPropagation()
@@ -217,23 +301,16 @@ export function FloatingActionMenu() {
217
301
  },
218
302
  [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
219
303
  )
220
- const handleEndpointMove = useCallback(
221
- (endpoint: 'start' | 'end', e: React.MouseEvent) => {
304
+ const handleMove = useCallback(
305
+ (e: React.MouseEvent) => {
222
306
  e.stopPropagation()
223
307
  if (!node) return
224
308
  sfxEmitter.emit('sfx:item-pick')
225
- if (node.type === 'wall') {
226
- setMovingWallEndpoint({ wall: node, endpoint })
227
- } else if (node.type === 'fence') {
228
- setMovingFenceEndpoint({ fence: node, endpoint })
229
- } else {
230
- return
231
- }
309
+ setMovingNode(node as any)
232
310
  setSelection({ selectedIds: [] })
233
311
  },
234
- [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
312
+ [node, setMovingNode, setSelection],
235
313
  )
236
-
237
314
  const handleDuplicate = useCallback(
238
315
  (e: React.MouseEvent) => {
239
316
  e.stopPropagation()
@@ -263,6 +340,8 @@ export function FloatingActionMenu() {
263
340
  duplicate = WindowNode.parse(duplicateInfo)
264
341
  } else if (node.type === 'item') {
265
342
  duplicate = ItemNode.parse(duplicateInfo)
343
+ } else if (node.type === 'elevator') {
344
+ duplicate = ElevatorNode.parse(duplicateInfo)
266
345
  } else if (node.type === 'column') {
267
346
  duplicate = ColumnNode.parse(duplicateInfo)
268
347
  } else if (node.type === 'wall') {
@@ -284,6 +363,16 @@ export function FloatingActionMenu() {
284
363
  } else if (node.type === 'spawn') {
285
364
  duplicate = SpawnNode.parse(duplicateInfo)
286
365
  }
366
+
367
+ // Registry-driven fallback: any kind with a NodeDefinition can be
368
+ // duplicated through its schema's parse(). Future built-in kinds
369
+ // get duplicate for free.
370
+ if (!duplicate) {
371
+ const def = nodeRegistry.get(node.type)
372
+ if (def) {
373
+ duplicate = def.schema.parse(duplicateInfo) as AnyNode
374
+ }
375
+ }
287
376
  } catch (error) {
288
377
  console.error('Failed to parse duplicate', error)
289
378
  useScene.temporal.getState().resume()
@@ -296,7 +385,11 @@ export function FloatingActionMenu() {
296
385
  }
297
386
 
298
387
  if (duplicate) {
299
- if (duplicate.type === 'door' || duplicate.type === 'window') {
388
+ if (
389
+ duplicate.type === 'door' ||
390
+ duplicate.type === 'window' ||
391
+ duplicate.type === 'elevator'
392
+ ) {
300
393
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
301
394
  } else if (duplicate.type === 'wall') {
302
395
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
@@ -322,9 +415,39 @@ export function FloatingActionMenu() {
322
415
  }
323
416
 
324
417
  // Duplicate children for stair nodes
418
+ } else if (
419
+ duplicate.type === 'item' ||
420
+ duplicate.type === 'chimney' ||
421
+ duplicate.type === 'dormer'
422
+ ) {
423
+ // Items, chimneys & dormers use pure drag-to-place: NO node is
424
+ // inserted into the scene until the user clicks to commit. The
425
+ // `setMovingNode` call below hands the clone (with
426
+ // `metadata.isNew = true` + no id) to its move tool —
427
+ // `MoveItemTool` / `MoveChimneyTool` / `MoveDormerTool` — which
428
+ // create a draft and call `createNode` on the commit click.
429
+ // Pre-creating here would drop a second copy into the scene
430
+ // before any click — the furnish-tab "duplicate auto-places an
431
+ // item without clicking" bug. (Item has its own
432
+ // draft-committing move tool, so it must skip the generic
433
+ // registry auto-create branch below.)
434
+ } else if (nodeRegistry.has(duplicate.type)) {
435
+ // Registry-driven kinds: offset the position slightly so the
436
+ // duplicate doesn't overlap exactly, then create + hand to the
437
+ // move tool. Mirrors the roof-segment / stair-segment behavior.
438
+ if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
439
+ const pos = (duplicate as { position: [number, number, number] }).position
440
+ ;(duplicate as { position: [number, number, number] }).position = [
441
+ pos[0] + 1,
442
+ pos[1],
443
+ pos[2] + 1,
444
+ ]
445
+ }
446
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
325
447
  }
326
448
  if (
327
449
  duplicate.type === 'item' ||
450
+ duplicate.type === 'elevator' ||
328
451
  duplicate.type === 'column' ||
329
452
  duplicate.type === 'wall' ||
330
453
  duplicate.type === 'fence' ||
@@ -332,7 +455,10 @@ export function FloatingActionMenu() {
332
455
  duplicate.type === 'door' ||
333
456
  duplicate.type === 'roof-segment' ||
334
457
  duplicate.type === 'spawn' ||
335
- duplicate.type === 'stair-segment'
458
+ duplicate.type === 'stair-segment' ||
459
+ // Registry-driven kinds get picked up by MoveTool's generic
460
+ // fallback (MoveRegistryNodeTool) so the user can reposition.
461
+ nodeRegistry.has(duplicate.type)
336
462
  ) {
337
463
  setMovingNode(duplicate as any)
338
464
  } else if (duplicate.type === 'stair') {
@@ -388,11 +514,7 @@ export function FloatingActionMenu() {
388
514
  (e: React.MouseEvent) => {
389
515
  e.stopPropagation()
390
516
  if (!selectedId) return
391
- if (node?.type === 'item') {
392
- sfxEmitter.emit('sfx:item-delete')
393
- } else {
394
- sfxEmitter.emit('sfx:structure-delete')
395
- }
517
+ emitDeleteSFX(node?.type)
396
518
  setSelection({ selectedIds: [] })
397
519
  useScene.getState().deleteNode(selectedId as AnyNodeId)
398
520
  },
@@ -416,86 +538,55 @@ export function FloatingActionMenu() {
416
538
  pointerEvents: 'auto',
417
539
  touchAction: 'none',
418
540
  }}
419
- zIndexRange={[100, 0]}
541
+ zIndexRange={[25, 0]}
420
542
  >
421
- <NodeActionMenu
422
- onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
423
- onCurve={
424
- node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
425
- ? handleCurve
426
- : undefined
427
- }
428
- onDelete={handleDelete}
429
- onDuplicate={
430
- node &&
431
- node.type !== 'spawn' &&
432
- !DELETE_ONLY_TYPES.includes(node.type) &&
433
- !HOLE_TYPES.includes(node.type)
434
- ? handleDuplicate
435
- : undefined
436
- }
437
- onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
438
- onPointerDown={(e) => e.stopPropagation()}
439
- onPointerUp={(e) => e.stopPropagation()}
440
- />
543
+ <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
544
+ <NodeActionMenu
545
+ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
546
+ onCurve={
547
+ node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
548
+ ? handleCurve
549
+ : undefined
550
+ }
551
+ onMove={
552
+ // Fully registry-driven: any kind that declares
553
+ // `capabilities.movable`, a `floorplanMoveTarget`, or a
554
+ // 3D `affordanceTools.move` mover gets the Move button.
555
+ // Adding a new movable kind never touches this file.
556
+ node && isRegistryMovable(node.type) ? handleMove : undefined
557
+ }
558
+ onDelete={handleDelete}
559
+ onDuplicate={
560
+ node &&
561
+ node.type !== 'spawn' &&
562
+ !DELETE_ONLY_TYPES.includes(node.type) &&
563
+ !HOLE_TYPES.includes(node.type)
564
+ ? handleDuplicate
565
+ : undefined
566
+ }
567
+ onPointerDown={(e) => e.stopPropagation()}
568
+ onPointerUp={(e) => e.stopPropagation()}
569
+ />
570
+ {/* Height-drag dimension pill. Absolutely positioned just above
571
+ the menu (away from the height arrow below it) so it rides the
572
+ same scale transform + anchor, never overlaps the menu, and
573
+ needs no menu lift — which is what caused the click flicker.
574
+ Non-interactive. */}
575
+ {isHeightDragPill && pillDims ? (
576
+ <div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2">
577
+ <MeasurementPill
578
+ height={pillDims.height}
579
+ length={pillDims.length}
580
+ primary="height"
581
+ ref={pillHeightRef}
582
+ thickness={pillDims.thickness}
583
+ unit={unit}
584
+ />
585
+ </div>
586
+ ) : null}
587
+ </div>
441
588
  </Html>
442
589
  </group>
443
- {(node?.type === 'wall' || node?.type === 'fence') && (
444
- <>
445
- <group ref={startEndpointGroupRef}>
446
- <Html
447
- center
448
- style={{ pointerEvents: 'auto', touchAction: 'none' }}
449
- zIndexRange={[100, 0]}
450
- >
451
- <button
452
- aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
453
- className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
454
- altPressed
455
- ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
456
- : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
457
- }`}
458
- onClick={(e) => handleEndpointMove('start', e)}
459
- onPointerDown={(e) => e.stopPropagation()}
460
- title={
461
- node.type === 'wall'
462
- ? 'Move wall start (Alt to detach)'
463
- : 'Move fence start (Alt to detach)'
464
- }
465
- type="button"
466
- >
467
- <Move className="h-4 w-4" />
468
- </button>
469
- </Html>
470
- </group>
471
- <group ref={endEndpointGroupRef}>
472
- <Html
473
- center
474
- style={{ pointerEvents: 'auto', touchAction: 'none' }}
475
- zIndexRange={[100, 0]}
476
- >
477
- <button
478
- aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
479
- className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
480
- altPressed
481
- ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
482
- : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
483
- }`}
484
- onClick={(e) => handleEndpointMove('end', e)}
485
- onPointerDown={(e) => e.stopPropagation()}
486
- title={
487
- node.type === 'wall'
488
- ? 'Move wall end (Alt to detach)'
489
- : 'Move fence end (Alt to detach)'
490
- }
491
- type="button"
492
- >
493
- <Move className="h-4 w-4" />
494
- </button>
495
- </Html>
496
- </group>
497
- </>
498
- )}
499
590
  </group>
500
591
  )
501
592
  }
@@ -57,7 +57,7 @@ export function FloatingBuildingActionMenu() {
57
57
  pointerEvents: 'auto',
58
58
  touchAction: 'none',
59
59
  }}
60
- zIndexRange={[100, 0]}
60
+ zIndexRange={[25, 0]}
61
61
  >
62
62
  <NodeActionMenu
63
63
  onMove={handleMove}