@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,75 @@
1
+ 'use client'
2
+
3
+ import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
4
+ import { memo } from 'react'
5
+ import { useFloorplanRender } from './floorplan-render-context'
6
+
7
+ /**
8
+ * "Magnetic" wall-snap beacon for the 2D floor plan — the top-down twin of the
9
+ * 3D `WallSnapBeaconLayer`. Subscribes to the shared `useWallSnapIndicator`
10
+ * store (published by the floor-plan wall draft + endpoint-move handlers) and
11
+ * draws a marker at the snap point whose shape tells you *what* it caught,
12
+ * matching the 3D glyphs:
13
+ *
14
+ * endpoint (corner) → square midpoint → triangle
15
+ * intersection → ✕ cross wall body (edge) → circle
16
+ *
17
+ * Indigo to match the 3D beacon and stay distinct from the red Figma alignment
18
+ * guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a
19
+ * constant size on screen at any zoom. Mounted inside the `data-floorplan-scene`
20
+ * group so coordinates are world meters (XZ) 1:1, like the alignment guides.
21
+ */
22
+ const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon
23
+
24
+ export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() {
25
+ const point = useWallSnapIndicator((s) => s.point)
26
+ const ctx = useFloorplanRender()
27
+
28
+ if (!point) return null
29
+
30
+ const upp = ctx?.unitsPerPixel ?? 0.01
31
+ const m = 6 * upp // base half-size of the glyph in world meters
32
+ const stroke = 1.5 * upp
33
+
34
+ return (
35
+ <g pointerEvents="none">
36
+ <SnapMarker color={COLOR} kind={point.kind} m={m} stroke={stroke} x={point.x} z={point.z} />
37
+ </g>
38
+ )
39
+ })
40
+
41
+ function SnapMarker({
42
+ color,
43
+ kind,
44
+ m,
45
+ stroke,
46
+ x,
47
+ z,
48
+ }: {
49
+ color: string
50
+ kind: WallSnapKind
51
+ m: number
52
+ stroke: number
53
+ x: number
54
+ z: number
55
+ }) {
56
+ if (kind === 'endpoint') {
57
+ return <rect fill={color} height={m * 2} width={m * 2} x={x - m} y={z - m} />
58
+ }
59
+ if (kind === 'midpoint') {
60
+ const t = m * 1.3
61
+ const points = `${x},${z - t} ${x - t},${z + t} ${x + t},${z + t}`
62
+ return <polygon fill={color} points={points} />
63
+ }
64
+ if (kind === 'intersection') {
65
+ const c = m * 1.4
66
+ return (
67
+ <g stroke={color} strokeLinecap="round" strokeWidth={stroke * 2}>
68
+ <line x1={x - c} x2={x + c} y1={z - c} y2={z + c} />
69
+ <line x1={x - c} x2={x + c} y1={z + c} y2={z - c} />
70
+ </g>
71
+ )
72
+ }
73
+ // 'wall' (edge / along-wall) → circle
74
+ return <circle cx={x} cy={z} fill={color} r={m} />
75
+ }
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import { memo } from 'react'
4
+ import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
5
+
6
+ /**
7
+ * Renders translucent dashed previews of bridge walls that the wall
8
+ * junction planner will insert on commit. Mirrors the 3D
9
+ * `GhostWallPreviewMesh` so 2D and 3D show the same intent mid-drag.
10
+ *
11
+ * Subscribes to `useWallMoveGhosts.bridges`; writes happen inside
12
+ * `wallFloorplanMoveTarget.apply` (cleared on `commit` and by the move
13
+ * overlay's unmount cleanup as a safety net).
14
+ */
15
+ export const FloorplanWallMoveGhostLayer = memo(function FloorplanWallMoveGhostLayer() {
16
+ const bridges = useWallMoveGhosts((s) => s.bridges)
17
+
18
+ if (bridges.length === 0) return null
19
+
20
+ return (
21
+ <g pointerEvents="none">
22
+ {bridges.map((bridge) => {
23
+ // Draw the bridge as a thick stroke at the wall's plan-space
24
+ // thickness — same convention as the actual wall renderer.
25
+ // Dashes scale with thickness so they read consistently no
26
+ // matter the wall size or viewport zoom (no `non-scaling-stroke`
27
+ // here; we want the line to be N meters thick, not N pixels).
28
+ const dash = bridge.thickness * 1.4
29
+ const gap = bridge.thickness * 0.9
30
+ return (
31
+ <line
32
+ key={bridge.id}
33
+ opacity={0.45}
34
+ stroke={bridge.color}
35
+ strokeDasharray={`${dash} ${gap}`}
36
+ strokeLinecap="butt"
37
+ strokeWidth={bridge.thickness}
38
+ x1={bridge.start[0]}
39
+ x2={bridge.end[0]}
40
+ y1={bridge.start[1]}
41
+ y2={bridge.end[1]}
42
+ />
43
+ )
44
+ })}
45
+ </g>
46
+ )
47
+ })
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
72
72
  )}
