@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
@@ -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
+ })
@@ -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,79 +155,90 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
124
155
 
125
156
  return (
126
157
  <>
127
- {measurements.map((measurement) => (
128
- <g
129
- className={className}
130
- key={measurement.id}
131
- pointerEvents="none"
132
- style={{ userSelect: 'none' }}
133
- >
134
- <FloorplanMeasurementLine
135
- dashed={measurement.dashedExtensions ?? true}
136
- isSelected={measurement.isSelected}
137
- palette={palette}
138
- segment={measurement.extensionStart}
139
- stroke={measurement.extensionStroke}
140
- />
141
- <FloorplanMeasurementLine
142
- isSelected={measurement.isSelected}
143
- palette={palette}
144
- segment={measurement.dimensionLineStart}
145
- stroke={measurement.stroke}
146
- />
147
- <FloorplanMeasurementLine
148
- isSelected={measurement.isSelected}
149
- palette={palette}
150
- segment={measurement.dimensionLineEnd}
151
- stroke={measurement.stroke}
152
- />
153
- <FloorplanMeasurementLine
154
- dashed={measurement.dashedExtensions ?? true}
155
- isSelected={measurement.isSelected}
156
- palette={palette}
157
- segment={measurement.extensionEnd}
158
- stroke={measurement.extensionStroke}
159
- />
160
- {measurement.showTicks !== false ? (
161
- <>
162
- <FloorplanMeasurementTick
163
- 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
164
180
  isSelected={measurement.isSelected}
165
181
  palette={palette}
182
+ path={measurement.dimensionPathStart}
183
+ segment={measurement.dimensionLineStart}
166
184
  stroke={measurement.stroke}
167
- x={measurement.dimensionLineStart.x1}
168
- y={measurement.dimensionLineStart.y1}
169
185
  />
170
- <FloorplanMeasurementTick
171
- angleDeg={measurement.labelAngleDeg}
186
+ <FloorplanMeasurementLine
172
187
  isSelected={measurement.isSelected}
173
188
  palette={palette}
189
+ path={measurement.dimensionPathEnd}
190
+ segment={measurement.dimensionLineEnd}
174
191
  stroke={measurement.stroke}
175
- x={measurement.dimensionLineEnd.x2}
176
- y={measurement.dimensionLineEnd.y2}
177
192
  />
178
- </>
179
- ) : null}
180
- <text
181
- dominantBaseline="central"
182
- fill={measurement.labelFill ?? palette.measurementStroke}
183
- fillOpacity={
184
- measurement.isSelected
185
- ? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
186
- : FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
187
- }
188
- fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
189
- fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
190
- fontWeight="600"
191
- textAnchor="middle"
192
- transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
193
- x={measurement.labelX}
194
- y={measurement.labelY}
195
- >
196
- {measurement.label}
197
- </text>
198
- </g>
199
- ))}
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
+ )}
200
242
  </>
201
243
  )
202
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
+ })