@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,156 @@
1
+ import {
2
+ type FenceNode,
3
+ getWallCurveFrameAt,
4
+ getWallCurveLength,
5
+ isCurvedWall,
6
+ type WallNode,
7
+ } from '@pascal-app/core'
8
+
9
+ export type PlanPoint = [number, number]
10
+
11
+ export type SegmentAngleLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
12
+
13
+ export type SegmentAngleReference = {
14
+ vector: PlanPoint
15
+ orientation: 'directed' | 'axis'
16
+ }
17
+
18
+ const POINT_MATCH_TOLERANCE = 1e-5
19
+ const SEGMENT_POINT_TOLERANCE = 0.15
20
+ const CURVE_TANGENT_SAMPLE_SPACING = 0.08
21
+
22
+ function distanceSquared(a: PlanPoint, b: PlanPoint) {
23
+ const dx = a[0] - b[0]
24
+ const dz = a[1] - b[1]
25
+
26
+ return dx * dx + dz * dz
27
+ }
28
+
29
+ function pointsMatch(a: PlanPoint, b: PlanPoint, tolerance = POINT_MATCH_TOLERANCE) {
30
+ return distanceSquared(a, b) <= tolerance * tolerance
31
+ }
32
+
33
+ function getProjectedPointOnSegment(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
34
+ const [x1, z1] = segment.start
35
+ const [x2, z2] = segment.end
36
+ const dx = x2 - x1
37
+ const dz = z2 - z1
38
+ const lengthSquared = dx * dx + dz * dz
39
+
40
+ if (lengthSquared < 1e-9) {
41
+ return null
42
+ }
43
+
44
+ const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
45
+ if (t <= 0 || t >= 1) {
46
+ return null
47
+ }
48
+
49
+ return [x1 + dx * t, z1 + dz * t]
50
+ }
51
+
52
+ function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
53
+ const curveLength = getWallCurveLength(segment)
54
+ const sampleCount = Math.max(24, Math.ceil(curveLength / CURVE_TANGENT_SAMPLE_SPACING))
55
+ let best: { distance: number; tangent: PlanPoint } | null = null
56
+
57
+ for (let index = 0; index <= sampleCount; index += 1) {
58
+ const frame = getWallCurveFrameAt(segment, index / sampleCount)
59
+ const candidate: PlanPoint = [frame.point.x, frame.point.y]
60
+ const distance = distanceSquared(point, candidate)
61
+
62
+ if (best && distance >= best.distance) {
63
+ continue
64
+ }
65
+
66
+ best = {
67
+ distance,
68
+ tangent: [frame.tangent.x, frame.tangent.y],
69
+ }
70
+ }
71
+
72
+ if (!best || best.distance > SEGMENT_POINT_TOLERANCE * SEGMENT_POINT_TOLERANCE) {
73
+ return null
74
+ }
75
+
76
+ return best.tangent
77
+ }
78
+
79
+ export function formatAngleRadians(angle: number) {
80
+ return `${Math.round((angle * 180) / Math.PI)}°`
81
+ }
82
+
83
+ export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null {
84
+ const firstLength = Math.hypot(first[0], first[1])
85
+ const secondLength = Math.hypot(second[0], second[1])
86
+
87
+ if (firstLength < 1e-6 || secondLength < 1e-6) return null
88
+
89
+ const dot = first[0] * second[0] + first[1] * second[1]
90
+ const cosine = Math.min(1, Math.max(-1, dot / (firstLength * secondLength)))
91
+
92
+ return Math.acos(cosine)
93
+ }
94
+
95
+ export function getAngleToSegmentReference(
96
+ vector: PlanPoint,
97
+ reference: SegmentAngleReference,
98
+ ): number | null {
99
+ const angle = getAngleBetweenVectors(vector, reference.vector)
100
+
101
+ if (angle === null || reference.orientation === 'directed') {
102
+ return angle
103
+ }
104
+
105
+ const reverseAngle = getAngleBetweenVectors(vector, [-reference.vector[0], -reference.vector[1]])
106
+
107
+ if (reverseAngle === null) {
108
+ return angle
109
+ }
110
+
111
+ return Math.min(angle, reverseAngle)
112
+ }
113
+
114
+ export function getSegmentAngleReferenceAtPoint(
115
+ point: PlanPoint,
116
+ segment: SegmentAngleLike,
117
+ ): SegmentAngleReference | null {
118
+ if (pointsMatch(point, segment.start)) {
119
+ const frame = getWallCurveFrameAt(segment, 0)
120
+
121
+ return {
122
+ vector: [frame.tangent.x, frame.tangent.y],
123
+ orientation: 'directed',
124
+ }
125
+ }
126
+
127
+ if (pointsMatch(point, segment.end)) {
128
+ const frame = getWallCurveFrameAt(segment, 1)
129
+
130
+ return {
131
+ vector: [-frame.tangent.x, -frame.tangent.y],
132
+ orientation: 'directed',
133
+ }
134
+ }
135
+
136
+ if (isCurvedWall(segment)) {
137
+ const tangent = getCurveTangentAtPoint(point, segment)
138
+
139
+ return tangent
140
+ ? {
141
+ vector: tangent,
142
+ orientation: 'axis',
143
+ }
144
+ : null
145
+ }
146
+
147
+ const projected = getProjectedPointOnSegment(point, segment)
148
+ if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
149
+ return null
150
+ }
151
+
152
+ return {
153
+ vector: [segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]],
154
+ orientation: 'axis',
155
+ }
156
+ }
@@ -0,0 +1,182 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ type FenceNode,
7
+ type GridEvent,
8
+ type LevelNode,
9
+ type SlabNode,
10
+ useScene,
11
+ type WallNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { useCallback, useEffect, useRef, useState } from 'react'
15
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
16
+ import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import useEditor from '../../../store/use-editor'
18
+ import { snapFenceDraftPoint } from '../fence/fence-drafting'
19
+ import { CursorSphere } from '../shared/cursor-sphere'
20
+
21
+ function translatePolygon(
22
+ polygon: Array<[number, number]>,
23
+ deltaX: number,
24
+ deltaZ: number,
25
+ ): Array<[number, number]> {
26
+ return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
27
+ }
28
+
29
+ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
30
+ if (polygon.length === 0) return [0, 0]
31
+ let sumX = 0
32
+ let sumZ = 0
33
+ for (const [x, z] of polygon) {
34
+ sumX += x
35
+ sumZ += z
36
+ }
37
+ return [sumX / polygon.length, sumZ / polygon.length]
38
+ }
39
+
40
+ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
41
+ const activatedAtRef = useRef<number>(Date.now())
42
+ const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
43
+ const originalHolesRef = useRef(
44
+ (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
45
+ )
46
+ const dragAnchorRef = useRef<[number, number] | null>(null)
47
+ const previousGridPosRef = useRef<[number, number] | null>(null)
48
+ const previewRef = useRef<{
49
+ polygon: Array<[number, number]>
50
+ holes: Array<Array<[number, number]>>
51
+ } | null>(null)
52
+
53
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
54
+ const center = getPolygonCenter(node.polygon)
55
+ return [center[0], 0, center[1]]
56
+ })
57
+
58
+ const exitMoveMode = useCallback(() => {
59
+ useEditor.getState().setMovingNode(null)
60
+ }, [])
61
+
62
+ useEffect(() => {
63
+ const originalPolygon = originalPolygonRef.current
64
+ const originalHoles = originalHolesRef.current
65
+ const levelNode =
66
+ node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
67
+ ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
68
+ : null
69
+ const levelChildren = levelNode?.children ?? []
70
+ const levelWalls = levelChildren
71
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
72
+ .filter((child): child is WallNode => child?.type === 'wall')
73
+ const levelFences = levelChildren
74
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
75
+ .filter((child): child is FenceNode => child?.type === 'fence')
76
+
77
+ useScene.temporal.getState().pause()
78
+ let wasCommitted = false
79
+
80
+ const applyPreview = (
81
+ polygon: Array<[number, number]>,
82
+ holes: Array<Array<[number, number]>>,
83
+ ) => {
84
+ previewRef.current = { polygon, holes }
85
+ const center = getPolygonCenter(polygon)
86
+ setCursorLocalPos([center[0], 0, center[1]])
87
+ useScene.getState().updateNode(node.id, { polygon, holes })
88
+ useScene.getState().markDirty(node.id as AnyNodeId)
89
+ }
90
+
91
+ const restoreOriginal = () => {
92
+ useScene.getState().updateNode(node.id, {
93
+ holes: originalHoles,
94
+ polygon: originalPolygon,
95
+ })
96
+ useScene.getState().markDirty(node.id as AnyNodeId)
97
+ }
98
+
99
+ const onGridMove = (event: GridEvent) => {
100
+ const [localX, localZ] = snapFenceDraftPoint({
101
+ point: [event.localPosition[0], event.localPosition[2]],
102
+ walls: levelWalls,
103
+ fences: levelFences,
104
+ })
105
+
106
+ if (
107
+ previousGridPosRef.current &&
108
+ (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
109
+ ) {
110
+ sfxEmitter.emit('sfx:grid-snap')
111
+ }
112
+ previousGridPosRef.current = [localX, localZ]
113
+
114
+ const anchor = dragAnchorRef.current ?? [localX, localZ]
115
+ dragAnchorRef.current = anchor
116
+
117
+ const deltaX = localX - anchor[0]
118
+ const deltaZ = localZ - anchor[1]
119
+
120
+ applyPreview(
121
+ translatePolygon(originalPolygon, deltaX, deltaZ),
122
+ originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
123
+ )
124
+ }
125
+
126
+ const onGridClick = (event: GridEvent) => {
127
+ if (Date.now() - activatedAtRef.current < 150) {
128
+ event.nativeEvent?.stopPropagation?.()
129
+ return
130
+ }
131
+
132
+ const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
133
+
134
+ wasCommitted = true
135
+
136
+ // Restore original baseline while paused so the next resume+update
137
+ // registers as a single tracked change (undo reverts to original).
138
+ useScene.getState().updateNode(node.id, {
139
+ polygon: originalPolygon,
140
+ holes: originalHoles,
141
+ })
142
+
143
+ useScene.temporal.getState().resume()
144
+ useScene.getState().updateNode(node.id, preview)
145
+ useScene.getState().markDirty(node.id as AnyNodeId)
146
+ useScene.temporal.getState().pause()
147
+
148
+ sfxEmitter.emit('sfx:item-place')
149
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
150
+ exitMoveMode()
151
+ event.nativeEvent?.stopPropagation?.()
152
+ }
153
+
154
+ const onCancel = () => {
155
+ restoreOriginal()
156
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
157
+ useScene.temporal.getState().resume()
158
+ markToolCancelConsumed()
159
+ exitMoveMode()
160
+ }
161
+
162
+ emitter.on('grid:move', onGridMove)
163
+ emitter.on('grid:click', onGridClick)
164
+ emitter.on('tool:cancel', onCancel)
165
+
166
+ return () => {
167
+ if (!wasCommitted) {
168
+ restoreOriginal()
169
+ }
170
+ useScene.temporal.getState().resume()
171
+ emitter.off('grid:move', onGridMove)
172
+ emitter.off('grid:click', onGridClick)
173
+ emitter.off('tool:cancel', onCancel)
174
+ }
175
+ }, [exitMoveMode, node.id])
176
+
177
+ return (
178
+ <group>
179
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
180
+ </group>
181
+ )
182
+ }
@@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
31
31
 