73
73
 
74
74
  {polygonDraftPolygonPoints && (
75
- <polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
75
+ <polygon
76
+ fill={draftFill}
77
+ fillOpacity={0.2}
78
+ points={polygonDraftPolygonPoints}
79
+ stroke="none"
80
+ />
76
81
  )}
77
82
 
78
83
  {polygonDraftPolylinePoints && (
@@ -0,0 +1,266 @@
1
+ 'use client'
2
+
3
+ import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
4
+ import { memo, useEffect, useState } from 'react'
5
+
6
+ /**
7
+ * Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
8
+ * `def.floorplan(node, ctx)` and emits the matching React-SVG nodes.
9
+ *
10
+ * Coordinates are level-local meters. The wrapping floor-plan panel
11
+ * applies the world→SVG transform via its viewBox, so kinds emit
12
+ * geometry in the same units they reason about in 3D.
13
+ *
14
+ * Group transforms compose: `transform={translate(x y) rotate(deg)}`.
15
+ * Rotations are radians at the data layer (consistent with three.js
16
+ * conventions used by `def.geometry`) and converted to degrees for SVG
17
+ * here — kinds never touch units.
18
+ *
19
+ * Styling props map straight onto SVG attributes. Builders that need
20
+ * theme colors should declare them inline or expose them as registry
21
+ * tokens later (deferred until a real need surfaces — AI-authored kinds
22
+ * can pick safe defaults today).
23
+ */
24
+ export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
25
+ geometry,
26
+ pointerEventsOverride,
27
+ }: {
28
+ geometry: FloorplanGeometry
29
+ pointerEventsOverride?: string
30
+ }) {
31
+ return renderNode(geometry, 0, pointerEventsOverride)
32
+ })
33
+
34
+ function styleAttrs(
35
+ g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
36
+ pointerEventsOverride?: string,
37
+ ) {
38
+ // Shared SVG attribute mapping for any styled primitive. Keeps the per-
39
+ // primitive switch arms terse and ensures new style fields land
40
+ // everywhere at once. `as any` avoids re-asserting every variant
41
+ // includes the style fields — they all do, except `group` (which is
42
+ // filtered out by the caller's type bound).
43
+ const s = g as unknown as {
44
+ fill?: string
45
+ fillOpacity?: number
46
+ stroke?: string
47
+ strokeWidth?: number
48
+ strokeDasharray?: string
49
+ strokeLinecap?: 'butt' | 'round' | 'square'
50
+ strokeLinejoin?: 'miter' | 'round' | 'bevel'
51
+ strokeOpacity?: number
52
+ opacity?: number
53
+ vectorEffect?: 'non-scaling-stroke'
54
+ pointerEvents?: string
55
+ cursor?: string
56
+ }
57
+ return {
58
+ fill: s.fill ?? 'none',
59
+ fillOpacity: s.fillOpacity,
60
+ stroke: s.stroke,
61
+ strokeWidth: s.strokeWidth,
62
+ strokeDasharray: s.strokeDasharray,
63
+ strokeLinecap: s.strokeLinecap,
64
+ strokeLinejoin: s.strokeLinejoin,
65
+ strokeOpacity: s.strokeOpacity,
66
+ opacity: s.opacity,
67
+ vectorEffect: s.vectorEffect,
68
+ pointerEvents: pointerEventsOverride ?? s.pointerEvents,
69
+ style: s.cursor ? { cursor: s.cursor } : undefined,
70
+ }
71
+ }
72
+
73
+ function renderNode(
74
+ g: FloorplanGeometry,
75
+ keyHint: number,
76
+ pointerEventsOverride?: string,
77
+ ): React.ReactElement | null {
78
+ switch (g.kind) {
79
+ case 'path':
80
+ return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} />
81
+
82
+ case 'polygon':
83
+ return (
84
+ <polygon
85
+ key={keyHint}
86
+ points={pointsToAttr(g.points)}
87
+ {...styleAttrs(g, pointerEventsOverride)}
88
+ />
89
+ )
90
+
91
+ case 'polyline':
92
+ return (
93
+ <polyline
94
+ key={keyHint}
95
+ points={pointsToAttr(g.points)}
96
+ {...styleAttrs(g, pointerEventsOverride)}
97
+ />
98
+ )
99
+
100
+ case 'rect':
101
+ return (
102
+ <rect
103
+ height={g.height}
104
+ key={keyHint}
105
+ rx={g.rx}
106
+ ry={g.ry}
107
+ width={g.width}
108
+ x={g.x}
109
+ y={g.y}
110
+ {...styleAttrs(g, pointerEventsOverride)}
111
+ />
112
+ )
113
+
114
+ case 'circle':
115
+ return (
116
+ <circle
117
+ cx={g.cx}
118
+ cy={g.cy}
119
+ key={keyHint}
120
+ r={g.r}
121
+ {...styleAttrs(g, pointerEventsOverride)}
122
+ />
123
+ )
124
+
125
+ case 'line':
126
+ return (
127
+ <line
128
+ key={keyHint}
129
+ x1={g.x1}
130
+ x2={g.x2}
131
+ y1={g.y1}
132
+ y2={g.y2}
133
+ {...styleAttrs(g, pointerEventsOverride)}
134
+ />
135
+ )
136
+
137
+ case 'text':
138
+ return (
139
+ <text
140
+ dominantBaseline={g.dominantBaseline ?? 'middle'}
141
+ fill={g.fill ?? '#171717'}
142
+ fontFamily={g.fontFamily}
143
+ fontSize={g.fontSize}
144
+ fontWeight={g.fontWeight}
145
+ key={keyHint}
146
+ opacity={g.opacity}
147
+ paintOrder={g.paintOrder}
148
+ stroke={g.stroke}
149
+ strokeLinecap={g.stroke ? 'round' : undefined}
150
+ strokeLinejoin={g.stroke ? 'round' : undefined}
151
+ strokeWidth={g.strokeWidth}
152
+ textAnchor={g.textAnchor ?? 'start'}
153
+ pointerEvents={pointerEventsOverride}
154
+ x={g.x}
155
+ y={g.y}
156
+ >
157
+ {g.text}
158
+ </text>
159
+ )
160
+
161
+ case 'image':
162
+ return (
163
+ <FloorplanImage
164
+ center={g.center}
165
+ height={g.height}
166
+ key={keyHint}
167
+ opacity={g.opacity}
168
+ preserveAspectRatio={g.preserveAspectRatio ?? 'xMidYMid meet'}
169
+ rotation={g.rotation ?? 0}
170
+ url={g.url}
171
+ width={g.width}
172
+ />
173
+ )
174
+
175
+ case 'group': {
176
+ const transform = formatTransform(g.transform)
177
+ return (
178
+ <g key={keyHint} transform={transform}>
179
+ {g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
180
+ </g>
181
+ )
182
+ }
183
+
184
+ // The interactive primitives (hatch / hit-line / endpoint-handle /
185
+ // dimension-label) need the SVG context + theme palette + units-per-
186
+ // pixel that only the registry layer has access to. They're rendered
187
+ // by `floorplan-registry-layer.tsx`'s interactive walker instead. If
188
+ // a caller routes one of these through this pure renderer it
189
+ // silently drops — the static renderer is for static output.
190
+ default:
191
+ return null
192
+ }
193
+ }
194
+
195
+ function pointsToAttr(points: readonly (readonly [number, number])[]): string {
196
+ return points.map(([x, y]) => `${x},${y}`).join(' ')
197
+ }
198
+
199
+ function formatTransform(t?: {
200
+ translate?: readonly [number, number]
201
+ rotate?: number
202
+ }): string | undefined {
203
+ if (!t) return undefined
204
+ const parts: string[] = []
205
+ if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`)
206
+ if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
207
+ return parts.length > 0 ? parts.join(' ') : undefined
208
+ }
209
+
210
+ /**
211
+ * `image` primitive renderer. Resolves the URL asynchronously via
212
+ * `loadAssetUrl` (handles CDN / Supabase storage) and renders an SVG
213
+ * `<image>` centered at `center`, rotated around it, sized in plan-local
214
+ * metres. While the resolution is in flight, renders nothing.
215
+ */
216
+ function FloorplanImage({
217
+ url,
218
+ center,
219
+ width,
220
+ height,
221
+ rotation,
222
+ preserveAspectRatio,
223
+ opacity,
224
+ }: {
225
+ url: string
226
+ center: readonly [number, number]
227
+ width: number
228
+ height: number
229
+ rotation: number
230
+ preserveAspectRatio: string
231
+ opacity?: number
232
+ }) {
233
+ const [resolvedUrl, setResolvedUrl] = useState<string | null>(null)
234
+ useEffect(() => {
235
+ if (!url) {
236
+ setResolvedUrl(null)
237
+ return
238
+ }
239
+ let cancelled = false
240
+ setResolvedUrl(null)
241
+ loadAssetUrl(url).then((next) => {
242
+ if (!cancelled) setResolvedUrl(next)
243
+ })
244
+ return () => {
245
+ cancelled = true
246
+ }
247
+ }, [url])
248
+ if (!resolvedUrl) return null
249
+ const rotationDeg = (rotation * 180) / Math.PI
250
+ return (
251
+ <g
252
+ pointerEvents="none"
253
+ transform={`translate(${center[0]} ${center[1]}) rotate(${rotationDeg})`}
254
+ >
255
+ <image
256
+ height={height}
257
+ href={resolvedUrl}
258
+ opacity={opacity}
259
+ preserveAspectRatio={preserveAspectRatio}
260
+ width={width}
261
+ x={-width / 2}
262
+ y={-height / 2}
263
+ />
264
+ </g>
265
+ )
266
+ }
@@ -12,6 +12,8 @@ const FLOORPLAN_MEASUREMENT_END_TICK = 0.18
12
12
  export type LinearMeasurementOverlay = {
13
13
  dashedExtensions?: boolean
14
14
  id: string
15
+ dimensionPathEnd?: string | null
16
+ dimensionPathStart?: string | null
15
17
  dimensionLineEnd: { x1: number; y1: number; x2: number; y2: number }
16
18
  dimensionLineStart: { x1: number; y1: number; x2: number; y2: number }
17
19
  extensionStart: { x1: number; y1: number; x2: number; y2: number }
@@ -34,6 +36,7 @@ type FloorplanMeasurementPalette = {
34
36
  type FloorplanMeasurementLineProps = {
35
37
  palette: FloorplanMeasurementPalette
36
38
  segment: { x1: number; y1: number; x2: number; y2: number }
39
+ path?: string | null
37
40
  isSelected?: boolean
38
41
  dashed?: boolean
39
42
  stroke?: string
@@ -42,6 +45,7 @@ type FloorplanMeasurementLineProps = {
42
45
  function FloorplanMeasurementLine({
43
46
  palette,
44
47
  segment,
48
+ path,
45
49
  isSelected,
46
50
  dashed = false,
47
51
  stroke,
@@ -50,7 +54,20 @@ function FloorplanMeasurementLine({
50
54
  ? FLOORPLAN_MEASUREMENT_LINE_OPACITY
51
55
  : FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
52
56
 
53
- return (
57
+ return path ? (
58
+ <path
59
+ d={path}
60
+ fill="none"
61
+ shapeRendering="geometricPrecision"
62
+ stroke={stroke ?? palette.measurementStroke}
63
+ strokeDasharray={dashed ? FLOORPLAN_MEASUREMENT_EXTENSION_DASH : undefined}
64
+ strokeLinecap="round"
65
+ strokeLinejoin="round"
66
+ strokeOpacity={lineOpacity}
67
+ strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
68
+ vectorEffect="non-scaling-stroke"
69
+ />
70
+ ) : (
54
71
  <line
55
72
  shapeRendering="geometricPrecision"
56
73
  stroke={stroke ?? palette.measurementStroke}
@@ -111,12 +128,26 @@ type FloorplanMeasurementsLayerProps = {
111
128
  className: string
112
129
  measurements: LinearMeasurementOverlay[]
113
130
  palette: FloorplanMeasurementPalette
131
+ sceneRotationDeg?: number
132
+ }
133
+
134
+ function normalizeReadableScreenAngle(angleDeg: number) {
135
+ let normalized = ((((angleDeg + 180) % 360) + 360) % 360) - 180
136
+
137
+ if (normalized > 90) {
138
+ normalized -= 180
139
+ } else if (normalized <= -90) {
140
+ normalized += 180
141
+ }
142
+
143
+ return normalized
114
144
  }
115
145
 
116
146
  export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({
117
147
  className,
118
148
  measurements,
119
149
  palette,
150
+ sceneRotationDeg = 0,
120
151
  }: FloorplanMeasurementsLayerProps) {
121
152
  if (measurements.length === 0) {
122
153
  return null
@@ -124,74 +155,90 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
124
155
 
125
156
  return (
126
157
  <>
127
- {measurements.map((measurement) => (
128
- <g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
129
- <FloorplanMeasurementLine
130
- dashed={measurement.dashedExtensions ?? true}
131
- isSelected={measurement.isSelected}
132
- palette={palette}
133
- segment={measurement.extensionStart}
134
- stroke={measurement.extensionStroke}
135
- />
136
- <FloorplanMeasurementLine
137
- isSelected={measurement.isSelected}
138
- palette={palette}
139
- segment={measurement.dimensionLineStart}
140
- stroke={measurement.stroke}
141
- />
142
- <FloorplanMeasurementLine
143
- isSelected={measurement.isSelected}
144
- palette={palette}
145
- segment={measurement.dimensionLineEnd}
146
- stroke={measurement.stroke}
147
- />
148
- <FloorplanMeasurementLine
149
- dashed={measurement.dashedExtensions ?? true}
150
- isSelected={measurement.isSelected}
151
- palette={palette}
152
- segment={measurement.extensionEnd}
153
- stroke={measurement.extensionStroke}
154
- />
155
- {measurement.showTicks !== false ? (
156
- <>
157
- <FloorplanMeasurementTick
158
- angleDeg={measurement.labelAngleDeg}
158
+ {measurements.map((measurement) =>
159
+ (() => {
160
+ const screenLabelAngleDeg = normalizeReadableScreenAngle(
161
+ measurement.labelAngleDeg + sceneRotationDeg,
162
+ )
163
+ const localLabelAngleDeg = screenLabelAngleDeg - sceneRotationDeg
164
+
165
+ return (
166
+ <g
167
+ className={className}
168
+ key={measurement.id}
169
+ pointerEvents="none"
170
+ style={{ userSelect: 'none' }}
171
+ >
172
+ <FloorplanMeasurementLine
173
+ dashed={measurement.dashedExtensions ?? true}
174
+ isSelected={measurement.isSelected}
175
+ palette={palette}
176
+ segment={measurement.extensionStart}
177
+ stroke={measurement.extensionStroke}
178
+ />
179
+ <FloorplanMeasurementLine
159
180
  isSelected={measurement.isSelected}
160
181
  palette={palette}
182
+ path={measurement.dimensionPathStart}
183
+ segment={measurement.dimensionLineStart}
161
184
  stroke={measurement.stroke}
162
- x={measurement.dimensionLineStart.x1}
163
- y={measurement.dimensionLineStart.y1}
164
185
  />
165
- <FloorplanMeasurementTick
166
- angleDeg={measurement.labelAngleDeg}
186
+ <FloorplanMeasurementLine
167
187
  isSelected={measurement.isSelected}
168
188
  palette={palette}
189
+ path={measurement.dimensionPathEnd}
190
+ segment={measurement.dimensionLineEnd}
169
191
  stroke={measurement.stroke}
170
- x={measurement.dimensionLineEnd.x2}
171
- y={measurement.dimensionLineEnd.y2}
172
192
  />
173
- </>
174
- ) : null}
175
- <text
176
- dominantBaseline="central"
177
- fill={measurement.labelFill ?? palette.measurementStroke}
178
- fillOpacity={
179
- measurement.isSelected
180
- ? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
181
- : FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
182
- }
183
- fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
184
- fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
185
- fontWeight="600"
186
- textAnchor="middle"
187
- transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
188
- x={measurement.labelX}
189
- y={measurement.labelY}
190
- >
191
- {measurement.label}
192
- </text>
193
- </g>
194
- ))}
193
+ <FloorplanMeasurementLine
194
+ dashed={measurement.dashedExtensions ?? true}
195
+ isSelected={measurement.isSelected}
196
+ palette={palette}
197
+ segment={measurement.extensionEnd}
198
+ stroke={measurement.extensionStroke}
199
+ />
200
+ {measurement.showTicks !== false ? (
201
+ <>
202
+ <FloorplanMeasurementTick
203
+ angleDeg={localLabelAngleDeg}
204
+ isSelected={measurement.isSelected}
205
+ palette={palette}
206
+ stroke={measurement.stroke}
207
+ x={measurement.dimensionLineStart.x1}
208
+ y={measurement.dimensionLineStart.y1}
209
+ />
210
+ <FloorplanMeasurementTick
211
+ angleDeg={localLabelAngleDeg}
212
+ isSelected={measurement.isSelected}
213
+ palette={palette}
214
+ stroke={measurement.stroke}
215
+ x={measurement.dimensionLineEnd.x2}
216
+ y={measurement.dimensionLineEnd.y2}
217
+ />
218
+ </>
219
+ ) : null}
220
+ <text
221
+ dominantBaseline="central"
222
+ fill={measurement.labelFill ?? palette.measurementStroke}
223
+ fillOpacity={
224
+ measurement.isSelected
225
+ ? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
226
+ : FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
227
+ }
228
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
229
+ fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
230
+ fontWeight="600"
231
+ textAnchor="middle"
232
+ transform={`rotate(${localLabelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
233
+ x={measurement.labelX}
234
+ y={measurement.labelY}
235
+ >
236
+ {measurement.label}
237
+ </text>
238
+ </g>
239
+ )
240
+ })(),
241
+ )}
195
242
  </>
