@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,322 @@
1
+ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+ import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
5
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
6
+ import { EDITOR_LAYER } from '../../../lib/constants'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
8
+ import { CursorSphere } from '../shared/cursor-sphere'
9
+
10
+ const Y_OFFSET = 0.02
11
+
12
+ /**
13
+ * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
14
+ */
15
+ const calculateSnapPoint = (
16
+ lastPoint: [number, number],
17
+ currentPoint: [number, number],
18
+ ): [number, number] => {
19
+ const [x1, y1] = lastPoint
20
+ const [x, y] = currentPoint
21
+
22
+ const dx = x - x1
23
+ const dy = y - y1
24
+ const absDx = Math.abs(dx)
25
+ const absDy = Math.abs(dy)
26
+
27
+ // Calculate distances to horizontal, vertical, and diagonal lines
28
+ const horizontalDist = absDy
29
+ const verticalDist = absDx
30
+ const diagonalDist = Math.abs(absDx - absDy)
31
+
32
+ // Find the minimum distance to determine which axis to snap to
33
+ const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
34
+
35
+ if (minDist === diagonalDist) {
36
+ // Snap to 45° diagonal
37
+ const diagonalLength = Math.min(absDx, absDy)
38
+ return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
39
+ }
40
+ if (minDist === horizontalDist) {
41
+ // Snap to horizontal
42
+ return [x, y1]
43
+ }
44
+ // Snap to vertical
45
+ return [x1, y]
46
+ }
47
+
48
+ /**
49
+ * Creates a slab with the given polygon points and returns its ID
50
+ */
51
+ const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
52
+ const { createNode, nodes } = useScene.getState()
53
+
54
+ // Count existing slabs for naming
55
+ const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
56
+ const name = `Slab ${slabCount + 1}`
57
+
58
+ const slab = SlabNode.parse({
59
+ name,
60
+ polygon: points,
61
+ })
62
+
63
+ createNode(slab, levelId)
64
+ sfxEmitter.emit('sfx:structure-build')
65
+ return slab.id
66
+ }
67
+
68
+ export const SlabTool: React.FC = () => {
69
+ const cursorRef = useRef<Group>(null)
70
+ const mainLineRef = useRef<Line>(null!)
71
+ const closingLineRef = useRef<Line>(null!)
72
+ const currentLevelId = useViewer((state) => state.selection.levelId)
73
+ const setSelection = useViewer((state) => state.setSelection)
74
+
75
+ const [points, setPoints] = useState<Array<[number, number]>>([])
76
+ const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
77
+ const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
78
+ const [levelY, setLevelY] = useState(0)
79
+ const previousSnappedPointRef = useRef<[number, number] | null>(null)
80
+ const shiftPressed = useRef(false)
81
+
82
+ // Update cursor position and lines on grid move
83
+ useEffect(() => {
84
+ if (!currentLevelId) return
85
+
86
+ const onGridMove = (event: GridEvent) => {
87
+ if (!cursorRef.current) return
88
+
89
+ const gridX = Math.round(event.position[0] * 2) / 2
90
+ const gridZ = Math.round(event.position[2] * 2) / 2
91
+ const gridPosition: [number, number] = [gridX, gridZ]
92
+
93
+ setCursorPosition(gridPosition)
94
+ setLevelY(event.position[1])
95
+
96
+ // Calculate snapped display position (bypass snap when Shift is held)
97
+ const lastPoint = points[points.length - 1]
98
+ const displayPoint =
99
+ shiftPressed.current || !lastPoint
100
+ ? gridPosition
101
+ : calculateSnapPoint(lastPoint, gridPosition)
102
+ setSnappedCursorPosition(displayPoint)
103
+
104
+ // Play snap sound when the snapped position actually changes (only when drawing)
105
+ if (
106
+ points.length > 0 &&
107
+ previousSnappedPointRef.current &&
108
+ (displayPoint[0] !== previousSnappedPointRef.current[0] ||
109
+ displayPoint[1] !== previousSnappedPointRef.current[1])
110
+ ) {
111
+ sfxEmitter.emit('sfx:grid-snap')
112
+ }
113
+
114
+ previousSnappedPointRef.current = displayPoint
115
+ cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
116
+ }
117
+
118
+ const onGridClick = (_event: GridEvent) => {
119
+ if (!currentLevelId) return
120
+
121
+ // Use the last displayed snapped position (respects Shift state from onGridMove)
122
+ const clickPoint = previousSnappedPointRef.current ?? cursorPosition
123
+
124
+ // Check if clicking on the first point to close the shape
125
+ const firstPoint = points[0]
126
+ if (
127
+ points.length >= 3 &&
128
+ firstPoint &&
129
+ Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
130
+ Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
131
+ ) {
132
+ // Create the slab and select it
133
+ const slabId = commitSlabDrawing(currentLevelId, points)
134
+ setSelection({ selectedIds: [slabId] })
135
+ setPoints([])
136
+ } else {
137
+ // Add point to polygon
138
+ setPoints([...points, clickPoint])
139
+ }
140
+ }
141
+
142
+ const onGridDoubleClick = (_event: GridEvent) => {
143
+ if (!currentLevelId) return
144
+
145
+ // Need at least 3 points to form a polygon
146
+ if (points.length >= 3) {
147
+ const slabId = commitSlabDrawing(currentLevelId, points)
148
+ setSelection({ selectedIds: [slabId] })
149
+ setPoints([])
150
+ }
151
+ }
152
+
153
+ const onCancel = () => {
154
+ if (points.length > 0) markToolCancelConsumed()
155
+ setPoints([])
156
+ }
157
+
158
+ const onKeyDown = (e: KeyboardEvent) => {
159
+ if (e.key === 'Shift') shiftPressed.current = true
160
+ }
161
+ const onKeyUp = (e: KeyboardEvent) => {
162
+ if (e.key === 'Shift') shiftPressed.current = false
163
+ }
164
+ document.addEventListener('keydown', onKeyDown)
165
+ document.addEventListener('keyup', onKeyUp)
166
+
167
+ emitter.on('grid:move', onGridMove)
168
+ emitter.on('grid:click', onGridClick)
169
+ emitter.on('grid:double-click', onGridDoubleClick)
170
+ emitter.on('tool:cancel', onCancel)
171
+
172
+ return () => {
173
+ document.removeEventListener('keydown', onKeyDown)
174
+ document.removeEventListener('keyup', onKeyUp)
175
+ emitter.off('grid:move', onGridMove)
176
+ emitter.off('grid:click', onGridClick)
177
+ emitter.off('grid:double-click', onGridDoubleClick)
178
+ emitter.off('tool:cancel', onCancel)
179
+ }
180
+ }, [currentLevelId, points, cursorPosition, setSelection])
181
+
182
+ // Update line geometries when points change
183
+ useEffect(() => {
184
+ if (!(mainLineRef.current && closingLineRef.current)) return
185
+
186
+ if (points.length === 0) {
187
+ mainLineRef.current.visible = false
188
+ closingLineRef.current.visible = false
189
+ return
190
+ }
191
+
192
+ const y = levelY + Y_OFFSET
193
+ const snappedCursor = snappedCursorPosition
194
+
195
+ // Build main line points
196
+ const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
197
+ linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
198
+
199
+ // Update main line
200
+ if (linePoints.length >= 2) {
201
+ mainLineRef.current.geometry.dispose()
202
+ mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
203
+ mainLineRef.current.visible = true
204
+ } else {
205
+ mainLineRef.current.visible = false
206
+ }
207
+
208
+ // Update closing line (from cursor back to first point)
209
+ const firstPoint = points[0]
210
+ if (points.length >= 2 && firstPoint) {
211
+ const closingPoints = [
212
+ new Vector3(snappedCursor[0], y, snappedCursor[1]),
213
+ new Vector3(firstPoint[0], y, firstPoint[1]),
214
+ ]
215
+ closingLineRef.current.geometry.dispose()
216
+ closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
217
+ closingLineRef.current.visible = true
218
+ } else {
219
+ closingLineRef.current.visible = false
220
+ }
221
+ }, [points, snappedCursorPosition, levelY])
222
+
223
+ // Create preview shape when we have 3+ points
224
+ const previewShape = useMemo(() => {
225
+ if (points.length < 3) return null
226
+
227
+ const snappedCursor = snappedCursorPosition
228
+
229
+ const allPoints = [...points, snappedCursor]
230
+
231
+ // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
232
+ // - Shape X -> World X
233
+ // - Shape Y -> World -Z (so we negate Z to get correct orientation)
234
+ const firstPt = allPoints[0]
235
+ if (!firstPt) return null
236
+
237
+ const shape = new Shape()
238
+ shape.moveTo(firstPt[0], -firstPt[1])
239
+
240
+ for (let i = 1; i < allPoints.length; i++) {
241
+ const pt = allPoints[i]
242
+ if (pt) {
243
+ shape.lineTo(pt[0], -pt[1])
244
+ }
245
+ }
246
+ shape.closePath()
247
+
248
+ return shape
249
+ }, [points, snappedCursorPosition])
250
+
251
+ return (
252
+ <group>
253
+ {/* Cursor */}
254
+ <CursorSphere ref={cursorRef} />
255
+
256
+ {/* Preview fill */}
257
+ {previewShape && (
258
+ <mesh
259
+ frustumCulled={false}
260
+ layers={EDITOR_LAYER}
261
+ position={[0, levelY + Y_OFFSET, 0]}
262
+ rotation={[-Math.PI / 2, 0, 0]}
263
+ >
264
+ <shapeGeometry args={[previewShape]} />
265
+ <meshBasicMaterial
266
+ color="#818cf8"
267
+ depthTest={false}
268
+ opacity={0.15}
269
+ side={DoubleSide}
270
+ transparent
271
+ />
272
+ </mesh>
273
+ )}
274
+
275
+ {/* Main line */}
276
+ {/* @ts-ignore */}
277
+ <line
278
+ frustumCulled={false}
279
+ layers={EDITOR_LAYER}
280
+ // @ts-expect-error
281
+ ref={mainLineRef}
282
+ renderOrder={1}
283
+ visible={false}
284
+ >
285
+ <bufferGeometry />
286
+ <lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
287
+ </line>
288
+
289
+ {/* Closing line */}
290
+ {/* @ts-ignore */}
291
+ <line
292
+ frustumCulled={false}
293
+ layers={EDITOR_LAYER}
294
+ // @ts-expect-error
295
+ ref={closingLineRef}
296
+ renderOrder={1}
297
+ visible={false}
298
+ >
299
+ <bufferGeometry />
300
+ <lineBasicNodeMaterial
301
+ color="#818cf8"
302
+ depthTest={false}
303
+ depthWrite={false}
304
+ linewidth={2}
305
+ opacity={0.5}
306
+ transparent
307
+ />
308
+ </line>
309
+
310
+ {/* Point markers */}
311
+ {points.map(([x, z], index) => (
312
+ <CursorSphere
313
+ color="#818cf8"
314
+ height={0}
315
+ key={index}
316
+ position={[x, levelY + Y_OFFSET + 0.01, z]}
317
+ showTooltip={false}
318
+ />
319
+ ))}
320
+ </group>
321
+ )
322
+ }
@@ -0,0 +1,7 @@
1
+ export const DEFAULT_STAIR_WIDTH = 1.0
2
+ export const DEFAULT_STAIR_LENGTH = 3.0
3
+ export const DEFAULT_STAIR_HEIGHT = 2.5
4
+ export const DEFAULT_STAIR_STEP_COUNT = 10
5
+ export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
6
+ export const DEFAULT_STAIR_FILL_TO_FLOOR = true
7
+ export const DEFAULT_STAIR_THICKNESS = 0.25
@@ -0,0 +1,194 @@
1
+ import {
2
+ type AnyNode,
3
+ emitter,
4
+ type GridEvent,
5
+ type LevelNode,
6
+ StairNode,
7
+ StairSegmentNode,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useEffect, useMemo, useRef } from 'react'
12
+ import * as THREE from 'three'
13
+ import { sfxEmitter } from '../../../lib/sfx-bus'
14
+ import { CursorSphere } from '../shared/cursor-sphere'
15
+ import {
16
+ DEFAULT_STAIR_ATTACHMENT_SIDE,
17
+ DEFAULT_STAIR_FILL_TO_FLOOR,
18
+ DEFAULT_STAIR_HEIGHT,
19
+ DEFAULT_STAIR_LENGTH,
20
+ DEFAULT_STAIR_STEP_COUNT,
21
+ DEFAULT_STAIR_THICKNESS,
22
+ DEFAULT_STAIR_WIDTH,
23
+ } from './stair-defaults'
24
+
25
+ const GRID_OFFSET = 0.02
26
+
27
+ /**
28
+ * Generates the step-profile geometry for the ghost preview.
29
+ * Same algorithm as StairSystem's generateStairSegmentGeometry.
30
+ */
31
+ function createStairPreviewGeometry(): THREE.BufferGeometry {
32
+ const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT
33
+ const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT
34
+
35
+ const shape = new THREE.Shape()
36
+ shape.moveTo(0, 0)
37
+
38
+ for (let i = 0; i < DEFAULT_STAIR_STEP_COUNT; i++) {
39
+ shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
40
+ shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
41
+ }
42
+
43
+ // Fill to floor (absoluteHeight = 0)
44
+ shape.lineTo(DEFAULT_STAIR_LENGTH, 0)
45
+ shape.lineTo(0, 0)
46
+
47
+ const geometry = new THREE.ExtrudeGeometry(shape, {
48
+ steps: 1,
49
+ depth: DEFAULT_STAIR_WIDTH,
50
+ bevelEnabled: false,
51
+ })
52
+
53
+ // Rotate so extrusion is along X (width), shape profile in XZ plane
54
+ const matrix = new THREE.Matrix4()
55
+ matrix.makeRotationY(-Math.PI / 2)
56
+ matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0)
57
+ geometry.applyMatrix4(matrix)
58
+
59
+ return geometry
60
+ }
61
+
62
+ /**
63
+ * Creates a stair group with one default stair segment at the given position/rotation.
64
+ */
65
+ function commitStairPlacement(
66
+ levelId: LevelNode['id'],
67
+ position: [number, number, number],
68
+ rotation: number,
69
+ ): void {
70
+ const { createNodes, nodes } = useScene.getState()
71
+
72
+ const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
73
+ const name = `Staircase ${stairCount + 1}`
74
+
75
+ const segment = StairSegmentNode.parse({
76
+ segmentType: 'stair',
77
+ width: DEFAULT_STAIR_WIDTH,
78
+ length: DEFAULT_STAIR_LENGTH,
79
+ height: DEFAULT_STAIR_HEIGHT,
80
+ stepCount: DEFAULT_STAIR_STEP_COUNT,
81
+ attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
82
+ fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
83
+ thickness: DEFAULT_STAIR_THICKNESS,
84
+ position: [0, 0, 0],
85
+ })
86
+
87
+ const stair = StairNode.parse({
88
+ name,
89
+ position,
90
+ rotation,
91
+ children: [segment.id],
92
+ })
93
+
94
+ createNodes([
95
+ { node: stair, parentId: levelId },
96
+ { node: segment, parentId: stair.id },
97
+ ])
98
+
99
+ sfxEmitter.emit('sfx:structure-build')
100
+ }
101
+
102
+ export const StairTool: React.FC = () => {
103
+ const cursorRef = useRef<THREE.Group>(null)
104
+ const previewRef = useRef<THREE.Group>(null)
105
+ const rotationRef = useRef(0)
106
+ const previousGridPosRef = useRef<[number, number] | null>(null)
107
+ const currentLevelId = useViewer((state) => state.selection.levelId)
108
+
109
+ const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
110
+
111
+ useEffect(() => {
112
+ if (!currentLevelId) return
113
+
114
+ // Reset rotation when tool activates
115
+ rotationRef.current = 0
116
+ if (previewRef.current) previewRef.current.rotation.y = 0
117
+
118
+ const onGridMove = (event: GridEvent) => {
119
+ const gridX = Math.round(event.position[0] * 2) / 2
120
+ const gridZ = Math.round(event.position[2] * 2) / 2
121
+ const y = event.position[1]
122
+
123
+ if (cursorRef.current) {
124
+ cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
125
+ }
126
+
127
+ if (previewRef.current) {
128
+ previewRef.current.position.set(gridX, y, gridZ)
129
+ }
130
+
131
+ if (
132
+ previousGridPosRef.current &&
133
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
134
+ ) {
135
+ sfxEmitter.emit('sfx:grid-snap')
136
+ }
137
+
138
+ previousGridPosRef.current = [gridX, gridZ]
139
+ }
140
+
141
+ const onGridClick = (event: GridEvent) => {
142
+ if (!currentLevelId) return
143
+
144
+ const gridX = Math.round(event.position[0] * 2) / 2
145
+ const gridZ = Math.round(event.position[2] * 2) / 2
146
+ const y = event.position[1]
147
+
148
+ commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
149
+ }
150
+
151
+ const onKeyDown = (event: KeyboardEvent) => {
152
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
153
+ return
154
+ }
155
+
156
+ const ROTATION_STEP = Math.PI / 4
157
+ let rotationDelta = 0
158
+ if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
159
+ else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
160
+
161
+ if (rotationDelta !== 0) {
162
+ event.preventDefault()
163
+ sfxEmitter.emit('sfx:item-rotate')
164
+ rotationRef.current += rotationDelta
165
+ if (previewRef.current) {
166
+ previewRef.current.rotation.y = rotationRef.current
167
+ }
168
+ }
169
+ }
170
+
171
+ emitter.on('grid:move', onGridMove)
172
+ emitter.on('grid:click', onGridClick)
173
+ window.addEventListener('keydown', onKeyDown)
174
+
175
+ return () => {
176
+ emitter.off('grid:move', onGridMove)
177
+ emitter.off('grid:click', onGridClick)
178
+ window.removeEventListener('keydown', onKeyDown)
179
+ }
180
+ }, [currentLevelId])
181
+
182
+ return (
183
+ <group>
184
+ <CursorSphere ref={cursorRef} />
185
+
186
+ {/* 3D ghost preview — position/rotation updated imperatively */}
187
+ <group ref={previewRef}>
188
+ <mesh castShadow geometry={previewGeometry}>
189
+ <meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
190
+ </mesh>
191
+ </group>
192
+ </group>
193
+ )
194
+ }
@@ -0,0 +1,120 @@
1
+ import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
4
+ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
5
+ import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
6
+ import { CeilingTool } from './ceiling/ceiling-tool'
7
+ import { DoorTool } from './door/door-tool'
8
+ import { ItemTool } from './item/item-tool'
9
+ import { MoveTool } from './item/move-tool'
10
+ import { RoofTool } from './roof/roof-tool'
11
+ import { SiteBoundaryEditor } from './site/site-boundary-editor'
12
+ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
13
+ import { SlabHoleEditor } from './slab/slab-hole-editor'
14
+ import { SlabTool } from './slab/slab-tool'
15
+ import { StairTool } from './stair/stair-tool'
16
+ import { WallTool } from './wall/wall-tool'
17
+ import { WindowTool } from './window/window-tool'
18
+ import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
19
+ import { ZoneTool } from './zone/zone-tool'
20
+
21
+ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
22
+ site: {
23
+ 'property-line': SiteBoundaryEditor,
24
+ },
25
+ structure: {
26
+ wall: WallTool,
27
+ slab: SlabTool,
28
+ ceiling: CeilingTool,
29
+ roof: RoofTool,
30
+ stair: StairTool,
31
+ door: DoorTool,
32
+ item: ItemTool,
33
+ zone: ZoneTool,
34
+ window: WindowTool,
35
+ },
36
+ furnish: {
37
+ item: ItemTool,
38
+ },
39
+ }
40
+
41
+ export const ToolManager: React.FC = () => {
42
+ const phase = useEditor((state) => state.phase)
43
+ const mode = useEditor((state) => state.mode)
44
+ const tool = useEditor((state) => state.tool)
45
+ const movingNode = useEditor((state) => state.movingNode)
46
+ const editingHole = useEditor((state) => state.editingHole)
47
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
48
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
49
+ const nodes = useScene((state) => state.nodes)
50
+
51
+ // Check if a slab is selected
52
+ const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
53
+ | SlabNode['id']
54
+ | undefined
55
+
56
+ // Check if a ceiling is selected
57
+ const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
58
+ | CeilingNode['id']
59
+ | undefined
60
+
61
+ // Show site boundary editor when in site phase (toggle controls entry/exit)
62
+ const showSiteBoundaryEditor = phase === 'site'
63
+
64
+ // Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
65
+ const showSlabBoundaryEditor =
66
+ phase === 'structure' &&
67
+ mode === 'select' &&
68
+ selectedSlabId !== undefined &&
69
+ (!editingHole || editingHole.nodeId !== selectedSlabId)
70
+
71
+ // Show slab hole editor when editing a hole on the selected slab
72
+ const showSlabHoleEditor =
73
+ selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId
74
+
75
+ // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
76
+ const showCeilingBoundaryEditor =
77
+ phase === 'structure' &&
78
+ mode === 'select' &&
79
+ selectedCeilingId !== undefined &&
80
+ (!editingHole || editingHole.nodeId !== selectedCeilingId)
81
+
82
+ // Show ceiling hole editor when editing a hole on the selected ceiling
83
+ const showCeilingHoleEditor =
84
+ selectedCeilingId !== undefined &&
85
+ editingHole !== null &&
86
+ editingHole.nodeId === selectedCeilingId
87
+
88
+ // Show zone boundary editor when in structure/select mode with a zone selected
89
+ // Hide when editing a slab or ceiling to avoid overlapping handles
90
+ const showZoneBoundaryEditor =
91
+ phase === 'structure' &&
92
+ mode === 'select' &&
93
+ selectedZoneId !== null &&
94
+ !showSlabBoundaryEditor &&
95
+ !showCeilingBoundaryEditor
96
+
97
+ // Show build tools when in build mode
98
+ const showBuildTool = mode === 'build' && tool !== null
99
+
100
+ const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
101
+
102
+ return (
103
+ <>
104
+ {showSiteBoundaryEditor && <SiteBoundaryEditor />}
105
+ {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
106
+ {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
107
+ {showSlabHoleEditor && selectedSlabId && editingHole && (
108
+ <SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
109
+ )}
110
+ {showCeilingBoundaryEditor && selectedCeilingId && (
111
+ <CeilingBoundaryEditor ceilingId={selectedCeilingId} />
112
+ )}
113
+ {showCeilingHoleEditor && selectedCeilingId && editingHole && (
114
+ <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
115
+ )}
116
+ {movingNode && <MoveTool />}
117
+ {!movingNode && BuildToolComponent && <BuildToolComponent />}
118
+ </>
119
+ )
120
+ }