@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,303 @@
1
+ import {
2
+ type AnyNodeId,
3
+ emitter,
4
+ sceneRegistry,
5
+ spatialGridManager,
6
+ useScene,
7
+ type WallEvent,
8
+ WindowNode,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useEffect, useRef } from 'react'
12
+ import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
13
+ import { LineBasicNodeMaterial } from 'three/webgpu'
14
+ import { EDITOR_LAYER } from '../../../lib/constants'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import {
17
+ calculateCursorRotation,
18
+ calculateItemRotation,
19
+ getSideFromNormal,
20
+ isValidWallSideFace,
21
+ snapToHalf,
22
+ } from '../item/placement-math'
23
+ import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
24
+
25
+ // Shared edge material — reuse across renders, just toggle color
26
+ const edgeMaterial = new LineBasicNodeMaterial({
27
+ color: 0xef_44_44, // red-500 default (invalid)
28
+ linewidth: 3,
29
+ depthTest: false,
30
+ depthWrite: false,
31
+ })
32
+
33
+ /**
34
+ * Window tool — places WindowNodes on walls only.
35
+ * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
36
+ */
37
+ export const WindowTool: React.FC = () => {
38
+ const draftRef = useRef<WindowNode | null>(null)
39
+ const cursorGroupRef = useRef<Group>(null!)
40
+ const edgesRef = useRef<LineSegments>(null!)
41
+
42
+ useEffect(() => {
43
+ useScene.temporal.getState().pause()
44
+
45
+ const getLevelId = () => useViewer.getState().selection.levelId
46
+ const getLevelYOffset = () => {
47
+ const id = getLevelId()
48
+ return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
49
+ }
50
+ const getSlabElevation = (wallEvent: WallEvent) =>
51
+ spatialGridManager.getSlabElevationForWall(
52
+ wallEvent.node.parentId ?? '',
53
+ wallEvent.node.start,
54
+ wallEvent.node.end,
55
+ )
56
+
57
+ const markWallDirty = (wallId: string) => {
58
+ useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
59
+ }
60
+
61
+ const destroyDraft = () => {
62
+ if (!draftRef.current) return
63
+ const wallId = draftRef.current.parentId
64
+ useScene.getState().deleteNode(draftRef.current.id)
65
+ draftRef.current = null
66
+ // Rebuild wall so it removes the cutout from the deleted draft
67
+ if (wallId) markWallDirty(wallId)
68
+ }
69
+
70
+ const hideCursor = () => {
71
+ if (cursorGroupRef.current) cursorGroupRef.current.visible = false
72
+ }
73
+
74
+ const updateCursor = (
75
+ worldPosition: [number, number, number],
76
+ cursorRotationY: number,
77
+ valid: boolean,
78
+ ) => {
79
+ const group = cursorGroupRef.current
80
+ if (!group) return
81
+ group.visible = true
82
+ group.position.set(...worldPosition)
83
+ group.rotation.y = cursorRotationY
84
+ edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
85
+ }
86
+
87
+ const onWallEnter = (event: WallEvent) => {
88
+ if (!isValidWallSideFace(event.normal)) return
89
+ const levelId = getLevelId()
90
+ if (!levelId) return
91
+ // Only interact with walls on the current level
92
+ if (event.node.parentId !== levelId) return
93
+
94
+ destroyDraft()
95
+
96
+ const side = getSideFromNormal(event.normal)
97
+ const itemRotation = calculateItemRotation(event.normal)
98
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
99
+
100
+ const localX = snapToHalf(event.localPosition[0])
101
+ const localY = snapToHalf(event.localPosition[1])
102
+
103
+ const width = 1.5
104
+ const height = 1.5
105
+
106
+ const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
107
+
108
+ const node = WindowNode.parse({
109
+ position: [clampedX, clampedY, 0],
110
+ rotation: [0, itemRotation, 0],
111
+ side,
112
+ wallId: event.node.id,
113
+ parentId: event.node.id,
114
+ metadata: { isTransient: true },
115
+ })
116
+
117
+ useScene.getState().createNode(node, event.node.id as AnyNodeId)
118
+ draftRef.current = node
119
+
120
+ const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
121
+
122
+ updateCursor(
123
+ wallLocalToWorld(
124
+ event.node,
125
+ clampedX,
126
+ clampedY,
127
+ getLevelYOffset(),
128
+ getSlabElevation(event),
129
+ ),
130
+ cursorRotation,
131
+ valid,
132
+ )
133
+ event.stopPropagation()
134
+ }
135
+
136
+ const onWallMove = (event: WallEvent) => {
137
+ if (!isValidWallSideFace(event.normal)) return
138
+ // Only interact with walls on the current level
139
+ if (event.node.parentId !== getLevelId()) return
140
+
141
+ const side = getSideFromNormal(event.normal)
142
+ const itemRotation = calculateItemRotation(event.normal)
143
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
144
+
145
+ const localX = snapToHalf(event.localPosition[0])
146
+ const localY = snapToHalf(event.localPosition[1])
147
+
148
+ const width = draftRef.current?.width ?? 1.5
149
+ const height = draftRef.current?.height ?? 1.5
150
+
151
+ const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
152
+
153
+ if (draftRef.current) {
154
+ useScene.getState().updateNode(draftRef.current.id, {
155
+ position: [clampedX, clampedY, 0],
156
+ rotation: [0, itemRotation, 0],
157
+ side,
158
+ parentId: event.node.id,
159
+ wallId: event.node.id,
160
+ })
161
+ }
162
+
163
+ const valid = !hasWallChildOverlap(
164
+ event.node.id,
165
+ clampedX,
166
+ clampedY,
167
+ width,
168
+ height,
169
+ draftRef.current?.id,
170
+ )
171
+
172
+ updateCursor(
173
+ wallLocalToWorld(
174
+ event.node,
175
+ clampedX,
176
+ clampedY,
177
+ getLevelYOffset(),
178
+ getSlabElevation(event),
179
+ ),
180
+ cursorRotation,
181
+ valid,
182
+ )
183
+ event.stopPropagation()
184
+ }
185
+
186
+ const onWallClick = (event: WallEvent) => {
187
+ if (!draftRef.current) return
188
+ if (!isValidWallSideFace(event.normal)) return
189
+ // Only interact with walls on the current level
190
+ if (event.node.parentId !== getLevelId()) return
191
+
192
+ const side = getSideFromNormal(event.normal)
193
+ const itemRotation = calculateItemRotation(event.normal)
194
+
195
+ const localX = snapToHalf(event.localPosition[0])
196
+ const localY = snapToHalf(event.localPosition[1])
197
+ const { clampedX, clampedY } = clampToWall(
198
+ event.node,
199
+ localX,
200
+ localY,
201
+ draftRef.current.width,
202
+ draftRef.current.height,
203
+ )
204
+ const valid = !hasWallChildOverlap(
205
+ event.node.id,
206
+ clampedX,
207
+ clampedY,
208
+ draftRef.current.width,
209
+ draftRef.current.height,
210
+ draftRef.current.id,
211
+ )
212
+ if (!valid) return
213
+
214
+ const draft = draftRef.current
215
+ draftRef.current = null
216
+
217
+ // Delete transient draft (paused, invisible to undo)
218
+ useScene.getState().deleteNode(draft.id)
219
+
220
+ // Resume → create permanent node (single undoable action)
221
+ useScene.temporal.getState().resume()
222
+
223
+ const levelId = getLevelId()
224
+ const state = useScene.getState()
225
+ const windowCount = Object.values(state.nodes).filter((n) => {
226
+ if (n.type !== 'window') return false
227
+ const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
228
+ return wall?.parentId === levelId
229
+ }).length
230
+ const name = `Window ${windowCount + 1}`
231
+
232
+ const node = WindowNode.parse({
233
+ name,
234
+ position: [clampedX, clampedY, 0],
235
+ rotation: [0, itemRotation, 0],
236
+ side,
237
+ wallId: event.node.id,
238
+ parentId: event.node.id,
239
+ width: draft.width,
240
+ height: draft.height,
241
+ frameThickness: draft.frameThickness,
242
+ frameDepth: draft.frameDepth,
243
+ columnRatios: draft.columnRatios,
244
+ rowRatios: draft.rowRatios,
245
+ columnDividerThickness: draft.columnDividerThickness,
246
+ rowDividerThickness: draft.rowDividerThickness,
247
+ sill: draft.sill,
248
+ sillDepth: draft.sillDepth,
249
+ sillThickness: draft.sillThickness,
250
+ })
251
+
252
+ useScene.getState().createNode(node, event.node.id as AnyNodeId)
253
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
254
+ useScene.temporal.getState().pause()
255
+ sfxEmitter.emit('sfx:item-place')
256
+
257
+ event.stopPropagation()
258
+ }
259
+
260
+ const onWallLeave = () => {
261
+ destroyDraft()
262
+ hideCursor()
263
+ }
264
+
265
+ const onCancel = () => {
266
+ destroyDraft()
267
+ hideCursor()
268
+ }
269
+
270
+ emitter.on('wall:enter', onWallEnter)
271
+ emitter.on('wall:move', onWallMove)
272
+ emitter.on('wall:click', onWallClick)
273
+ emitter.on('wall:leave', onWallLeave)
274
+ emitter.on('tool:cancel', onCancel)
275
+
276
+ return () => {
277
+ destroyDraft()
278
+ hideCursor()
279
+ useScene.temporal.getState().resume()
280
+ emitter.off('wall:enter', onWallEnter)
281
+ emitter.off('wall:move', onWallMove)
282
+ emitter.off('wall:click', onWallClick)
283
+ emitter.off('wall:leave', onWallLeave)
284
+ emitter.off('tool:cancel', onCancel)
285
+ }
286
+ }, [])
287
+
288
+ // Cursor geometry: window outline rectangle (width × height × frameDepth)
289
+ const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
290
+ const edgesGeo = new EdgesGeometry(boxGeo)
291
+ boxGeo.dispose()
292
+
293
+ return (
294
+ <group ref={cursorGroupRef} visible={false}>
295
+ <lineSegments
296
+ geometry={edgesGeo}
297
+ layers={EDITOR_LAYER}
298
+ material={edgeMaterial}
299
+ ref={edgesRef}
300
+ />
301
+ </group>
302
+ )
303
+ }
@@ -0,0 +1,39 @@
1
+ import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core'
2
+ import { useCallback } from 'react'
3
+ import { PolygonEditor } from '../shared/polygon-editor'
4
+
5
+ interface ZoneBoundaryEditorProps {
6
+ zoneId: ZoneNode['id']
7
+ }
8
+
9
+ /**
10
+ * Zone boundary editor - allows editing zone polygon vertices for a specific zone
11
+ * Uses the generic PolygonEditor component
12
+ */
13
+ export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }) => {
14
+ const zoneNode = useScene((state) => state.nodes[zoneId])
15
+ const updateNode = useScene((state) => state.updateNode)
16
+
17
+ const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
18
+
19
+ const handlePolygonChange = useCallback(
20
+ (newPolygon: Array<[number, number]>) => {
21
+ updateNode(zoneId, { polygon: newPolygon })
22
+ },
23
+ [zoneId, updateNode],
24
+ )
25
+
26
+ if (!zone?.polygon || zone.polygon.length < 3) return null
27
+
28
+ const zoneColor = zone.color || '#3b82f6'
29
+
30
+ return (
31
+ <PolygonEditor
32
+ color={zoneColor}
33
+ levelId={resolveLevelId(zone, useScene.getState().nodes)}
34
+ minVertices={3}
35
+ onPolygonChange={handlePolygonChange}
36
+ polygon={zone.polygon}
37
+ />
38
+ )
39
+ }
@@ -0,0 +1,364 @@
1
+ import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } 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 { EDITOR_LAYER } from './../../../lib/constants'
6
+ import useEditor from './../../../store/use-editor'
7
+ import { CursorSphere } from '../shared/cursor-sphere'
8
+
9
+ const Y_OFFSET = 0.02
10
+
11
+ /**
12
+ * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
13
+ */
14
+ const calculateSnapPoint = (
15
+ lastPoint: [number, number],
16
+ currentPoint: [number, number],
17
+ ): [number, number] => {
18
+ const [x1, y1] = lastPoint
19
+ const [x, y] = currentPoint
20
+
21
+ const dx = x - x1
22
+ const dy = y - y1
23
+ const absDx = Math.abs(dx)
24
+ const absDy = Math.abs(dy)
25
+
26
+ // Calculate distances to horizontal, vertical, and diagonal lines
27
+ const horizontalDist = absDy
28
+ const verticalDist = absDx
29
+ const diagonalDist = Math.abs(absDx - absDy)
30
+
31
+ // Find the minimum distance to determine which axis to snap to
32
+ const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
33
+
34
+ if (minDist === diagonalDist) {
35
+ // Snap to 45° diagonal
36
+ const diagonalLength = Math.min(absDx, absDy)
37
+ return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
38
+ }
39
+ if (minDist === horizontalDist) {
40
+ // Snap to horizontal
41
+ return [x, y1]
42
+ }
43
+ // Snap to vertical
44
+ return [x1, y]
45
+ }
46
+
47
+ /**
48
+ * Creates a zone with the given polygon points
49
+ */
50
+ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
51
+ const { createNode, nodes } = useScene.getState()
52
+
53
+ // Count existing zones for naming and color cycling
54
+ const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
55
+ const name = `Zone ${zoneCount + 1}`
56
+
57
+ // Default to blue, cycle through palette for subsequent zones
58
+ const color = '#3b82f6'
59
+
60
+ const zone = ZoneNode.parse({
61
+ name,
62
+ polygon: points,
63
+ color,
64
+ })
65
+
66
+ createNode(zone, levelId)
67
+
68
+ // Select the newly created zone
69
+ useViewer.getState().setSelection({ zoneId: zone.id })
70
+ }
71
+
72
+ type PreviewState = {
73
+ points: Array<[number, number]>
74
+ cursorPoint: [number, number] | null
75
+ levelY: number
76
+ }
77
+
78
+ // Helper to validate point values (no NaN or Infinity)
79
+ const isValidPoint = (pt: [number, number] | null | undefined): pt is [number, number] => {
80
+ if (!pt) return false
81
+ return Number.isFinite(pt[0]) && Number.isFinite(pt[1])
82
+ }
83
+
84
+ export const ZoneTool: React.FC = () => {
85
+ const cursorRef = useRef<Group>(null)
86
+ const mainLineRef = useRef<Line>(null!)
87
+ const closingLineRef = useRef<Line>(null!)
88
+ const pointsRef = useRef<Array<[number, number]>>([])
89
+ const levelYRef = useRef(0) // Track current level Y position
90
+ const currentLevelId = useViewer((state) => state.selection.levelId)
91
+ const setTool = useEditor((state) => state.setTool)
92
+
93
+ // Preview state for reactive rendering (for shape and point markers)
94
+ const [preview, setPreview] = useState<PreviewState>({
95
+ points: [],
96
+ cursorPoint: null,
97
+ levelY: 0,
98
+ })
99
+
100
+ useEffect(() => {
101
+ if (!currentLevelId) return
102
+
103
+ let cursorPosition: [number, number] = [0, 0]
104
+
105
+ // Initialize line geometries
106
+ mainLineRef.current.geometry = new BufferGeometry()
107
+ closingLineRef.current.geometry = new BufferGeometry()
108
+
109
+ const updateLines = () => {
110
+ const points = pointsRef.current
111
+ const y = levelYRef.current + Y_OFFSET
112
+
113
+ if (points.length === 0) {
114
+ mainLineRef.current.visible = false
115
+ closingLineRef.current.visible = false
116
+ return
117
+ }
118
+
119
+ // Build main line points
120
+ const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
121
+
122
+ // Add cursor point
123
+ const lastPoint = points[points.length - 1]
124
+ if (lastPoint) {
125
+ const snapped = calculateSnapPoint(lastPoint, cursorPosition)
126
+ if (isValidPoint(snapped)) {
127
+ linePoints.push(new Vector3(snapped[0], y, snapped[1]))
128
+ }
129
+ }
130
+
131
+ // Update main line geometry
132
+ if (linePoints.length >= 2) {
133
+ mainLineRef.current.geometry.dispose()
134
+ mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
135
+ mainLineRef.current.visible = true
136
+ } else {
137
+ mainLineRef.current.visible = false
138
+ }
139
+
140
+ // Update closing line (from cursor back to first point)
141
+ const firstPoint = points[0]
142
+ if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
143
+ const snapped = calculateSnapPoint(lastPoint, cursorPosition)
144
+ if (isValidPoint(snapped)) {
145
+ const closingPoints = [
146
+ new Vector3(snapped[0], y, snapped[1]),
147
+ new Vector3(firstPoint[0], y, firstPoint[1]),
148
+ ]
149
+ closingLineRef.current.geometry.dispose()
150
+ closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
151
+ closingLineRef.current.visible = true
152
+ }
153
+ } else {
154
+ closingLineRef.current.visible = false
155
+ }
156
+ }
157
+
158
+ const updatePreview = () => {
159
+ const points = pointsRef.current
160
+ const lastPoint = points[points.length - 1]
161
+
162
+ let cursorPt: [number, number] | null = null
163
+ if (lastPoint) {
164
+ cursorPt = calculateSnapPoint(lastPoint, cursorPosition)
165
+ } else if (points.length === 0) {
166
+ cursorPt = cursorPosition
167
+ }
168
+
169
+ setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
170
+ updateLines()
171
+ }
172
+
173
+ const onGridMove = (event: GridEvent) => {
174
+ if (!cursorRef.current) return
175
+
176
+ // Snap to 0.5 grid
177
+ const gridX = Math.round(event.position[0] * 2) / 2
178
+ const gridZ = Math.round(event.position[2] * 2) / 2
179
+ cursorPosition = [gridX, gridZ]
180
+ levelYRef.current = event.position[1]
181
+
182
+ // If we have points, snap to axis from last point
183
+ const lastPoint = pointsRef.current[pointsRef.current.length - 1]
184
+ if (lastPoint) {
185
+ const snapped = calculateSnapPoint(lastPoint, cursorPosition)
186
+ cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])
187
+ } else {
188
+ cursorRef.current.position.set(gridX, event.position[1], gridZ)
189
+ }
190
+
191
+ updatePreview()
192
+ }
193
+
194
+ const onGridClick = (event: GridEvent) => {
195
+ if (!currentLevelId) return
196
+
197
+ const gridX = Math.round(event.position[0] * 2) / 2
198
+ const gridZ = Math.round(event.position[2] * 2) / 2
199
+ let clickPoint: [number, number] = [gridX, gridZ]
200
+
201
+ // Snap to axis from last point
202
+ const lastPoint = pointsRef.current[pointsRef.current.length - 1]
203
+ if (lastPoint) {
204
+ clickPoint = calculateSnapPoint(lastPoint, clickPoint)
205
+ }
206
+
207
+ // Check if clicking on the first point to close the shape
208
+ const firstPoint = pointsRef.current[0]
209
+ if (
210
+ pointsRef.current.length >= 3 &&
211
+ firstPoint &&
212
+ Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
213
+ Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
214
+ ) {
215
+ // Create the zone
216
+ commitZoneDrawing(currentLevelId, pointsRef.current)
217
+
218
+ // Reset state
219
+ pointsRef.current = []
220
+ setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
221
+ mainLineRef.current.visible = false
222
+ closingLineRef.current.visible = false
223
+ } else {
224
+ // Add point to polygon
225
+ pointsRef.current = [...pointsRef.current, clickPoint]
226
+ updatePreview()
227
+ }
228
+ }
229
+
230
+ const onGridDoubleClick = (_event: GridEvent) => {
231
+ if (!currentLevelId) return
232
+
233
+ // Need at least 3 points to form a polygon
234
+ if (pointsRef.current.length >= 3) {
235
+ commitZoneDrawing(currentLevelId, pointsRef.current)
236
+
237
+ // Reset state
238
+ pointsRef.current = []
239
+ setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
240
+ mainLineRef.current.visible = false
241
+ closingLineRef.current.visible = false
242
+ }
243
+ }
244
+
245
+ // Subscribe to events
246
+ emitter.on('grid:move', onGridMove)
247
+ emitter.on('grid:click', onGridClick)
248
+ emitter.on('grid:double-click', onGridDoubleClick)
249
+
250
+ return () => {
251
+ emitter.off('grid:move', onGridMove)
252
+ emitter.off('grid:click', onGridClick)
253
+ emitter.off('grid:double-click', onGridDoubleClick)
254
+
255
+ // Reset state on unmount
256
+ pointsRef.current = []
257
+ }
258
+ }, [currentLevelId])
259
+
260
+ const { points, cursorPoint, levelY } = preview
261
+
262
+ // Create preview shape when we have 3+ points
263
+ const previewShape = useMemo(() => {
264
+ if (points.length < 3) return null
265
+
266
+ const allPoints = [...points]
267
+ if (isValidPoint(cursorPoint)) {
268
+ allPoints.push(cursorPoint)
269
+ }
270
+
271
+ // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
272
+ // - Shape X -> World X
273
+ // - Shape Y -> World -Z (so we negate Z to get correct orientation)
274
+ const firstPt = allPoints[0]
275
+ if (!isValidPoint(firstPt)) return null
276
+
277
+ const shape = new Shape()
278
+ shape.moveTo(firstPt[0], -firstPt[1])
279
+
280
+ for (let i = 1; i < allPoints.length; i++) {
281
+ const pt = allPoints[i]
282
+ if (isValidPoint(pt)) {
283
+ shape.lineTo(pt[0], -pt[1])
284
+ }
285
+ }
286
+ shape.closePath()
287
+
288
+ return shape
289
+ }, [points, cursorPoint])
290
+
291
+ return (
292
+ <group>
293
+ {/* Cursor */}
294
+ <CursorSphere ref={cursorRef} />
295
+
296
+ {/* Preview fill */}
297
+ {previewShape && (
298
+ <mesh
299
+ frustumCulled={false}
300
+ layers={EDITOR_LAYER}
301
+ position={[0, levelY + Y_OFFSET, 0]}
302
+ rotation={[-Math.PI / 2, 0, 0]}
303
+ >
304
+ <shapeGeometry args={[previewShape]} />
305
+ <meshBasicMaterial
306
+ color="#818cf8"
307
+ depthTest={false}
308
+ opacity={0.15}
309
+ side={DoubleSide}
310
+ transparent
311
+ />
312
+ </mesh>
313
+ )}
314
+
315
+ {/* Main line - uses native line element with TSL-compatible material */}
316
+ {/* @ts-ignore */}
317
+ <line
318
+ frustumCulled={false}
319
+ layers={EDITOR_LAYER}
320
+ // @ts-expect-error
321
+ ref={mainLineRef}
322
+ renderOrder={1}
323
+ visible={false}
324
+ >
325
+ <bufferGeometry />
326
+ <lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
327
+ </line>
328
+
329
+ {/* Closing line - uses native line element with TSL-compatible material */}
330
+ {/* @ts-ignore */}
331
+ <line
332
+ frustumCulled={false}
333
+ layers={EDITOR_LAYER}
334
+ // @ts-expect-error
335
+ ref={closingLineRef}
336
+ renderOrder={1}
337
+ visible={false}
338
+ >
339
+ <bufferGeometry />
340
+ <lineBasicNodeMaterial
341
+ color="#818cf8"
342
+ depthTest={false}
343
+ depthWrite={false}
344
+ linewidth={2}
345
+ opacity={0.5}
346
+ transparent
347
+ />
348
+ </line>
349
+
350
+ {/* Point markers */}
351
+ {points.map(([x, z], index) =>
352
+ isValidPoint([x, z]) ? (
353
+ <CursorSphere
354
+ color="#818cf8"
355
+ height={0}
356
+ key={index}
357
+ position={[x, levelY + Y_OFFSET + 0.01, z]}
358
+ showTooltip={false}
359
+ />
360
+ ) : null,
361
+ )}
362
+ </group>
363
+ )
364
+ }