32
32
  return (
33
33
  <PolygonEditor
34
+ allowEdgeMove
34
35
  color="#a3a3a3"
35
36
  levelId={resolveLevelId(slab, useScene.getState().nodes)}
36
37
  minVertices={3}
@@ -36,6 +36,8 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
36
36
 
37
37
  return (
38
38
  <PolygonEditor
39
+ allowEdgeMove
40
+ allowPolygonMove
39
41
  color="#ef4444"
40
42
  levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
41
43
  minVertices={3}
@@ -0,0 +1,101 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ emitter,
5
+ type GridEvent,
6
+ type SpawnNode,
7
+ sceneRegistry,
8
+ useLiveTransforms,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { useCallback, useEffect, useState } from 'react'
12
+ import { Vector3 } from 'three'
13
+ import { sfxEmitter } from '../../../lib/sfx-bus'
14
+ import useEditor from '../../../store/use-editor'
15
+ import { CursorSphere } from '../shared/cursor-sphere'
16
+
17
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
18
+ const worldVector = new Vector3()
19
+
20
+ function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
21
+ const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
22
+ if (!levelObject) {
23
+ return [
24
+ roundToHalf(event.localPosition[0]),
25
+ event.localPosition[1],
26
+ roundToHalf(event.localPosition[2]),
27
+ ]
28
+ }
29
+
30
+ worldVector.set(event.position[0], event.position[1], event.position[2])
31
+ levelObject.updateWorldMatrix(true, false)
32
+ levelObject.worldToLocal(worldVector)
33
+
34
+ return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
35
+ }
36
+
37
+ export const MoveSpawnTool: React.FC<{
38
+ node: SpawnNode
39
+ onCommitted?: (nodeId: SpawnNode['id']) => void
40
+ }> = ({ node, onCommitted }) => {
41
+ const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
42
+
43
+ const exitMoveMode = useCallback(() => {
44
+ useEditor.getState().setMovingNode(null)
45
+ }, [])
46
+
47
+ useEffect(() => {
48
+ useScene.temporal.getState().pause()
49
+
50
+ let committed = false
51
+
52
+ const onGridMove = (event: GridEvent) => {
53
+ const nextPosition: [number, number, number] = [
54
+ roundToHalf(event.localPosition[0]),
55
+ event.localPosition[1],
56
+ roundToHalf(event.localPosition[2]),
57
+ ]
58
+ setPreviewPosition(nextPosition)
59
+ useLiveTransforms.getState().set(node.id, {
60
+ position: [...nextPosition],
61
+ rotation: node.rotation,
62
+ })
63
+ }
64
+
65
+ const onGridClick = (event: GridEvent) => {
66
+ const nextPosition = getLevelLocalSpawnPosition(node, event)
67
+
68
+ committed = true
69
+ useScene.temporal.getState().resume()
70
+ useScene.getState().updateNode(node.id, { position: nextPosition })
71
+ onCommitted?.(node.id)
72
+ useLiveTransforms.getState().clear(node.id)
73
+ sfxEmitter.emit('sfx:item-place')
74
+ exitMoveMode()
75
+ }
76
+
77
+ const onCancel = () => {
78
+ useLiveTransforms.getState().clear(node.id)
79
+ useScene.temporal.getState().resume()
80
+ exitMoveMode()
81
+ }
82
+
83
+ emitter.on('grid:move', onGridMove)
84
+ emitter.on('grid:click', onGridClick)
85
+ emitter.on('tool:cancel', onCancel)
86
+
87
+ return () => {
88
+ emitter.off('grid:move', onGridMove)
89
+ emitter.off('grid:click', onGridClick)
90
+ emitter.off('tool:cancel', onCancel)
91
+ useLiveTransforms.getState().clear(node.id)
92
+ if (!committed) {
93
+ useScene.temporal.getState().resume()
94
+ }
95
+ }
96
+ }, [exitMoveMode, node, onCommitted])
97
+
98
+ return (
99
+ <CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
100
+ )
101
+ }
@@ -0,0 +1,130 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ emitter,
5
+ type GridEvent,
6
+ type LevelNode,
7
+ SpawnNode,
8
+ type SpawnNode as SpawnNodeType,
9
+ sceneRegistry,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import type { Group } from 'three'
14
+ import { Vector3 } 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 SPAWN_ICON = (
20
+ // eslint-disable-next-line @next/next/no-img-element
21
+ <img
22
+ alt="Spawn Point"
23
+ src="/icons/site.png"
24
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
25
+ />
26
+ )
27
+
28
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
29
+ const worldVector = new Vector3()
30
+
31
+ function getExistingSpawnIds() {
32
+ const nodes = useScene.getState().nodes
33
+ return Object.values(nodes)
34
+ .filter((node) => node.type === 'spawn')
35
+ .map((node) => node.id)
36
+ .sort()
37
+ }
38
+
39
+ function getLevelLocalSpawnPosition(
40
+ levelId: LevelNode['id'],
41
+ event: GridEvent,
42
+ ): [number, number, number] {
43
+ const levelObject = sceneRegistry.nodes.get(levelId)
44
+ if (!levelObject) {
45
+ return [
46
+ roundToHalf(event.localPosition[0]),
47
+ event.localPosition[1],
48
+ roundToHalf(event.localPosition[2]),
49
+ ]
50
+ }
51
+
52
+ worldVector.set(event.position[0], event.position[1], event.position[2])
53
+ levelObject.updateWorldMatrix(true, false)
54
+ levelObject.worldToLocal(worldVector)
55
+
56
+ return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
57
+ }
58
+
59
+ type SpawnToolProps = {
60
+ currentLevelId: LevelNode['id'] | null
61
+ onPlaced?: (spawnId: SpawnNodeType['id']) => void
62
+ }
63
+
64
+ export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
65
+ const [, setCursorPosition] = useState<[number, number, number] | null>(null)
66
+ const cursorRef = useRef<Group>(null)
67
+
68
+ useEffect(() => {
69
+ if (!currentLevelId) return
70
+
71
+ const onGridMove = (event: GridEvent) => {
72
+ const nextPosition: [number, number, number] = [
73
+ roundToHalf(event.localPosition[0]),
74
+ event.localPosition[1],
75
+ roundToHalf(event.localPosition[2]),
76
+ ]
77
+ setCursorPosition(nextPosition)
78
+ cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
79
+ }
80
+
81
+ const onGridClick = (event: GridEvent) => {
82
+ const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
83
+
84
+ const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
85
+ if (existingSpawnId) {
86
+ useScene.getState().updateNode(existingSpawnId, {
87
+ parentId: currentLevelId,
88
+ position: nextPosition,
89
+ rotation: 0,
90
+ })
91
+ if (duplicateSpawnIds.length > 0) {
92
+ useScene.getState().deleteNodes(duplicateSpawnIds)
93
+ }
94
+ onPlaced?.(existingSpawnId)
95
+ } else {
96
+ const spawn = SpawnNode.parse({
97
+ name: 'Spawn Point',
98
+ position: nextPosition,
99
+ rotation: 0,
100
+ })
101
+ useScene.getState().createNode(spawn, currentLevelId)
102
+ onPlaced?.(spawn.id)
103
+ }
104
+
105
+ sfxEmitter.emit('sfx:structure-build')
106
+ useEditor.getState().setTool(null)
107
+ useEditor.getState().setMode('select')
108
+ }
109
+
110
+ emitter.on('grid:move', onGridMove)
111
+ emitter.on('grid:click', onGridClick)
112
+
113
+ return () => {
114
+ emitter.off('grid:move', onGridMove)
115
+ emitter.off('grid:click', onGridClick)
116
+ }
117
+ }, [currentLevelId, onPlaced])
118
+
119
+ if (!currentLevelId) return null
120
+
121
+ return (
122
+ <CursorSphere
123
+ color="#60a5fa"
124
+ height={2.2}
125
+ ref={cursorRef}
126
+ showTooltip
127
+ tooltipContent={SPAWN_ICON}
128
+ />
129
+ )
130
+ }
@@ -93,11 +93,21 @@ function commitStairPlacement(
93
93
  position: [0, 0, 0],
94
94
  })
