@pascal-app/editor 0.4.0

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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,288 @@
1
+ import {
2
+ type AnyNodeId,
3
+ emitter,
4
+ type GridEvent,
5
+ type RoofNode,
6
+ type RoofSegmentNode,
7
+ type StairNode,
8
+ type StairSegmentNode,
9
+ sceneRegistry,
10
+ useLiveTransforms,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { useCallback, useEffect, useRef, useState } from 'react'
15
+ import * as THREE from 'three'
16
+ import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import useEditor from '../../../store/use-editor'
18
+ import { CursorSphere } from '../shared/cursor-sphere'
19
+
20
+ export const MoveRoofTool: React.FC<{
21
+ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
22
+ }> = ({ node: movingNode }) => {
23
+ const exitMoveMode = useCallback(() => {
24
+ useEditor.getState().setMovingNode(null)
25
+ }, [])
26
+
27
+ const previousGridPosRef = useRef<[number, number] | null>(null)
28
+
29
+ const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
30
+ const obj = sceneRegistry.nodes.get(movingNode.id)
31
+ if (obj) {
32
+ const pos = new THREE.Vector3()
33
+ obj.getWorldPosition(pos)
34
+ return [pos.x, pos.y, pos.z]
35
+ }
36
+ // Fallback if not registered (e.g. newly created duplicate without mesh yet)
37
+ if (
38
+ (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
39
+ movingNode.parentId
40
+ ) {
41
+ const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
42
+ if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
43
+ const parentAngle = parentNode.rotation as number
44
+ const px = parentNode.position[0] as number
45
+ const py = parentNode.position[1] as number
46
+ const pz = parentNode.position[2] as number
47
+ const lx = movingNode.position[0]
48
+ const ly = movingNode.position[1]
49
+ const lz = movingNode.position[2]
50
+
51
+ const wx = lx * Math.cos(parentAngle) - lz * Math.sin(parentAngle) + px
52
+ const wz = lx * Math.sin(parentAngle) + lz * Math.cos(parentAngle) + pz
53
+ return [wx, py + ly, wz]
54
+ }
55
+ }
56
+ return [movingNode.position[0], movingNode.position[1], movingNode.position[2]]
57
+ })
58
+
59
+ useEffect(() => {
60
+ useScene.temporal.getState().pause()
61
+
62
+ const meta =
63
+ typeof movingNode.metadata === 'object' && movingNode.metadata !== null
64
+ ? (movingNode.metadata as Record<string, unknown>)
65
+ : {}
66
+ const isNew = !!meta.isNew
67
+ const committedMeta: RoofNode['metadata'] = (() => {
68
+ if (
69
+ typeof movingNode.metadata !== 'object' ||
70
+ movingNode.metadata === null ||
71
+ Array.isArray(movingNode.metadata)
72
+ ) {
73
+ return movingNode.metadata
74
+ }
75
+
76
+ const nextMeta = { ...movingNode.metadata } as Record<string, unknown>
77
+ delete nextMeta.isNew
78
+ delete nextMeta.isTransient
79
+ return nextMeta as RoofNode['metadata']
80
+ })()
81
+
82
+ const original = {
83
+ position: [...movingNode.position] as [number, number, number],
84
+ rotation: movingNode.rotation,
85
+ parentId: movingNode.parentId,
86
+ metadata: movingNode.metadata,
87
+ }
88
+
89
+ // Track whether the move was committed so cleanup knows whether to revert.
90
+ // We avoid setting isTransient on the store to prevent RoofSystem from
91
+ // resetting the mesh position (it resets on dirty) and from triggering
92
+ // expensive merged-mesh CSG rebuilds on every frame.
93
+ let wasCommitted = false
94
+
95
+ // Track pending rotation — no store updates during drag
96
+ let pendingRotation: number = movingNode.rotation as number
97
+
98
+ // For roof-segment moves: the selection was cleared before entering move mode,
99
+ // so isSelected=false on the parent roof, hiding individual segment meshes and
100
+ // showing only the merged mesh. We directly flip Three.js visibility so the
101
+ // user sees the individual segment tracking the cursor.
102
+ let segmentWrapperGroup: THREE.Object3D | null = null
103
+ let mergedRoofMesh: THREE.Object3D | null = null
104
+ if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
105
+ const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
106
+ if (segmentMesh?.parent) {
107
+ // segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
108
+ // segmentMesh.parent.parent = the registered roof/stair group
109
+ segmentWrapperGroup = segmentMesh.parent
110
+ const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
111
+ mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
112
+ segmentWrapperGroup.visible = true
113
+ if (mergedRoofMesh) mergedRoofMesh.visible = false
114
+ }
115
+ }
116
+
117
+ const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => {
118
+ let localX = gridX
119
+ let localZ = gridZ
120
+
121
+ if (
122
+ (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
123
+ movingNode.parentId
124
+ ) {
125
+ const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
126
+ if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
127
+ const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
128
+ if (parentObj) {
129
+ const worldVec = new THREE.Vector3(gridX, y, gridZ)
130
+ parentObj.worldToLocal(worldVec)
131
+ localX = worldVec.x
132
+ localZ = worldVec.z
133
+ } else {
134
+ const dx = gridX - (parentNode.position[0] as number)
135
+ const dz = gridZ - (parentNode.position[2] as number)
136
+ const angle = -(parentNode.rotation as number)
137
+ localX = dx * Math.cos(angle) - dz * Math.sin(angle)
138
+ localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
139
+ }
140
+ }
141
+ }
142
+
143
+ return [localX, localZ]
144
+ }
145
+
146
+ const onGridMove = (event: GridEvent) => {
147
+ const gridX = Math.round(event.position[0] * 2) / 2
148
+ const gridZ = Math.round(event.position[2] * 2) / 2
149
+ const y = event.position[1]
150
+
151
+ if (
152
+ previousGridPosRef.current &&
153
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
154
+ ) {
155
+ sfxEmitter.emit('sfx:grid-snap')
156
+ }
157
+
158
+ previousGridPosRef.current = [gridX, gridZ]
159
+ setCursorWorldPos([gridX, y, gridZ])
160
+
161
+ const [localX, localZ] = computeLocal(gridX, gridZ, y)
162
+
163
+ // Directly update the Three.js mesh — no store update during drag
164
+ const mesh = sceneRegistry.nodes.get(movingNode.id)
165
+ if (mesh) {
166
+ mesh.position.x = localX
167
+ mesh.position.z = localZ
168
+ }
169
+
170
+ // Publish world-space position so the 2D floorplan can track the drag
171
+ useLiveTransforms.getState().set(movingNode.id, {
172
+ position: [gridX, y, gridZ],
173
+ rotation: pendingRotation,
174
+ })
175
+ }
176
+
177
+ const onGridClick = (event: GridEvent) => {
178
+ const gridX = Math.round(event.position[0] * 2) / 2
179
+ const gridZ = Math.round(event.position[2] * 2) / 2
180
+ const y = event.position[1]
181
+
182
+ const [localX, localZ] = computeLocal(gridX, gridZ, y)
183
+
184
+ wasCommitted = true
185
+
186
+ // The store still holds the original values (we didn't update during drag).
187
+ // Resume temporal and apply the final state as a single undoable step.
188
+ useScene.temporal.getState().resume()
189
+
190
+ useScene.getState().updateNode(movingNode.id, {
191
+ position: [localX, movingNode.position[1], localZ],
192
+ rotation: pendingRotation,
193
+ metadata: committedMeta,
194
+ })
195
+
196
+ useScene.temporal.getState().pause()
197
+
198
+ sfxEmitter.emit('sfx:item-place')
199
+ useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
200
+ useLiveTransforms.getState().clear(movingNode.id)
201
+ exitMoveMode()
202
+ event.nativeEvent?.stopPropagation?.()
203
+ }
204
+
205
+ const onCancel = () => {
206
+ useLiveTransforms.getState().clear(movingNode.id)
207
+ if (isNew) {
208
+ useScene.getState().deleteNode(movingNode.id)
209
+ } else {
210
+ useScene.getState().updateNode(movingNode.id, {
211
+ position: original.position,
212
+ rotation: original.rotation,
213
+ metadata: original.metadata,
214
+ })
215
+ }
216
+ useScene.temporal.getState().resume()
217
+ exitMoveMode()
218
+ }
219
+
220
+ const onKeyDown = (event: KeyboardEvent) => {
221
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
222
+ return
223
+ }
224
+
225
+ const ROTATION_STEP = Math.PI / 4
226
+ let rotationDelta = 0
227
+ if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
228
+ else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
229
+
230
+ if (rotationDelta !== 0) {
231
+ event.preventDefault()
232
+ sfxEmitter.emit('sfx:item-rotate')
233
+
234
+ pendingRotation += rotationDelta
235
+
236
+ // Directly update the Three.js mesh — no store update during drag
237
+ const mesh = sceneRegistry.nodes.get(movingNode.id)
238
+ if (mesh) mesh.rotation.y = pendingRotation
239
+
240
+ // Update live transform rotation for 2D floorplan
241
+ const currentLive = useLiveTransforms.getState().get(movingNode.id)
242
+ if (currentLive) {
243
+ useLiveTransforms.getState().set(movingNode.id, {
244
+ ...currentLive,
245
+ rotation: pendingRotation,
246
+ })
247
+ }
248
+ }
249
+ }
250
+
251
+ emitter.on('grid:move', onGridMove)
252
+ emitter.on('grid:click', onGridClick)
253
+ emitter.on('tool:cancel', onCancel)
254
+ window.addEventListener('keydown', onKeyDown)
255
+
256
+ return () => {
257
+ // Restore segment wrapper visibility (React will re-sync on next render)
258
+ if (segmentWrapperGroup) segmentWrapperGroup.visible = false
259
+ if (mergedRoofMesh) mergedRoofMesh.visible = true
260
+
261
+ // Clear ephemeral live transform
262
+ useLiveTransforms.getState().clear(movingNode.id)
263
+
264
+ if (!wasCommitted) {
265
+ if (isNew) {
266
+ useScene.getState().deleteNode(movingNode.id)
267
+ } else {
268
+ useScene.getState().updateNode(movingNode.id, {
269
+ position: original.position,
270
+ rotation: original.rotation,
271
+ metadata: original.metadata,
272
+ })
273
+ }
274
+ }
275
+ useScene.temporal.getState().resume()
276
+ emitter.off('grid:move', onGridMove)
277
+ emitter.off('grid:click', onGridClick)
278
+ emitter.off('tool:cancel', onCancel)
279
+ window.removeEventListener('keydown', onKeyDown)
280
+ }
281
+ }, [movingNode, exitMoveMode])
282
+
283
+ return (
284
+ <group>
285
+ <CursorSphere position={cursorWorldPos} showTooltip={false} />
286
+ </group>
287
+ )
288
+ }
@@ -0,0 +1,318 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ emitter,
5
+ type GridEvent,
6
+ type LevelNode,
7
+ RoofNode,
8
+ RoofSegmentNode,
9
+ sceneRegistry,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { useEffect, useMemo, useRef, useState } from 'react'
14
+ import * as THREE from 'three'
15
+ import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
16
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
+ import { EDITOR_LAYER } from '../../../lib/constants'
18
+ import { sfxEmitter } from '../../../lib/sfx-bus'
19
+ import useEditor from '../../../store/use-editor'
20
+ import { CursorSphere } from '../shared/cursor-sphere'
21
+
22
+ const DEFAULT_WALL_HEIGHT = 0.5
23
+ const DEFAULT_ROOF_HEIGHT = 2.5
24
+ const GRID_OFFSET = 0.02
25
+
26
+ /**
27
+ * Creates a roof group with one default gable segment
28
+ */
29
+ const commitRoofPlacement = (
30
+ levelId: LevelNode['id'],
31
+ corner1: [number, number, number],
32
+ corner2: [number, number, number],
33
+ selectedIds: string[],
34
+ ): AnyNode['id'] => {
35
+ const { createNode, createNodes, nodes } = useScene.getState()
36
+
37
+ const centerX = (corner1[0] + corner2[0]) / 2
38
+ const centerZ = (corner1[2] + corner2[2]) / 2
39
+
40
+ const width = Math.max(Math.abs(corner2[0] - corner1[0]), 1)
41
+ const depth = Math.max(Math.abs(corner2[2] - corner1[2]), 1)
42
+
43
+ // Determine if there is an active roof node we should add to
44
+ let targetRoofId: RoofNode['id'] | null = null
45
+ const selectedId = selectedIds[0]
46
+ if (selectedIds.length === 1 && selectedId) {
47
+ const selectedNode = nodes[selectedId as AnyNodeId]
48
+ if (selectedNode?.type === 'roof') {
49
+ targetRoofId = selectedNode.id
50
+ } else if (selectedNode?.type === 'roof-segment' && selectedNode.parentId) {
51
+ targetRoofId = selectedNode.parentId as RoofNode['id']
52
+ }
53
+ }
54
+
55
+ if (targetRoofId) {
56
+ const targetRoof = nodes[targetRoofId] as RoofNode
57
+ let localX = centerX
58
+ let localZ = centerZ
59
+
60
+ // Convert world coordinates to the local space of the parent roof
61
+ const targetObj = sceneRegistry.nodes.get(targetRoofId)
62
+ if (targetObj) {
63
+ const worldVec = new THREE.Vector3(centerX, 0, centerZ)
64
+ targetObj.worldToLocal(worldVec)
65
+ localX = worldVec.x
66
+ localZ = worldVec.z
67
+ } else {
68
+ // Math fallback if mesh isn't ready
69
+ const dx = centerX - targetRoof.position[0]
70
+ const dz = centerZ - targetRoof.position[2]
71
+ const angle = -targetRoof.rotation
72
+ localX = dx * Math.cos(angle) - dz * Math.sin(angle)
73
+ localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
74
+ }
75
+
76
+ const segment = RoofSegmentNode.parse({
77
+ width,
78
+ depth,
79
+ wallHeight: DEFAULT_WALL_HEIGHT,
80
+ roofHeight: DEFAULT_ROOF_HEIGHT,
81
+ roofType: 'gable',
82
+ position: [localX, 0, localZ],
83
+ })
84
+
85
+ createNode(segment, targetRoofId as AnyNode['id'])
86
+ sfxEmitter.emit('sfx:structure-build')
87
+ return segment.id // Returns segment ID so it can be selected immediately
88
+ }
89
+
90
+ // Count existing roofs for naming
91
+ const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
92
+ const name = `Roof ${roofCount + 1}`
93
+
94
+ // Create the segment first (centered in its new parent)
95
+ const segment = RoofSegmentNode.parse({
96
+ width,
97
+ depth,
98
+ wallHeight: DEFAULT_WALL_HEIGHT,
99
+ roofHeight: DEFAULT_ROOF_HEIGHT,
100
+ roofType: 'gable',
101
+ position: [0, 0, 0],
102
+ })
103
+
104
+ // Create the roof container
105
+ const roof = RoofNode.parse({
106
+ name,
107
+ position: [centerX, 0, centerZ],
108
+ children: [segment.id],
109
+ })
110
+
111
+ // Create roof first (so segment can be parented to it), then segment
112
+ createNodes([
113
+ { node: roof, parentId: levelId },
114
+ { node: segment, parentId: roof.id },
115
+ ])
116
+
117
+ sfxEmitter.emit('sfx:structure-build')
118
+ return roof.id
119
+ }
120
+
121
+ type PreviewState = {
122
+ corner1: [number, number, number] | null
123
+ cursorPosition: [number, number, number]
124
+ levelY: number
125
+ }
126
+
127
+ export const RoofTool: React.FC = () => {
128
+ const cursorRef = useRef<Group>(null)
129
+ const outlineRef = useRef<Line>(null!)
130
+ const currentLevelId = useViewer((state) => state.selection.levelId)
131
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
132
+ const setSelection = useViewer((state) => state.setSelection)
133
+ const setTool = useEditor((state) => state.setTool)
134
+ const setMode = useEditor((state) => state.setMode)
135
+
136
+ const selectedIdsRef = useRef(selectedIds)
137
+ useEffect(() => {
138
+ selectedIdsRef.current = selectedIds
139
+ }, [selectedIds])
140
+
141
+ const corner1Ref = useRef<[number, number, number] | null>(null)
142
+ const previousGridPosRef = useRef<[number, number] | null>(null)
143
+ const [preview, setPreview] = useState<PreviewState>({
144
+ corner1: null,
145
+ cursorPosition: [0, 0, 0],
146
+ levelY: 0,
147
+ })
148
+
149
+ useEffect(() => {
150
+ if (!currentLevelId) return
151
+
152
+ outlineRef.current.geometry = new BufferGeometry()
153
+
154
+ const updateOutline = (
155
+ corner1: [number, number, number],
156
+ corner2: [number, number, number],
157
+ ) => {
158
+ const gridY = corner1[1] + GRID_OFFSET
159
+
160
+ const groundPoints = [
161
+ new Vector3(corner1[0], gridY, corner1[2]),
162
+ new Vector3(corner2[0], gridY, corner1[2]),
163
+ new Vector3(corner2[0], gridY, corner2[2]),
164
+ new Vector3(corner1[0], gridY, corner2[2]),
165
+ new Vector3(corner1[0], gridY, corner1[2]),
166
+ ]
167
+
168
+ outlineRef.current.geometry.dispose()
169
+ outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints)
170
+ outlineRef.current.visible = true
171
+ }
172
+
173
+ const onGridMove = (event: GridEvent) => {
174
+ if (!cursorRef.current) return
175
+
176
+ const gridX = Math.round(event.position[0] * 2) / 2
177
+ const gridZ = Math.round(event.position[2] * 2) / 2
178
+ const y = event.position[1]
179
+
180
+ const cursorPosition: [number, number, number] = [gridX, y, gridZ]
181
+ const gridY = y + GRID_OFFSET
182
+
183
+ cursorRef.current.position.set(gridX, gridY, gridZ)
184
+
185
+ if (
186
+ corner1Ref.current &&
187
+ previousGridPosRef.current &&
188
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
189
+ ) {
190
+ sfxEmitter.emit('sfx:grid-snap')
191
+ }
192
+
193
+ previousGridPosRef.current = [gridX, gridZ]
194
+
195
+ setPreview({
196
+ corner1: corner1Ref.current,
197
+ cursorPosition,
198
+ levelY: y,
199
+ })
200
+
201
+ if (corner1Ref.current) {
202
+ updateOutline(corner1Ref.current, cursorPosition)
203
+ }
204
+ }
205
+
206
+ const onGridClick = (event: GridEvent) => {
207
+ if (!currentLevelId) return
208
+
209
+ const gridX = Math.round(event.position[0] * 2) / 2
210
+ const gridZ = Math.round(event.position[2] * 2) / 2
211
+ const y = event.position[1]
212
+
213
+ if (corner1Ref.current) {
214
+ const roofId = commitRoofPlacement(
215
+ currentLevelId,
216
+ corner1Ref.current,
217
+ [gridX, y, gridZ],
218
+ selectedIdsRef.current,
219
+ )
220
+
221
+ setSelection({ selectedIds: [roofId as AnyNode['id']] })
222
+
223
+ corner1Ref.current = null
224
+ outlineRef.current.visible = false
225
+ } else {
226
+ corner1Ref.current = [gridX, y, gridZ]
227
+ setPreview((prev) => ({
228
+ ...prev,
229
+ corner1: corner1Ref.current,
230
+ }))
231
+ }
232
+ }
233
+
234
+ const onCancel = () => {
235
+ if (corner1Ref.current) {
236
+ markToolCancelConsumed()
237
+ corner1Ref.current = null
238
+ outlineRef.current.visible = false
239
+ setPreview((prev) => ({ ...prev, corner1: null }))
240
+ }
241
+ }
242
+
243
+ emitter.on('grid:move', onGridMove)
244
+ emitter.on('grid:click', onGridClick)
245
+ emitter.on('tool:cancel', onCancel)
246
+
247
+ return () => {
248
+ emitter.off('grid:move', onGridMove)
249
+ emitter.off('grid:click', onGridClick)
250
+ emitter.off('tool:cancel', onCancel)
251
+
252
+ corner1Ref.current = null
253
+ }
254
+ }, [currentLevelId, setSelection])
255
+
256
+ const { corner1, cursorPosition, levelY } = preview
257
+
258
+ const previewDimensions = useMemo(() => {
259
+ if (!corner1) return null
260
+ const length = Math.abs(cursorPosition[0] - corner1[0])
261
+ const width = Math.abs(cursorPosition[2] - corner1[2])
262
+ const centerX = (corner1[0] + cursorPosition[0]) / 2
263
+ const centerZ = (corner1[2] + cursorPosition[2]) / 2
264
+ return { length, width, centerX, centerZ }
265
+ }, [corner1, cursorPosition])
266
+
267
+ return (
268
+ <group>
269
+ <CursorSphere ref={cursorRef} />
270
+
271
+ {/* @ts-ignore */}
272
+ <line
273
+ frustumCulled={false}
274
+ layers={EDITOR_LAYER}
275
+ // @ts-expect-error
276
+ ref={outlineRef}
277
+ renderOrder={1}
278
+ visible={false}
279
+ >
280
+ <bufferGeometry />
281
+ <lineBasicNodeMaterial
282
+ color="#818cf8"
283
+ depthTest={false}
284
+ depthWrite={false}
285
+ linewidth={2}
286
+ opacity={0.3}
287
+ transparent
288
+ />
289
+ </line>
290
+
291
+ {corner1 && (
292
+ <CursorSphere
293
+ color="#818cf8"
294
+ position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
295
+ showTooltip={false}
296
+ />
297
+ )}
298
+
299
+ {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
300
+ <mesh
301
+ layers={EDITOR_LAYER}
302
+ position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
303
+ rotation={[-Math.PI / 2, 0, 0]}
304
+ >
305
+ <planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
306
+ <meshBasicMaterial
307
+ color="#818cf8"
308
+ depthTest={false}
309
+ depthWrite={false}
310
+ opacity={0.1}
311
+ side={DoubleSide}
312
+ transparent
313
+ />
314
+ </mesh>
315
+ )}
316
+ </group>
317
+ )
318
+ }