@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
@@ -1,356 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- type AnyNodeId,
5
- emitter,
6
- type GridEvent,
7
- pauseSceneHistory,
8
- resumeSceneHistory,
9
- useScene,
10
- type WallNode,
11
- } from '@pascal-app/core'
12
- import { useViewer } from '@pascal-app/viewer'
13
- import { useCallback, useEffect, useRef, useState } from 'react'
14
- import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
15
- import { sfxEmitter } from '../../../lib/sfx-bus'
16
- import useEditor from '../../../store/use-editor'
17
- import { CursorSphere } from '../shared/cursor-sphere'
18
- import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
19
-
20
- function rotateVector([x, z]: [number, number], angle: number): [number, number] {
21
- const cos = Math.cos(angle)
22
- const sin = Math.sin(angle)
23
- return [x * cos - z * sin, x * sin + z * cos]
24
- }
25
-
26
- function samePoint(a: [number, number], b: [number, number]) {
27
- return a[0] === b[0] && a[1] === b[1]
28
- }
29
-
30
- function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
31
- if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
32
- return meta
33
- }
34
-
35
- const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
36
- delete nextMeta.isNew
37
- return nextMeta as WallNode['metadata']
38
- }
39
-
40
- type LinkedWallSnapshot = {
41
- id: WallNode['id']
42
- start: [number, number]
43
- end: [number, number]
44
- }
45
-
46
- function getLinkedWallSnapshots(args: {
47
- wallId: WallNode['id']
48
- wallParentId: string | null
49
- originalStart: [number, number]
50
- originalEnd: [number, number]
51
- }) {
52
- const { wallId, wallParentId, originalStart, originalEnd } = args
53
- const { nodes } = useScene.getState()
54
- const snapshots: LinkedWallSnapshot[] = []
55
-
56
- for (const node of Object.values(nodes)) {
57
- if (!(node?.type === 'wall' && node.id !== wallId)) {
58
- continue
59
- }
60
-
61
- if ((node.parentId ?? null) !== wallParentId) {
62
- continue
63
- }
64
-
65
- if (
66
- !samePoint(node.start, originalStart) &&
67
- !samePoint(node.start, originalEnd) &&
68
- !samePoint(node.end, originalStart) &&
69
- !samePoint(node.end, originalEnd)
70
- ) {
71
- continue
72
- }
73
-
74
- snapshots.push({
75
- id: node.id,
76
- start: [...node.start] as [number, number],
77
- end: [...node.end] as [number, number],
78
- })
79
- }
80
-
81
- return snapshots
82
- }
83
-
84
- function getLinkedWallUpdates(
85
- linkedWalls: LinkedWallSnapshot[],
86
- originalStart: [number, number],
87
- originalEnd: [number, number],
88
- nextStart: [number, number],
89
- nextEnd: [number, number],
90
- ) {
91
- return linkedWalls.map((wall) => ({
92
- id: wall.id,
93
- start: samePoint(wall.start, originalStart)
94
- ? nextStart
95
- : samePoint(wall.start, originalEnd)
96
- ? nextEnd
97
- : wall.start,
98
- end: samePoint(wall.end, originalStart)
99
- ? nextStart
100
- : samePoint(wall.end, originalEnd)
101
- ? nextEnd
102
- : wall.end,
103
- }))
104
- }
105
-
106
- export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
107
- const meta =
108
- typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
109
- ? (node.metadata as Record<string, unknown>)
110
- : {}
111
- const isNew = !!meta.isNew
112
- const activatedAtRef = useRef<number>(Date.now())
113
- const previousGridPosRef = useRef<[number, number] | null>(null)
114
- const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
115
- const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
116
- const originalCenterRef = useRef<[number, number]>([
117
- (node.start[0] + node.end[0]) / 2,
118
- (node.start[1] + node.end[1]) / 2,
119
- ])
120
- const originalHalfVectorRef = useRef<[number, number]>([
121
- (node.end[0] - node.start[0]) / 2,
122
- (node.end[1] - node.start[1]) / 2,
123
- ])
124
- const linkedOriginalsRef = useRef(
125
- isNew
126
- ? []
127
- : getLinkedWallSnapshots({
128
- wallId: node.id,
129
- wallParentId: node.parentId ?? null,
130
- originalStart: node.start,
131
- originalEnd: node.end,
132
- }),
133
- )
134
- const dragAnchorRef = useRef<[number, number] | null>(null)
135
- const nodeIdRef = useRef(node.id)
136
- const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
137
- const pendingRotationRef = useRef(0)
138
- const shiftPressedRef = useRef(false)
139
-
140
- const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
141
- const centerX = (node.start[0] + node.end[0]) / 2
142
- const centerZ = (node.start[1] + node.end[1]) / 2
143
- return [centerX, 0, centerZ]
144
- })
145
-
146
- const exitMoveMode = useCallback(() => {
147
- useEditor.getState().setMovingNode(null)
148
- }, [])
149
-
150
- useEffect(() => {
151
- const nodeId = nodeIdRef.current
152
- const originalStart = originalStartRef.current
153
- const originalEnd = originalEndRef.current
154
- const originalCenter = originalCenterRef.current
155
- const originalHalfVector = originalHalfVectorRef.current
156
-
157
- pauseSceneHistory(useScene)
158
- let wasCommitted = false
159
-
160
- const applyNodePreview = (
161
- updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
162
- ) => {
163
- useScene.getState().updateNodes(
164
- updates.map((entry) => ({
165
- id: entry.id as AnyNodeId,
166
- data: { start: entry.start, end: entry.end },
167
- })),
168
- )
169
- for (const entry of updates) {
170
- useScene.getState().markDirty(entry.id as AnyNodeId)
171
- }
172
- }
173
-
174
- const buildWallFromCenter = (center: [number, number]) => {
175
- const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
176
- const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
177
- const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
178
- return { start: nextStart, end: nextEnd }
179
- }
180
-
181
- const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
182
- previewRef.current = { start: nextStart, end: nextEnd }
183
- const centerX = (nextStart[0] + nextEnd[0]) / 2
184
- const centerZ = (nextStart[1] + nextEnd[1]) / 2
185
- setCursorLocalPos([centerX, 0, centerZ])
186
- applyNodePreview([
187
- { id: nodeId, start: nextStart, end: nextEnd },
188
- ...getLinkedWallUpdates(
189
- linkedOriginalsRef.current,
190
- originalStart,
191
- originalEnd,
192
- nextStart,
193
- nextEnd,
194
- ),
195
- ])
196
- }
197
-
198
- const restoreOriginal = () => {
199
- applyNodePreview([
200
- { id: nodeId, start: originalStart, end: originalEnd },
201
- ...linkedOriginalsRef.current,
202
- ])
203
- }
204
-
205
- const onGridMove = (event: GridEvent) => {
206
- const rawX = event.localPosition[0]
207
- const rawZ = event.localPosition[2]
208
- const snapStep = getWallGridStep()
209
- const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
210
- const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
211
-
212
- if (
213
- previousGridPosRef.current &&
214
- (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
215
- ) {
216
- sfxEmitter.emit('sfx:grid-snap')
217
- }
218
- previousGridPosRef.current = [localX, localZ]
219
-
220
- const anchor = dragAnchorRef.current ?? [localX, localZ]
221
- dragAnchorRef.current = anchor
222
-
223
- const deltaX = localX - anchor[0]
224
- const deltaZ = localZ - anchor[1]
225
-
226
- const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
227
- const nextWall = buildWallFromCenter(nextCenter)
228
- applyPreview(nextWall.start, nextWall.end)
229
- }
230
-
231
- const onGridClick = (event: GridEvent) => {
232
- if (Date.now() - activatedAtRef.current < 150) {
233
- event.nativeEvent?.stopPropagation?.()
234
- return
235
- }
236
-
237
- const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
238
-
239
- wasCommitted = true
240
-
241
- // Restore original baseline while paused so the next resume+update
242
- // registers as a single tracked change (undo reverts to original).
243
- applyNodePreview([
244
- { id: nodeId, start: originalStart, end: originalEnd },
245
- ...linkedOriginalsRef.current,
246
- ])
247
-
248
- resumeSceneHistory(useScene)
249
-
250
- const commitUpdates = [
251
- {
252
- id: nodeId as AnyNodeId,
253
- data: isNew
254
- ? {
255
- start: preview.start,
256
- end: preview.end,
257
- metadata: stripWallIsNewMetadata(node.metadata),
258
- }
259
- : { start: preview.start, end: preview.end },
260
- },
261
- ...getLinkedWallUpdates(
262
- linkedOriginalsRef.current,
263
- originalStart,
264
- originalEnd,
265
- preview.start,
266
- preview.end,
267
- ).map((entry) => ({
268
- id: entry.id as AnyNodeId,
269
- data: { start: entry.start, end: entry.end },
270
- })),
271
- ]
272
- useScene.getState().updateNodes(commitUpdates)
273
- for (const { id } of commitUpdates) {
274
- useScene.getState().markDirty(id)
275
- }
276
-
277
- pauseSceneHistory(useScene)
278
-
279
- sfxEmitter.emit('sfx:item-place')
280
- useViewer.getState().setSelection({ selectedIds: [nodeId] })
281
- exitMoveMode()
282
- event.nativeEvent?.stopPropagation?.()
283
- }
284
-
285
- const onKeyDown = (event: KeyboardEvent) => {
286
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
287
- return
288
- }
289
-
290
- if (event.key === 'Shift') {
291
- shiftPressedRef.current = true
292
- return
293
- }
294
-
295
- const ROTATION_STEP = Math.PI / 4
296
- let rotationDelta = 0
297
- if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
298
- else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
299
-
300
- if (rotationDelta === 0) {
301
- return
302
- }
303
-
304
- event.preventDefault()
305
- pendingRotationRef.current += rotationDelta
306
- sfxEmitter.emit('sfx:item-rotate')
307
-
308
- const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
309
- const currentCenter: [number, number] = [
310
- (preview.start[0] + preview.end[0]) / 2,
311
- (preview.start[1] + preview.end[1]) / 2,
312
- ]
313
- const nextWall = buildWallFromCenter(currentCenter)
314
- applyPreview(nextWall.start, nextWall.end)
315
- }
316
-
317
- const onKeyUp = (event: KeyboardEvent) => {
318
- if (event.key === 'Shift') {
319
- shiftPressedRef.current = false
320
- }
321
- }
322
-
323
- const onCancel = () => {
324
- restoreOriginal()
325
- useViewer.getState().setSelection({ selectedIds: [nodeId] })
326
- resumeSceneHistory(useScene)
327
- markToolCancelConsumed()
328
- exitMoveMode()
329
- }
330
-
331
- emitter.on('grid:move', onGridMove)
332
- emitter.on('grid:click', onGridClick)
333
- emitter.on('tool:cancel', onCancel)
334
- window.addEventListener('keydown', onKeyDown)
335
- window.addEventListener('keyup', onKeyUp)
336
-
337
- return () => {
338
- if (!wasCommitted) {
339
- restoreOriginal()
340
- }
341
- shiftPressedRef.current = false
342
- resumeSceneHistory(useScene)
343
- emitter.off('grid:move', onGridMove)
344
- emitter.off('grid:click', onGridClick)
345
- emitter.off('tool:cancel', onCancel)
346
- window.removeEventListener('keydown', onKeyDown)
347
- window.removeEventListener('keyup', onKeyUp)
348
- }
349
- }, [exitMoveMode, isNew, node.metadata])
350
-
351
- return (
352
- <group>
353
- <CursorSphere position={cursorLocalPos} showTooltip={false} />
354
- </group>
355
- )
356
- }
@@ -1,332 +0,0 @@
1
- import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
2
- import { useViewer } from '@pascal-app/viewer'
3
- import { Html } from '@react-three/drei'
4
- import { useEffect, useRef, useState } from 'react'
5
- import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
6
- import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
- import { EDITOR_LAYER } from '../../../lib/constants'
8
- import { sfxEmitter } from '../../../lib/sfx-bus'
9
- import { CursorSphere } from '../shared/cursor-sphere'
10
- import {
11
- formatAngleRadians,
12
- getAngleToSegmentReference,
13
- getSegmentAngleReferenceAtPoint,
14
- } from '../shared/segment-angle'
15
- import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
16
-
17
- const WALL_HEIGHT = 2.5
18
- const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
19
- const DRAFT_ANGLE_LABEL_Y = 0.28
20
-
21
- type DraftAngleLabel = {
22
- id: string
23
- label: string
24
- position: [number, number, number]
25
- }
26
-
27
- type DraftMeasurementState = {
28
- lengthLabel: string
29
- lengthPosition: [number, number, number]
30
- angleLabels: DraftAngleLabel[]
31
- } | null
32
-
33
- function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
34
- if (unit === 'imperial') {
35
- const feet = value * 3.280_84
36
- const wholeFeet = Math.floor(feet)
37
- const inches = Math.round((feet - wholeFeet) * 12)
38
- if (inches === 12) return `${wholeFeet + 1}'0"`
39
- return `${wholeFeet}'${inches}"`
40
- }
41
-
42
- return `${Number.parseFloat(value.toFixed(2))}m`
43
- }
44
-
45
- function getDraftAngleLabels(
46
- start: WallPlanPoint,
47
- end: WallPlanPoint,
48
- walls: WallNode[],
49
- ): DraftAngleLabel[] {
50
- const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
51
- const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
52
- const endpoints = [
53
- { id: 'start', point: start, draftVector: draftFromStart },
54
- { id: 'end', point: end, draftVector: draftFromEnd },
55
- ]
56
- const labels: DraftAngleLabel[] = []
57
-
58
- for (const endpoint of endpoints) {
59
- const connectedWall = walls.find((wall) =>
60
- Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
61
- )
62
- if (!connectedWall) continue
63
-
64
- const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
65
- if (!connectedReference) continue
66
-
67
- const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
68
- if (angle === null) continue
69
-
70
- labels.push({
71
- id: endpoint.id,
72
- label: formatAngleRadians(angle),
73
- position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
74
- })
75
- }
76
-
77
- return labels
78
- }
79
-
80
- function getDraftMeasurementState(
81
- start: WallPlanPoint,
82
- end: WallPlanPoint,
83
- walls: WallNode[],
84
- unit: 'metric' | 'imperial',
85
- ): DraftMeasurementState {
86
- const dx = end[0] - start[0]
87
- const dz = end[1] - start[1]
88
- const length = Math.hypot(dx, dz)
89
-
90
- if (length < 0.01) return null
91
-
92
- return {
93
- lengthLabel: formatMeasurement(length, unit),
94
- lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
95
- angleLabels: getDraftAngleLabels(start, end, walls),
96
- }
97
- }
98
-
99
- /**
100
- * Update wall preview mesh geometry to create a vertical plane between two points
101
- */
102
- const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
103
- // Calculate direction and perpendicular for wall thickness
104
- const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
105
- const length = direction.length()
106
-
107
- if (length < 0.01) {
108
- mesh.visible = false
109
- return
110
- }
111
-
112
- mesh.visible = true
113
- direction.normalize()
114
-
115
- // Create wall shape (vertical rectangle in XY plane)
116
- const shape = new Shape()
117
- shape.moveTo(0, 0)
118
- shape.lineTo(length, 0)
119
- shape.lineTo(length, WALL_HEIGHT)
120
- shape.lineTo(0, WALL_HEIGHT)
121
- shape.closePath()
122
-
123
- // Create geometry
124
- const geometry = new ShapeGeometry(shape)
125
-
126
- // Calculate rotation angle
127
- // Negate the angle to fix the opposite direction issue
128
- const angle = -Math.atan2(direction.z, direction.x)
129
-
130
- // Position at start point and rotate
131
- mesh.position.set(start.x, start.y, start.z)
132
- mesh.rotation.y = angle
133
-
134
- // Dispose old geometry and assign new one
135
- if (mesh.geometry) {
136
- mesh.geometry.dispose()
137
- }
138
- mesh.geometry = geometry
139
- }
140
-
141
- const getCurrentLevelWalls = (): WallNode[] => {
142
- const currentLevelId = useViewer.getState().selection.levelId
143
- const { nodes } = useScene.getState()
144
-
145
- if (!currentLevelId) return []
146
-
147
- const levelNode = nodes[currentLevelId]
148
- if (!levelNode || levelNode.type !== 'level') return []
149
-
150
- return (levelNode as LevelNode).children
151
- .map((childId) => nodes[childId])
152
- .filter((node): node is WallNode => node?.type === 'wall')
153
- }
154
-
155
- export const WallTool: React.FC = () => {
156
- const unit = useViewer((state) => state.unit)
157
- const cursorRef = useRef<Group>(null)
158
- const wallPreviewRef = useRef<Mesh>(null!)
159
- const startingPoint = useRef(new Vector3(0, 0, 0))
160
- const endingPoint = useRef(new Vector3(0, 0, 0))
161
- const buildingState = useRef(0)
162
- const shiftPressed = useRef(false)
163
- const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
164
-
165
- useEffect(() => {
166
- let gridPosition: WallPlanPoint = [0, 0]
167
- let previousWallEnd: [number, number] | null = null
168
-
169
- // All positions are building-local: this tool is inside the ToolManager building group,
170
- // so local coords are used for both data and visual positioning.
171
- const onGridMove = (event: GridEvent) => {
172
- if (!(cursorRef.current && wallPreviewRef.current)) return
173
-
174
- const walls = getCurrentLevelWalls()
175
- // event.localPosition is building-local — consistent with stored wall start/end
176
- const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
177
- gridPosition = snapWallDraftPoint({ point: localPoint, walls })
178
-
179
- if (buildingState.current === 1) {
180
- const snappedLocal = snapWallDraftPoint({
181
- point: localPoint,
182
- walls,
183
- start: [startingPoint.current.x, startingPoint.current.z],
184
- angleSnap: !shiftPressed.current,
185
- })
186
- endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
187
- cursorRef.current.position.copy(endingPoint.current)
188
-
189
- // Play snap sound only when the actual wall end position changes
190
- const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
191
- if (
192
- previousWallEnd &&
193
- (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
194
- ) {
195
- sfxEmitter.emit('sfx:grid-snap')
196
- }
197
- previousWallEnd = currentWallEnd
198
-
199
- updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
200
- setDraftMeasurement(
201
- getDraftMeasurementState(
202
- [startingPoint.current.x, startingPoint.current.z],
203
- snappedLocal,
204
- walls,
205
- unit,
206
- ),
207
- )
208
- } else {
209
- // Not drawing a wall yet, show the snapped anchor point.
210
- cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
211
- setDraftMeasurement(null)
212
- }
213
- }
214
-
215
- const onGridClick = (event: GridEvent) => {
216
- const walls = getCurrentLevelWalls()
217
- const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
218
-
219
- if (buildingState.current === 0) {
220
- const snappedStart = snapWallDraftPoint({ point: localClick, walls })
221
- gridPosition = snappedStart
222
- startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
223
- endingPoint.current.copy(startingPoint.current)
224
- buildingState.current = 1
225
- wallPreviewRef.current.visible = true
226
- setDraftMeasurement(null)
227
- } else if (buildingState.current === 1) {
228
- const snappedEnd = snapWallDraftPoint({
229
- point: localClick,
230
- walls,
231
- start: [startingPoint.current.x, startingPoint.current.z],
232
- angleSnap: !shiftPressed.current,
233
- })
234
- const dx = snappedEnd[0] - startingPoint.current.x
235
- const dz = snappedEnd[1] - startingPoint.current.z
236
- if (dx * dx + dz * dz < 0.01 * 0.01) return
237
- // Both start and end are building-local ✓
238
- createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
239
- wallPreviewRef.current.visible = false
240
- buildingState.current = 0
241
- setDraftMeasurement(null)
242
- }
243
- }
244
-
245
- const onKeyDown = (e: KeyboardEvent) => {
246
- if (e.key === 'Shift') {
247
- shiftPressed.current = true
248
- }
249
- }
250
-
251
- const onKeyUp = (e: KeyboardEvent) => {
252
- if (e.key === 'Shift') {
253
- shiftPressed.current = false
254
- }
255
- }
256
-
257
- const onCancel = () => {
258
- if (buildingState.current === 1) {
259
- markToolCancelConsumed()
260
- buildingState.current = 0
261
- wallPreviewRef.current.visible = false
262
- setDraftMeasurement(null)
263
- }
264
- }
265
-
266
- emitter.on('grid:move', onGridMove)
267
- emitter.on('grid:click', onGridClick)
268
- emitter.on('tool:cancel', onCancel)
269
- window.addEventListener('keydown', onKeyDown)
270
- window.addEventListener('keyup', onKeyUp)
271
-
272
- return () => {
273
- emitter.off('grid:move', onGridMove)
274
- emitter.off('grid:click', onGridClick)
275
- emitter.off('tool:cancel', onCancel)
276
- window.removeEventListener('keydown', onKeyDown)
277
- window.removeEventListener('keyup', onKeyUp)
278
- }
279
- }, [unit])
280
-
281
- return (
282
- <group>
283
- {/* Cursor indicator */}
284
- <CursorSphere ref={cursorRef} />
285
-
286
- {/* Wall preview */}
287
- <mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
288
- <shapeGeometry />
289
- <meshBasicMaterial
290
- color="#818cf8"
291
- depthTest={false}
292
- depthWrite={false}
293
- opacity={0.5}
294
- side={DoubleSide}
295
- transparent
296
- />
297
- </mesh>
298
-
299
- {draftMeasurement && (
300
- <>
301
- <DraftMeasurementLabel
302
- label={draftMeasurement.lengthLabel}
303
- position={draftMeasurement.lengthPosition}
304
- />
305
- {draftMeasurement.angleLabels.map((angleLabel) => (
306
- <DraftMeasurementLabel
307
- key={angleLabel.id}
308
- label={angleLabel.label}
309
- position={angleLabel.position}
310
- />
311
- ))}
312
- </>
313
- )}
314
- </group>
315
- )
316
- }
317
-
318
- function DraftMeasurementLabel({
319
- label,
320
- position,
321
- }: {
322
- label: string
323
- position: [number, number, number]
324
- }) {
325
- return (
326
- <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
327
- <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
328
- {label}
329
- </div>
330
- </Html>
331
- )
332
- }