@pascal-app/editor 0.5.1 → 0.7.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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -0,0 +1,272 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type CeilingNode,
5
+ emitter,
6
+ resolveLevelId,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { createPortal, type ThreeEvent } from '@react-three/fiber'
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import type { Object3D } from 'three'
14
+ import { useShallow } from 'zustand/react/shallow'
15
+ import useEditor from '../../../store/use-editor'
16
+
17
+ const BRACKET_THICKNESS = 0.04
18
+ const BRACKET_HEIGHT = 0.04
19
+ const BRACKET_Y_OFFSET = 0.035
20
+ const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
21
+
22
+ type CornerBracketData = {
23
+ corner: [number, number]
24
+ incomingDirection: [number, number]
25
+ outgoingDirection: [number, number]
26
+ incomingLength: number
27
+ outgoingLength: number
28
+ cornerStrength: number
29
+ }
30
+
31
+ export const CeilingSelectionAffordanceSystem = () => {
32
+ const phase = useEditor((state) => state.phase)
33
+ const mode = useEditor((state) => state.mode)
34
+ const structureLayer = useEditor((state) => state.structureLayer)
35
+ const movingNode = useEditor((state) => state.movingNode)
36
+ const curvingWall = useEditor((state) => state.curvingWall)
37
+ const currentLevelId = useViewer((state) => state.selection.levelId)
38
+
39
+ const ceilings = useScene(
40
+ useShallow((state) =>
41
+ Object.values(state.nodes).filter((node): node is CeilingNode => {
42
+ return (
43
+ node.type === 'ceiling' &&
44
+ node.visible !== false &&
45
+ currentLevelId !== null &&
46
+ resolveLevelId(node, state.nodes) === currentLevelId
47
+ )
48
+ }),
49
+ ),
50
+ )
51
+
52
+ const shouldRender =
53
+ phase === 'structure' &&
54
+ mode === 'select' &&
55
+ structureLayer === 'elements' &&
56
+ !movingNode &&
57
+ !curvingWall &&
58
+ currentLevelId !== null
59
+
60
+ if (!shouldRender) return null
61
+
62
+ return (
63
+ <>
64
+ {ceilings.map((ceiling) => (
65
+ <CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
66
+ ))}
67
+ </>
68
+ )
69
+ }
70
+
71
+ const CeilingSelectionAffordance = ({
72
+ ceiling,
73
+ levelId,
74
+ }: {
75
+ ceiling: CeilingNode
76
+ levelId: string
77
+ }) => {
78
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
79
+
80
+ const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
81
+
82
+ useEffect(() => {
83
+ let frameId = 0
84
+
85
+ const resolveLevelObject = () => {
86
+ const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
87
+ setLevelObject((currentLevelObject) => {
88
+ if (currentLevelObject === nextLevelObject) {
89
+ return currentLevelObject
90
+ }
91
+ return nextLevelObject
92
+ })
93
+
94
+ if (!nextLevelObject) {
95
+ frameId = window.requestAnimationFrame(resolveLevelObject)
96
+ }
97
+ }
98
+
99
+ resolveLevelObject()
100
+
101
+ return () => {
102
+ if (frameId) {
103
+ window.cancelAnimationFrame(frameId)
104
+ }
105
+ }
106
+ }, [levelId])
107
+
108
+ if (!levelObject || corners.length === 0) return null
109
+
110
+ return createPortal(
111
+ <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
112
+ {corners.map((corner, index) => (
113
+ <CornerBracket
114
+ ceiling={ceiling}
115
+ corner={corner}
116
+ key={`${ceiling.id}-corner-${index}`}
117
+ />
118
+ ))}
119
+ </group>,
120
+ levelObject,
121
+ )
122
+ }
123
+
124
+ const CornerBracket = ({
125
+ ceiling,
126
+ corner,
127
+ }: {
128
+ ceiling: CeilingNode
129
+ corner: CornerBracketData
130
+ }) => {
131
+ const [isHovered, setIsHovered] = useState(false)
132
+ const color = '#d4d4d4'
133
+ const opacity = 0.72
134
+ const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
135
+ const cubeOpacity = isHovered ? 0.92 : 0.72
136
+
137
+ const handleClick = (e: ThreeEvent<MouseEvent>) => {
138
+ e.stopPropagation()
139
+
140
+ const nodes = useScene.getState().nodes
141
+
142
+ useEditor.getState().setMovingNode(null)
143
+ useEditor.getState().setMovingWallEndpoint(null)
144
+ useEditor.getState().setCurvingWall(null)
145
+ useEditor.getState().setEditingHole(null)
146
+ useEditor.getState().setMode('select')
147
+
148
+ emitter.emit('ceiling:click' as any, {
149
+ node: ceiling,
150
+ nativeEvent: e.nativeEvent,
151
+ localPosition: [0, 0, 0],
152
+ position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
153
+ stopPropagation: () => e.stopPropagation(),
154
+ })
155
+ }
156
+
157
+ return (
158
+ <group position={[corner.corner[0], 0, corner.corner[1]]}>
159
+ <BracketLeg
160
+ color={color}
161
+ direction={corner.incomingDirection}
162
+ length={corner.incomingLength}
163
+ onClick={handleClick}
164
+ opacity={opacity}
165
+ />
166
+ <BracketLeg
167
+ color={color}
168
+ direction={corner.outgoingDirection}
169
+ length={corner.outgoingLength}
170
+ onClick={handleClick}
171
+ opacity={opacity}
172
+ />
173
+
174
+ <mesh
175
+ onClick={handleClick}
176
+ onPointerEnter={(e) => {
177
+ e.stopPropagation()
178
+ setIsHovered(true)
179
+ }}
180
+ onPointerLeave={(e) => {
181
+ e.stopPropagation()
182
+ setIsHovered(false)
183
+ }}
184
+ >
185
+ <boxGeometry args={HIT_BOX_SIZE} />
186
+ <meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
187
+ </mesh>
188
+ </group>
189
+ )
190
+ }
191
+
192
+ const BracketLeg = ({
193
+ direction,
194
+ length,
195
+ color,
196
+ onClick,
197
+ opacity,
198
+ }: {
199
+ direction: [number, number]
200
+ length: number
201
+ color: string
202
+ onClick: (e: ThreeEvent<MouseEvent>) => void
203
+ opacity: number
204
+ }) => {
205
+ const angle = Math.atan2(direction[1], direction[0])
206
+ const position: [number, number, number] = [
207
+ direction[0] * (length / 2),
208
+ 0,
209
+ direction[1] * (length / 2),
210
+ ]
211
+
212
+ return (
213
+ <mesh
214
+ onClick={onClick}
215
+ position={position}
216
+ rotation={[0, angle, 0]}
217
+ >
218
+ <boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
219
+ <meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
220
+ </mesh>
221
+ )
222
+ }
223
+
224
+ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
225
+ if (polygon.length < 3) return []
226
+
227
+ const allCorners = polygon.map((corner, index) => {
228
+ const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
229
+ const next = polygon[(index + 1) % polygon.length]!
230
+ const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
231
+ const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
232
+ const incomingDirection = normalize2D(incomingVector)
233
+ const outgoingDirection = normalize2D(outgoingVector)
234
+
235
+ const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
236
+ const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
237
+ const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
238
+
239
+ return {
240
+ corner,
241
+ incomingDirection,
242
+ outgoingDirection,
243
+ incomingLength: getBracketLength(incomingLength),
244
+ outgoingLength: getBracketLength(outgoingLength),
245
+ cornerStrength,
246
+ }
247
+ })
248
+
249
+ if (allCorners.length <= 4) {
250
+ return allCorners
251
+ }
252
+
253
+ const selectedIndices = new Set(
254
+ allCorners
255
+ .map((corner, index) => ({ index, strength: corner.cornerStrength }))
256
+ .sort((a, b) => b.strength - a.strength)
257
+ .slice(0, 4)
258
+ .map(({ index }) => index),
259
+ )
260
+
261
+ return allCorners.filter((_, index) => selectedIndices.has(index))
262
+ }
263
+
264
+ function normalize2D(vector: [number, number]): [number, number] {
265
+ const length = Math.hypot(vector[0], vector[1])
266
+ if (length < 1e-6) return [1, 0]
267
+ return [vector[0] / length, vector[1] / length]
268
+ }
269
+
270
+ function getBracketLength(edgeLength: number): number {
271
+ return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
272
+ }
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
6
6
  * Imperatively toggles the Three.js visibility of roof objects based on the
