@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,119 @@
1
+ import { Html } from '@react-three/drei'
2
+ import type { ThreeElements } from '@react-three/fiber'
3
+ import { forwardRef } from 'react'
4
+ import type { Group } from 'three'
5
+ import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
6
+ import { tools } from '../../../components/ui/action-menu/structure-tools'
7
+ import { EDITOR_LAYER } from '../../../lib/constants'
8
+ import useEditor from '../../../store/use-editor'
9
+
10
+ interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
11
+ color?: string
12
+ depthWrite?: boolean
13
+ showTooltip?: boolean
14
+ height?: number
15
+ /** Custom tooltip content — overrides the auto-detected build tool icon */
16
+ tooltipContent?: React.ReactNode
17
+ }
18
+
19
+ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
20
+ { color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props },
21
+ ref,
22
+ ) {
23
+ const tool = useEditor((s) => s.tool)
24
+ const mode = useEditor((s) => s.mode)
25
+ const catalogCategory = useEditor((s) => s.catalogCategory)
26
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
27
+
28
+ // Find the icon for the current tool
29
+ let activeToolConfig = null
30
+ if (mode === 'build' && tool) {
31
+ if (tool === 'item' && catalogCategory) {
32
+ activeToolConfig = furnishTools.find((t) => t.catalogCategory === catalogCategory)
33
+ } else {
34
+ activeToolConfig = tools.find((t) => t.id === tool)
35
+ }
36
+ }
37
+
38
+ const isVisible = visible && !isFloorplanHovered
39
+
40
+ return (
41
+ <group ref={ref} {...props} visible={isVisible}>
42
+ {/* Flat marker on the ground */}
43
+ <group rotation={[-Math.PI / 2, 0, 0]}>
44
+ {/* Center dot */}
45
+ <mesh layers={EDITOR_LAYER} renderOrder={2}>
46
+ <circleGeometry args={[0.06, 32]} />
47
+ <meshBasicMaterial
48
+ color={color}
49
+ depthTest={false}
50
+ depthWrite={false}
51
+ opacity={0.9}
52
+ transparent
53
+ />
54
+ </mesh>
55
+
56
+ {/* Outer ring / glow */}
57
+ <mesh layers={EDITOR_LAYER} renderOrder={2}>
58
+ <circleGeometry args={[0.2, 32]} />
59
+ <meshBasicMaterial
60
+ color={color}
61
+ depthTest={false}
62
+ depthWrite={false}
63
+ opacity={0.25}
64
+ transparent
65
+ />
66
+ </mesh>
67
+ </group>
68
+
69
+ {/* Vertical line */}
70
+ {height > 0 && (
71
+ <mesh layers={EDITOR_LAYER} position={[0, height / 2, 0]} renderOrder={2}>
72
+ <cylinderGeometry args={[0.01, 0.01, height, 8]} />
73
+ <meshBasicMaterial
74
+ color={color}
75
+ depthTest={false}
76
+ depthWrite={false}
77
+ opacity={0.7}
78
+ transparent
79
+ />
80
+ </mesh>
81
+ )}
82
+
83
+ {/* Tool Icon Tooltip at the top of the line */}
84
+ {isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
85
+ <Html
86
+ center
87
+ position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
88
+ style={{
89
+ pointerEvents: 'none',
90
+ background: '#18181b', // zinc-900
91
+ padding: '6px',
92
+ borderRadius: '12px',
93
+ border: '1px solid rgba(255,255,255,0.05)',
94
+ boxShadow: '0 8px 16px -4px rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.2)',
95
+ display: 'flex',
96
+ alignItems: 'center',
97
+ justifyContent: 'center',
98
+ width: '36px',
99
+ height: '36px',
100
+ }}
101
+ >
102
+ {tooltipContent || (
103
+ // eslint-disable-next-line @next/next/no-img-element
104
+ <img
105
+ alt={activeToolConfig!.label}
106
+ src={activeToolConfig!.iconSrc}
107
+ style={{
108
+ width: '100%',
109
+ height: '100%',
110
+ objectFit: 'contain',
111
+ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
112
+ }}
113
+ />
114
+ )}
115
+ </Html>
116
+ )}
117
+ </group>
118
+ )
119
+ })
@@ -0,0 +1,361 @@
1
+ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
2
+ import { createPortal } from '@react-three/fiber'
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
5
+ import { EDITOR_LAYER } from '../../../lib/constants'
6
+ import { sfxEmitter } from '../../../lib/sfx-bus'
7
+
8
+ const Y_OFFSET = 0.02
9
+
10
+ type DragState = {
11
+ isDragging: boolean
12
+ vertexIndex: number
13
+ initialPosition: [number, number]
14
+ pointerId: number
15
+ }
16
+
17
+ export interface PolygonEditorProps {
18
+ polygon: Array<[number, number]>
19
+ color?: string
20
+ onPolygonChange: (polygon: Array<[number, number]>) => void
21
+ minVertices?: number
22
+ /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
23
+ levelId?: string
24
+ /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
25
+ surfaceHeight?: number
26
+ }
27
+
28
+ /**
29
+ * Generic polygon editor component for editing polygon vertices
30
+ * Used by zone and site boundary editors
31
+ */
32
+ const MIN_HANDLE_HEIGHT = 0.15
33
+
34
+ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
35
+ polygon,
36
+ color = '#3b82f6',
37
+ onPolygonChange,
38
+ minVertices = 3,
39
+ levelId,
40
+ surfaceHeight = 0,
41
+ }) => {
42
+ // Get level node from registry if levelId is provided
43
+ const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
44
+
45
+ // When using portal, edit at Y_OFFSET (local to level)
46
+ // When not using portal, edit at world origin
47
+ const editY = levelNode ? Y_OFFSET : 0
48
+
49
+ // Local state for dragging
50
+ const [dragState, setDragState] = useState<DragState | null>(null)
51
+ const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
52
+ const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
53
+
54
+ // Keep ref in sync
55
+ useEffect(() => {
56
+ previewPolygonRef.current = previewPolygon
57
+ }, [previewPolygon])
58
+
59
+ const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
60
+ const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
61
+ const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
62
+
63
+ const lineRef = useRef<Line>(null!)
64
+ const previousPositionRef = useRef<[number, number] | null>(null)
65
+
66
+ // Track the last polygon prop to detect external changes (undo/redo)
67
+ const lastPolygonRef = useRef(polygon)
68
+ if (polygon !== lastPolygonRef.current) {
69
+ lastPolygonRef.current = polygon
70
+ // External change (e.g. undo/redo) — clear any stale preview/drag state
71
+ if (previewPolygon) setPreviewPolygon(null)
72
+ if (dragState) setDragState(null)
73
+ }
74
+
75
+ // The polygon to display (preview during drag, or actual polygon)
76
+ const displayPolygon = previewPolygon ?? polygon
77
+
78
+ // Calculate midpoints for adding new vertices
79
+ const midpoints = useMemo(() => {
80
+ if (displayPolygon.length < 2) return []
81
+ return displayPolygon.map(([x1, z1], index) => {
82
+ const nextIndex = (index + 1) % displayPolygon.length
83
+ const [x2, z2] = displayPolygon[nextIndex]!
84
+ return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number]
85
+ })
86
+ }, [displayPolygon])
87
+
88
+ // Update vertex position using grid cursor position
89
+ const handleVertexDrag = useCallback(
90
+ (vertexIndex: number, position: [number, number]) => {
91
+ setPreviewPolygon((prev) => {
92
+ const basePolygon = prev ?? polygon
93
+ const newPolygon = [...basePolygon]
94
+ newPolygon[vertexIndex] = position
95
+ return newPolygon
96
+ })
97
+ },
98
+ [polygon],
99
+ )
100
+
101
+ // Commit polygon changes
102
+ const commitPolygonChange = useCallback(() => {
103
+ if (previewPolygonRef.current) {
104
+ onPolygonChange(previewPolygonRef.current)
105
+ }
106
+ setPreviewPolygon(null)
107
+ setDragState(null)
108
+ }, [onPolygonChange])
109
+
110
+ // Handle adding a new vertex at midpoint
111
+ const handleAddVertex = useCallback(
112
+ (afterIndex: number, position: [number, number]) => {
113
+ const basePolygon = previewPolygon ?? polygon
114
+ const newPolygon = [
115
+ ...basePolygon.slice(0, afterIndex + 1),
116
+ position,
117
+ ...basePolygon.slice(afterIndex + 1),
118
+ ]
119
+
120
+ setPreviewPolygon(newPolygon)
121
+ return afterIndex + 1 // Return new vertex index
122
+ },
123
+ [polygon, previewPolygon],
124
+ )
125
+
126
+ // Handle deleting a vertex
127
+ const handleDeleteVertex = useCallback(
128
+ (index: number) => {
129
+ const basePolygon = previewPolygon ?? polygon
130
+ if (basePolygon.length <= minVertices) return // Need at least minVertices points
131
+
132
+ const newPolygon = basePolygon.filter((_, i) => i !== index)
133
+ onPolygonChange(newPolygon)
134
+ setPreviewPolygon(null)
135
+ },
136
+ [polygon, previewPolygon, onPolygonChange, minVertices],
137
+ )
138
+
139
+ // Listen to grid:move events to track cursor position
140
+ useEffect(() => {
141
+ const onGridMove = (event: GridEvent) => {
142
+ const gridX = Math.round(event.position[0] * 2) / 2
143
+ const gridZ = Math.round(event.position[2] * 2) / 2
144
+ const newPosition: [number, number] = [gridX, gridZ]
145
+
146
+ // Play snap sound when cursor moves to a new grid cell during drag
147
+ if (
148
+ dragState?.isDragging &&
149
+ previousPositionRef.current &&
150
+ (newPosition[0] !== previousPositionRef.current[0] ||
151
+ newPosition[1] !== previousPositionRef.current[1])
152
+ ) {
153
+ sfxEmitter.emit('sfx:grid-snap')
154
+ }
155
+
156
+ previousPositionRef.current = newPosition
157
+ setCursorPosition(newPosition)
158
+
159
+ // Update vertex position during drag
160
+ if (dragState?.isDragging) {
161
+ handleVertexDrag(dragState.vertexIndex, newPosition)
162
+ }
163
+ }
164
+
165
+ emitter.on('grid:move', onGridMove)
166
+ return () => {
167
+ emitter.off('grid:move', onGridMove)
168
+ }
169
+ }, [dragState, handleVertexDrag])
170
+
171
+ // Set up pointer up listener for ending drag
172
+ useEffect(() => {
173
+ if (!dragState?.isDragging) return
174
+
175
+ const handlePointerUp = (e: PointerEvent | MouseEvent) => {
176
+ // Only handle the specific pointer that started the drag, if it's a PointerEvent
177
+ if (
178
+ 'pointerId' in e &&
179
+ dragState.pointerId !== undefined &&
180
+ e.pointerId !== dragState.pointerId
181
+ )
182
+ return
183
+
184
+ // Stop the event from propagating to prevent grid click
185
+ e.stopImmediatePropagation()
186
+ e.preventDefault()
187
+
188
+ // Suppress the follow-up click event that browsers fire after pointerup
189
+ const suppressClick = (ce: MouseEvent) => {
190
+ ce.stopImmediatePropagation()
191
+ ce.preventDefault()
192
+ window.removeEventListener('click', suppressClick, true)
193
+ }
194
+ window.addEventListener('click', suppressClick, true)
195
+
196
+ // Safety cleanup in case no click fires
197
+ requestAnimationFrame(() => {
198
+ window.removeEventListener('click', suppressClick, true)
199
+ })
200
+
201
+ commitPolygonChange()
202
+ }
203
+
204
+ window.addEventListener('pointerup', handlePointerUp as EventListener, true)
205
+ window.addEventListener('pointercancel', handlePointerUp as EventListener, true)
206
+ return () => {
207
+ window.removeEventListener('pointerup', handlePointerUp as EventListener, true)
208
+ window.removeEventListener('pointercancel', handlePointerUp as EventListener, true)
209
+ }
210
+ }, [dragState, commitPolygonChange])
211
+
212
+ // Update line geometry when polygon changes
213
+ useEffect(() => {
214
+ if (!lineRef.current || displayPolygon.length < 2) return
215
+
216
+ const positions: number[] = []
217
+ for (const [x, z] of displayPolygon) {
218
+ positions.push(x!, editY + 0.01, z!)
219
+ }
220
+ // Close the loop
221
+ const first = displayPolygon[0]!
222
+ positions.push(first[0]!, editY + 0.01, first[1]!)
223
+
224
+ const geometry = new BufferGeometry()
225
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
226
+
227
+ lineRef.current.geometry.dispose()
228
+ lineRef.current.geometry = geometry
229
+ }, [displayPolygon, editY])
230
+
231
+ if (displayPolygon.length < minVertices) return null
232
+
233
+ const canDelete = displayPolygon.length > minVertices
234
+
235
+ const editorContent = (
236
+ <group>
237
+ {/* Border line */}
238
+ <line
239
+ frustumCulled={false}
240
+ layers={EDITOR_LAYER}
241
+ raycast={() => {}}
242
+ // @ts-expect-error R3F <line> element conflicts with SVG <line> type
243
+ ref={lineRef}
244
+ renderOrder={10}
245
+ >
246
+ <bufferGeometry />
247
+ <lineBasicNodeMaterial
248
+ color={color}
249
+ depthTest={false}
250
+ depthWrite={false}
251
+ linewidth={2}
252
+ opacity={0.8}
253
+ transparent
254
+ />
255
+ </line>
256
+
257
+ {/* Vertex handles - blue cylinders that match surface height */}
258
+ {displayPolygon.map(([x, z], index) => {
259
+ const isHovered = hoveredVertex === index
260
+ const isDragging = dragState?.vertexIndex === index
261
+ const radius = 0.1
262
+ const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
263
+
264
+ return (
265
+ <mesh
266
+ castShadow
267
+ key={`vertex-${index}`}
268
+ layers={EDITOR_LAYER}
269
+ onClick={(e) => {
270
+ if (e.button !== 0) return
271
+ e.stopPropagation()
272
+ }}
273
+ onDoubleClick={(e) => {
274
+ if (e.button !== 0) return
275
+ e.stopPropagation()
276
+ if (canDelete) {
277
+ handleDeleteVertex(index)
278
+ }
279
+ }}
280
+ onPointerDown={(e) => {
281
+ if (e.button !== 0) return
282
+ e.stopPropagation()
283
+ setDragState({
284
+ isDragging: true,
285
+ vertexIndex: index,
286
+ initialPosition: [x!, z!],
287
+ pointerId: e.pointerId,
288
+ })
289
+ }}
290
+ onPointerEnter={(e) => {
291
+ e.stopPropagation()
292
+ setHoveredVertex(index)
293
+ }}
294
+ onPointerLeave={(e) => {
295
+ e.stopPropagation()
296
+ setHoveredVertex(null)
297
+ }}
298
+ position={[x!, editY + height / 2, z!]}
299
+ >
300
+ <cylinderGeometry args={[radius, radius, height, 16]} />
301
+ <meshStandardMaterial
302
+ color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
303
+ />
304
+ </mesh>
305
+ )
306
+ })}
307
+
308
+ {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
309
+ {!dragState &&
310
+ midpoints.map(([x, z], index) => {
311
+ const isHovered = hoveredMidpoint === index
312
+ const radius = 0.06
313
+ const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
314
+
315
+ return (
316
+ <mesh
317
+ key={`midpoint-${index}`}
318
+ layers={EDITOR_LAYER}
319
+ onClick={(e) => {
320
+ if (e.button !== 0) return
321
+ e.stopPropagation()
322
+ }}
323
+ onPointerDown={(e) => {
324
+ if (e.button !== 0) return
325
+ e.stopPropagation()
326
+ const newVertexIndex = handleAddVertex(index, [x!, z!])
327
+ if (newVertexIndex >= 0) {
328
+ setDragState({
329
+ isDragging: true,
330
+ vertexIndex: newVertexIndex,
331
+ initialPosition: [x!, z!],
332
+ pointerId: e.pointerId,
333
+ })
334
+ setHoveredMidpoint(null)
335
+ }
336
+ }}
337
+ onPointerEnter={(e) => {
338
+ e.stopPropagation()
339
+ setHoveredMidpoint(index)
340
+ }}
341
+ onPointerLeave={(e) => {
342
+ e.stopPropagation()
343
+ setHoveredMidpoint(null)
344
+ }}
345
+ position={[x!, editY + height / 2, z!]}
346
+ >
347
+ <cylinderGeometry args={[radius, radius, height, 16]} />
348
+ <meshStandardMaterial
349
+ color={isHovered ? '#4ade80' : '#22c55e'}
350
+ opacity={isHovered ? 1 : 0.7}
351
+ transparent
352
+ />
353
+ </mesh>
354
+ )
355
+ })}
356
+ </group>
357
+ )
358
+
359
+ // Mount to level node if available, otherwise render at world origin
360
+ return levelNode ? createPortal(editorContent, levelNode) : editorContent
361
+ }
@@ -0,0 +1,42 @@
1
+ import { type SiteNode, useScene } from '@pascal-app/core'
2
+ import { useCallback } from 'react'
3
+ import { PolygonEditor } from '../shared/polygon-editor'
4
+
5
+ /**
6
+ * Site boundary editor - allows editing site polygon when in site phase
7
+ * Uses the generic PolygonEditor component
8
+ */
9
+ export const SiteBoundaryEditor: React.FC = () => {
10
+ const nodes = useScene((state) => state.nodes)
11
+ const rootNodeIds = useScene((state) => state.rootNodeIds)
12
+ const updateNode = useScene((state) => state.updateNode)
13
+
14
+ // Get the site node (first root node)
15
+ const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
16
+ const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
17
+
18
+ const handlePolygonChange = useCallback(
19
+ (newPolygon: Array<[number, number]>) => {
20
+ if (site) {
21
+ updateNode(site.id, {
22
+ polygon: {
23
+ type: 'polygon',
24
+ points: newPolygon,
25
+ },
26
+ })
27
+ }
28
+ },
29
+ [site, updateNode],
30
+ )
31
+
32
+ if (!site?.polygon?.points || site.polygon.points.length < 3) return null
33
+
34
+ return (
35
+ <PolygonEditor
36
+ color="#10b981"
37
+ minVertices={3}
38
+ onPolygonChange={handlePolygonChange}
39
+ polygon={site.polygon.points}
40
+ />
41
+ )
42
+ }
@@ -0,0 +1,42 @@
1
+ import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { useCallback } from 'react'
4
+ import { PolygonEditor } from '../shared/polygon-editor'
5
+
6
+ interface SlabBoundaryEditorProps {
7
+ slabId: SlabNode['id']
8
+ }
9
+
10
+ /**
11
+ * Slab boundary editor - allows editing slab polygon vertices for a specific slab
12
+ * Uses the generic PolygonEditor component
13
+ */
14
+ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
15
+ const slabNode = useScene((state) => state.nodes[slabId])
16
+ const updateNode = useScene((state) => state.updateNode)
17
+ const setSelection = useViewer((state) => state.setSelection)
18
+
19
+ const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
20
+
21
+ const handlePolygonChange = useCallback(
22
+ (newPolygon: Array<[number, number]>) => {
23
+ updateNode(slabId, { polygon: newPolygon })
24
+ // Re-assert selection so the slab stays selected after the edit
25
+ setSelection({ selectedIds: [slabId] })
26
+ },
27
+ [slabId, updateNode, setSelection],
28
+ )
29
+
30
+ if (!slab?.polygon || slab.polygon.length < 3) return null
31
+
32
+ return (
33
+ <PolygonEditor
34
+ color="#a3a3a3"
35
+ levelId={resolveLevelId(slab, useScene.getState().nodes)}
36
+ minVertices={3}
37
+ onPolygonChange={handlePolygonChange}
38
+ polygon={slab.polygon}
39
+ surfaceHeight={slab.elevation ?? 0.05}
40
+ />
41
+ )
42
+ }
@@ -0,0 +1,47 @@
1
+ import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { useCallback } from 'react'
4
+ import { PolygonEditor } from '../shared/polygon-editor'
5
+
6
+ interface SlabHoleEditorProps {
7
+ slabId: SlabNode['id']
8
+ holeIndex: number
9
+ }
10
+
11
+ /**
12
+ * Slab hole editor - allows editing a specific hole polygon within a slab
13
+ * Uses the generic PolygonEditor component
14
+ */
15
+ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
16
+ const slabNode = useScene((state) => state.nodes[slabId])
17
+ const updateNode = useScene((state) => state.updateNode)
18
+ const setSelection = useViewer((state) => state.setSelection)
19
+
20
+ const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
21
+ const holes = slab?.holes || []
22
+ const hole = holes[holeIndex]
23
+
24
+ const handlePolygonChange = useCallback(
25
+ (newPolygon: Array<[number, number]>) => {
26
+ const updatedHoles = [...holes]
27
+ updatedHoles[holeIndex] = newPolygon
28
+ updateNode(slabId, { holes: updatedHoles })
29
+ // Re-assert selection so the slab stays selected after the edit
30
+ setSelection({ selectedIds: [slabId] })
31
+ },
32
+ [slabId, holeIndex, holes, updateNode, setSelection],
33
+ )
34
+
35
+ if (!(slab && hole) || hole.length < 3) return null
36
+
37
+ return (
38
+ <PolygonEditor
39
+ color="#ef4444"
40
+ levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
41
+ minVertices={3}
42
+ onPolygonChange={handlePolygonChange}
43
+ polygon={hole}
44
+ surfaceHeight={slab.elevation ?? 0.05}
45
+ />
46
+ )
47
+ }