@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,256 @@
1
+ import {
2
+ type AlignmentAnchor,
3
+ type AlignmentGuide,
4
+ type AnyNode,
5
+ collectAlignmentAnchors,
6
+ getWallCurveFrameAt,
7
+ getWallCurveLength,
8
+ isCurvedWall,
9
+ resolveLevelId,
10
+ useScene,
11
+ type WallNode,
12
+ } from '@pascal-app/core'
13
+ import {
14
+ getSegmentGridStep,
15
+ snapWallDraftPointDetailed,
16
+ WALL_CONNECT_SNAP_RADIUS,
17
+ type WallDraftSnapKind,
18
+ type WallPlanPoint,
19
+ type WallSnapRadii,
20
+ } from '../components/tools/wall/wall-drafting'
21
+ import useAlignmentGuides from '../store/use-alignment-guides'
22
+ import { isMagneticSnapActive } from '../store/use-editor'
23
+ import useWallSnapIndicator from '../store/use-wall-snap-indicator'
24
+ import { resolveAlignmentForFloorplanView } from './world-grid-snap'
25
+
26
+ const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
27
+ export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08
28
+ const SURFACE_WALL_SNAP_RADII = {
29
+ endpoint: 0.38,
30
+ midpoint: 0.28,
31
+ intersection: 0.28,
32
+ wall: 0.18,
33
+ } satisfies WallSnapRadii
34
+ const WALL_SOURCE_MATCH_EPSILON = 0.035
35
+
36
+ export type SurfacePlanSnapInput = {
37
+ rawPoint: WallPlanPoint
38
+ fallbackPoint?: WallPlanPoint
39
+ levelId?: string | null
40
+ excludeId?: string | null
41
+ movingId?: string
42
+ nodes?: Readonly<Record<string, AnyNode>>
43
+ walls?: readonly WallNode[]
44
+ candidates?: readonly AlignmentAnchor[]
45
+ threshold?: number
46
+ magnetic?: boolean
47
+ align?: boolean
48
+ highlightWalls?: boolean
49
+ step?: number
50
+ snapRadii?: WallSnapRadii
51
+ }
52
+
53
+ export type SurfacePlanSnapResult = {
54
+ point: WallPlanPoint
55
+ wallSnap: WallDraftSnapKind | null
56
+ guides: AlignmentGuide[]
57
+ wallIds: string[]
58
+ }
59
+
60
+ export function getLevelWalls(
61
+ nodes: Readonly<Record<string, AnyNode>>,
62
+ levelId: string | null | undefined,
63
+ walls?: readonly WallNode[],
64
+ ): WallNode[] {
65
+ const source =
66
+ walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall')
67
+ if (!levelId) return source.filter((wall) => wall.visible !== false)
68
+
69
+ return source.filter(
70
+ (wall) =>
71
+ wall.visible !== false && resolveLevelId(wall, nodes as Record<string, AnyNode>) === levelId,
72
+ )
73
+ }
74
+
75
+ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) {
76
+ const dx = a[0] - b[0]
77
+ const dz = a[1] - b[1]
78
+ return dx * dx + dz * dz
79
+ }
80
+
81
+ function wallMidpoint(wall: WallNode): WallPlanPoint {
82
+ if (isCurvedWall(wall)) {
83
+ const frame = getWallCurveFrameAt(wall, 0.5)
84
+ return [frame.point.x, frame.point.y]
85
+ }
86
+ return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
87
+ }
88
+
89
+ function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) {
90
+ const dx = end[0] - start[0]
91
+ const dz = end[1] - start[1]
92
+ const lengthSquared = dx * dx + dz * dz
93
+ if (lengthSquared < 1e-9) return distanceSquared(point, start)
94
+
95
+ const t = Math.max(
96
+ 0,
97
+ Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
98
+ )
99
+ const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t]
100
+ return distanceSquared(point, projected)
101
+ }
102
+
103
+ function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) {
104
+ if (!isCurvedWall(wall)) {
105
+ return distanceToSegmentSquared(point, wall.start, wall.end)
106
+ }
107
+
108
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
109
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
110
+ let previous = getWallCurveFrameAt(wall, 0).point
111
+ for (let index = 1; index <= sampleCount; index += 1) {
112
+ const current = getWallCurveFrameAt(wall, index / sampleCount).point
113
+ const distance = distanceToSegmentSquared(
114
+ point,
115
+ [previous.x, previous.y],
116
+ [current.x, current.y],
117
+ )
118
+ bestDistanceSquared = Math.min(bestDistanceSquared, distance)
119
+ previous = current
120
+ }
121
+ return bestDistanceSquared
122
+ }
123
+
124
+ function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) {
125
+ return walls
126
+ .map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) }))
127
+ .sort((a, b) => a.distance - b.distance)
128
+ .slice(0, count)
129
+ .map(({ id }) => id)
130
+ }
131
+
132
+ function findSnapSourceWallIds(
133
+ point: WallPlanPoint,
134
+ kind: WallDraftSnapKind,
135
+ walls: readonly WallNode[],
136
+ ): string[] {
137
+ const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2
138
+
139
+ if (kind === 'endpoint') {
140
+ const endpointMatches = walls.filter(
141
+ (wall) =>
142
+ distanceSquared(point, wall.start) <= epsilonSquared ||
143
+ distanceSquared(point, wall.end) <= epsilonSquared,
144
+ )
145
+ if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id)
146
+ return closestWallIds(point, walls, 1)
147
+ }
148
+
149
+ if (kind === 'midpoint') {
150
+ const midpointMatches = walls.filter(
151
+ (wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared,
152
+ )
153
+ if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id)
154
+ return closestWallIds(point, walls, 1)
155
+ }
156
+
157
+ if (kind === 'intersection') {
158
+ const crossingMatches = walls.filter(
159
+ (wall) => distanceToWallSquared(point, wall) <= epsilonSquared,
160
+ )
161
+ if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2)
162
+ return closestWallIds(point, walls, 2)
163
+ }
164
+
165
+ return closestWallIds(point, walls, 1)
166
+ }
167
+
168
+ export function clearSurfacePlanSnapFeedback() {
169
+ useAlignmentGuides.getState().clear()
170
+ useWallSnapIndicator.getState().clear()
171
+ }
172
+
173
+ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult {
174
+ const nodes = input.nodes ?? useScene.getState().nodes
175
+ const walls = getLevelWalls(nodes, input.levelId, input.walls)
176
+ const fallbackPoint = input.fallbackPoint
177
+ const magnetic = input.magnetic ?? isMagneticSnapActive()
178
+
179
+ const wallSnap = snapWallDraftPointDetailed({
180
+ point: input.rawPoint,
181
+ walls,
182
+ step: input.step ?? getSegmentGridStep(),
183
+ magnetic,
184
+ snapRadii: input.snapRadii ?? SURFACE_WALL_SNAP_RADII,
185
+ gridSnap: fallbackPoint ? () => fallbackPoint : undefined,
186
+ })
187
+
188
+ if (wallSnap.snap) {
189
+ const wallIds =
190
+ input.highlightWalls === false
191
+ ? []
192
+ : findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls)
193
+ useWallSnapIndicator.getState().set({
194
+ x: wallSnap.point[0],
195
+ z: wallSnap.point[1],
196
+ kind: wallSnap.snap,
197
+ ...(wallIds.length > 0 ? { wallIds } : {}),
198
+ })
199
+ useAlignmentGuides.getState().clear()
200
+ return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds }
201
+ }
202
+
203
+ useWallSnapIndicator.getState().clear()
204
+
205
+ // Alignment "lines" are DISPLAYED in every mode (grid / lines / angles /
206
+ // off); the magnetic pull onto an axis is applied only when magnetic
207
+ // ('lines'). `align: false` fully bypasses (no guides).
208
+ const basePoint = fallbackPoint ?? wallSnap.point
209
+ if (input.align === false) {
210
+ useAlignmentGuides.getState().clear()
211
+ return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
212
+ }
213
+
214
+ const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID
215
+ const allCandidates =
216
+ input.candidates ??
217
+ collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
218
+
219
+ // In non-magnetic modes nothing pulls the point onto a guide, so restrict
220
+ // alignment to anchors already within connect distance — a corner then reads
221
+ // no differently from any other nearby point, and far corners stop lighting up
222
+ // from across the plan. Magnetic ('lines') keeps the full-range guides since
223
+ // its snap closes the gap. Mirrors the wall draft/endpoint tools. Filtering the
224
+ // candidates (both local-frame, like `basePoint`) rather than the resolved
225
+ // guides avoids the floor-plan view rotation baked into the guide coords.
226
+ const candidates = magnetic
227
+ ? allCandidates
228
+ : allCandidates.filter(
229
+ (candidate) =>
230
+ distanceSquared(basePoint, [candidate.x, candidate.z]) <= WALL_CONNECT_SNAP_RADIUS ** 2,
231
+ )
232
+
233
+ if (candidates.length === 0) {
234
+ useAlignmentGuides.getState().clear()
235
+ return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
236
+ }
237
+
238
+ const alignment = resolveAlignmentForFloorplanView({
239
+ moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
240
+ candidates,
241
+ threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M,
242
+ })
243
+
244
+ useAlignmentGuides.getState().set(alignment.guides)
245
+
246
+ if (!alignment.snap || !magnetic) {
247
+ return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
248
+ }
249
+
250
+ return {
251
+ point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz],
252
+ wallSnap: null,
253
+ guides: alignment.guides,
254
+ wallIds: [],
255
+ }
256
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { useCallback } from 'react'
5
+ import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements'
6
+
7
+ /**
8
+ * Shared display/storage conversion for numeric property controls so that
9
+ * every length input honors the metric/imperial toggle identically.
10
+ *
11
+ * Values are always STORED in the field's own unit (meters for `unit === 'm'`).
12
+ * When the viewer preference is imperial AND the field is a meter length, the
13
+ * value is DISPLAYED (and edited) in feet; otherwise the conversions are the
14
+ * identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
15
+ * …) behave exactly as before.
16
+ *
17
+ * Used by both `SliderControl` and `MetricControl` — keep the two in sync via
18
+ * this single source of truth.
19
+ */
20
+ export function useLinearDisplay(unit: string, precision: number) {
21
+ const viewerUnit = useViewer((state) => state.unit)
22
+ const isImperial = viewerUnit === 'imperial' && unit === 'm'
23
+ const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
24
+
25
+ const toDisplay = useCallback(
26
+ (stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored),
27
+ [isImperial],
28
+ )
29
+ const toStored = useCallback(
30
+ (display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
31
+ [isImperial],
32
+ )
33
+ // Round a stored value so it lands on a clean number of DISPLAY-unit digits.
34
+ const roundStored = useCallback(
35
+ (stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
36
+ [toDisplay, toStored, precision],
37
+ )
38
+
39
+ return { isImperial, displayUnit, toDisplay, toStored, roundStored }
40
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import {
12
12
  type AlignmentAnchor,
13
+ type AlignmentGuide,
13
14
  type AnyNodeId,
14
15
  type BuildingPose,
15
16
  type ResolveAlignmentInBuildingResult,
@@ -54,7 +55,7 @@ export function getActiveBuildingPose(): BuildingPose | null {
54
55
  }
55
56
  if (!buildingId) buildingId = sel.buildingId ?? null
56
57
  const building = buildingId ? nodes[buildingId] : null
57
- if (!building || building.type !== 'building') return null
58
+ if (building?.type !== 'building') return null
58
59
  const live = useLiveTransforms.getState().transforms.get(buildingId as string)
59
60
  return {
60
61
  position: live?.position ?? building.position,
@@ -80,12 +81,43 @@ export function resolveAlignmentForActiveBuilding(args: {
80
81
  return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
81
82
  }
82
83
 
84
+ function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [number, number] {
85
+ if (!pose) return [x, z]
86
+ const cos = Math.cos(pose.rotationY)
87
+ const sin = Math.sin(pose.rotationY)
88
+ const dx = x - pose.position[0]
89
+ const dz = z - pose.position[2]
90
+ return [dx * cos - dz * sin, dx * sin + dz * cos]
91
+ }
92
+
93
+ /**
94
+ * Project WORLD-frame alignment guides into the active building's LOCAL frame.
95
+ *
96
+ * The 3D alignment layer is still mounted inside the building-local tool group,
97
+ * so tools that resolve alignment on the world axes (item placement, slab move)
98
+ * need their guides converted before publishing to `useAlignmentGuides`.
99
+ */
100
+ export function projectAlignmentGuidesWorldToActiveBuildingLocal(
101
+ guides: readonly AlignmentGuide[],
102
+ ): AlignmentGuide[] {
103
+ const pose = getActiveBuildingPose()
104
+ return guides.map((guide) => {
105
+ const [fromX, fromZ] = worldXZToPoseLocal(guide.from.x, guide.from.z, pose)
106
+ const [toX, toZ] = worldXZToPoseLocal(guide.to.x, guide.to.z, pose)
107
+ return {
108
+ ...guide,
109
+ from: { x: fromX, z: fromZ },
110
+ to: { x: toX, z: toZ },
111
+ }
112
+ })
113
+ }
114
+
83
115
  /**
84
116
  * Baseline rotation the floor-plan view applies on top of the building
85
- * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx
117
+ * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 0` in lib/floorplan/geometry.ts
86
118
  * the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
87
119
  */
88
- const FLOORPLAN_VIEW_ROTATION_RAD = Math.PI / 2
120
+ const FLOORPLAN_VIEW_ROTATION_RAD = 0
89
121
 
90
122
  function rotateAnchorsBy(
91
123
  anchors: readonly AlignmentAnchor[],
@@ -159,7 +191,7 @@ export function snapWorldXZForActiveBuilding(
159
191
  ): { world: [number, number]; local: [number, number] } {
160
192
  const buildingId = useViewer.getState().selection.buildingId
161
193
  const building = buildingId ? useScene.getState().nodes[buildingId] : null
162
- if (!building || building.type !== 'building') {
194
+ if (building?.type !== 'building') {
163
195
  if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
164
196
  const sx = Math.round(worldX / step) * step
165
197
  const sz = Math.round(worldZ / step) * step
@@ -186,7 +218,7 @@ export function snapBuildingLocalToWorldGrid(
186
218
  ): [number, number] {
187
219
  const buildingId = useViewer.getState().selection.buildingId
188
220
  const building = buildingId ? useScene.getState().nodes[buildingId] : null
189
- if (!building || building.type !== 'building') {
221
+ if (building?.type !== 'building') {
190
222
  if (step <= 0) return [local[0], local[1]]
191
223
  return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
192
224
  }
@@ -0,0 +1,20 @@
1
+ import type { AnyNodeId } from '@pascal-app/core'
2
+ import { create } from 'zustand'
3
+
4
+ type DirectManipulationFeedbackState = {
5
+ activeRotateNodeId: AnyNodeId | null
6
+ setActiveRotateNodeId(nodeId: AnyNodeId | null): void
7
+ clearActiveRotateNodeId(nodeId?: AnyNodeId): void
8
+ }
9
+
10
+ const useDirectManipulationFeedback = create<DirectManipulationFeedbackState>((set) => ({
11
+ activeRotateNodeId: null,
12
+ setActiveRotateNodeId: (activeRotateNodeId) => set({ activeRotateNodeId }),
13
+ clearActiveRotateNodeId: (nodeId) =>
14
+ set((state) => {
15
+ if (nodeId !== undefined && state.activeRotateNodeId !== nodeId) return {}
16
+ return { activeRotateNodeId: null }
17
+ }),
18
+ }))
19
+
20
+ export default useDirectManipulationFeedback