@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -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
+ }
@@ -10,6 +10,7 @@ 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'
14
15
  import { CurveFenceTool } from './fence/curve-fence-tool'
15
16
  import { FenceTool } from './fence/fence-tool'
@@ -21,6 +22,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
21
22
  import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
22
23
  import { SlabHoleEditor } from './slab/slab-hole-editor'
23
24
  import { SlabTool } from './slab/slab-tool'
25
+ import { SpawnTool } from './spawn/spawn-tool'
24
26
  import { StairTool } from './stair/stair-tool'
25
27
  import { CurveWallTool } from './wall/curve-wall-tool'
26
28
  import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
@@ -43,6 +45,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
43
45
  door: DoorTool,
44
46
  item: ItemTool,
45
47
  zone: ZoneTool,
48
+ spawn: SpawnTool,
46
49
  window: WindowTool,
47
50
  },
48
51
  furnish: {
@@ -61,8 +64,9 @@ export const ToolManager: React.FC = () => {
61
64
  const curvingFence = useEditor((state) => state.curvingFence)
62
65
  const editingHole = useEditor((state) => state.editingHole)
63
66
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
64
- const buildingId = useViewer((state) => state.selection.buildingId)
65
67
  const selectedIds = useViewer((state) => state.selection.selectedIds)
68
+ const buildingId = useViewer((state) => state.selection.buildingId)
69
+ const activeLevelId = useViewer((state) => state.selection.levelId)
66
70
  const nodes = useScene((state) => state.nodes)
67
71
 
68
72
  // Building transform for the local group — all building-relative tools live inside this group
@@ -123,12 +127,15 @@ export const ToolManager: React.FC = () => {
123
127
  const showBuildTool = mode === 'build' && tool !== null
124
128
 
125
129
  const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
130
+ const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
131
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
132
+ }
126
133
 
127
134
  return (
128
135
  <>
129
- {showSiteBoundaryEditor && <SiteBoundaryEditor />}
130
136
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
131
- {movingNode?.type === 'building' && <MoveTool />}
137
+ {showSiteBoundaryEditor && <SiteBoundaryEditor />}
138
+ {movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
132
139
 
133
140
  {/* Building-local group: all other tools are relative to the selected building.
134
141
  Cursor visuals set positions in building-local space; this group applies the
@@ -152,8 +159,16 @@ export const ToolManager: React.FC = () => {
152
159
  {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
153
160
  {curvingWall && <CurveWallTool node={curvingWall} />}
154
161
  {curvingFence && <CurveFenceTool node={curvingFence} />}
155
- {movingNode && movingNode.type !== 'building' && <MoveTool />}
156
- {!movingNode && BuildToolComponent && <BuildToolComponent />}
162
+ {movingNode && movingNode.type !== 'building' && (
163
+ <MoveTool onSpawnMoved={handlePlacedNodeSelected} />
164
+ )}
165
+ {!movingNode && BuildToolComponent && tool === 'spawn' ? (
166
+ <SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
167
+ ) : !movingNode && showBuildTool && tool === 'column' ? (
168
+ <ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
169
+ ) : !movingNode && BuildToolComponent && tool !== 'column' ? (
170
+ <BuildToolComponent />
171
+ ) : null}
157
172
  </group>
158
173
  </>
159
174
  )
@@ -81,15 +81,17 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
81
81
  ? event.localPosition[2]
82
82
  : snapScalarToGrid(event.localPosition[2], snapStep)
83
83
 
84
- const offsetFromMidpoint =
85
- -(
86
- (localX - chord.midpoint.x) * chord.normal.x +
87
- (localZ - chord.midpoint.y) * chord.normal.y
88
- )
84
+ const offsetFromMidpoint = -(
85
+ (localX - chord.midpoint.x) * chord.normal.x +
86
+ (localZ - chord.midpoint.y) * chord.normal.y
87
+ )
89
88
  const snappedOffset = shiftPressedRef.current
90
89
  ? offsetFromMidpoint
91
90
  : snapScalarToGrid(offsetFromMidpoint, snapStep)
92
- const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
91
+ const nextCurveOffset = normalizeWallCurveOffset(
92
+ node,
93
+ Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
94
+ )
93
95
 
94
96
  if (
95
97
  previousCurveOffsetRef.current !== null &&
@@ -9,27 +9,87 @@ import {
9
9
  useScene,
10
10
  type WallNode,
11
11
  } from '@pascal-app/core'
12
- import { Html } from '@react-three/drei'
13
12
  import { useViewer } from '@pascal-app/viewer'
13
+ import { Html } from '@react-three/drei'
14
14
  import { useCallback, useEffect, useRef, useState } from 'react'
15
15
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
16
16
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
17
  import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
18
18
  import { CursorSphere } from '../shared/cursor-sphere'
19
19
  import {
20
- isWallLongEnough,
21
- snapWallDraftPoint,
22
- type WallPlanPoint,
23
- } from './wall-drafting'
20
+ formatAngleRadians,
21
+ getAngleToSegmentReference,
22
+ getSegmentAngleReferenceAtPoint,
23
+ } from '../shared/segment-angle'
24
+ import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
24
25
 
25
26
  function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
26
27
  return a[0] === b[0] && a[1] === b[1]
27
28
  }
28
29
 
30
+ type WallSegmentLike = {
31
+ id: WallNode['id']
32
+ start: WallPlanPoint
33
+ end: WallPlanPoint
34
+ curveOffset?: number
35
+ }
36
+
37
+ type AngleLabelState = {
38
+ label: string
39
+ position: [number, number, number]
40
+ } | null
41
+
42
+ function getEndpointAngleLabel(args: {
43
+ preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
44
+ walls: WallSegmentLike[]
45
+ nodeId: WallNode['id']
46
+ }): AngleLabelState {
47
+ const { preview, walls, nodeId } = args
48
+ const endpoints = [
49
+ {
50
+ point: preview.start,
51
+ },
52
+ {
53
+ point: preview.end,
54
+ },
55
+ ]
56
+ const targetSegment: WallSegmentLike = {
57
+ id: nodeId,
58
+ start: preview.start,
59
+ end: preview.end,
60
+ curveOffset: preview.curveOffset,
61
+ }
62
+
63
+ for (const endpoint of endpoints) {
64
+ const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
65
+ if (!targetReference) continue
66
+
67
+ const connectedWall = walls.find(
68
+ (wall) =>
69
+ wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
70
+ )
71
+ if (!connectedWall) continue
72
+
73
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
74
+ if (!connectedReference) continue
75
+
76
+ const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
77
+ if (angle === null) continue
78
+
79
+ return {
80
+ label: formatAngleRadians(angle),
81
+ position: [endpoint.point[0], 0.34, endpoint.point[1]],
82
+ }
83
+ }
84
+
85
+ return null
86
+ }
87
+
29
88
  type LinkedWallSnapshot = {
30
89
  id: WallNode['id']
31
90
  start: WallPlanPoint
32
91
  end: WallPlanPoint
92
+ curveOffset?: number
33
93
  }
34
94
 
35
95
  function getLinkedWallSnapshots(args: {
@@ -52,10 +112,12 @@ function getLinkedWallSnapshots(args: {
52
112
  }
53
113
 
54
114
  if (
55
- !samePoint(node.start, originalStart) &&
56
- !samePoint(node.start, originalEnd) &&
57
- !samePoint(node.end, originalStart) &&
58
- !samePoint(node.end, originalEnd)
115
+ !(
116
+ samePoint(node.start, originalStart) ||
117
+ samePoint(node.start, originalEnd) ||
118
+ samePoint(node.end, originalStart) ||
119
+ samePoint(node.end, originalEnd)
120
+ )
59
121
  ) {
60
122
  continue
61
123
  }
@@ -64,6 +126,7 @@ function getLinkedWallSnapshots(args: {
64
126
  id: node.id,
65
127
  start: [...node.start] as WallPlanPoint,
66
128
  end: [...node.end] as WallPlanPoint,
129
+ curveOffset: node.curveOffset,
67
130
  })
68
131
  }
69
132
 
@@ -79,6 +142,7 @@ function getLinkedWallUpdates(
79
142
  ) {
80
143
  return linkedWalls.map((wall) => ({
81
144
  id: wall.id,
145
+ curveOffset: wall.curveOffset,
82
146
  start: samePoint(wall.start, originalStart)
83
147
  ? nextStart
84
148
  : samePoint(wall.start, originalEnd)
@@ -114,6 +178,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
114
178
  }),
115
179
  )
116
180
  const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
181
+ const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
117
182
 
118
183
  const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
119
184
  const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
@@ -155,24 +220,43 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
155
220
  const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
156
221
  const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
157
222
  const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
223
+ const linkedUpdates = detachLinkedWalls
224
+ ? []
225
+ : getLinkedWallUpdates(
226
+ linkedOriginalsRef.current,
227
+ originalStart,
228
+ originalEnd,
229
+ nextStart,
230
+ nextEnd,
231
+ )
158
232
  previewRef.current = { start: nextStart, end: nextEnd }
159
233
  setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
160
- applyNodePreview([
161
- { id: nodeId, start: nextStart, end: nextEnd },
162
- ...(detachLinkedWalls
163
- ? []
164
- : getLinkedWallUpdates(
165
- linkedOriginalsRef.current,
166
- originalStart,
167
- originalEnd,
168
- nextStart,
169
- nextEnd,
170
- )),
171
- ])
234
+ setAngleLabel(
235
+ getEndpointAngleLabel({
236
+ preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
237
+ walls: [
238
+ ...levelWalls.map((wall) => ({
239
+ id: wall.id,
240
+ start: wall.start,
241
+ end: wall.end,
242
+ curveOffset: wall.curveOffset,
243
+ })),
244
+ ...linkedUpdates,
245
+ ],
246
+ nodeId,
247
+ }),
248
+ )
249
+ applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
172
250
  }
173
251
 
174
- const restoreOriginal = () => {
175
- applyNodePreview([{ id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current])
252
+ const restoreOriginal = (clearAngleLabel = true) => {
253
+ applyNodePreview([
254
+ { id: nodeId, start: originalStart, end: originalEnd },
255
+ ...linkedOriginalsRef.current,
256
+ ])
257
+ if (clearAngleLabel) {
258
+ setAngleLabel(null)
259
+ }
176
260
  }
177
261
 
178
262
  const onGridMove = (event: GridEvent) => {
@@ -204,8 +288,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
204
288
  }
205
289
 
206
290
  const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
207
- const hasChanged =
208
- !samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
291
+ const hasChanged = !(
292
+ samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
293
+ )
209
294
 
210
295
  if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
211
296
  wasCommitted = true
@@ -235,6 +320,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
235
320
  }
236
321
 
237
322
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
323
+ setAngleLabel(null)
238
324
  exitMoveMode()
239
325
  event.nativeEvent?.stopPropagation?.()
240
326
  }
@@ -243,6 +329,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
243
329
  restoreOriginal()
244
330
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
245
331
  resumeSceneHistory(useScene)
332
+ setAngleLabel(null)
246
333
  markToolCancelConsumed()
247
334
  exitMoveMode()
248
335
  }
@@ -285,7 +372,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
285
372
 
286
373
  return () => {
287
374
  if (!wasCommitted) {
288
- restoreOriginal()
375
+ restoreOriginal(false)
289
376
  }
290
377
  resumeSceneHistory(useScene)
291
378
  emitter.off('grid:move', onGridMove)
@@ -307,7 +394,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
307
394
  >
308
395
  <div className="translate-y-10">
309
396
  <div
310
- className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
397
+ className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
311
398
  altPressed
312
399
  ? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
313
400
  : 'border-border bg-background/95 text-muted-foreground'
@@ -317,6 +404,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
317
404
  </div>
318
405
  </div>
319
406
  </Html>
407
+ {angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
320
408
  </group>
321
409
  )
322
410
  }
411
+
412
+ function EndpointAngleLabel({
413
+ label,
414
+ position,
415
+ }: {
416
+ label: string
417
+ position: [number, number, number]
418
+ }) {
419
+ return (
420
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
421
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
422
+ {label}
423
+ </div>
424
+ </Html>
425
+ )
426
+ }
@@ -63,10 +63,12 @@ function getLinkedWallSnapshots(args: {
63
63
  }
64
64
 
65
65
  if (
66
- !samePoint(node.start, originalStart) &&
67
- !samePoint(node.start, originalEnd) &&
68
- !samePoint(node.end, originalStart) &&
69
- !samePoint(node.end, originalEnd)
66
+ !(
67
+ samePoint(node.start, originalStart) ||
68
+ samePoint(node.start, originalEnd) ||
69
+ samePoint(node.end, originalStart) ||
70
+ samePoint(node.end, originalEnd)
71
+ )
70
72
  ) {
71
73
  continue
72
74
  }
@@ -3,7 +3,10 @@ import {
3
3
  type AnyNodeId,
4
4
  type DoorNode,
5
5
  getScaledDimensions,
6
+ getWallCurveFrameAt,
7
+ getWallCurveLength,
6
8
  type ItemNode,
9
+ isCurvedWall,
7
10
  useScene,
8
11
  type WallNode,
9
12
  WallNode as WallSchema,
@@ -62,10 +65,10 @@ export function snapPointTo45Degrees(
62
65
  const snappedAngle = Math.round(angle / angleStep) * angleStep
63
66
  const distance = Math.sqrt(dx * dx + dz * dz)
64
67
 
65
- return snapPointToGrid([
66
- start[0] + Math.cos(snappedAngle) * distance,
67
- start[1] + Math.sin(snappedAngle) * distance,
68
- ], step)
68
+ return snapPointToGrid(
69
+ [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
70
+ step,
71
+ )
69
72
  }
70
73
 
71
74
  export function getWallAngleSnapStep(step = getWallGridStep()): number {
@@ -336,11 +339,17 @@ export function findWallSnapTarget(
336
339
  continue
337
340
  }
338
341
 
339
- const candidates: Array<WallPlanPoint | null> = [
340
- wall.start,
341
- wall.end,
342
- projectPointOntoWall(point, wall),
343
- ]
342
+ const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
343
+
344
+ if (isCurvedWall(wall)) {
345
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
346
+ for (let index = 0; index <= sampleCount; index += 1) {
347
+ const frame = getWallCurveFrameAt(wall, index / sampleCount)
348
+ candidates.push([frame.point.x, frame.point.y])
349
+ }
350
+ } else {
351
+ candidates.push(projectPointOntoWall(point, wall))
352
+ }
344
353
  for (const candidate of candidates) {
345
354
  if (!candidate) {
346
355
  continue