@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
@@ -1,14 +1,100 @@
1
1
  import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
- import { useEffect, useRef } from 'react'
3
+ import { Html } from '@react-three/drei'
4
+ import { useEffect, useRef, useState } from 'react'
4
5
  import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
5
6
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
6
7
  import { EDITOR_LAYER } from '../../../lib/constants'
7
8
  import { sfxEmitter } from '../../../lib/sfx-bus'
8
9
  import { CursorSphere } from '../shared/cursor-sphere'
10
+ import {
11
+ formatAngleRadians,
12
+ getAngleToSegmentReference,
13
+ getSegmentAngleReferenceAtPoint,
14
+ } from '../shared/segment-angle'
9
15
  import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
10
16
 
11
17
  const WALL_HEIGHT = 2.5
18
+ const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
19
+ const DRAFT_ANGLE_LABEL_Y = 0.28
20
+
21
+ type DraftAngleLabel = {
22
+ id: string
23
+ label: string
24
+ position: [number, number, number]
25
+ }
26
+
27
+ type DraftMeasurementState = {
28
+ lengthLabel: string
29
+ lengthPosition: [number, number, number]
30
+ angleLabels: DraftAngleLabel[]
31
+ } | null
32
+
33
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
34
+ if (unit === 'imperial') {
35
+ const feet = value * 3.280_84
36
+ const wholeFeet = Math.floor(feet)
37
+ const inches = Math.round((feet - wholeFeet) * 12)
38
+ if (inches === 12) return `${wholeFeet + 1}'0"`
39
+ return `${wholeFeet}'${inches}"`
40
+ }
41
+
42
+ return `${Number.parseFloat(value.toFixed(2))}m`
43
+ }
44
+
45
+ function getDraftAngleLabels(
46
+ start: WallPlanPoint,
47
+ end: WallPlanPoint,
48
+ walls: WallNode[],
49
+ ): DraftAngleLabel[] {
50
+ const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
51
+ const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
52
+ const endpoints = [
53
+ { id: 'start', point: start, draftVector: draftFromStart },
54
+ { id: 'end', point: end, draftVector: draftFromEnd },
55
+ ]
56
+ const labels: DraftAngleLabel[] = []
57
+
58
+ for (const endpoint of endpoints) {
59
+ const connectedWall = walls.find((wall) =>
60
+ Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
61
+ )
62
+ if (!connectedWall) continue
63
+
64
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
65
+ if (!connectedReference) continue
66
+
67
+ const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
68
+ if (angle === null) continue
69
+
70
+ labels.push({
71
+ id: endpoint.id,
72
+ label: formatAngleRadians(angle),
73
+ position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
74
+ })
75
+ }
76
+
77
+ return labels
78
+ }
79
+
80
+ function getDraftMeasurementState(
81
+ start: WallPlanPoint,
82
+ end: WallPlanPoint,
83
+ walls: WallNode[],
84
+ unit: 'metric' | 'imperial',
85
+ ): DraftMeasurementState {
86
+ const dx = end[0] - start[0]
87
+ const dz = end[1] - start[1]
88
+ const length = Math.hypot(dx, dz)
89
+
90
+ if (length < 0.01) return null
91
+
92
+ return {
93
+ lengthLabel: formatMeasurement(length, unit),
94
+ lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
95
+ angleLabels: getDraftAngleLabels(start, end, walls),
96
+ }
97
+ }
12
98
 