95
95
 
96
+ const sortedLevels = Object.values(nodes)
97
+ .filter((node): node is LevelNode => node.type === 'level')
98
+ .sort((left, right) => left.level - right.level)
99
+ const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
100
+ const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
101
+
96
102
  const stair = StairNode.parse({
97
103
  name,
98
104
  position,
99
105
  rotation,
100
106
  stairType: DEFAULT_STAIR_TYPE,
107
+ fromLevelId: levelId,
108
+ toLevelId: nextLevelId,
109
+ slabOpeningMode: 'destination',
110
+ openingOffset: 0.08,
101
111
  width: DEFAULT_STAIR_WIDTH,
102
112
  totalRise: DEFAULT_STAIR_HEIGHT,
103
113
  stepCount: DEFAULT_STAIR_STEP_COUNT,
@@ -166,9 +176,7 @@ export const StairTool: React.FC = () => {
166
176
 
167
177
  const gridX = Math.round(event.localPosition[0] * 2) / 2
168
178
  const gridZ = Math.round(event.localPosition[2] * 2) / 2
169
- const y = event.localPosition[1]
170
-
171
- commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
179
+ commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
172
180
  }
173
181
 
174
182
  const onKeyDown = (event: KeyboardEvent) => {
@@ -10,8 +10,11 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
10
10
  import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
11
11
  import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
12
12
  import { CeilingTool } from './ceiling/ceiling-tool'
13
+ import { ColumnTool } from './column/column-tool'
13
14
  import { DoorTool } from './door/door-tool'
15
+ import { CurveFenceTool } from './fence/curve-fence-tool'
14
16
  import { FenceTool } from './fence/fence-tool'
17
+ import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
15
18
  import { ItemTool } from './item/item-tool'
16
19
  import { MoveTool } from './item/move-tool'
17
20
  import { RoofTool } from './roof/roof-tool'
@@ -19,7 +22,10 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
19
22
  import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
20
23
  import { SlabHoleEditor } from './slab/slab-hole-editor'
21
24
  import { SlabTool } from './slab/slab-tool'
25
+ import { SpawnTool } from './spawn/spawn-tool'
22
26
  import { StairTool } from './stair/stair-tool'
27
+ import { CurveWallTool } from './wall/curve-wall-tool'
28
+ import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
23
29
  import { WallTool } from './wall/wall-tool'
24
30
  import { WindowTool } from './window/window-tool'
25
31
  import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -51,10 +57,16 @@ export const ToolManager: React.FC = () => {
51
57
  const mode = useEditor((state) => state.mode)
52
58
  const tool = useEditor((state) => state.tool)
53
59
  const movingNode = useEditor((state) => state.movingNode)
60
+ const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
61
+ const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
62
+ const curvingWall = useEditor((state) => state.curvingWall)
63
+ const curvingFence = useEditor((state) => state.curvingFence)
54
64
  const editingHole = useEditor((state) => state.editingHole)
55
65
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
66
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
56
67
  const buildingId = useViewer((state) => state.selection.buildingId)
57
68
  const selectedIds = useViewer((state) => state.selection.selectedIds)
69
+ const setSelection = useViewer((state) => state.setSelection)
58
70
  const nodes = useScene((state) => state.nodes)
59
71
 
60
72
  // Building transform for the local group — all building-relative tools live inside this group
@@ -115,12 +127,15 @@ export const ToolManager: React.FC = () => {
115
127
  const showBuildTool = mode === 'build' && tool !== null
116
128
 
117
129
  const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
130
+ const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
131
+ setSelection({ selectedIds: [nodeId] })
132
+ }
118
133
 
119
134
  return (
120
135
  <>
121
136
  {showSiteBoundaryEditor && <SiteBoundaryEditor />}
122
137
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
123
- {movingNode?.type === 'building' && <MoveTool />}
138
+ {movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
124
139
 
125
140
  {/* Building-local group: all other tools are relative to the selected building.
126
141
  Cursor visuals set positions in building-local space; this group applies the
@@ -140,8 +155,20 @@ export const ToolManager: React.FC = () => {
140
155
  {showCeilingHoleEditor && selectedCeilingId && editingHole && (
141
156
  <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
142
157
  )}
143
- {movingNode && movingNode.type !== 'building' && <MoveTool />}
144
- {!movingNode && BuildToolComponent && <BuildToolComponent />}
158
+ {movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
159
+ {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
160
+ {curvingWall && <CurveWallTool node={curvingWall} />}
161
+ {curvingFence && <CurveFenceTool node={curvingFence} />}
162
+ {movingNode && movingNode.type !== 'building' && (
163
+ <MoveTool onSpawnMoved={handlePlacedNodeSelected} />
164
+ )}
165
+ {!movingNode && showBuildTool && tool === 'spawn' && (
166
+ <SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
167
+ )}
168
+ {!movingNode && showBuildTool && tool === 'column' && (
169
+ <ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
170
+ )}
171
+ {!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
145
172
  </group>
146
173
  </>
147
174
  )