@pascal-app/editor 0.7.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  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 +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  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 +5837 -12006
  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 +234 -190
  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 +435 -158
  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 +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  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-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { createPortal, useFrame, useThree } from '@react-three/fiber'
6
+ import { useEffect, useMemo, useRef } from 'react'
7
+ import { BoxGeometry, type BufferGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
8
+ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
9
+ import { EDITOR_LAYER } from '../../lib/constants'
10
+
11
+ // How far the outline sits outside the opening's own extents, so it reads as
12
+ // a frame *around* the opening rather than coinciding with its edges.
13
+ const PAD = 0.05
14
+
15
+ const ACCENT = 0x83_81_ed
16
+
17
+ const NO_RAYCAST = () => null
18
+ const scratchScale = new Vector3()
19
+
20
+ // Indigo accent matching the resize-arrow handles — deliberately distinct
21
+ // from the white selection outline so the highlight reads as "editable child
22
+ // here", not "this is selected". `depthTest: false` keeps both layers drawn
23
+ // on top of the wall so frameless openings (which have no visible geometry)
24
+ // are still located. Shared across every box; never disposed.
25
+ const outlineMaterial = new LineBasicNodeMaterial({
26
+ color: ACCENT,
27
+ depthTest: false,
28
+ depthWrite: false,
29
+ })
30
+
31
+ // Translucent block that fills the opening volume so the cutout reads as an
32
+ // occupied slot from every angle — the front face shows it head-on, the top
33
+ // face shows it from a top-down floorplan view (a single vertical pane would
34
+ // be edge-on, hence invisible, when looking straight down). Front-side culling
35
+ // keeps the translucency even instead of doubling up on overlapping back faces.
36
+ const fillMaterial = new MeshBasicNodeMaterial({
37
+ color: ACCENT,
38
+ transparent: true,
39
+ opacity: 0.5,
40
+ depthTest: false,
41
+ depthWrite: false,
42
+ })
43
+
44
+ function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry {
45
+ const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD)
46
+ const edges = new EdgesGeometry(box)
47
+ box.dispose()
48
+ return edges
49
+ }
50
+
51
+ /**
52
+ * When a wall is selected, draws a translucent indigo highlight (filled block
53
+ * + outline) over each door / window it hosts. Openings whose `openingKind`
54
+ * is `'opening'` have no visible geometry, so without this affordance the
55
+ * user can't tell an editable cutout lives there — the fill marks it (and
56
+ * stays out of the way of clicking the opening itself, which selects it).
57
+ *
58
+ * Highlights are portalled to the scene root so they sit outside the wall's
59
+ * selection-outline subtree and keep their own accent colour.
60
+ */
61
+ export function WallOpeningHighlights() {
62
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
63
+ const { scene } = useThree()
64
+
65
+ if (selectedIds.length === 0) return null
66
+
67
+ return createPortal(
68
+ <>
69
+ {selectedIds.map((id) => (
70
+ <SelectionOpeningHighlights key={id} selectedId={id} />
71
+ ))}
72
+ </>,
73
+ scene,
74
+ )
75
+ }
76
+
77
+ // Resolves a selected node into the opening highlight(s) to draw:
78
+ // - a selected wall → a hint over each door / window it hosts ("editable
79
+ // child here").
80
+ // - a directly-selected frameless opening (a `door` whose `openingKind` is
81
+ // `'opening'`) → a fill over its own cutout, so the selection reads as
82
+ // occupied even though the opening renders no geometry of its own.
83
+ function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) {
84
+ const node = useScene((state) => state.nodes[selectedId as AnyNodeId])
85
+
86
+ if (node?.type === 'wall') {
87
+ const depth = node.thickness ?? 0.1
88
+ return (
89
+ <>
90
+ {(node.children ?? []).map((childId) => (
91
+ <OpeningHighlight depth={depth} key={childId} openingId={childId} />
92
+ ))}
93
+ </>
94
+ )
95
+ }
96
+
97
+ if (node?.type === 'door' && node.openingKind === 'opening') {
98
+ return <SelectedOpeningHighlight openingId={selectedId} parentId={node.parentId} />
99
+ }
100
+
101
+ return null
102
+ }
103
+
104
+ // A frameless opening selected on its own. Pulls the cutout depth from its
105
+ // host wall's thickness so the fill block matches the wall it sits in.
106
+ function SelectedOpeningHighlight({
107
+ openingId,
108
+ parentId,
109
+ }: {
110
+ openingId: string
111
+ parentId: string | null
112
+ }) {
113
+ const parent = useScene((state) => (parentId ? state.nodes[parentId as AnyNodeId] : undefined))
114
+ const depth = parent?.type === 'wall' ? (parent.thickness ?? 0.1) : 0.1
115
+ return <OpeningHighlight depth={depth} openingId={openingId} />
116
+ }
117
+
118
+ function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) {
119
+ const node = useScene((state) => state.nodes[openingId as AnyNodeId])
120
+ // Resize arrows publish width/height to the live-override store during the
121
+ // drag and only commit to the scene node on pointer-up, so read the
122
+ // override-merged dimensions to keep the highlight box tracking the resize.
123
+ const override = useLiveNodeOverrides((s) => s.overrides.get(openingId))
124
+ const groupRef = useRef<Group>(null)
125
+
126
+ const isOpening = node?.type === 'door' || node?.type === 'window'
127
+ const width = isOpening ? ((override?.width as number | undefined) ?? node.width) : 0
128
+ const height = isOpening ? ((override?.height as number | undefined) ?? node.height) : 0
129
+
130
+ const outlineGeometry = useMemo(
131
+ () => (isOpening ? makeOutlineGeometry(width, height, depth) : null),
132
+ [isOpening, width, height, depth],
133
+ )
134
+ const fillGeometry = useMemo(
135
+ () => (isOpening ? new BoxGeometry(width, height, depth) : null),
136
+ [isOpening, width, height, depth],
137
+ )
138
+ useEffect(() => () => outlineGeometry?.dispose(), [outlineGeometry])
139
+ useEffect(() => () => fillGeometry?.dispose(), [fillGeometry])
140
+
141
+ // The opening's mesh (registered by its renderer) already carries the wall
142
+ // transform + its own local pose baked into `matrixWorld`. Copy that
143
+ // straight onto the highlight each frame so it tracks moves, resizes, and
144
+ // wall rotation without any wall-local maths here.
145
+ useFrame(() => {
146
+ const group = groupRef.current
147
+ if (!group) return
148
+ const obj = sceneRegistry.nodes.get(openingId as AnyNodeId)
149
+ if (!obj) {
150
+ group.visible = false
151
+ return
152
+ }
153
+ group.visible = true
154
+ obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale)
155
+ })
156
+
157
+ if (!isOpening || !outlineGeometry || !fillGeometry) return null
158
+
159
+ return (
160
+ <group ref={groupRef}>
161
+ <mesh
162
+ frustumCulled={false}
163
+ geometry={fillGeometry}
164
+ layers={EDITOR_LAYER}
165
+ material={fillMaterial}
166
+ raycast={NO_RAYCAST}
167
+ renderOrder={1004}
168
+ />
169
+ <lineSegments
170
+ frustumCulled={false}
171
+ geometry={outlineGeometry}
172
+ layers={EDITOR_LAYER}
173
+ material={outlineMaterial}
174
+ raycast={NO_RAYCAST}
175
+ renderOrder={1005}
176
+ />
177
+ </group>
178
+ )
179
+ }
180
+
181
+ export default WallOpeningHighlights
@@ -0,0 +1,150 @@
1
+ 'use client'
2
+
3
+ import { sceneRegistry } from '@pascal-app/core'
4
+ import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
5
+ import { useViewer } from '@pascal-app/viewer'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { memo, useRef } from 'react'
8
+ import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
9
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
10
+ import { EDITOR_LAYER } from '../../lib/constants'
11
+
12
+ /**
13
+ * "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that
14
+ * stands at the draft / move endpoint while it's locked onto existing wall
15
+ * geometry. It's the spatial cue from the design reference: a standing pillar
16
+ * so you can see *where* the snap caught even at an angle, plus a floor marker
17
+ * whose shape tells you *what* it caught (CAD-style osnap glyphs):
18
+ *
19
+ * endpoint (corner) → square midpoint → triangle
20
+ * intersection → ✕ cross wall body (edge) → circle
21
+ *
22
+ * Subscribes to the shared `useWallSnapIndicator` store (published by the wall
23
+ * draft + endpoint-move tools). Shares the indigo accent of
24
+ * `Alignment3DGuideLayer` for visual consistency.
25
+ *
26
+ * The point carries only XZ (building-local plan coords); like the alignment
27
+ * guides it's lifted to the active level's building-local Y each frame so it
28
+ * stands on the floor being edited when floors are stacked. Mounted inside
29
+ * ToolManager's building-local group.
30
+ */
31
+
32
+ const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent
33
+ const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
34
+ const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
35
+ const MARKER = 0.13 // world-meter base size of the floor glyph
36
+ const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
37
+
38
+ // Shared resources — one material + unit geometries, so snap churn during a
39
+ // drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
40
+ const beaconMaterial = new MeshBasicNodeMaterial({
41
+ color: BEACON_COLOR,
42
+ depthTest: false,
43
+ depthWrite: false,
44
+ toneMapped: false,
45
+ transparent: true,
46
+ opacity: 0.9,
47
+ })
48
+ const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
49
+ // Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
50
+ // a flat plate; circles/triangles lie flat via an X rotation at the mesh.
51
+ const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
52
+ const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
53
+ const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
54
+
55
+ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
56
+ const point = useWallSnapIndicator((s) => s.point)
57
+ const levelId = useViewer((s) => s.selection.levelId)
58
+ const groupRef = useRef<Group>(null)
59
+
60
+ // Track the active level's building-local Y each frame so the beacon stands
61
+ // on the floor being edited, not the building base — same source the
62
+ // alignment guide layer and `grid.tsx` read.
63
+ useFrame(() => {
64
+ const group = groupRef.current
65
+ if (!group) return
66
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
67
+ group.position.y = levelMesh ? levelMesh.position.y : 0
68
+ })
69
+
70
+ if (!point) return null
71
+ return (
72
+ <group ref={groupRef}>
73
+ <mesh
74
+ geometry={PILLAR_GEOMETRY}
75
+ layers={EDITOR_LAYER}
76
+ material={beaconMaterial}
77
+ position={[point.x, BEACON_HEIGHT / 2, point.z]}
78
+ renderOrder={1002}
79
+ />
80
+ <SnapMarker kind={point.kind} x={point.x} z={point.z} />
81
+ </group>
82
+ )
83
+ })
84
+
85
+ /** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
86
+ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
87
+ const y = FLOOR_LIFT
88
+ if (kind === 'endpoint') {
89
+ return (
90
+ <mesh
91
+ geometry={FLAT_BOX_GEOMETRY}
92
+ layers={EDITOR_LAYER}
93
+ material={beaconMaterial}
94
+ position={[x, y, z]}
95
+ renderOrder={1001}
96
+ scale={[MARKER * 2, 1, MARKER * 2]}
97
+ />
98
+ )
99
+ }
100
+ if (kind === 'midpoint') {
101
+ return (
102
+ <mesh
103
+ geometry={TRIANGLE_GEOMETRY}
104
+ layers={EDITOR_LAYER}
105
+ material={beaconMaterial}
106
+ position={[x, y, z]}
107
+ renderOrder={1001}
108
+ rotation={[-Math.PI / 2, 0, 0]}
109
+ scale={[MARKER * 1.4, MARKER * 1.4, MARKER * 1.4]}
110
+ />
111
+ )
112
+ }
113
+ if (kind === 'intersection') {
114
+ // Two crossed bars → an ✕, the universal "crossing" glyph.
115
+ return (
116
+ <>
117
+ <mesh
118
+ geometry={FLAT_BOX_GEOMETRY}
119
+ layers={EDITOR_LAYER}
120
+ material={beaconMaterial}
121
+ position={[x, y, z]}
122
+ renderOrder={1001}
123
+ rotation={[0, Math.PI / 4, 0]}
124
+ scale={[MARKER * 2.6, 1, MARKER * 0.5]}
125
+ />
126
+ <mesh
127
+ geometry={FLAT_BOX_GEOMETRY}
128
+ layers={EDITOR_LAYER}
129
+ material={beaconMaterial}
130
+ position={[x, y, z]}
131
+ renderOrder={1001}
132
+ rotation={[0, -Math.PI / 4, 0]}
133
+ scale={[MARKER * 2.6, 1, MARKER * 0.5]}
134
+ />
135
+ </>
136
+ )
137
+ }
138
+ // 'wall' (edge / along-wall) → circle
139
+ return (
140
+ <mesh
141
+ geometry={CIRCLE_GEOMETRY}
142
+ layers={EDITOR_LAYER}
143
+ material={beaconMaterial}
144
+ position={[x, y, z]}
145
+ renderOrder={1001}
146
+ rotation={[-Math.PI / 2, 0, 0]}
147
+ scale={[MARKER, MARKER, MARKER]}
148
+ />
149
+ )
150
+ }
@@ -14,12 +14,14 @@ export type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonEleme
14
14
  export type FloorplanActionMenuEntry = {
15
15
  position: SvgPoint | null
16
16
  onDelete: FloorplanActionMenuHandler
17
- onMove: FloorplanActionMenuHandler
17
+ onMove?: FloorplanActionMenuHandler
18
18
  onAddHole?: FloorplanActionMenuHandler
19
+ onCurve?: FloorplanActionMenuHandler
19
20
  onDuplicate?: FloorplanActionMenuHandler
20
21
  }
21
22
 
22
23
  type FloorplanActionMenuLayerProps = {
24
+ elevator: FloorplanActionMenuEntry
23
25
  item: FloorplanActionMenuEntry
24
26
  wall: FloorplanActionMenuEntry
25
27
  fence: FloorplanActionMenuEntry
@@ -33,6 +35,7 @@ type FloorplanActionMenuLayerProps = {
33
35
  }
34
36
 
35
37
  export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
38
+ elevator,
36
39
  item,
37
40
  wall,
38
41
  fence,
@@ -55,6 +58,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
55
58
  }
56
59
 
57
60
  const entries: FloorplanActionMenuEntry[] = [
61
+ elevator,
58
62
  item,
59
63
  wall,
60
64
  fence,
@@ -81,6 +85,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
81
85
  >
82
86
  <NodeActionMenu
83
87
  onAddHole={entry.onAddHole}
88
+ onCurve={entry.onCurve}
84
89
  onDelete={entry.onDelete}
85
90
  onDuplicate={entry.onDuplicate}
86
91
  onMove={entry.onMove}
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { memo } from 'react'
5
+ import useAlignmentGuides from '../../store/use-alignment-guides'
6
+ import { formatMeasurement } from '../editor/measurement-pill'
7
+ import { useFloorplanRender } from './floorplan-render-context'
8
+
9
+ /**
10
+ * Figma-style alignment guides for the 2D floor plan.
11
+ *
12
+ * Subscribes to the editor-local `useAlignmentGuides` store (separate
13
+ * from the core store the 3D layer reads). Guides come in
14
+ * building-local meters, so the layer is mounted INSIDE the rotated
15
+ * `<g data-floorplan-scene>` — the SVG transform that takes the rest
16
+ * of the floor-plan geometry from local → screen carries the guide
17
+ * lines too. Pill labels are counter-rotated by `sceneRotationDeg`
18
+ * (from `FloorplanRenderProvider`) so they stay upright even when the
19
+ * scene `<g>` is rotated by building rotation.
20
+ *
21
+ * Each guide renders as a red line between the moving and matched
22
+ * candidate anchors with small `×` end-caps. A distance pill is drawn
23
+ * at the midpoint when the perpendicular gap is non-zero.
24
+ *
25
+ * Stroke widths and handle radii are scaled by `unitsPerPixel` so they
26
+ * stay a constant size on screen no matter the zoom.
27
+ */
28
+ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
29
+ const guides = useAlignmentGuides((s) => s.guides)
30
+ const unit = useViewer((s) => s.unit)
31
+ const ctx = useFloorplanRender()
32
+
33
+ if (guides.length === 0) return null
34
+
35
+ const upp = ctx?.unitsPerPixel ?? 0.01
36
+ const sceneRot = ctx?.sceneRotationDeg ?? 0
37
+
38
+ // Pixel-budgeted sizes converted to world meters so visuals stay
39
+ // constant across zoom. Numbers picked to mirror Figma's snap chrome.
40
+ const stroke = 1 * upp
41
+ const xCapSize = 4 * upp
42
+ const pillFontSize = 11 * upp
43
+ const pillPadX = 5 * upp
44
+ const pillPadY = 3 * upp
45
+ const pillRadius = 3 * upp
46
+ const pillOffset = 8 * upp
47
+
48
+ const color = '#ef4444' // tailwind red-500 — matches Figma's snap red
49
+
50
+ return (
51
+ <g pointerEvents="none">
52
+ {guides.map((guide, i) => {
53
+ const { from, to, axis } = guide
54
+ const midX = (from.x + to.x) / 2
55
+ const midZ = (from.z + to.z) / 2
56
+ const distMeters = guide.distance
57
+
58
+ // Pill placed offset perpendicular to the guide's axis so it
59
+ // doesn't sit on top of the line itself. For an X-axis guide
60
+ // (vertical line) we offset along Z; for a Z-axis guide we
61
+ // offset along X.
62
+ const pillX = axis === 'x' ? midX + pillOffset : midX
63
+ const pillZ = axis === 'z' ? midZ + pillOffset : midZ
64
+ const distLabel = formatMeasurement(distMeters, unit)
65
+ const charWidth = pillFontSize * 0.55
66
+ const pillWidth = distLabel.length * charWidth + pillPadX * 2
67
+ const pillHeight = pillFontSize + pillPadY * 2
68
+
69
+ return (
70
+ <g key={i}>
71
+ <line stroke={color} strokeWidth={stroke} x1={from.x} x2={to.x} y1={from.z} y2={to.z} />
72
+ <XCap color={color} size={xCapSize} stroke={stroke} x={from.x} y={from.z} />
73
+ <XCap color={color} size={xCapSize} stroke={stroke} x={to.x} y={to.z} />
74
+ {distMeters > 1e-4 && (
75
+ // Counter-rotate the pill so it stays upright when the
76
+ // scene `<g>` is rotated by building rotation. SVG's
77
+ // `transform` runs in the local coord system, so the
78
+ // rotation pivots around the pill's center.
79
+ <g transform={`rotate(${-sceneRot} ${pillX} ${pillZ})`}>
80
+ <rect
81
+ fill={color}
82
+ height={pillHeight}
83
+ rx={pillRadius}
84
+ ry={pillRadius}
85
+ width={pillWidth}
86
+ x={pillX - pillWidth / 2}
87
+ y={pillZ - pillHeight / 2}
88
+ />
89
+ <text
90
+ fill="#ffffff"
91
+ fontFamily="-apple-system, system-ui, sans-serif"
92
+ fontSize={pillFontSize}
93
+ fontWeight={500}
94
+ textAnchor="middle"
95
+ x={pillX}
96
+ y={pillZ + pillFontSize * 0.35}
97
+ >
98
+ {distLabel}
99
+ </text>
100
+ </g>
101
+ )}
102
+ </g>
103
+ )
104
+ })}
105
+ </g>
106
+ )
107
+ })
108
+
109
+ function XCap({
110
+ color,
111
+ size,
112
+ stroke,
113
+ x,
114
+ y,
115
+ }: {
116
+ color: string
117
+ size: number
118
+ stroke: number
119
+ x: number
120
+ y: number
121
+ }) {
122
+ return (
123
+ <g>
124
+ <line
125
+ stroke={color}
126
+ strokeWidth={stroke}
127
+ x1={x - size}
128
+ x2={x + size}
129
+ y1={y - size}
130
+ y2={y + size}
131
+ />
132
+ <line
133
+ stroke={color}
134
+ strokeWidth={stroke}
135
+ x1={x - size}
136
+ x2={x + size}
137
+ y1={y + size}
138
+ y2={y - size}
139
+ />
140
+ </g>
141
+ )
142
+ }
@@ -23,7 +23,6 @@ type FloorplanCursorIndicator =
23
23
 
24
24
  type FloorplanCursorIndicatorOverlayProps = {
25
25
  cursorPosition: SvgPoint | null
26
- cursorAnchorPosition: SvgPoint | null
27
26
  floorplanSelectionTool: FloorplanSelectionTool
28
27
  movingOpeningType: 'door' | 'window' | null
29
28
  isPanning: boolean
@@ -35,7 +34,6 @@ type FloorplanCursorIndicatorOverlayProps = {
35
34
 
36
35
  export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
37
36
  cursorPosition,
38
- cursorAnchorPosition,
39
37
  floorplanSelectionTool,
40
38
  movingOpeningType,
41
39
  isPanning,
@@ -78,10 +76,14 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
78
76
  return { kind: 'icon', icon: 'mdi:trash-can-outline' }
79
77
  }
80
78
 
79
+ if (mode === 'material-paint') {
80
+ return { kind: 'asset', iconSrc: '/icons/paint.png' }
81
+ }
82
+
81
83
  return null
82
84
  }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
83
85
 
84
- const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
86
+ const position = cursorPosition
85
87
 
86
88
  if (!(indicator && position) || isPanning) {
87
89
  return null