13
99
  /**
14
100
  * Update wall preview mesh geometry to create a vertical plane between two points
@@ -67,19 +153,21 @@ const getCurrentLevelWalls = (): WallNode[] => {
67
153
  }
68
154
 
69
155
  export const WallTool: React.FC = () => {
156
+ const unit = useViewer((state) => state.unit)
70
157
  const cursorRef = useRef<Group>(null)
71
158
  const wallPreviewRef = useRef<Mesh>(null!)
159
+ // All positions are building-local: this tool is inside the ToolManager building group,
160
+ // so local coords are used for both data and visual positioning.
72
161
  const startingPoint = useRef(new Vector3(0, 0, 0))
73
162
  const endingPoint = useRef(new Vector3(0, 0, 0))
74
163
  const buildingState = useRef(0)
75
164
  const shiftPressed = useRef(false)
165
+ const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
76
166
 
77
167
  useEffect(() => {
78
168
  let gridPosition: WallPlanPoint = [0, 0]
79
169
  let previousWallEnd: [number, number] | null = null
80
170
 
81
- // All positions are building-local: this tool is inside the ToolManager building group,
82
- // so local coords are used for both data and visual positioning.
83
171
  const onGridMove = (event: GridEvent) => {
84
172
  if (!(cursorRef.current && wallPreviewRef.current)) return
85
173
 
@@ -109,9 +197,18 @@ export const WallTool: React.FC = () => {
109
197
  previousWallEnd = currentWallEnd
110
198
 
111
199
  updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
200
+ setDraftMeasurement(
201
+ getDraftMeasurementState(
202
+ [startingPoint.current.x, startingPoint.current.z],
203
+ snappedLocal,
204
+ walls,
205
+ unit,
206
+ ),
207
+ )
112
208
  } else {
113
209
  // Not drawing a wall yet, show the snapped anchor point.
114
210
  cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
211
+ setDraftMeasurement(null)
115
212
  }
116
213
  }
117
214
 
@@ -126,6 +223,7 @@ export const WallTool: React.FC = () => {
126
223
  endingPoint.current.copy(startingPoint.current)
127
224
  buildingState.current = 1
128
225
  wallPreviewRef.current.visible = true
226
+ setDraftMeasurement(null)
129
227
  } else if (buildingState.current === 1) {
130
228
  const snappedEnd = snapWallDraftPoint({
131
229
  point: localClick,
@@ -140,6 +238,7 @@ export const WallTool: React.FC = () => {
140
238
  createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
141
239
  wallPreviewRef.current.visible = false
142
240
  buildingState.current = 0
241
+ setDraftMeasurement(null)
143
242
  }
144
243
  }
145
244
 
@@ -160,6 +259,7 @@ export const WallTool: React.FC = () => {
160
259
  markToolCancelConsumed()
161
260
  buildingState.current = 0
162
261
  wallPreviewRef.current.visible = false
262
+ setDraftMeasurement(null)
163
263
  }
164
264
  }
165
265
 
@@ -176,7 +276,7 @@ export const WallTool: React.FC = () => {
176
276
  window.removeEventListener('keydown', onKeyDown)
177
277
  window.removeEventListener('keyup', onKeyUp)
178
278
  }
179
- }, [])
279
+ }, [unit])
180
280
 
181
281
  return (
182
282
  <group>
@@ -195,6 +295,38 @@ export const WallTool: React.FC = () => {
195
295
  transparent
196
296
  />
197
297
  </mesh>
298
+
299
+ {draftMeasurement && (
300
+ <>
301
+ <DraftMeasurementLabel
302
+ label={draftMeasurement.lengthLabel}
303
+ position={draftMeasurement.lengthPosition}
304
+ />
305
+ {draftMeasurement.angleLabels.map((angleLabel) => (
306
+ <DraftMeasurementLabel
307
+ key={angleLabel.id}
308
+ label={angleLabel.label}
309
+ position={angleLabel.position}
310
+ />
311
+ ))}
312
+ </>
313
+ )}
198
314
  </group>
199
315
  )
200
316
  }
317
+
318
+ function DraftMeasurementLabel({
319
+ label,
320
+ position,
321
+ }: {
322
+ label: string
323
+ position: [number, number, number]
324
+ }) {
325
+ return (
326
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
327
+ <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">
328
+ {label}
329
+ </div>
330
+ </Html>
331
+ )
332
+ }
@@ -4,6 +4,7 @@ import {
4
4
  isCurvedWall,
5
5
  sceneRegistry,
6
6
  spatialGridManager,
7
+ useLiveTransforms,
7
8
  useScene,
8
9
  type WallEvent,
9
10
  WindowNode,
@@ -144,6 +145,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
144
145
  parentId: event.node.id,
145
146
  wallId: event.node.id,
146
147
  })
148
+ useLiveTransforms.getState().set(movingWindowNode.id, {
149
+ position: [clampedX, clampedY, 0],
150
+ rotation: itemRotation,
151
+ })
147
152
 
148
153
  if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
149
154
  markWallDirty(event.node.id)
@@ -215,6 +220,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
215
220
  windowMesh.updateMatrixWorld(true)
216
221
  }
217
222
  }
223
+ useLiveTransforms.getState().set(movingWindowNode.id, {
224
+ position: [clampedX, clampedY, 0],
225
+ rotation: itemRotation,
226
+ })
218
227
  markWallDirty(event.node.id)
219
228
 
220
229
  const valid = !hasWallChildOverlap(
@@ -285,6 +294,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
285
294
  parentId: event.node.id,
286
295
  width: movingWindowNode.width,
287
296
  height: movingWindowNode.height,
297
+ windowType: movingWindowNode.windowType,
298
+ operationState: movingWindowNode.operationState,
299
+ awningDirection: movingWindowNode.awningDirection,
300
+ casementStyle: movingWindowNode.casementStyle,
301
+ hingesSide: movingWindowNode.hingesSide,
288
302
  frameThickness: movingWindowNode.frameThickness,
289
303
  frameDepth: movingWindowNode.frameDepth,
290
304
  columnRatios: movingWindowNode.columnRatios,
@@ -326,6 +340,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
326
340
  }
327
341
 
328
342
  markWallDirty(event.node.id)
343
+ useLiveTransforms.getState().clear(movingWindowNode.id)
329
344
  useScene.temporal.getState().pause()
330
345
 
331
346
  sfxEmitter.emit('sfx:item-place')
@@ -337,6 +352,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
337
352
 
338
353
  const onWallLeave = () => {
339
354
  hideCursor()
355
+ useLiveTransforms.getState().clear(movingWindowNode.id)
340
356
  if (isNew) return // No original to restore for duplicates
341
357
  // Move mode: restore to original position while off-wall
342
358
  if (currentWallId && currentWallId !== original.parentId) {
@@ -354,6 +370,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
354
370
  }
355
371
 
356
372
  const onCancel = () => {
373
+ useLiveTransforms.getState().clear(movingWindowNode.id)
357
374
  if (isNew) {
358
375
  useScene.getState().deleteNode(movingWindowNode.id)
359
376
  if (currentWallId) markWallDirty(currentWallId)
@@ -401,6 +418,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
401
418
  if (original.parentId) markWallDirty(original.parentId)
402
419
  }
403
420
  }
421
+ useLiveTransforms.getState().clear(movingWindowNode.id)
404
422
  useScene.temporal.getState().resume()
405
423
  emitter.off('wall:enter', onWallEnter)
406
424
  emitter.off('wall:move', onWallMove)
@@ -262,6 +262,11 @@ export const WindowTool: React.FC = () => {
262
262
  parentId: event.node.id,
263
263
  width: draft.width,
264
264
  height: draft.height,
265
+ windowType: draft.windowType,
266
+ operationState: draft.operationState,
267
+ awningDirection: draft.awningDirection,
268
+ casementStyle: draft.casementStyle,
269
+ hingesSide: draft.hingesSide,
265
270
  frameThickness: draft.frameThickness,
266
271
  frameDepth: draft.frameDepth,
267
272
  columnRatios: draft.columnRatios,
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
3
3
  import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
5
5
  import { EDITOR_LAYER } from './../../../lib/constants'
6
+ import { sfxEmitter } from './../../../lib/sfx-bus'
6
7
  import useEditor from './../../../store/use-editor'
7
8
  import { CursorSphere } from '../shared/cursor-sphere'
8
9
 
@@ -67,6 +68,9 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
67
68
 
68
69
  // Select the newly created zone
69
70
  useViewer.getState().setSelection({ zoneId: zone.id })
71
+
72
+ // Play structure build sound
73
+ sfxEmitter.emit('sfx:structure-build')
70
74
  }
71
75
 
72
76
  type PreviewState = {
@@ -86,6 +90,7 @@ export const ZoneTool: React.FC = () => {
86
90
  const mainLineRef = useRef<Line>(null!)
87
91
  const closingLineRef = useRef<Line>(null!)
88
92
  const pointsRef = useRef<Array<[number, number]>>([])
93
+ const previousSnappedPointRef = useRef<[number, number] | null>(null)
89
94
  const levelYRef = useRef(0) // Track current level Y position
90
95
  const currentLevelId = useViewer((state) => state.selection.levelId)
91
96
  const setTool = useEditor((state) => state.setTool)
@@ -181,12 +186,22 @@ export const ZoneTool: React.FC = () => {
181
186
 
182
187
  // If we have points, snap to axis from last point
183
188
  const lastPoint = pointsRef.current[pointsRef.current.length - 1]
184
- if (lastPoint) {
185
- const snapped = calculateSnapPoint(lastPoint, cursorPosition)
186
- cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
187
- } else {
188
- cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
189
+ const displayPoint = lastPoint
190
+ ? calculateSnapPoint(lastPoint, cursorPosition)
191
+ : cursorPosition
192
+
193
+ // Play snap sound when the snapped position changes during drawing
194
+ if (
195
+ pointsRef.current.length > 0 &&
196
+ previousSnappedPointRef.current &&
197
+ (displayPoint[0] !== previousSnappedPointRef.current[0] ||
198
+ displayPoint[1] !== previousSnappedPointRef.current[1])
199
+ ) {
200
+ sfxEmitter.emit('sfx:grid-snap')
189
201
  }
202
+ previousSnappedPointRef.current = displayPoint
203
+
204
+ cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
190
205
 
191
206
  updatePreview()
192
207
  }
@@ -4,7 +4,7 @@ import { emitter } from '@pascal-app/core'
4
4
  import Image from 'next/image'
5
5
  import { ActionButton } from './action-button'
6
6
 
7
- export function CameraActions() {
7
+ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
8
8
  const goToTopView = () => {
9
9
  emitter.emit('camera-controls:top-view')
10
10
  }
@@ -19,39 +19,43 @@ export function CameraActions() {
19
19
 
20
20
  return (
21
21
  <div className="flex items-center gap-1">
22
- {/* Orbit CCW */}
23
- <ActionButton
24
- className="group hover:bg-white/5"
25
- label="Orbit Left"
26
- onClick={orbitCCW}
27
- size="icon"
28
- variant="ghost"
29
- >
30
- <Image
31
- alt="Orbit Left"
32
- className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
33
- height={28}
34
- src="/icons/rotate.png"
35
- width={28}
36
- />
37
- </ActionButton>
22
+ {!hideOrbit && (
23
+ <>
24
+ {/* Orbit CCW */}
25
+ <ActionButton
26
+ className="group hover:bg-white/5"
27
+ label="Orbit Left"
28
+ onClick={orbitCCW}
29
+ size="icon"
30
+ variant="ghost"
31
+ >
32
+ <Image
33
+ alt="Orbit Left"
34
+ className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
35
+ height={28}
36
+ src="/icons/rotate.png"
37
+ width={28}
38
+ />
39
+ </ActionButton>
38
40
 
39
- {/* Orbit CW */}
40
- <ActionButton
41
- className="group hover:bg-white/5"
42
- label="Orbit Right"
43
- onClick={orbitCW}
44
- size="icon"
45
- variant="ghost"
46
- >
47
- <Image
48
- alt="Orbit Right"
49
- className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
50
- height={28}
51
- src="/icons/rotate.png"
52
- width={28}
53
- />
54
- </ActionButton>
41
+ {/* Orbit CW */}
42
+ <ActionButton
43
+ className="group hover:bg-white/5"
44
+ label="Orbit Right"
45
+ onClick={orbitCW}
46
+ size="icon"
47
+ variant="ghost"
48
+ >
49
+ <Image
50
+ alt="Orbit Right"
51
+ className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
52
+ height={28}
53
+ src="/icons/rotate.png"
54
+ width={28}
55
+ />
56
+ </ActionButton>
57
+ </>
58
+ )}
55
59
 
