@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,146 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ LevelNode,
7
+ StairNode,
8
+ } from '@pascal-app/core/schema'
9
+ import {
10
+ getBuildingLevelsForLevel,
11
+ getStairLevelOptions,
12
+ resolveStairDestinationLevel,
13
+ resolveStairFromLevelId,
14
+ resolveStairPlacementLevelId,
15
+ resolveStairToLevelId,
16
+ } from './stair-levels'
17
+
18
+ describe('stair level helpers', () => {
19
+ test('creates a missing upper level in the same building', () => {
20
+ const ground = LevelNode.parse({ level: 0, children: [] })
21
+ const building = BuildingNode.parse({ children: [ground.id] })
22
+ const nodes = {
23
+ [building.id]: building,
24
+ [ground.id]: ground,
25
+ } as Record<AnyNodeId, AnyNode>
26
+
27
+ const plan = resolveStairDestinationLevel({
28
+ createMissing: true,
29
+ fromLevelId: ground.id,
30
+ nodes,
31
+ })
32
+
33
+ expect(plan?.buildingId).toBe(building.id)
34
+ expect(plan?.fromLevel.id).toBe(ground.id)
35
+ expect(plan?.toLevel.level).toBe(1)
36
+ expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
37
+ expect(plan?.createdLevel?.parentId).toBe(building.id)
38
+ })
39
+
40
+ test('uses the nearest higher sibling level instead of creating one', () => {
41
+ const building = BuildingNode.parse({})
42
+ const ground = LevelNode.parse({ level: 0, parentId: building.id })
43
+ const second = LevelNode.parse({ level: 1, parentId: building.id })
44
+ const third = LevelNode.parse({ level: 2, parentId: building.id })
45
+ const nodes = {
46
+ [building.id]: { ...building, children: [ground.id, third.id, second.id] },
47
+ [ground.id]: ground,
48
+ [second.id]: second,
49
+ [third.id]: third,
50
+ } as Record<AnyNodeId, AnyNode>
51
+
52
+ const plan = resolveStairDestinationLevel({
53
+ createMissing: true,
54
+ fromLevelId: ground.id,
55
+ nodes,
56
+ })
57
+
58
+ expect(plan?.createdLevel).toBeNull()
59
+ expect(plan?.toLevel.id).toBe(second.id)
60
+ })
61
+
62
+ test('ignores levels from other buildings', () => {
63
+ const buildingA = BuildingNode.parse({})
64
+ const buildingB = BuildingNode.parse({})
65
+ const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
66
+ const upperA = LevelNode.parse({ level: 1, parentId: buildingA.id })
67
+ const upperB = LevelNode.parse({ level: 1, parentId: buildingB.id })
68
+ const nodes = {
69
+ [buildingA.id]: { ...buildingA, children: [groundA.id, upperA.id] },
70
+ [buildingB.id]: { ...buildingB, children: [upperB.id] },
71
+ [groundA.id]: groundA,
72
+ [upperA.id]: upperA,
73
+ [upperB.id]: upperB,
74
+ } as Record<AnyNodeId, AnyNode>
75
+
76
+ expect(getBuildingLevelsForLevel(nodes, groundA.id).map((level) => level.id)).toEqual([
77
+ groundA.id,
78
+ upperA.id,
79
+ ])
80
+ expect(
81
+ resolveStairDestinationLevel({ createMissing: true, fromLevelId: groundA.id, nodes })?.toLevel
82
+ .id,
83
+ ).toBe(upperA.id)
84
+ })
85
+
86
+ test('includes source and parent-linked sibling levels when building children are stale', () => {
87
+ const building = BuildingNode.parse({ children: [] })
88
+ const ground = LevelNode.parse({ level: 0, parentId: building.id })
89
+ const upper = LevelNode.parse({ level: 1, parentId: building.id })
90
+ const nodes = {
91
+ [building.id]: building,
92
+ [ground.id]: ground,
93
+ [upper.id]: upper,
94
+ } as Record<AnyNodeId, AnyNode>
95
+
96
+ const levels = getBuildingLevelsForLevel(nodes, ground.id)
97
+ const plan = resolveStairDestinationLevel({
98
+ createMissing: true,
99
+ fromLevelId: ground.id,
100
+ nodes,
101
+ })
102
+
103
+ expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
104
+ expect(plan?.createdLevel).toBeNull()
105
+ expect(plan?.toLevel.id).toBe(upper.id)
106
+ })
107
+
108
+ test('falls back from stale placement level ids to a valid level in the selected building', () => {
109
+ const buildingA = BuildingNode.parse({})
110
+ const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
111
+ const buildingB = BuildingNode.parse({})
112
+ const groundB = LevelNode.parse({ level: 0, parentId: buildingB.id })
113
+ const nodes = {
114
+ [buildingA.id]: { ...buildingA, children: [groundA.id] },
115
+ [groundA.id]: groundA,
116
+ [buildingB.id]: { ...buildingB, children: [groundB.id] },
117
+ [groundB.id]: groundB,
118
+ } as Record<AnyNodeId, AnyNode>
119
+
120
+ expect(resolveStairPlacementLevelId(nodes, 'level_missing', buildingB.id)).toBe(groundB.id)
121
+ })
122
+
123
+ test('repairs panel level ids for stairs with stale from-level data', () => {
124
+ const building = BuildingNode.parse({})
125
+ const ground = LevelNode.parse({ level: 0, parentId: building.id })
126
+ const upper = LevelNode.parse({ level: 1, parentId: building.id })
127
+ const stair = StairNode.parse({
128
+ parentId: ground.id,
129
+ fromLevelId: 'default',
130
+ toLevelId: upper.id,
131
+ })
132
+ const nodes = {
133
+ [building.id]: { ...building, children: [ground.id, upper.id] },
134
+ [ground.id]: ground,
135
+ [upper.id]: upper,
136
+ [stair.id]: stair,
137
+ } as Record<AnyNodeId, AnyNode>
138
+
139
+ const levels = getStairLevelOptions(nodes, stair)
140
+ const fromLevelId = resolveStairFromLevelId(nodes, stair, levels)
141
+
142
+ expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
143
+ expect(fromLevelId).toBe(ground.id)
144
+ expect(resolveStairToLevelId(nodes, stair, fromLevelId, levels)).toBe(upper.id)
145
+ })
146
+ })
@@ -0,0 +1,177 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ LevelNode,
5
+ type LevelNode as LevelNodeType,
6
+ resolveBuildingForLevel,
7
+ type StairNode,
8
+ } from '@pascal-app/core'
9
+
10
+ function sortLevelsByHeight(levels: LevelNodeType[]) {
11
+ return [...levels].sort((left, right) => left.level - right.level)
12
+ }
13
+
14
+ function isLevelNode(node: AnyNode | undefined): node is LevelNodeType {
15
+ return node?.type === 'level'
16
+ }
17
+
18
+ function getAllSceneLevels(nodes: Record<string, AnyNode>) {
19
+ return sortLevelsByHeight(
20
+ Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'),
21
+ )
22
+ }
23
+
24
+ function getBuildingLevels(
25
+ nodes: Record<string, AnyNode>,
26
+ buildingId: AnyNodeId | string | null | undefined,
27
+ source?: LevelNodeType,
28
+ ) {
29
+ if (!buildingId) return source ? [source] : []
30
+ const building = nodes[buildingId as AnyNodeId]
31
+ if (building?.type !== 'building') return source ? [source] : []
32
+
33
+ const levels = new Map<string, LevelNodeType>()
34
+ if (source) levels.set(source.id, source)
35
+
36
+ for (const childId of building.children ?? []) {
37
+ const child = nodes[childId as AnyNodeId]
38
+ if (isLevelNode(child)) levels.set(child.id, child)
39
+ }
40
+
41
+ for (const candidate of Object.values(nodes)) {
42
+ if (isLevelNode(candidate) && candidate.parentId === building.id) {
43
+ levels.set(candidate.id, candidate)
44
+ }
45
+ }
46
+
47
+ return sortLevelsByHeight(Array.from(levels.values()))
48
+ }
49
+
50
+ export function getBuildingLevelsForLevel(
51
+ nodes: Record<string, AnyNode>,
52
+ levelId: AnyNodeId | string | null | undefined,
53
+ ) {
54
+ if (!levelId) return []
55
+ const source = nodes[levelId as AnyNodeId]
56
+ if (!isLevelNode(source)) return []
57
+
58
+ const buildingId = resolveBuildingForLevel(
59
+ source.id as AnyNodeId,
60
+ nodes as Record<AnyNodeId, AnyNode>,
61
+ )
62
+ return getBuildingLevels(nodes, buildingId, source)
63
+ }
64
+
65
+ export function getStairLevelOptions(nodes: Record<string, AnyNode>, stair: StairNode) {
66
+ for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) {
67
+ if (isLevelNode(nodes[candidateId as AnyNodeId])) {
68
+ return getBuildingLevelsForLevel(nodes, candidateId)
69
+ }
70
+ }
71
+
72
+ return getAllSceneLevels(nodes)
73
+ }
74
+
75
+ export function resolveStairPlacementLevelId(
76
+ nodes: Record<string, AnyNode>,
77
+ preferredLevelId: AnyNodeId | string | null | undefined,
78
+ preferredBuildingId?: AnyNodeId | string | null,
79
+ ) {
80
+ if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) {
81
+ return preferredLevelId as LevelNodeType['id']
82
+ }
83
+
84
+ const buildingLevels = getBuildingLevels(nodes, preferredBuildingId)
85
+ return buildingLevels[0]?.id ?? getAllSceneLevels(nodes)[0]?.id ?? null
86
+ }
87
+
88
+ export function resolveStairFromLevelId(
89
+ nodes: Record<string, AnyNode>,
90
+ stair: StairNode,
91
+ levels = getStairLevelOptions(nodes, stair),
92
+ ) {
93
+ const optionIds = new Set<string>(levels.map((level) => level.id))
94
+ if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId
95
+ if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId
96
+
97
+ const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined
98
+ if (isLevelNode(toLevel)) {
99
+ const lowerLevel = [...levels].reverse().find((level) => level.level < toLevel.level)
100
+ if (lowerLevel) return lowerLevel.id
101
+ }
102
+
103
+ return levels[0]?.id ?? null
104
+ }
105
+
106
+ export function resolveStairToLevelId(
107
+ nodes: Record<string, AnyNode>,
108
+ stair: StairNode,
109
+ fromLevelId: AnyNodeId | string | null | undefined,
110
+ levels = getStairLevelOptions(nodes, stair),
111
+ ) {
112
+ const optionIds = new Set<string>(levels.map((level) => level.id))
113
+ if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) {
114
+ return stair.toLevelId
115
+ }
116
+
117
+ const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined
118
+ if (isLevelNode(fromLevel)) {
119
+ return levels.find((level) => level.level > fromLevel.level)?.id ?? fromLevel.id
120
+ }
121
+
122
+ return levels[0]?.id ?? null
123
+ }
124
+
125
+ export function resolveStairDestinationLevel({
126
+ createMissing,
127
+ fromLevelId,
128
+ nodes,
129
+ }: {
130
+ createMissing?: boolean
131
+ fromLevelId: AnyNodeId | string | null | undefined
132
+ nodes: Record<string, AnyNode>
133
+ }) {
134
+ if (!fromLevelId) return null
135
+ const fromLevel = nodes[fromLevelId as AnyNodeId]
136
+ if (!isLevelNode(fromLevel)) return null
137
+
138
+ const buildingId = resolveBuildingForLevel(
139
+ fromLevel.id as AnyNodeId,
140
+ nodes as Record<AnyNodeId, AnyNode>,
141
+ )
142
+ const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
143
+ const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null
144
+ if (nextExistingLevel) {
145
+ return {
146
+ buildingId,
147
+ createdLevel: null,
148
+ fromLevel,
149
+ levels,
150
+ toLevel: nextExistingLevel,
151
+ }
152
+ }
153
+
154
+ if (createMissing && buildingId) {
155
+ const createdLevel = LevelNode.parse({
156
+ children: [],
157
+ level: fromLevel.level + 1,
158
+ parentId: buildingId,
159
+ })
160
+
161
+ return {
162
+ buildingId,
163
+ createdLevel,
164
+ fromLevel,
165
+ levels: sortLevelsByHeight([...levels, createdLevel]),
166
+ toLevel: createdLevel,
167
+ }
168
+ }
169
+
170
+ return {
171
+ buildingId,
172
+ createdLevel: null,
173
+ fromLevel,
174
+ levels,
175
+ toLevel: fromLevel,
176
+ }
177
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * World-grid snap for tools that consume `grid:move` / `grid:click`.
3
+ *
4
+ * Tools historically snapped on `event.localPosition` (the cursor in the
5
+ * active building's local frame). After the floor-plan grid was pulled
6
+ * out of the rotated scene group, snapping has to follow the WORLD XZ
7
+ * grid — otherwise a rotated building drags every placement off the
8
+ * visible grid lines. This helper resolves the active building's pose
9
+ * and projects the world snap back into local coords for storage.
10
+ */
11
+ import {
12
+ type AlignmentAnchor,
13
+ type AnyNodeId,
14
+ type BuildingPose,
15
+ type ResolveAlignmentInBuildingResult,
16
+ resolveAlignmentInBuildingWorld,
17
+ snapWorldXZToBuildingLocal,
18
+ useLiveTransforms,
19
+ useScene,
20
+ } from '@pascal-app/core'
21
+ import { useViewer } from '@pascal-app/viewer'
22
+
23
+ /**
24
+ * Look up the active building's pose, or null when we're at the site root.
25
+ * Used by tools that need to resolve alignment in world coords.
26
+ *
27
+ * Falls back through the active level's `parentId` because the 2D floor-plan
28
+ * often runs with `selection.buildingId === null` while still operating
29
+ * inside a specific building (the user is editing a level, not the building
30
+ * shell itself). Without the fallback, alignment in the floor plan saw a
31
+ * `buildingRotY === 0` pose and emitted world-axis guides — which appeared
32
+ * diagonal once the building was rotated.
33
+ *
34
+ * Honours `useLiveTransforms` overrides: while the building is being moved
35
+ * or rotated, the floor-plan scene <g> is driven by the live transform
36
+ * (see `use-floorplan-scene-data.ts`), so alignment has to read the same
37
+ * pose or the rotated anchors fall out of sync with the SVG transform and
38
+ * guides drift off the visible grid mid-drag (and through any post-drag
39
+ * frame where the live override is still set).
40
+ */
41
+ export function getActiveBuildingPose(): BuildingPose | null {
42
+ const sel = useViewer.getState().selection
43
+ const nodes = useScene.getState().nodes
44
+ // Match `use-floorplan-scene-data.ts`: prefer the active level's
45
+ // owning building, fall back to selection.buildingId. If the user
46
+ // has a stale building selected from another scope while editing a
47
+ // different level, the level path is the authoritative one.
48
+ let buildingId: AnyNodeId | null = null
49
+ if (sel.levelId) {
50
+ const level = nodes[sel.levelId]
51
+ if (level && level.type === 'level' && level.parentId) {
52
+ buildingId = level.parentId as AnyNodeId
53
+ }
54
+ }
55
+ if (!buildingId) buildingId = sel.buildingId ?? null
56
+ const building = buildingId ? nodes[buildingId] : null
57
+ if (!building || building.type !== 'building') return null
58
+ const live = useLiveTransforms.getState().transforms.get(buildingId as string)
59
+ return {
60
+ position: live?.position ?? building.position,
61
+ rotationY: live?.rotation ?? building.rotation[1] ?? 0,
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Resolve Figma-style alignment for tools whose anchors are in the active
67
+ * building's local frame, but where alignment must run on the WORLD axes
68
+ * (the frame the user sees the grid in). Wraps `resolveAlignmentInBuildingWorld`
69
+ * with the active building lookup so callers don't repeat the boilerplate.
70
+ *
71
+ * Returns:
72
+ * - `guides` in WORLD coords (renderer must live in a world-space group),
73
+ * - `snap` in BUILDING-LOCAL coords (ready to add to a local position).
74
+ */
75
+ export function resolveAlignmentForActiveBuilding(args: {
76
+ moving: readonly AlignmentAnchor[]
77
+ candidates: readonly AlignmentAnchor[]
78
+ threshold: number
79
+ }): ResolveAlignmentInBuildingResult {
80
+ return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
81
+ }
82
+
83
+ /**
84
+ * Baseline rotation the floor-plan view applies on top of the building
85
+ * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx —
86
+ * the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
87
+ */
88
+ const FLOORPLAN_VIEW_ROTATION_RAD = Math.PI / 2
89
+
90
+ function rotateAnchorsBy(
91
+ anchors: readonly AlignmentAnchor[],
92
+ cos: number,
93
+ sin: number,
94
+ ): AlignmentAnchor[] {
95
+ return anchors.map((a) => ({
96
+ nodeId: a.nodeId,
97
+ kind: a.kind,
98
+ x: a.x * cos - a.z * sin,
99
+ z: a.x * sin + a.z * cos,
100
+ }))
101
+ }
102
+
103
+ /**
104
+ * Resolve alignment in the 2D floor-plan view frame — the frame the user
105
+ * sees the (always axis-aligned) grid lines in, regardless of how the
106
+ * building has been rotated. Use this from EVERY 2D floor-plan path so
107
+ * alignment guides stay parallel to the visible grid.
108
+ *
109
+ * Why it differs from `resolveAlignmentForActiveBuilding`: the 3D viewport
110
+ * shows the world XZ grid, so world-frame alignment matches the visible
111
+ * grid there. The 2D floor plan, however, rotates the scene `<g>` by
112
+ * `floorplanSceneRotationDeg = FVR − buildingRot` and renders the grid
113
+ * OUTSIDE that rotated group — so the visible axes are
114
+ * `R(FVR − buildingRot) · local`. World-frame alignment would land on
115
+ * world axes, which appear diagonal in this view; view-frame alignment
116
+ * lands on the SVG axes the user actually reads.
117
+ *
118
+ * Returns guides in view-frame coords (correct input for the floor-plan
119
+ * alignment-guide layer, which is mounted outside the rotated scene
120
+ * group) and a snap delta projected back into building-local (so callers
121
+ * can add it to a local position as-is).
122
+ */
123
+ export function resolveAlignmentForFloorplanView(args: {
124
+ moving: readonly AlignmentAnchor[]
125
+ candidates: readonly AlignmentAnchor[]
126
+ threshold: number
127
+ }): ResolveAlignmentInBuildingResult {
128
+ const pose = getActiveBuildingPose()
129
+ const buildingRotY = pose?.rotationY ?? 0
130
+ const rot = FLOORPLAN_VIEW_ROTATION_RAD - buildingRotY
131
+ const cos = Math.cos(rot)
132
+ const sin = Math.sin(rot)
133
+ const result = resolveAlignmentInBuildingWorld({
134
+ moving: rotateAnchorsBy(args.moving, cos, sin),
135
+ candidates: rotateAnchorsBy(args.candidates, cos, sin),
136
+ threshold: args.threshold,
137
+ // Pose `null` keeps `resolveAlignmentInBuildingWorld` in its no-op
138
+ // path: it just runs `resolveAlignment` on the already-rotated
139
+ // anchors and returns the snap delta in the same view frame.
140
+ pose: null,
141
+ })
142
+ if (!result.snap) return result
143
+ // View-frame delta → local-frame delta (transpose of R(rot)).
144
+ const { dx, dz } = result.snap
145
+ return {
146
+ guides: result.guides,
147
+ snap: { dx: dx * cos + dz * sin, dz: -dx * sin + dz * cos },
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Snap a world XZ position to the grid, then express it in the active
153
+ * building's local frame. When no building is active, world == local.
154
+ */
155
+ export function snapWorldXZForActiveBuilding(
156
+ worldX: number,
157
+ worldZ: number,
158
+ step: number,
159
+ ): { world: [number, number]; local: [number, number] } {
160
+ const buildingId = useViewer.getState().selection.buildingId
161
+ const building = buildingId ? useScene.getState().nodes[buildingId] : null
162
+ if (!building || building.type !== 'building') {
163
+ if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
164
+ const sx = Math.round(worldX / step) * step
165
+ const sz = Math.round(worldZ / step) * step
166
+ return { world: [sx, sz], local: [sx, sz] }
167
+ }
168
+ return snapWorldXZToBuildingLocal(
169
+ worldX,
170
+ worldZ,
171
+ building.position,
172
+ building.rotation[1] ?? 0,
173
+ step,
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Snap a building-local plan point so the resulting position sits on the
179
+ * world XZ grid. The returned point is still in building-local coords —
180
+ * useful as a `gridSnap` callback for snapWallDraftPoint / snapFenceDraftPoint
181
+ * etc., which operate entirely in the local frame.
182
+ */
183
+ export function snapBuildingLocalToWorldGrid(
184
+ local: readonly [number, number],
185
+ step: number,
186
+ ): [number, number] {
187
+ const buildingId = useViewer.getState().selection.buildingId
188
+ const building = buildingId ? useScene.getState().nodes[buildingId] : null
189
+ if (!building || building.type !== 'building') {
190
+ if (step <= 0) return [local[0], local[1]]
191
+ return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
192
+ }
193
+ const rotY = building.rotation[1] ?? 0
194
+ const cos = Math.cos(rotY)
195
+ const sin = Math.sin(rotY)
196
+ const worldX = building.position[0] + local[0] * cos + local[1] * sin
197
+ const worldZ = building.position[2] - local[0] * sin + local[1] * cos
198
+ return snapWorldXZToBuildingLocal(worldX, worldZ, building.position, rotY, step).local
199
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type CeilingNode,
5
+ type ItemNode,
6
+ pointInPolygon2D,
7
+ pointOnSegment,
8
+ type SlabNode,
9
+ type WallNode,
10
+ type ZoneNode,
11
+ } from '@pascal-app/core'
12
+
13
+ type Point2D = [number, number]
14
+
15
+ const POINT_TOLERANCE = 0.5
16
+ const COLLINEAR_TOLERANCE = 1e-6
17
+ const SURFACE_POLYGON_TOLERANCE = 0.15
18
+
19
+ function getPointToSegmentDistance(point: Point2D, start: Point2D, end: Point2D): number {
20
+ const dx = end[0] - start[0]
21
+ const dz = end[1] - start[1]
22
+ const lengthSq = dx * dx + dz * dz
23
+ if (lengthSq === 0) return Math.hypot(point[0] - start[0], point[1] - start[1])
24
+
25
+ const rawT = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq
26
+ const t = Math.max(0, Math.min(1, rawT))
27
+ const projected: Point2D = [start[0] + t * dx, start[1] + t * dz]
28
+ return Math.hypot(point[0] - projected[0], point[1] - projected[1])
29
+ }
30
+
31
+ function pointInPolygonWithTolerance(point: Point2D, polygon: Point2D[]): boolean {
32
+ if (pointInPolygon2D(point, polygon, { includeBoundary: true })) return true
33
+ return polygon.some((start, index) => {
34
+ const end = polygon[(index + 1) % polygon.length]
35
+ return end ? getPointToSegmentDistance(point, start, end) <= POINT_TOLERANCE : false
36
+ })
37
+ }
38
+
39
+ function polygonContainsWithTolerance(
40
+ outer: Point2D[],
41
+ inner: Point2D[],
42
+ tolerance: number,
43
+ ): boolean {
44
+ return inner.every((point) => {
45
+ if (pointInPolygon2D(point, outer, { includeBoundary: true })) return true
46
+ return outer.some((start, index) => {
47
+ const end = outer[(index + 1) % outer.length]
48
+ return end ? getPointToSegmentDistance(point, start, end) <= tolerance : false
49
+ })
50
+ })
51
+ }
52
+
53
+ function polygonMatchesZoneFootprint(surfacePolygon: Point2D[], footprint: Point2D[]): boolean {
54
+ if (surfacePolygon.length < 3) return false
55
+ return (
56
+ polygonContainsWithTolerance(footprint, surfacePolygon, SURFACE_POLYGON_TOLERANCE) &&
57
+ polygonContainsWithTolerance(surfacePolygon, footprint, SURFACE_POLYGON_TOLERANCE)
58
+ )
59
+ }
60
+
61
+ function areSegmentsCollinear(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
62
+ const abx = b[0] - a[0]
63
+ const abz = b[1] - a[1]
64
+ const acx = c[0] - a[0]
65
+ const acz = c[1] - a[1]
66
+ const adx = d[0] - a[0]
67
+ const adz = d[1] - a[1]
68
+ const crossC = abx * acz - abz * acx
69
+ const crossD = abx * adz - abz * adx
70
+ return Math.abs(crossC) <= COLLINEAR_TOLERANCE && Math.abs(crossD) <= COLLINEAR_TOLERANCE
71
+ }
72
+
73
+ function segmentsOverlap(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
74
+ const useX = Math.abs(b[0] - a[0]) >= Math.abs(b[1] - a[1])
75
+ const a0 = useX ? a[0] : a[1]
76
+ const a1 = useX ? b[0] : b[1]
77
+ const c0 = useX ? c[0] : c[1]
78
+ const c1 = useX ? d[0] : d[1]
79
+ const minA = Math.min(a0, a1)
80
+ const maxA = Math.max(a0, a1)
81
+ const minC = Math.min(c0, c1)
82
+ const maxC = Math.max(c0, c1)
83
+ return Math.max(minA, minC) <= Math.min(maxA, maxC) + COLLINEAR_TOLERANCE
84
+ }
85
+
86
+ function wallLiesOnZoneBoundary(wall: WallNode, polygon: Point2D[]): boolean {
87
+ return polygon.some((start, index) => {
88
+ const end = polygon[(index + 1) % polygon.length]
89
+ if (!end) return false
90
+ return (
91
+ areSegmentsCollinear(wall.start, wall.end, start, end) &&
92
+ segmentsOverlap(wall.start, wall.end, start, end) &&
93
+ pointOnSegment(wall.start, start, end, POINT_TOLERANCE) &&
94
+ pointOnSegment(wall.end, start, end, POINT_TOLERANCE)
95
+ )
96
+ })
97
+ }
98
+
99
+ export function collectZoneContentIds(
100
+ nodes: Readonly<Record<AnyNodeId, AnyNode>>,
101
+ zone: ZoneNode,
102
+ ): AnyNodeId[] {
103
+ const levelId = zone.parentId
104
+ if (!levelId) return []
105
+
106
+ const footprint = zone.polygon.map((point) => [point[0], point[1]] as Point2D)
107
+ const boundaryWalls = Object.values(nodes)
108
+ .filter((node): node is WallNode => node.type === 'wall' && node.parentId === levelId)
109
+ .filter((wall) => wallLiesOnZoneBoundary(wall, footprint))
110
+ const surfaces = Object.values(nodes)
111
+ .filter(
112
+ (node): node is SlabNode | CeilingNode =>
113
+ (node.type === 'slab' || node.type === 'ceiling') && node.parentId === levelId,
114
+ )
115
+ .filter((surface) => {
116
+ const polygon = surface.polygon.map((point) => [point[0], point[1]] as Point2D)
117
+ return polygonMatchesZoneFootprint(polygon, footprint)
118
+ })
119
+ const floorItems = Object.values(nodes)
120
+ .filter((node): node is ItemNode => node.type === 'item' && node.parentId === levelId)
121
+ .filter((item) => pointInPolygonWithTolerance([item.position[0], item.position[2]], footprint))
122
+
123
+ return Array.from(
124
+ new Set<AnyNodeId>([
125
+ ...boundaryWalls.map((wall) => wall.id as AnyNodeId),
126
+ ...surfaces.map((surface) => surface.id as AnyNodeId),
127
+ ...floorItems.map((item) => item.id as AnyNodeId),
128
+ ]),
129
+ )
130
+ }
@@ -0,0 +1,21 @@
1
+ // Ephemeral store for Figma-style alignment guides published during a
2
+ // move / placement drag. The producer (a tool or move overlay) writes
3
+ // guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes
4
+ // and draws them. Both sides clear on commit, cancel, and unmount.
5
+
6
+ import type { AlignmentGuide } from '@pascal-app/core'
7
+ import { create } from 'zustand'
8
+
9
+ type AlignmentGuidesState = {
10
+ guides: AlignmentGuide[]
11
+ set(guides: AlignmentGuide[]): void
12
+ clear(): void
13
+ }
14
+
15
+ const useAlignmentGuides = create<AlignmentGuidesState>((set) => ({
16
+ guides: [],
17
+ set: (guides) => set({ guides }),
18
+ clear: () => set({ guides: [] }),
19
+ }))
20
+
21
+ export default useAlignmentGuides