@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,109 @@
1
+ import { beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ nodeRegistry,
6
+ registerNode,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import { useViewer } from '@pascal-app/viewer'
10
+ import { z } from 'zod'
11
+ import useEditor from '../../../store/use-editor'
12
+ import { collectSelectableCandidateIds } from './select-candidates'
13
+
14
+ function registerSelectableElevatorTestKind() {
15
+ if (nodeRegistry.has('elevator')) return
16
+
17
+ registerNode({
18
+ kind: 'elevator',
19
+ schemaVersion: 1,
20
+ schema: z.object({ type: z.literal('elevator') }) as never,
21
+ category: 'structure',
22
+ defaults: () => ({}),
23
+ capabilities: { selectable: {} },
24
+ floorplanScope: 'building',
25
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
26
+ } as AnyNodeDefinition)
27
+ }
28
+
29
+ describe('selectable candidates', () => {
30
+ beforeAll(() => {
31
+ registerSelectableElevatorTestKind()
32
+ })
33
+
34
+ beforeEach(() => {
35
+ useScene.setState({
36
+ nodes: {},
37
+ rootNodeIds: [],
38
+ dirtyNodes: new Set(),
39
+ collections: {},
40
+ } as never)
41
+ useViewer.setState({
42
+ selection: {
43
+ buildingId: 'building_test',
44
+ levelId: 'level_test',
45
+ zoneId: null,
46
+ selectedIds: [],
47
+ },
48
+ previewSelectedIds: [],
49
+ })
50
+ useEditor.setState({
51
+ phase: 'structure',
52
+ structureLayer: 'elements',
53
+ })
54
+ })
55
+
56
+ test('includes building-scoped elevators for the active level building', () => {
57
+ useScene.setState({
58
+ nodes: {
59
+ building_test: {
60
+ id: 'building_test',
61
+ type: 'building',
62
+ children: ['level_test', 'elevator_test'],
63
+ },
64
+ level_test: {
65
+ id: 'level_test',
66
+ type: 'level',
67
+ parentId: 'building_test',
68
+ children: [],
69
+ },
70
+ elevator_test: {
71
+ id: 'elevator_test',
72
+ type: 'elevator',
73
+ parentId: 'building_test',
74
+ position: [1, 0, 2],
75
+ rotation: 0,
76
+ },
77
+ } as unknown as Record<string, AnyNode>,
78
+ } as never)
79
+
80
+ expect(collectSelectableCandidateIds()).toContain('elevator_test')
81
+ })
82
+
83
+ test('includes legacy level-parented elevators already loaded in the editor', () => {
84
+ useScene.setState({
85
+ nodes: {
86
+ building_test: {
87
+ id: 'building_test',
88
+ type: 'building',
89
+ children: ['level_test'],
90
+ },
91
+ level_test: {
92
+ id: 'level_test',
93
+ type: 'level',
94
+ parentId: 'building_test',
95
+ children: ['elevator_test'],
96
+ },
97
+ elevator_test: {
98
+ id: 'elevator_test',
99
+ type: 'elevator',
100
+ parentId: 'level_test',
101
+ position: [1, 0, 2],
102
+ rotation: 0,
103
+ },
104
+ } as unknown as Record<string, AnyNode>,
105
+ } as never)
106
+
107
+ expect(collectSelectableCandidateIds()).toContain('elevator_test')
108
+ })
109
+ })
@@ -0,0 +1,85 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ isRegistrySelectable,
5
+ type LevelNode,
6
+ nodeRegistry,
7
+ resolveBuildingForLevel,
8
+ resolveLevelId,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { useViewer } from '@pascal-app/viewer'
12
+ import useEditor from '../../../store/use-editor'
13
+
14
+ function isVisibleSelectableNode(node: AnyNode): boolean {
15
+ if ((node as { visible?: boolean }).visible === false) return false
16
+ return isRegistrySelectable(node.type)
17
+ }
18
+
19
+ export function collectSelectableCandidateIds(): string[] {
20
+ const { levelId } = useViewer.getState().selection
21
+ const { nodes } = useScene.getState()
22
+ const { phase, structureLayer } = useEditor.getState()
23
+ const result: string[] = []
24
+ const seen = new Set<string>()
25
+ const addNode = (node: AnyNode | undefined) => {
26
+ if (!node || seen.has(node.id) || (node as { visible?: boolean }).visible === false) return
27
+ seen.add(node.id)
28
+ result.push(node.id)
29
+ }
30
+ const visitLevelDescendant = (id: AnyNodeId) => {
31
+ const node = nodes[id]
32
+ if (!node || seen.has(node.id) || (node as { visible?: boolean }).visible === false) return
33
+
34
+ if (isRegistrySelectable(node.type)) {
35
+ addNode(node)
36
+ }
37
+
38
+ const children = 'children' in node && Array.isArray(node.children) ? node.children : []
39
+ for (const childId of children) {
40
+ visitLevelDescendant(childId as AnyNodeId)
41
+ }
42
+ }
43
+
44
+ if (phase === 'site') {
45
+ for (const node of Object.values(nodes)) {
46
+ if (node.type === 'building') addNode(node)
47
+ }
48
+ return result
49
+ }
50
+
51
+ if (!levelId) return []
52
+ const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined
53
+ if (!levelNode || levelNode.type !== 'level') return []
54
+
55
+ if (phase === 'structure' && structureLayer === 'zones') {
56
+ for (const childId of levelNode.children) {
57
+ const node = nodes[childId as AnyNodeId]
58
+ if (node?.type === 'zone') addNode(node)
59
+ }
60
+ return result
61
+ }
62
+
63
+ for (const childId of levelNode.children) {
64
+ visitLevelDescendant(childId as AnyNodeId)
65
+ }
66
+
67
+ const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
68
+ for (const node of Object.values(nodes)) {
69
+ if (!node || node.type === 'level' || !isVisibleSelectableNode(node)) continue
70
+
71
+ const def = nodeRegistry.get(node.type)
72
+ const isBuildingScoped = def?.floorplanScope === 'building'
73
+ const parentId = (node as { parentId?: AnyNodeId | null }).parentId
74
+ if (isBuildingScoped && buildingId && parentId === buildingId) {
75
+ addNode(node)
76
+ continue
77
+ }
78
+
79
+ if (!isBuildingScoped && resolveLevelId(node, nodes) === levelId) {
80
+ addNode(node)
81
+ }
82
+ }
83
+
84
+ return result
85
+ }
@@ -0,0 +1,30 @@
1
+ import { nodeRegistry } from '@pascal-app/core'
2
+ import { type ComponentType, lazy } from 'react'
3
+
4
+ /**
5
+ * Phase 5 Stage D — runtime lazy-load of a kind's affordance tool.
6
+ *
7
+ * The editor can't statically import from `@pascal-app/nodes` (the
8
+ * nodes package depends on editor — static imports would cycle). The
9
+ * kind declares its drag-affordance components in
10
+ * `def.affordanceTools[<key>]: () => import('./<name>-tool')`; this
11
+ * helper resolves that to a `React.lazy` component at the call site.
12
+ *
13
+ * Returns null when the kind doesn't declare the affordance — callers
14
+ * mount the legacy fallback in that case.
15
+ */
16
+ const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
17
+
18
+ export function getRegistryAffordanceTool(
19
+ kind: string,
20
+ affordance: string,
21
+ ): ComponentType<any> | null {
22
+ const def = nodeRegistry.get(kind)
23
+ const loader = def?.affordanceTools?.[affordance]
24
+ if (!loader) return null
25
+ const cached = lazyToolCache.get(loader)
26
+ if (cached) return cached
27
+ const Comp = lazy(loader as () => Promise<{ default: ComponentType<any> }>)
28
+ lazyToolCache.set(loader, Comp as unknown as ComponentType)
29
+ return Comp as unknown as ComponentType<any>
30
+ }
@@ -0,0 +1,171 @@
1
+ 'use client'
2
+
3
+ import { sceneRegistry } from '@pascal-app/core'
4
+ import { useEffect, useMemo } from 'react'
5
+ import {
6
+ Box3,
7
+ BoxGeometry,
8
+ EdgesGeometry,
9
+ Matrix4,
10
+ type Mesh,
11
+ type Object3D,
12
+ PlaneGeometry,
13
+ Vector3,
14
+ } from 'three'
15
+ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
16
+ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
17
+ import { EDITOR_LAYER } from '../../../lib/constants'
18
+
19
+ const NO_RAYCAST = () => null
20
+
21
+ /** green-500 — matches the item placement box's "placeable" state. */
22
+ const DEFAULT_COLOR = 0x22_c5_5e
23
+
24
+ type LocalBounds = { size: [number, number, number]; center: [number, number, number] }
25
+
26
+ /**
27
+ * The node's bounding box in its OWN, unrotated frame — measured from the
28
+ * rendered geometry so it captures the full extent (base, cap, overhang),
29
+ * not just the declared width/height/depth. World position + rotation are
30
+ * cancelled out (invert the root's world matrix), so the result is stable
31
+ * regardless of where the live drag has moved the node; the caller re-applies
32
+ * the current Y rotation on the group. Returns null when nothing measurable.
33
+ */
34
+ function measureLocalBounds(obj: Object3D): LocalBounds | null {
35
+ obj.updateWorldMatrix(true, true)
36
+ const inverseRoot = new Matrix4().copy(obj.matrixWorld).invert()
37
+ const box = new Box3()
38
+ const meshBox = new Box3()
39
+ const toLocal = new Matrix4()
40
+ let measured = false
41
+ obj.traverse((child) => {
42
+ const mesh = child as Mesh
43
+ if (!mesh.isMesh || !mesh.geometry) return
44
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
45
+ const bb = mesh.geometry.boundingBox
46
+ if (!bb) return
47
+ toLocal.copy(inverseRoot).multiply(mesh.matrixWorld)
48
+ meshBox.copy(bb).applyMatrix4(toLocal)
49
+ box.union(meshBox)
50
+ measured = true
51
+ })
52
+ if (!measured || box.isEmpty()) return null
53
+ const size = box.getSize(new Vector3())
54
+ const center = box.getCenter(new Vector3())
55
+ return { size: [size.x, size.y, size.z], center: [center.x, center.y, center.z] }
56
+ }
57
+
58
+ interface DragBoundingBoxProps {
59
+ /** Node whose rendered geometry is measured for the box extents. */
60
+ nodeId: string
61
+ /** Footprint origin on the floor, `[x, 0, z]` — the node's live position. */
62
+ position: [number, number, number]
63
+ /** Y rotation (radians) applied to the box, matching the dragged node. */
64
+ rotationY?: number
65
+ /** Declared `[width, height, depth]`, used until/if the mesh can't be measured. */
66
+ fallbackSize?: [number, number, number]
67
+ /**
68
+ * Hard override for the box extents — wins over both mesh measurement and
69
+ * `fallbackSize`. Use when the rendered mesh contains extras the user
70
+ * wouldn't read as "the thing being dragged" (e.g. an elevator whose mesh
71
+ * includes per-level landings outside the shaft footprint).
72
+ */
73
+ size?: [number, number, number]
74
+ /** Y center of the box in the node's local frame. Defaults to `size[1] / 2`. */
75
+ centerY?: number
76
+ color?: number
77
+ }
78
+
79
+ /**
80
+ * Footprint box drawn around a node while it is being dragged — the same
81
+ * affordance items get during placement: a wireframe cube spanning the node's
82
+ * full measured extent plus a ground plane with a radial opacity gradient
83
+ * (transparent in the centre, opaque toward the edges). Overlay layer +
84
+ * `depthTest: false` keep it drawn on top of scene geometry throughout the
85
+ * drag, and the box visualises the bounds that drive alignment snapping.
86
+ */
87
+ export function DragBoundingBox({
88
+ nodeId,
89
+ position,
90
+ rotationY = 0,
91
+ fallbackSize = [0, 0, 0],
92
+ size,
93
+ centerY,
94
+ color = DEFAULT_COLOR,
95
+ }: DragBoundingBoxProps) {
96
+ const measured = useMemo(() => {
97
+ if (size) return null
98
+ const obj = sceneRegistry.nodes.get(nodeId)
99
+ return obj ? measureLocalBounds(obj) : null
100
+ }, [nodeId, size])
101
+
102
+ const [w, h, d] = size ?? measured?.size ?? fallbackSize
103
+ const [cx, cy, cz] = size
104
+ ? [0, centerY ?? size[1] / 2, 0]
105
+ : (measured?.center ?? [0, fallbackSize[1] / 2, 0])
106
+ const minY = cy - h / 2
107
+
108
+ const edgeGeometry = useMemo(() => {
109
+ const box = new BoxGeometry(w, h, d)
110
+ const edges = new EdgesGeometry(box)
111
+ box.dispose()
112
+ return edges
113
+ }, [w, h, d])
114
+
115
+ // Flat on the ground (XZ) at the box's base, nudged up 0.01m to avoid
116
+ // z-fighting with slabs.
117
+ const planeGeometry = useMemo(() => {
118
+ const plane = new PlaneGeometry(w, d)
119
+ plane.rotateX(-Math.PI / 2)
120
+ plane.translate(cx, minY + 0.01, cz)
121
+ return plane
122
+ }, [w, d, cx, minY, cz])
123
+
124
+ const edgeMaterial = useMemo(
125
+ () => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }),
126
+ [color],
127
+ )
128
+
129
+ const planeMaterial = useMemo(() => {
130
+ const material = new MeshBasicNodeMaterial({
131
+ color,
132
+ transparent: true,
133
+ depthTest: false,
134
+ depthWrite: false,
135
+ })
136
+ material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6)
137
+ return material
138
+ }, [color])
139
+
140
+ useEffect(
141
+ () => () => {
142
+ edgeGeometry.dispose()
143
+ planeGeometry.dispose()
144
+ edgeMaterial.dispose()
145
+ planeMaterial.dispose()
146
+ },
147
+ [edgeGeometry, planeGeometry, edgeMaterial, planeMaterial],
148
+ )
149
+
150
+ if (w <= 0 || h <= 0 || d <= 0) return null
151
+
152
+ return (
153
+ <group position={position} rotation={[0, rotationY, 0]}>
154
+ <mesh
155
+ geometry={planeGeometry}
156
+ layers={EDITOR_LAYER}
157
+ material={planeMaterial}
158
+ raycast={NO_RAYCAST}
159
+ renderOrder={999}
160
+ />
161
+ <lineSegments
162
+ geometry={edgeGeometry}
163
+ layers={EDITOR_LAYER}
164
+ material={edgeMaterial}
165
+ position={[cx, cy, cz]}
166
+ raycast={NO_RAYCAST}
167
+ renderOrder={999}
168
+ />
169
+ </group>
170
+ )
171
+ }
@@ -0,0 +1,25 @@
1
+ import { type AnyNode, type AnyNodeId, getFloorStackedPosition, useScene } from '@pascal-app/core'
2
+
3
+ type FloorStackPreviewArgs = {
4
+ node: AnyNode
5
+ position: [number, number, number]
6
+ rotation?: unknown
7
+ levelId?: string | null
8
+ nodes?: Record<AnyNodeId, AnyNode>
9
+ }
10
+
11
+ export function getFloorStackPreviewPosition({
12
+ node,
13
+ position,
14
+ rotation,
15
+ levelId,
16
+ nodes,
17
+ }: FloorStackPreviewArgs): [number, number, number] {
18
+ return getFloorStackedPosition({
19
+ node,
20
+ nodes: nodes ?? useScene.getState().nodes,
21
+ position,
22
+ rotation,
23
+ levelId,
24
+ })
25
+ }
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useCallback, useRef, useState } from 'react'
5
+ import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
6
+ import useEditor from '../../../store/use-editor'
7
+
8
+ type FreshPlacementNode = Pick<AnyNode, 'id' | 'metadata'>
9
+
10
+ type FreshPlacementVisibilityArgs = {
11
+ node: FreshPlacementNode
12
+ enabled?: boolean
13
+ }
14
+
15
+ export function useFreshPlacementVisibility({
16
+ node,
17
+ enabled = true,
18
+ }: FreshPlacementVisibilityArgs) {
19
+ const isFreshPlacement = enabled && isFreshPlacementMetadata(node.metadata)
20
+ const useAbsoluteCursorPlacement = isFreshPlacement && !useEditor.getState().placementDragMode
21
+ const shouldStartHidden = useAbsoluteCursorPlacement
22
+
23
+ const [visibility, setVisibility] = useState(() => ({
24
+ nodeId: node.id,
25
+ visible: !shouldStartHidden,
26
+ }))
27
+ const visibilityRef = useRef(visibility)
28
+ const previewVisible = visibility.nodeId === node.id ? visibility.visible : !shouldStartHidden
29
+
30
+ const setPreviewVisibleForNode = useCallback(
31
+ (visible: boolean) => {
32
+ const current = visibilityRef.current
33
+ if (current.nodeId === node.id && current.visible === visible) return
34
+ const next = { nodeId: node.id, visible }
35
+ visibilityRef.current = next
36
+ setVisibility(next)
37
+ },
38
+ [node.id],
39
+ )
40
+
41
+ const revealFreshPlacement = useCallback(() => {
42
+ if (!isFreshPlacement) return
43
+ setPreviewVisibleForNode(true)
44
+
45
+ sceneRegistry.nodes.get(node.id)?.traverse((child) => {
46
+ child.visible = true
47
+ })
48
+
49
+ const liveNode = useScene.getState().nodes[node.id as AnyNodeId]
50
+ if (liveNode?.visible === false) {
51
+ useScene.getState().updateNode(node.id as AnyNodeId, { visible: true } as Partial<AnyNode>)
52
+ }
53
+ }, [isFreshPlacement, node.id, setPreviewVisibleForNode])
54
+
55
+ return {
56
+ isFreshPlacement,
57
+ previewVisible,
58
+ revealFreshPlacement,
59
+ useAbsoluteCursorPlacement,
60
+ }
61
+ }
@@ -0,0 +1,123 @@
1
+ import { BufferGeometry, Float32BufferAttribute, type LineSegments } from 'three'
2
+
3
+ /**
4
+ * Axis-aligned box description shared by every placement-cursor wireframe.
5
+ * `dimensions` is the box extent on each axis; `center` is its centre in the
6
+ * cursor group's local space (so an off-centre mesh bbox stays off-centre).
7
+ * `min`/`max` are kept for callers that need the explicit corners.
8
+ */
9
+ export type PreviewBounds = {
10
+ min: [number, number, number]
11
+ max: [number, number, number]
12
+ dimensions: [number, number, number]
13
+ center: [number, number, number]
14
+ }
15
+
16
+ export function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
17
+ const geometry = new BufferGeometry()
18
+ geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
19
+ return geometry
20
+ }
21
+
22
+ /** Flatten a box's 12 edges into a `LineSegments` position array. */
23
+ export function getBoxEdgePoints(bounds: PreviewBounds): number[] {
24
+ const [width, height, depth] = bounds.dimensions
25
+ const [centerX, centerY, centerZ] = bounds.center
26
+ const minX = centerX - width / 2
27
+ const maxX = centerX + width / 2
28
+ const minY = centerY - height / 2
29
+ const maxY = centerY + height / 2
30
+ const minZ = centerZ - depth / 2
31
+ const maxZ = centerZ + depth / 2
32
+
33
+ return [
34
+ minX,
35
+ minY,
36
+ minZ,
37
+ maxX,
38
+ minY,
39
+ minZ,
40
+ maxX,
41
+ minY,
42
+ minZ,
43
+ maxX,
44
+ minY,
45
+ maxZ,
46
+ maxX,
47
+ minY,
48
+ maxZ,
49
+ minX,
50
+ minY,
51
+ maxZ,
52
+ minX,
53
+ minY,
54
+ maxZ,
55
+ minX,
56
+ minY,
57
+ minZ,
58
+
59
+ minX,
60
+ maxY,
61
+ minZ,
62
+ maxX,
63
+ maxY,
64
+ minZ,
65
+ maxX,
66
+ maxY,
67
+ minZ,
68
+ maxX,
69
+ maxY,
70
+ maxZ,
71
+ maxX,
72
+ maxY,
73
+ maxZ,
74
+ minX,
75
+ maxY,
76
+ maxZ,
77
+ minX,
78
+ maxY,
79
+ maxZ,
80
+ minX,
81
+ maxY,
82
+ minZ,
83
+
84
+ minX,
85
+ minY,
86
+ minZ,
87
+ minX,
88
+ maxY,
89
+ minZ,
90
+ maxX,
91
+ minY,
92
+ minZ,
93
+ maxX,
94
+ maxY,
95
+ minZ,
96
+ maxX,
97
+ minY,
98
+ maxZ,
99
+ maxX,
100
+ maxY,
101
+ maxZ,
102
+ minX,
103
+ minY,
104
+ maxZ,
105
+ minX,
106
+ maxY,
107
+ maxZ,
108
+ ]
109
+ }
110
+
111
+ export function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
112
+ const geometry = ref.current?.geometry
113
+ if (!geometry) return
114
+
115
+ const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
116
+ if (!attribute || attribute.array.length !== points.length) {
117
+ geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
118
+ } else {
119
+ attribute.set(points)
120
+ attribute.needsUpdate = true
121
+ }
122
+ geometry.computeBoundingSphere()
123
+ }