7
7
  * editor selection — without causing React re-renders in RoofRenderer.
8
8
  *
9
- * When a roof (or one of its segments) is selected:
9
+ * When a roof-segment is selected:
10
10
  * - merged-roof mesh is hidden
11
11
  * - segments-wrapper group is shown (individual segments visible for editing)
12
12
  * - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
22
22
  useEffect(() => {
23
23
  const nodes = useScene.getState().nodes
24
24
 
25
- // Collect which roof nodes should be in "edit mode"
25
+ // Collect which roof nodes should be in "edit mode".
26
+ // Selecting the roof itself should keep the merged visual intact so
27
+ // material appearance does not jump between merged and per-segment meshes.
26
28
  const activeRoofIds = new Set<string>()
27
29
  for (const id of selectedIds) {
28
30
  const node = nodes[id as AnyNodeId]
29
31
  if (!node) continue
30
- if (node.type === 'roof') {
31
- activeRoofIds.add(id)
32
- } else if (node.type === 'roof-segment' && node.parentId) {
32
+ if (node.type === 'roof-segment' && node.parentId) {
33
33
  activeRoofIds.add(node.parentId)
34
34
  }
35
35
  }
@@ -31,6 +31,7 @@ export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ce
31
31
 
32
32
  return (
33
33
  <PolygonEditor
34
+ allowEdgeMove
34
35
  color="#d4d4d4"
35
36
  levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
36
37
  minVertices={3}
@@ -36,6 +36,8 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
36
36
 
37
37
  return (
38
38
  <PolygonEditor
39
+ allowEdgeMove
40
+ allowPolygonMove
39
41
  color="#ef4444"
40
42
  levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
41
43
  minVertices={3}
@@ -111,15 +111,15 @@ export const CeilingTool: React.FC = () => {
111
111
  const onGridMove = (event: GridEvent) => {
112
112
  if (!(cursorRef.current && gridCursorRef.current)) return
113
113
 
114
- const gridX = Math.round(event.position[0] * 2) / 2
115
- const gridZ = Math.round(event.position[2] * 2) / 2
114
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
115
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
116
116
  const gridPosition: [number, number] = [gridX, gridZ]
117
117
 
118
118
  setCursorPosition(gridPosition)
119
- setLevelY(event.position[1])
119
+ setLevelY(event.localPosition[1])
120
120
 
121
- const ceilingY = event.position[1] + CEILING_HEIGHT
122
- const gridY = event.position[1] + GRID_OFFSET
121
+ const ceilingY = event.localPosition[1] + CEILING_HEIGHT
122
+ const gridY = event.localPosition[1] + GRID_OFFSET
123
123
 
124
124
  // Calculate snapped display position (bypass snap when Shift is held)
125
125
  const lastPoint = points[points.length - 1]
@@ -0,0 +1,257 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
8
+ import useEditor from '../../../store/use-editor'
9
+ import { CursorSphere } from '../shared/cursor-sphere'
10
+ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
11
+
12
+ function snap(value: number) {
13
+ return Math.round(value * 2) / 2
14
+ }
15
+
16
+ function translatePolygon(
17
+ polygon: Array<[number, number]>,
18
+ deltaX: number,
19
+ deltaZ: number,
20
+ ): Array<[number, number]> {
21
+ return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
22
+ }
23
+
24
+ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
25
+ if (polygon.length === 0) return [0, 0]
26
+ let sumX = 0
27
+ let sumZ = 0
28
+ for (const [x, z] of polygon) {
29
+ sumX += x
30
+ sumZ += z
31
+ }
32
+ return [sumX / polygon.length, sumZ / polygon.length]
33
+ }
34
+
35
+ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
36
+ const activatedAtRef = useRef<number>(Date.now())
37
+ const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
38
+ const originalHolesRef = useRef(
39
+ (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
40
+ )
41
+ const dragAnchorRef = useRef<[number, number] | null>(null)
42
+ const previousGridPosRef = useRef<[number, number] | null>(null)
43
+ const previousCursorPosRef = useRef<[number, number, number] | null>(null)
44
+ const previousDeltaRef = useRef<[number, number] | null>(null)
45
+ const previewRef = useRef<{
46
+ polygon: Array<[number, number]>
47
+ holes: Array<Array<[number, number]>>
48
+ } | null>(null)
49
+
50
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
51
+ const center = getPolygonCenter(node.polygon)
52
+ return [center[0], node.height ?? 2.5, center[1]]
53
+ })
54
+ const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
55
+ const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
56
+
57
+ const exitMoveMode = useCallback(() => {
58
+ useEditor.getState().setMovingNode(null)
59
+ }, [])
60
+
61
+ useEffect(() => {
62
+ const originalPolygon = originalPolygonRef.current
63
+ const originalHoles = originalHolesRef.current
64
+
65
+ useScene.temporal.getState().pause()
66
+ let wasCommitted = false
67
+
68
+ const applyPreview = (
69
+ polygon: Array<[number, number]>,
70
+ holes: Array<Array<[number, number]>>,
71
+ ) => {
72
+ previewRef.current = { polygon, holes }
73
+ setPreviewPolygon(polygon)
74
+ setPreviewHoles(holes)
75
+ const center = getPolygonCenter(polygon)
76
+ const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
77
+ if (
78
+ !previousCursorPosRef.current ||
79
+ previousCursorPosRef.current[0] !== nextCursorPos[0] ||
80
+ previousCursorPosRef.current[1] !== nextCursorPos[1] ||
81
+ previousCursorPosRef.current[2] !== nextCursorPos[2]
82
+ ) {
83
+ previousCursorPosRef.current = nextCursorPos
84
+ setCursorLocalPos(nextCursorPos)
85
+ }
86
+ useScene.getState().updateNode(node.id, { polygon, holes })
87
+ useScene.getState().markDirty(node.id as AnyNodeId)
88
+ }
89
+
90
+ const restoreOriginal = () => {
91
+ setPreviewPolygon(originalPolygon)
92
+ setPreviewHoles(originalHoles)
93
+ useScene.getState().updateNode(node.id, {
94
+ holes: originalHoles,
95
+ polygon: originalPolygon,
96
+ })
97
+ useScene.getState().markDirty(node.id as AnyNodeId)
98
+ }
99
+
100
+ const onGridMove = (event: GridEvent) => {
101
+ const localX = snap(event.localPosition[0])
102
+ const localZ = snap(event.localPosition[2])
103
+
104
+ if (
105
+ previousGridPosRef.current &&
106
+ (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
107
+ ) {
108
+ sfxEmitter.emit('sfx:grid-snap')
109
+ }
110
+ previousGridPosRef.current = [localX, localZ]
111
+
112
+ const anchor = dragAnchorRef.current ?? [localX, localZ]
113
+ dragAnchorRef.current = anchor
114
+
115
+ const deltaX = localX - anchor[0]
116
+ const deltaZ = localZ - anchor[1]
117
+
118
+ if (
119
+ previousDeltaRef.current &&
120
+ previousDeltaRef.current[0] === deltaX &&
121
+ previousDeltaRef.current[1] === deltaZ
122
+ ) {
123
+ return
124
+ }
125
+ previousDeltaRef.current = [deltaX, deltaZ]
126
+
127
+ applyPreview(
128
+ translatePolygon(originalPolygon, deltaX, deltaZ),
129
+ originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
130
+ )
131
+ }
132
+
133
+ const onGridClick = (event: GridEvent) => {
134
+ if (Date.now() - activatedAtRef.current < 150) {
135
+ event.nativeEvent?.stopPropagation?.()
136
+ return
137
+ }
138
+
139
+ const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
140
+
141
+ wasCommitted = true
142
+
143
+ // Restore original baseline while paused so the next resume+update
144
+ // registers as a single tracked change (undo reverts to original).
145
+ useScene.getState().updateNode(node.id, {
146
+ polygon: originalPolygon,
147
+ holes: originalHoles,
148
+ })
149
+
150
+ useScene.temporal.getState().resume()
151
+ useScene.getState().updateNode(node.id, preview)
152
+ useScene.getState().markDirty(node.id as AnyNodeId)
153
+ useScene.temporal.getState().pause()
154
+
155
+ sfxEmitter.emit('sfx:item-place')
156
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
157
+ exitMoveMode()
158
+ event.nativeEvent?.stopPropagation?.()
159
+ }
160
+
161
+ const onCancel = () => {
162
+ restoreOriginal()
163
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
164
+ useScene.temporal.getState().resume()
165
+ markToolCancelConsumed()
166
+ exitMoveMode()
167
+ }
168
+
169
+ emitter.on('grid:move', onGridMove)
170
+ emitter.on('grid:click', onGridClick)
171
+ emitter.on('tool:cancel', onCancel)
172
+
173
+ return () => {
174
+ if (!wasCommitted) {
175
+ restoreOriginal()
176
+ }
177
+ useScene.temporal.getState().resume()
178
+ emitter.off('grid:move', onGridMove)
179
+ emitter.off('grid:click', onGridClick)
180
+ emitter.off('tool:cancel', onCancel)
181
+ }
182
+ }, [exitMoveMode, node.height, node.id])
183
+
184
+ const previewFillGeometry = useMemo(
185
+ () => createCeilingPreviewGeometry(previewPolygon, previewHoles),
186
+ [previewHoles, previewPolygon],
187
+ )
188
+
189
+ const previewOutlineGeometry = useMemo(
190
+ () => createCeilingOutlineGeometry(previewPolygon),
191
+ [previewPolygon],
192
+ )
193
+
194
+ return (
195
+ <group>
196
+ <mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
197
+ <meshBasicMaterial
198
+ color="#f5f5f4"
199
+ depthWrite={false}
200
+ opacity={0.3}
201
+ side={DoubleSide}
202
+ transparent
203
+ />
204
+ </mesh>
205
+ <line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
206
+ <lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
207
+ </line>
208
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
209
+ </group>
210
+ )
211
+ }
212
+
213
+ function createCeilingPreviewGeometry(
214
+ polygon: Array<[number, number]>,
215
+ holes: Array<Array<[number, number]>>,
216
+ ): BufferGeometry {
217
+ if (polygon.length < 3) return new BufferGeometry()
218
+
219
+ const shape = new Shape()
220
+ const [firstX, firstZ] = polygon[0]!
221
+ shape.moveTo(firstX, -firstZ)
222
+
223
+ for (let i = 1; i < polygon.length; i++) {
224
+ const [x, z] = polygon[i]!
225
+ shape.lineTo(x, -z)
226
+ }
227
+ shape.closePath()
228
+
229
+ for (const holePolygon of holes) {
230
+ if (holePolygon.length < 3) continue
231
+ const hole = new Path()
232
+ const [hx, hz] = holePolygon[0]!
233
+ hole.moveTo(hx, -hz)
234
+ for (let i = 1; i < holePolygon.length; i++) {
235
+ const [x, z] = holePolygon[i]!
236
+ hole.lineTo(x, -z)
237
+ }
238
+ hole.closePath()
239
+ shape.holes.push(hole)
240
+ }
241
+
242
+ const geometry = new ShapeGeometry(shape)
243
+ geometry.rotateX(-Math.PI / 2)
244
+ geometry.computeVertexNormals()
245
+ return geometry
246
+ }
247
+
248
+ function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
249
+ const geometry = new BufferGeometry()
250
+ if (polygon.length < 2) return geometry
251
+
252
+ const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
253
+ const [firstX, firstZ] = polygon[0]!
254
+ points.push(new Vector3(firstX, 0, firstZ))
255
+ geometry.setFromPoints(points)
256
+ return geometry
257
+ }
@@ -0,0 +1,97 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ COLUMN_PRESETS,
5
+ ColumnNode,
6
+ type ColumnNode as ColumnNodeType,
7
+ type ColumnPresetId,
8
+ emitter,
9
+ type GridEvent,
10
+ type LevelNode,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useEffect, useRef, useState } from 'react'
14
+ import type { Group } from 'three'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import { CursorSphere } from '../shared/cursor-sphere'
18
+
19
+ const COLUMN_ICON = (
20
+ // eslint-disable-next-line @next/next/no-img-element
21
+ <img
22
+ alt="Column"
23
+ src="/icons/column.png"
24
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
25
+ />
26
+ )
27
+
28
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
29
+ const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
30
+
31
+ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
32
+ const { label, ...preset } = COLUMN_PRESETS[presetId]
33
+ return ColumnNode.parse({
34
+ name: label,
35
+ position,
36
+ rotation: 0,
37
+ ...preset,
38
+ })
39
+ }
40
+
41
+ type ColumnToolProps = {
42
+ currentLevelId: LevelNode['id'] | null
43
+ onPlaced?: (nodeId: ColumnNodeType['id']) => void
44
+ }
45
+
46
+ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced }) => {
47
+ const [, setCursorPosition] = useState<[number, number, number] | null>(null)
48
+ const cursorRef = useRef<Group>(null)
49
+
50
+ useEffect(() => {
51
+ if (!currentLevelId) return
52
+
53
+ const onGridMove = (event: GridEvent) => {
54
+ const nextPosition: [number, number, number] = [
55
+ roundToHalf(event.localPosition[0]),
56
+ 0,
57
+ roundToHalf(event.localPosition[2]),
58
+ ]
59
+ setCursorPosition(nextPosition)
60
+ cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
61
+ }
62
+
63
+ const onGridClick = (event: GridEvent) => {
64
+ const position: [number, number, number] = [
65
+ roundToHalf(event.localPosition[0]),
66
+ 0,
67
+ roundToHalf(event.localPosition[2]),
68
+ ]
69
+ const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
70
+ useScene.getState().createNode(column, currentLevelId)
71
+ onPlaced?.(column.id)
72
+ sfxEmitter.emit('sfx:structure-build')
73
+ useEditor.getState().setTool(null)
74
+ useEditor.getState().setMode('select')
75
+ }
76
+
77
+ emitter.on('grid:move', onGridMove)
78
+ emitter.on('grid:click', onGridClick)
79
+
80
+ return () => {
81
+ emitter.off('grid:move', onGridMove)
82
+ emitter.off('grid:click', onGridClick)
83
+ }
84
+ }, [currentLevelId, onPlaced])
85
+
86
+ if (!currentLevelId) return null
87
+
88
+ return (
89
+ <CursorSphere
90
+ color="#a78bfa"
91
+ height={2.8}
92
+ ref={cursorRef}
93
+ showTooltip
94
+ tooltipContent={COLUMN_ICON}
95
+ />
96
+ )
97
+ }