196
243
  )
197
244
  })
@@ -0,0 +1,59 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type FloorplanGeometry,
7
+ type GeometryContext,
8
+ nodeRegistry,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { memo } from 'react'
12
+ import usePlacementPreview from '../../../store/use-placement-preview'
13
+ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
14
+
15
+ /**
16
+ * Renders a faint, non-interactive ghost of the node being placed by a
17
+ * registry placement tool (e.g. column), following the cursor in the floor
18
+ * plan. The 3D view shows a translucent mesh preview; in 2D that mesh is
19
+ * hidden (canvas `display:none`), so without this the user only saw the grid
20
+ * cursor dot + alignment guides — no sense of the footprint they were about
21
+ * to drop. The placement tool publishes a transient, already-positioned +
22
+ * aligned node to `usePlacementPreview`; we build its `def.floorplan`
23
+ * footprint with a minimal (unselected) context and render it.
24
+ *
25
+ * Mounted inside the floor-plan scene `<g>` so the geometry's level-local
26
+ * meters get the same world→SVG transform every other entry does.
27
+ */
28
+ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
29
+ const node = usePlacementPreview((s) => s.node)
30
+ if (!node) return null
31
+
32
+ const builder = nodeRegistry.get(node.type)?.floorplan
33
+ if (!builder) return null
34
+
35
+ // Minimal, unselected context — preview never shows selection chrome
36
+ // (move handles / resize arrows / hatch live behind `viewState.selected`).
37
+ // `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
38
+ // and `parent: null` short-circuits the elevator's level walk) so the layer
39
+ // never subscribes to / bulk-reads the nodes map during render.
40
+ const ctx = {
41
+ resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
42
+ children: [],
43
+ siblings: [],
44
+ parent: null,
45
+ viewState: undefined,
46
+ } as unknown as GeometryContext
47
+
48
+ const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
49
+ node,
50
+ ctx,
51
+ )
52
+ if (!geometry) return null
53
+
54
+ return (
55
+ <g data-floorplan-placement-preview opacity={0.5} pointerEvents="none">
56
+ <FloorplanGeometryRenderer geometry={geometry} />
57
+ </g>
58
+ )
59
+ })