56
60
  {/* Top View */}
57
61
  <ActionButton
@@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils'
9
9
  import useEditor from './../../../store/use-editor'
10
10
  import { ActionButton } from './action-button'
11
11
 
12
- type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete'
12
+ type ControlId =
13
+ | 'select'
14
+ | 'box-select'
15
+ | 'site-edit'
16
+ | 'build'
17
+ | 'material-paint'
18
+ | 'furnish'
19
+ | 'zone'
20
+ | 'delete'
13
21
 
14
22
  type ControlConfig = {
15
23
  id: ControlId
@@ -54,6 +62,14 @@ const controls: ControlConfig[] = [
54
62
  color: 'hover:bg-green-500/20 hover:text-green-400',
55
63
  activeColor: 'bg-green-500/20 text-green-400',
56
64
  },
65
+ {
66
+ id: 'material-paint',
67
+ imageSrc: '/icons/paint.png',
68
+ label: 'Material Paint',
69
+ shortcut: 'P',
70
+ color: 'hover:bg-amber-500/20 hover:text-amber-400',
71
+ activeColor: 'bg-amber-500/20 text-amber-400',
72
+ },
57
73
  {
58
74
  id: 'furnish',
59
75
  imageSrc: '/icons/couch.png',
@@ -88,6 +104,9 @@ export function ControlModes() {
88
104
  const setPhase = useEditor((state) => state.setPhase)
89
105
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
90
106
  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
107
+ const primeMaterialPaintFromSelection = useEditor(
108
+ (state) => state.primeMaterialPaintFromSelection,
109
+ )
91
110
  const levelId = useViewer((s) => s.selection.levelId)
92
111
 
93
112
  // Only subscribe to the primitive `level` number — when walls are added to
@@ -112,6 +131,7 @@ export function ControlModes() {
112
131
  if (id === 'site-edit') return false
113
132
  if (id === 'build')
114
133
  return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
134
+ if (id === 'material-paint') return mode === 'material-paint'
115
135
  if (id === 'furnish') return mode === 'build' && phase === 'furnish'
116
136
  if (id === 'zone')
117
137
  return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
@@ -130,6 +150,8 @@ export function ControlModes() {
130
150
  // setPhase('site') calls viewer.resetSelection() which clears levelId,
131
151
  // breaking the 2D floorplan (it needs a level to render the SVG).
132
152
  useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
153
+ // Clear object selection so the polygon editor handles receive pointer events
154
+ useViewer.getState().setSelection({ selectedIds: [] })
133
155
  }
134
156
  return
135
157
  }
@@ -155,12 +177,23 @@ export function ControlModes() {
155
177
  setStructureLayer('elements')
156
178
  setMode('build')
157
179
  }
180
+ } else if (id === 'material-paint') {
181
+ if (getIsActive('material-paint')) {
182
+ setMode('select')
183
+ } else {
184
+ primeMaterialPaintFromSelection()
185
+ setPhase('structure')
186
+ setStructureLayer('elements')
187
+ setMode('material-paint')
188
+ }
158
189
  } else if (id === 'furnish') {
159
190
  if (getIsActive('furnish')) {
160
191
  setMode('select')
161
192
  } else {
162
193
  setPhase('furnish')
163
194
  setMode('build')
195
+ // Auto-switch sidebar to the items panel so the user can pick furniture
196
+ useEditor.getState().setActiveSidebarPanel('items')
164
197
  }
165
198
  } else if (id === 'zone') {
166
199
  if (getIsActive('zone')) {
@@ -1,9 +1,4 @@
1
- 'use client'
2
-
3
- import NextImage from 'next/image'
4
- import { cn } from './../../../lib/utils'
5
- import useEditor, { type CatalogCategory } from './../../../store/use-editor'
6
- import { ActionButton } from './action-button'
1
+ import type { CatalogCategory } from './../../../store/use-editor'
7
2
 
8
3
  export type FurnishToolConfig = {
9
4
  id: 'item'
@@ -12,91 +7,10 @@ export type FurnishToolConfig = {
12
7
  catalogCategory: CatalogCategory
13
8
  }
14
9
 
15
- // Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
16
10
  export const furnishTools: FurnishToolConfig[] = [
17
- {
18
- id: 'item',
19
- iconSrc: '/icons/couch.png',
20
- label: 'Furniture',
21
- catalogCategory: 'furniture',
22
- },
23
- {
24
- id: 'item',
25
- iconSrc: '/icons/appliance.png',
26
- label: 'Appliance',
27
- catalogCategory: 'appliance',
28
- },
29
- {
30
- id: 'item',
31
- iconSrc: '/icons/kitchen.png',
32
- label: 'Kitchen',
33
- catalogCategory: 'kitchen',
34
- },
35
- {
36
- id: 'item',
37
- iconSrc: '/icons/bathroom.png',
38
- label: 'Bathroom',
39
- catalogCategory: 'bathroom',
40
- },
41
- {
42
- id: 'item',
43
- iconSrc: '/icons/tree.png',
44
- label: 'Outdoor',
45
- catalogCategory: 'outdoor',
46
- },
11
+ { id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
12
+ { id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
13
+ { id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
14
+ { id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
15
+ { id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
47
16
  ]
48
-
49
- export function FurnishTools() {
50
- const mode = useEditor((state) => state.mode)
51
- const activeTool = useEditor((state) => state.tool)
52
- const setActiveTool = useEditor((state) => state.setTool)
53
- const setMode = useEditor((state) => state.setMode)
54
- const catalogCategory = useEditor((state) => state.catalogCategory)
55
- const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
56
-
57
- const hasActiveTool = furnishTools.some(
58
- (tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
59
- )
60
-
61
- return (
62
- <div className="flex items-center gap-1.5 px-1">
63
- {furnishTools.map((tool, index) => {
64
- // For item tools with catalog category, check both tool and category match
65
- const isActive =
66
- mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
67
-
68
- return (
69
- <ActionButton
70
- className={cn(
71
- 'rounded-lg duration-300',
72
- isActive
73
- ? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
74
- : 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
75
- )}
76
- key={`${tool.id}-${tool.catalogCategory ?? index}`}
77
- label={tool.label}
78
- onClick={() => {
79
- if (!isActive) {
80
- setCatalogCategory(tool.catalogCategory)
81
- setActiveTool('item')
82
- if (mode !== 'build') {
83
- setMode('build')
84
- }
85
- }
86
- }}
87
- size="icon"
88
- variant="ghost"
89
- >
90
- <NextImage
91
- alt={tool.label}
92
- className="size-full object-contain"
93
- height={28}
94
- src={tool.iconSrc}
95
- width={28}
96
- />
97
- </ActionButton>
98
- )
99
- })}
100
- </div>
101
- )
102
- }