@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,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
+ }