@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,465 @@
1
+ import { CeilingNode, emitter, type GridEvent, type LevelNode, 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 { mix, positionLocal } from 'three/tsl'
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
+
11
+ const CEILING_HEIGHT = 2.52
12
+ const GRID_OFFSET = 0.02
13
+
14
+ /**
15
+ * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
16
+ */
17
+ const calculateSnapPoint = (
18
+ lastPoint: [number, number],
19
+ currentPoint: [number, number],
20
+ ): [number, number] => {
21
+ const [x1, y1] = lastPoint
22
+ const [x, y] = currentPoint
23
+
24
+ const dx = x - x1
25
+ const dy = y - y1
26
+ const absDx = Math.abs(dx)
27
+ const absDy = Math.abs(dy)
28
+
29
+ // Calculate distances to horizontal, vertical, and diagonal lines
30
+ const horizontalDist = absDy
31
+ const verticalDist = absDx
32
+ const diagonalDist = Math.abs(absDx - absDy)
33
+
34
+ // Find the minimum distance to determine which axis to snap to
35
+ const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
36
+
37
+ if (minDist === diagonalDist) {
38
+ // Snap to 45° diagonal
39
+ const diagonalLength = Math.min(absDx, absDy)
40
+ return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
41
+ }
42
+ if (minDist === horizontalDist) {
43
+ // Snap to horizontal
44
+ return [x, y1]
45
+ }
46
+ // Snap to vertical
47
+ return [x1, y]
48
+ }
49
+
50
+ /**
51
+ * Creates a ceiling with the given polygon points and returns its ID
52
+ */
53
+ const commitCeilingDrawing = (
54
+ levelId: LevelNode['id'],
55
+ points: Array<[number, number]>,
56
+ ): string => {
57
+ const { createNode, nodes } = useScene.getState()
58
+
59
+ // Count existing ceilings for naming
60
+ const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
61
+ const name = `Ceiling ${ceilingCount + 1}`
62
+
63
+ const ceiling = CeilingNode.parse({
64
+ name,
65
+ polygon: points,
66
+ })
67
+
68
+ createNode(ceiling, levelId)
69
+ sfxEmitter.emit('sfx:structure-build')
70
+ return ceiling.id
71
+ }
72
+
73
+ export const CeilingTool: React.FC = () => {
74
+ const cursorRef = useRef<Group>(null)
75
+ const gridCursorRef = useRef<Group>(null)
76
+ const mainLineRef = useRef<Line>(null!)
77
+ const closingLineRef = useRef<Line>(null!)
78
+ const groundMainLineRef = useRef<Line>(null!)
79
+ const groundClosingLineRef = useRef<Line>(null!)
80
+ const verticalLineRef = useRef<Line>(null!)
81
+ const currentLevelId = useViewer((state) => state.selection.levelId)
82
+ const setSelection = useViewer((state) => state.setSelection)
83
+
84
+ const [points, setPoints] = useState<Array<[number, number]>>([])
85
+ const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
86
+ const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
87
+ const [levelY, setLevelY] = useState(0)
88
+ const previousSnappedPointRef = useRef<[number, number] | null>(null)
89
+ const shiftPressed = useRef(false)
90
+
91
+ // Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
92
+ const verticalGeo = useMemo(
93
+ () =>
94
+ new BufferGeometry().setFromPoints([
95
+ new Vector3(0, 0, 0),
96
+ new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
97
+ ]),
98
+ [],
99
+ )
100
+
101
+ // opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
102
+ const gradientOpacityNode = useMemo(
103
+ () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
104
+ [],
105
+ )
106
+
107
+ // Update cursor position and lines on grid move
108
+ useEffect(() => {
109
+ if (!currentLevelId) return
110
+
111
+ const onGridMove = (event: GridEvent) => {
112
+ if (!(cursorRef.current && gridCursorRef.current)) return
113
+
114
+ const gridX = Math.round(event.position[0] * 2) / 2
115
+ const gridZ = Math.round(event.position[2] * 2) / 2
116
+ const gridPosition: [number, number] = [gridX, gridZ]
117
+
118
+ setCursorPosition(gridPosition)
119
+ setLevelY(event.position[1])
120
+
121
+ const ceilingY = event.position[1] + CEILING_HEIGHT
122
+ const gridY = event.position[1] + GRID_OFFSET
123
+
124
+ // Calculate snapped display position (bypass snap when Shift is held)
125
+ const lastPoint = points[points.length - 1]
126
+ const displayPoint =
127
+ shiftPressed.current || !lastPoint
128
+ ? gridPosition
129
+ : calculateSnapPoint(lastPoint, gridPosition)
130
+ setSnappedCursorPosition(displayPoint)
131
+
132
+ // Play snap sound when the snapped position actually changes (only when drawing)
133
+ if (
134
+ points.length > 0 &&
135
+ previousSnappedPointRef.current &&
136
+ (displayPoint[0] !== previousSnappedPointRef.current[0] ||
137
+ displayPoint[1] !== previousSnappedPointRef.current[1])
138
+ ) {
139
+ sfxEmitter.emit('sfx:grid-snap')
140
+ }
141
+
142
+ previousSnappedPointRef.current = displayPoint
143
+ cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
144
+ gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
145
+
146
+ if (verticalLineRef.current) {
147
+ verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
148
+ }
149
+ }
150
+
151
+ const onGridClick = (_event: GridEvent) => {
152
+ if (!currentLevelId) return
153
+
154
+ // Use the last displayed snapped position (respects Shift state from onGridMove)
155
+ const clickPoint = previousSnappedPointRef.current ?? cursorPosition
156
+
157
+ // Check if clicking on the first point to close the shape
158
+ const firstPoint = points[0]
159
+ if (
160
+ points.length >= 3 &&
161
+ firstPoint &&
162
+ Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
163
+ Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
164
+ ) {
165
+ // Create the ceiling and select it
166
+ const ceilingId = commitCeilingDrawing(currentLevelId, points)
167
+ setSelection({ selectedIds: [ceilingId] })
168
+ setPoints([])
169
+ } else {
170
+ // Add point to polygon
171
+ setPoints([...points, clickPoint])
172
+ }
173
+ }
174
+
175
+ const onGridDoubleClick = (_event: GridEvent) => {
176
+ if (!currentLevelId) return
177
+
178
+ // Need at least 3 points to form a polygon
179
+ if (points.length >= 3) {
180
+ const ceilingId = commitCeilingDrawing(currentLevelId, points)
181
+ setSelection({ selectedIds: [ceilingId] })
182
+ setPoints([])
183
+ }
184
+ }
185
+
186
+ const onCancel = () => {
187
+ if (points.length > 0) markToolCancelConsumed()
188
+ setPoints([])
189
+ }
190
+
191
+ const onKeyDown = (e: KeyboardEvent) => {
192
+ if (e.key === 'Shift') shiftPressed.current = true
193
+ }
194
+ const onKeyUp = (e: KeyboardEvent) => {
195
+ if (e.key === 'Shift') shiftPressed.current = false
196
+ }
197
+ document.addEventListener('keydown', onKeyDown)
198
+ document.addEventListener('keyup', onKeyUp)
199
+
200
+ emitter.on('grid:move', onGridMove)
201
+ emitter.on('grid:click', onGridClick)
202
+ emitter.on('grid:double-click', onGridDoubleClick)
203
+ emitter.on('tool:cancel', onCancel)
204
+
205
+ return () => {
206
+ document.removeEventListener('keydown', onKeyDown)
207
+ document.removeEventListener('keyup', onKeyUp)
208
+ emitter.off('grid:move', onGridMove)
209
+ emitter.off('grid:click', onGridClick)
210
+ emitter.off('grid:double-click', onGridDoubleClick)
211
+ emitter.off('tool:cancel', onCancel)
212
+ }
213
+ }, [currentLevelId, points, cursorPosition, setSelection])
214
+
215
+ // Update line geometries when points change
216
+ useEffect(() => {
217
+ if (!(mainLineRef.current && closingLineRef.current)) return
218
+
219
+ if (points.length === 0) {
220
+ mainLineRef.current.visible = false
221
+ closingLineRef.current.visible = false
222
+ return
223
+ }
224
+
225
+ const ceilingY = levelY + CEILING_HEIGHT
226
+ const snappedCursor = snappedCursorPosition
227
+
228
+ // Build main line points
229
+ const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
230
+ linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
231
+
232
+ const gridY = levelY + GRID_OFFSET
233
+ const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
234
+ groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
235
+
236
+ // Update main line
237
+ if (linePoints.length >= 2) {
238
+ mainLineRef.current.geometry.dispose()
239
+ mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
240
+ mainLineRef.current.visible = true
241
+
242
+ groundMainLineRef.current.geometry.dispose()
243
+ groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
244
+ groundMainLineRef.current.visible = true
245
+ } else {
246
+ mainLineRef.current.visible = false
247
+ groundMainLineRef.current.visible = false
248
+ }
249
+
250
+ // Update closing line (from cursor back to first point)
251
+ const firstPoint = points[0]
252
+ if (points.length >= 2 && firstPoint) {
253
+ const closingPoints = [
254
+ new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
255
+ new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
256
+ ]
257
+ closingLineRef.current.geometry.dispose()
258
+ closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
259
+ closingLineRef.current.visible = true
260
+
261
+ const groundClosingPoints = [
262
+ new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
263
+ new Vector3(firstPoint[0], gridY, firstPoint[1]),
264
+ ]
265
+ groundClosingLineRef.current.geometry.dispose()
266
+ groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
267
+ groundClosingPoints,
268
+ )
269
+ groundClosingLineRef.current.visible = true
270
+ } else {
271
+ closingLineRef.current.visible = false
272
+ groundClosingLineRef.current.visible = false
273
+ }
274
+ }, [points, snappedCursorPosition, levelY])
275
+
276
+ // Create preview shape when we have 3+ points
277
+ const previewShape = useMemo(() => {
278
+ if (points.length < 3) return null
279
+
280
+ const snappedCursor = snappedCursorPosition
281
+
282
+ const allPoints = [...points, snappedCursor]
283
+
284
+ // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
285
+ // - Shape X -> World X
286
+ // - Shape Y -> World -Z (so we negate Z to get correct orientation)
287
+ const firstPt = allPoints[0]
288
+ if (!firstPt) return null
289
+
290
+ const shape = new Shape()
291
+ shape.moveTo(firstPt[0], -firstPt[1])
292
+
293
+ for (let i = 1; i < allPoints.length; i++) {
294
+ const pt = allPoints[i]
295
+ if (pt) {
296
+ shape.lineTo(pt[0], -pt[1])
297
+ }
298
+ }
299
+ shape.closePath()
300
+
301
+ return shape
302
+ }, [points, snappedCursorPosition])
303
+
304
+ return (
305
+ <group>
306
+ {/* Cursor at ceiling height */}
307
+ <CursorSphere ref={cursorRef} />
308
+
309
+ {/* Grid-level cursor indicator */}
310
+ <mesh
311
+ layers={EDITOR_LAYER}
312
+ ref={gridCursorRef}
313
+ renderOrder={2}
314
+ rotation={[-Math.PI / 2, 0, 0]}
315
+ >
316
+ <ringGeometry args={[0.15, 0.2, 32]} />
317
+ <meshBasicMaterial
318
+ color="#818cf8"
319
+ depthTest={false}
320
+ depthWrite={true}
321
+ opacity={0.5}
322
+ side={DoubleSide}
323
+ transparent
324
+ />
325
+ </mesh>
326
+
327
+ {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
328
+ {/* @ts-ignore */}
329
+ <line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
330
+ <lineBasicNodeMaterial
331
+ color="#818cf8"
332
+ depthTest={false}
333
+ depthWrite={false}
334
+ opacityNode={gradientOpacityNode}
335
+ transparent
336
+ />
337
+ </line>
338
+
339
+ {/* Preview fill (Top) */}
340
+ {previewShape && (
341
+ <mesh
342
+ frustumCulled={false}
343
+ layers={EDITOR_LAYER}
344
+ position={[0, levelY + CEILING_HEIGHT, 0]}
345
+ rotation={[-Math.PI / 2, 0, 0]}
346
+ >
347
+ <shapeGeometry args={[previewShape]} />
348
+ <meshBasicMaterial
349
+ color="#818cf8"
350
+ depthTest={false}
351
+ opacity={0.15}
352
+ side={DoubleSide}
353
+ transparent
354
+ />
355
+ </mesh>
356
+ )}
357
+
358
+ {/* Preview fill (Ground) */}
359
+ {previewShape && (
360
+ <mesh
361
+ frustumCulled={false}
362
+ layers={EDITOR_LAYER}
363
+ position={[0, levelY + GRID_OFFSET, 0]}
364
+ rotation={[-Math.PI / 2, 0, 0]}
365
+ >
366
+ <shapeGeometry args={[previewShape]} />
367
+ <meshBasicMaterial
368
+ color="#818cf8"
369
+ depthTest={false}
370
+ opacity={0.1}
371
+ side={DoubleSide}
372
+ transparent
373
+ />
374
+ </mesh>
375
+ )}
376
+
377
+ {/* Main line */}
378
+ {/* @ts-ignore */}
379
+ <line
380
+ frustumCulled={false}
381
+ layers={EDITOR_LAYER}
382
+ // @ts-expect-error
383
+ ref={mainLineRef}
384
+ renderOrder={1}
385
+ visible={false}
386
+ >
387
+ <bufferGeometry />
388
+ <lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
389
+ </line>
390
+
391
+ {/* Closing line */}
392
+ {/* @ts-ignore */}
393
+ <line
394
+ frustumCulled={false}
395
+ layers={EDITOR_LAYER}
396
+ // @ts-expect-error
397
+ ref={closingLineRef}
398
+ renderOrder={1}
399
+ visible={false}
400
+ >
401
+ <bufferGeometry />
402
+ <lineBasicNodeMaterial
403
+ color="#818cf8"
404
+ depthTest={false}
405
+ depthWrite={false}
406
+ linewidth={2}
407
+ opacity={0.5}
408
+ transparent
409
+ />
410
+ </line>
411
+
412
+ {/* Ground main line */}
413
+ {/* @ts-ignore */}
414
+ <line
415
+ frustumCulled={false}
416
+ layers={EDITOR_LAYER}
417
+ // @ts-expect-error
418
+ ref={groundMainLineRef}
419
+ renderOrder={1}
420
+ visible={false}
421
+ >
422
+ <bufferGeometry />
423
+ <lineBasicNodeMaterial
424
+ color="#818cf8"
425
+ depthTest={false}
426
+ depthWrite={false}
427
+ linewidth={3}
428
+ opacity={0.3}
429
+ transparent
430
+ />
431
+ </line>
432
+
433
+ {/* Ground closing line */}
434
+ {/* @ts-ignore */}
435
+ <line
436
+ frustumCulled={false}
437
+ layers={EDITOR_LAYER}
438
+ // @ts-expect-error
439
+ ref={groundClosingLineRef}
440
+ renderOrder={1}
441
+ visible={false}
442
+ >
443
+ <bufferGeometry />
444
+ <lineBasicNodeMaterial
445
+ color="#818cf8"
446
+ depthTest={false}
447
+ depthWrite={false}
448
+ linewidth={2}
449
+ opacity={0.15}
450
+ transparent
451
+ />
452
+ </line>
453
+
454
+ {/* Point markers */}
455
+ {points.map(([x, z], index) => (
456
+ <CursorSphere
457
+ color="#818cf8"
458
+ key={index}
459
+ position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
460
+ showTooltip={false}
461
+ />
462
+ ))}
463
+ </group>
464
+ )
465
+ }
@@ -0,0 +1,110 @@
1
+ import {
2
+ type AnyNodeId,
3
+ type DoorNode,
4
+ getScaledDimensions,
5
+ type ItemNode,
6
+ useScene,
7
+ type WallNode,
8
+ type WindowNode,
9
+ } from '@pascal-app/core'
10
+
11
+ /**
12
+ * Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
13
+ */
14
+ export function wallLocalToWorld(
15
+ wallNode: WallNode,
16
+ localX: number,
17
+ localY: number,
18
+ levelYOffset = 0,
19
+ slabElevation = 0,
20
+ ): [number, number, number] {
21
+ const wallAngle = Math.atan2(
22
+ wallNode.end[1] - wallNode.start[1],
23
+ wallNode.end[0] - wallNode.start[0],
24
+ )
25
+ return [
26
+ wallNode.start[0] + localX * Math.cos(wallAngle),
27
+ slabElevation + localY + levelYOffset,
28
+ wallNode.start[1] + localX * Math.sin(wallAngle),
29
+ ]
30
+ }
31
+
32
+ /**
33
+ * Clamps door center X so it stays fully within wall bounds.
34
+ * Y is always height/2 — doors sit at floor level.
35
+ */
36
+ export function clampToWall(
37
+ wallNode: WallNode,
38
+ localX: number,
39
+ width: number,
40
+ height: number,
41
+ ): { clampedX: number; clampedY: number } {
42
+ const dx = wallNode.end[0] - wallNode.start[0]
43
+ const dz = wallNode.end[1] - wallNode.start[1]
44
+ const wallLength = Math.sqrt(dx * dx + dz * dz)
45
+
46
+ const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
47
+ const clampedY = height / 2 // Doors always sit at floor level
48
+ return { clampedX, clampedY }
49
+ }
50
+
51
+ /**
52
+ * Checks if a proposed door position overlaps any existing wall children.
53
+ * Handles item, window, and door types.
54
+ */
55
+ export function hasWallChildOverlap(
56
+ wallId: string,
57
+ clampedX: number,
58
+ clampedY: number,
59
+ width: number,
60
+ height: number,
61
+ ignoreId?: string,
62
+ ): boolean {
63
+ const nodes = useScene.getState().nodes
64
+ const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
65
+ if (!wallNode) return true
66
+ const halfW = width / 2
67
+ const halfH = height / 2
68
+ const newBottom = clampedY - halfH
69
+ const newTop = clampedY + halfH
70
+ const newLeft = clampedX - halfW
71
+ const newRight = clampedX + halfW
72
+
73
+ for (const childId of wallNode.children) {
74
+ if (childId === ignoreId) continue
75
+ const child = nodes[childId as AnyNodeId]
76
+ if (!child) continue
77
+
78
+ let childLeft: number, childRight: number, childBottom: number, childTop: number
79
+
80
+ if (child.type === 'item') {
81
+ const item = child as ItemNode
82
+ if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
83
+ const [w, h] = getScaledDimensions(item)
84
+ childLeft = item.position[0] - w / 2
85
+ childRight = item.position[0] + w / 2
86
+ childBottom = item.position[1]
87
+ childTop = item.position[1] + h
88
+ } else if (child.type === 'window') {
89
+ const win = child as WindowNode
90
+ childLeft = win.position[0] - win.width / 2
91
+ childRight = win.position[0] + win.width / 2
92
+ childBottom = win.position[1] - win.height / 2
93
+ childTop = win.position[1] + win.height / 2
94
+ } else if (child.type === 'door') {
95
+ const door = child as DoorNode
96
+ childLeft = door.position[0] - door.width / 2
97
+ childRight = door.position[0] + door.width / 2
98
+ childBottom = door.position[1] - door.height / 2
99
+ childTop = door.position[1] + door.height / 2
100
+ } else {
101
+ continue
102
+ }
103
+
104
+ const xOverlap = newLeft < childRight && newRight > childLeft
105
+ const yOverlap = newBottom < childTop && newTop > childBottom
106
+ if (xOverlap && yOverlap) return true
107
+ }
108
+
109
+ return false
110
+ }