@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
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
75
75
  ceiling: CeilingNode
76
76
  levelId: string
77
77
  }) => {
78
- const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
78
+ const [levelObject, setLevelObject] = useState<Object3D | null>(
79
+ () => sceneRegistry.nodes.get(levelId) ?? null,
80
+ )
79
81
 
80
82
  const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
81
83
 
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
110
112
  return createPortal(
111
113
  <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
112
114
  {corners.map((corner, index) => (
113
- <CornerBracket
114
- ceiling={ceiling}
115
- corner={corner}
116
- key={`${ceiling.id}-corner-${index}`}
117
- />
115
+ <CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
118
116
  ))}
119
117
  </group>,
120
118
  levelObject,
@@ -210,11 +208,7 @@ const BracketLeg = ({
210
208
  ]
211
209
 
212
210
  return (
213
- <mesh
214
- onClick={onClick}
215
- position={position}
216
- rotation={[0, angle, 0]}
217
- >
211
+ <mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
218
212
  <boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
219
213
  <meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
220
214
  </mesh>
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
234
228
 
235
229
  const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
236
230
  const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
237
- const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
231
+ const cornerStrength =
232
+ 1 -
233
+ Math.abs(
234
+ incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
235
+ )
238
236
 
239
237
  return {
240
238
  corner,
@@ -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-segment is selected:
9
+ * When a roof (or one of its segments) 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
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
68
68
  const segmentsWrapper = group.getObjectByName('segments-wrapper')
69
69
  const isActive = activeStairIds.has(stairId)
70
70
 
71
- if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
71
+ if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
72
72
  if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
73
73
 
74
74
  if (stairNode?.children?.length) {
File without changes
@@ -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,7 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
36
36
 
37
37
  return (
38
38
  <PolygonEditor
39
+ allowEdgeMove
39
40
  allowPolygonMove
40
41
  color="#ef4444"
41
42
  levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
@@ -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]
@@ -1,13 +1,19 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
3
+ import {
4
+ type AnyNodeId,
5
+ type CeilingNode,
6
+ emitter,
7
+ type GridEvent,
8
+ useScene,
9
+ } from '@pascal-app/core'
4
10
  import { useViewer } from '@pascal-app/viewer'
5
11
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
+ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
6
13
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
14
  import { sfxEmitter } from '../../../lib/sfx-bus'
8
15
  import useEditor from '../../../store/use-editor'
9
16
  import { CursorSphere } from '../shared/cursor-sphere'
10
- import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
11
17
 
12
18
  function snap(value: number) {
13
19
  return Math.round(value * 2) / 2
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
202
208
  transparent
203
209
  />
204
210
  </mesh>
211
+ {/* @ts-ignore */}
205
212
  <line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
206
213
  <lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
207
214
  </line>
@@ -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
+ }
@@ -0,0 +1,105 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ ColumnNode,
6
+ type ColumnNode as ColumnNodeType,
7
+ emitter,
8
+ type GridEvent,
9
+ sceneRegistry,
10
+ useLiveTransforms,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useCallback, useEffect, useState } from 'react'
14
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import { CursorSphere } from '../shared/cursor-sphere'
18
+
19
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
20
+
21
+ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
22
+ const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
23
+
24
+ const exitMoveMode = useCallback(() => {
25
+ useEditor.getState().setMovingNode(null)
26
+ }, [])
27
+
28
+ useEffect(() => {
29
+ useScene.temporal.getState().pause()
30
+ let committed = false
31
+
32
+ const applyPreview = (position: [number, number, number]) => {
33
+ setPreviewPosition(position)
34
+ useLiveTransforms.getState().set(node.id, {
35
+ position,
36
+ rotation: node.rotation,
37
+ })
38
+ sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
39
+ }
40
+
41
+ const onGridMove = (event: GridEvent) => {
42
+ applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
43
+ }
44
+
45
+ const onGridClick = (event: GridEvent) => {
46
+ const position: [number, number, number] = [
47
+ roundToHalf(event.localPosition[0]),
48
+ 0,
49
+ roundToHalf(event.localPosition[2]),
50
+ ]
51
+ const nodeId = (node as { id?: ColumnNodeType['id'] }).id
52
+
53
+ if (nodeId && useScene.getState().nodes[nodeId]) {
54
+ committed = true
55
+ useLiveTransforms.getState().clear(nodeId)
56
+ useScene.temporal.getState().resume()
57
+ useScene.getState().updateNode(nodeId, { position })
58
+ } else if (node.parentId) {
59
+ const column = ColumnNode.parse({
60
+ ...node,
61
+ id: undefined,
62
+ metadata: {},
63
+ position,
64
+ })
65
+ committed = true
66
+ useScene.temporal.getState().resume()
67
+ useScene.getState().createNode(column, node.parentId as AnyNodeId)
68
+ }
69
+
70
+ useLiveTransforms.getState().clear(node.id)
71
+ sfxEmitter.emit('sfx:item-place')
72
+ exitMoveMode()
73
+ event.nativeEvent?.stopPropagation?.()
74
+ }
75
+
76
+ const onCancel = () => {
77
+ useLiveTransforms.getState().clear(node.id)
78
+ sceneRegistry.nodes
79
+ .get(node.id)
80
+ ?.position.set(node.position[0], node.position[1], node.position[2])
81
+ useScene.temporal.getState().resume()
82
+ markToolCancelConsumed()
83
+ exitMoveMode()
84
+ }
85
+
86
+ emitter.on('grid:move', onGridMove)
87
+ emitter.on('grid:click', onGridClick)
88
+ emitter.on('tool:cancel', onCancel)
89
+
90
+ return () => {
91
+ emitter.off('grid:move', onGridMove)
92
+ emitter.off('grid:click', onGridClick)
93
+ emitter.off('tool:cancel', onCancel)
94
+ useLiveTransforms.getState().clear(node.id)
95
+ if (!committed) {
96
+ sceneRegistry.nodes
97
+ .get(node.id)
98
+ ?.position.set(node.position[0], node.position[1], node.position[2])
99
+ useScene.temporal.getState().resume()
100
+ }
101
+ }
102
+ }, [exitMoveMode, node])
103
+
104
+ return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
105
+ }
@@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => {
248
248
  parentId: event.node.id,
249
249
  width: draft.width,
250
250
  height: draft.height,
251
+ doorCategory: draft.doorCategory,
252
+ doorType: draft.doorType,
253
+ leafCount: draft.leafCount,
254
+ operationState: draft.operationState,
255
+ slideDirection: draft.slideDirection,
256
+ trackStyle: draft.trackStyle,
257
+ garagePanelCount: draft.garagePanelCount,
251
258
  frameThickness: draft.frameThickness,
252
259
  frameDepth: draft.frameDepth,
253
260
  threshold: draft.threshold,
@@ -5,6 +5,7 @@ import {
5
5
  isCurvedWall,
6
6
  sceneRegistry,
7
7
  spatialGridManager,
8
+ useLiveTransforms,
8
9
  useScene,
9
10
  type WallEvent,
10
11
  } from '@pascal-app/core'
@@ -97,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
97
98
  edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
98
99
  }
99
100
 
101
+ const getPlacementOrientation = (event: WallEvent) => {
102
+ const faceSide = getSideFromNormal(event.normal)
103
+ const side = movingDoorNode.side ?? faceSide
104
+ const rotationOffset = side !== faceSide ? Math.PI : 0
105
+ return {
106
+ side,
107
+ itemRotation: calculateItemRotation(event.normal) + rotationOffset,
108
+ cursorRotation:
109
+ calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
110
+ }
111
+ }
112
+
100
113
  const onWallEnter = (event: WallEvent) => {
101
114
  if (!isValidWallSideFace(event.normal)) return
102
115
  if (isCurvedWall(event.node)) {
@@ -105,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
105
118
  }
106
119
  if (event.node.parentId !== getLevelId()) return
107
120
 
108
- const side = getSideFromNormal(event.normal)
109
- const itemRotation = calculateItemRotation(event.normal)
110
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
121
+ const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
111
122
 
112
123
  const localX = snapToHalf(event.localPosition[0])
113
124
  const { clampedX, clampedY } = clampToWall(
@@ -127,6 +138,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
127
138
  parentId: event.node.id,
128
139
  wallId: event.node.id,
129
140
  })
141
+ useLiveTransforms.getState().set(movingDoorNode.id, {
142
+ position: [clampedX, clampedY, 0],
143
+ rotation: itemRotation,
144
+ })
130
145
 
131
146
  if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
132
147
  markWallDirty(event.node.id)
@@ -162,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
162
177
  }
163
178
  if (event.node.parentId !== getLevelId()) return
164
179
 
165
- const side = getSideFromNormal(event.normal)
166
- const itemRotation = calculateItemRotation(event.normal)
167
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
180
+ const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
168
181
 
169
182
  const localX = snapToHalf(event.localPosition[0])
170
183
  const { clampedX, clampedY } = clampToWall(
@@ -195,6 +208,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
195
208
  doorMesh.updateMatrixWorld(true)
196
209
  }
197
210
  }
211
+ useLiveTransforms.getState().set(movingDoorNode.id, {
212
+ position: [clampedX, clampedY, 0],
213
+ rotation: itemRotation,
214
+ })
198
215
  markWallDirty(event.node.id)
199
216
 
200
217
  const valid = !hasWallChildOverlap(
@@ -225,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
225
242
  if (isCurvedWall(event.node)) return
226
243
  if (event.node.parentId !== getLevelId()) return
227
244
 
228
- const side = getSideFromNormal(event.normal)
229
- const itemRotation = calculateItemRotation(event.normal)
245
+ const { side, itemRotation } = getPlacementOrientation(event)
230
246
 
231
247
  const localX = snapToHalf(event.localPosition[0])
232
248
  const { clampedX, clampedY } = clampToWall(
@@ -291,6 +307,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
291
307
  }
292
308
 
293
309
  markWallDirty(event.node.id)
310
+ useLiveTransforms.getState().clear(movingDoorNode.id)
294
311
  useScene.temporal.getState().pause()
295
312
 
296
313
  sfxEmitter.emit('sfx:item-place')
@@ -302,6 +319,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
302
319
 
303
320
  const onWallLeave = () => {
304
321
  hideCursor()
322
+ useLiveTransforms.getState().clear(movingDoorNode.id)
305
323
  if (isNew) return
306
324
  if (currentWallId && currentWallId !== original.parentId) {
307
325
  markWallDirty(currentWallId)
@@ -318,6 +336,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
318
336
  }
319
337
 
320
338
  const onCancel = () => {
339
+ useLiveTransforms.getState().clear(movingDoorNode.id)
321
340
  if (isNew) {
322
341
  useScene.getState().deleteNode(movingDoorNode.id)
323
342
  if (currentWallId) markWallDirty(currentWallId)
@@ -364,6 +383,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
364
383
  if (original.parentId) markWallDirty(original.parentId)
365
384
  }
366
385
  }
386
+ useLiveTransforms.getState().clear(movingDoorNode.id)
367
387
  useScene.temporal.getState().resume()
368
388
  emitter.off('wall:enter', onWallEnter)
369
389
  emitter.off('wall:move', onWallMove)
@@ -83,11 +83,10 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
83
83
  ? event.localPosition[2]
84
84
  : snapScalarToGrid(event.localPosition[2], snapStep)
85
85
 
86
- const offsetFromMidpoint =
87
- -(
88
- (localX - chord.midpoint.x) * chord.normal.x +
89
- (localZ - chord.midpoint.y) * chord.normal.y
90
- )
86
+ const offsetFromMidpoint = -(
87
+ (localX - chord.midpoint.x) * chord.normal.x +
88
+ (localZ - chord.midpoint.y) * chord.normal.y
89
+ )
91
90
  const snappedOffset = shiftPressedRef.current
92
91
  ? offsetFromMidpoint
93
92
  : snapScalarToGrid(offsetFromMidpoint, snapStep)
@@ -1,14 +1,21 @@
1
- import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
1
+ import {
2
+ FenceNode,
3
+ getWallCurveFrameAt,
4
+ getWallCurveLength,
5
+ isCurvedWall,
6
+ useScene,
7
+ type WallNode,
8
+ } from '@pascal-app/core'
2
9
  import { useViewer } from '@pascal-app/viewer'
3
10
  import { sfxEmitter } from '../../../lib/sfx-bus'
4
11
  import {
12
+ findWallSnapTarget,
5
13
  getWallAngleSnapStep,
6
14
  getWallGridStep,
7
- type WallPlanPoint,
8
- findWallSnapTarget,
9
15
  isWallLongEnough,
10
16
  snapPointTo45Degrees,
11
17
  snapPointToGrid,
18
+ type WallPlanPoint,
12
19
  } from '../wall/wall-drafting'
13
20
 
14
21
  export type FencePlanPoint = WallPlanPoint