@pascal-app/editor 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  type AnyNodeId,
3
3
  emitter,
4
+ isCurvedWall,
4
5
  sceneRegistry,
5
6
  spatialGridManager,
7
+ useLiveTransforms,
6
8
  useScene,
7
9
  type WallEvent,
8
10
  WindowNode,
@@ -112,6 +114,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
112
114
 
113
115
  const onWallEnter = (event: WallEvent) => {
114
116
  if (!isValidWallSideFace(event.normal)) return
117
+ if (isCurvedWall(event.node)) {
118
+ hideCursor()
119
+ return
120
+ }
115
121
  // Only interact with walls on the current level
116
122
  if (event.node.parentId !== getLevelId()) return
117
123
 
@@ -139,6 +145,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
139
145
  parentId: event.node.id,
140
146
  wallId: event.node.id,
141
147
  })
148
+ useLiveTransforms.getState().set(movingWindowNode.id, {
149
+ position: [clampedX, clampedY, 0],
150
+ rotation: itemRotation,
151
+ })
142
152
 
143
153
  if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
144
154
  markWallDirty(event.node.id)
@@ -168,6 +178,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
168
178
 
169
179
  const onWallMove = (event: WallEvent) => {
170
180
  if (!isValidWallSideFace(event.normal)) return
181
+ if (isCurvedWall(event.node)) {
182
+ hideCursor()
183
+ return
184
+ }
171
185
  // Only interact with walls on the current level
172
186
  if (event.node.parentId !== getLevelId()) return
173
187
 
@@ -206,6 +220,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
206
220
  windowMesh.updateMatrixWorld(true)
207
221
  }
208
222
  }
223
+ useLiveTransforms.getState().set(movingWindowNode.id, {
224
+ position: [clampedX, clampedY, 0],
225
+ rotation: itemRotation,
226
+ })
209
227
  markWallDirty(event.node.id)
210
228
 
211
229
  const valid = !hasWallChildOverlap(
@@ -233,6 +251,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
233
251
 
234
252
  const onWallClick = (event: WallEvent) => {
235
253
  if (!isValidWallSideFace(event.normal)) return
254
+ if (isCurvedWall(event.node)) return
236
255
  // Only interact with walls on the current level
237
256
  if (event.node.parentId !== getLevelId()) return
238
257
 
@@ -275,6 +294,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
275
294
  parentId: event.node.id,
276
295
  width: movingWindowNode.width,
277
296
  height: movingWindowNode.height,
297
+ windowType: movingWindowNode.windowType,
298
+ operationState: movingWindowNode.operationState,
299
+ awningDirection: movingWindowNode.awningDirection,
300
+ casementStyle: movingWindowNode.casementStyle,
301
+ hingesSide: movingWindowNode.hingesSide,
278
302
  frameThickness: movingWindowNode.frameThickness,
279
303
  frameDepth: movingWindowNode.frameDepth,
280
304
  columnRatios: movingWindowNode.columnRatios,
@@ -316,6 +340,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
316
340
  }
317
341
 
318
342
  markWallDirty(event.node.id)
343
+ useLiveTransforms.getState().clear(movingWindowNode.id)
319
344
  useScene.temporal.getState().pause()
320
345
 
321
346
  sfxEmitter.emit('sfx:item-place')
@@ -327,6 +352,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
327
352
 
328
353
  const onWallLeave = () => {
329
354
  hideCursor()
355
+ useLiveTransforms.getState().clear(movingWindowNode.id)
330
356
  if (isNew) return // No original to restore for duplicates
331
357
  // Move mode: restore to original position while off-wall
332
358
  if (currentWallId && currentWallId !== original.parentId) {
@@ -344,6 +370,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
344
370
  }
345
371
 
346
372
  const onCancel = () => {
373
+ useLiveTransforms.getState().clear(movingWindowNode.id)
347
374
  if (isNew) {
348
375
  useScene.getState().deleteNode(movingWindowNode.id)
349
376
  if (currentWallId) markWallDirty(currentWallId)
@@ -391,6 +418,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
391
418
  if (original.parentId) markWallDirty(original.parentId)
392
419
  }
393
420
  }
421
+ useLiveTransforms.getState().clear(movingWindowNode.id)
394
422
  useScene.temporal.getState().resume()
395
423
  emitter.off('wall:enter', onWallEnter)
396
424
  emitter.off('wall:move', onWallMove)
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNodeId,
3
3
  emitter,
4
+ isCurvedWall,
4
5
  sceneRegistry,
5
6
  spatialGridManager,
6
7
  useScene,
@@ -86,6 +87,11 @@ export const WindowTool: React.FC = () => {
86
87
 
87
88
  const onWallEnter = (event: WallEvent) => {
88
89
  if (!isValidWallSideFace(event.normal)) return
90
+ if (isCurvedWall(event.node)) {
91
+ destroyDraft()
92
+ hideCursor()
93
+ return
94
+ }
89
95
  const levelId = getLevelId()
90
96
  if (!levelId) return
91
97
  // Only interact with walls on the current level
@@ -135,6 +141,11 @@ export const WindowTool: React.FC = () => {
135
141
 
136
142
  const onWallMove = (event: WallEvent) => {
137
143
  if (!isValidWallSideFace(event.normal)) return
144
+ if (isCurvedWall(event.node)) {
145
+ destroyDraft()
146
+ hideCursor()
147
+ return
148
+ }
138
149
  // Only interact with walls on the current level
139
150
  if (event.node.parentId !== getLevelId()) return
140
151
 
@@ -198,6 +209,7 @@ export const WindowTool: React.FC = () => {
198
209
  const onWallClick = (event: WallEvent) => {
199
210
  if (!draftRef.current) return
200
211
  if (!isValidWallSideFace(event.normal)) return
212
+ if (isCurvedWall(event.node)) return
201
213
  // Only interact with walls on the current level
202
214
  if (event.node.parentId !== getLevelId()) return
203
215
 
@@ -250,6 +262,11 @@ export const WindowTool: React.FC = () => {
250
262
  parentId: event.node.id,
251
263
  width: draft.width,
252
264
  height: draft.height,
265
+ windowType: draft.windowType,
266
+ operationState: draft.operationState,
267
+ awningDirection: draft.awningDirection,
268
+ casementStyle: draft.casementStyle,
269
+ hingesSide: draft.hingesSide,
253
270
  frameThickness: draft.frameThickness,
254
271
  frameDepth: draft.frameDepth,
255
272
  columnRatios: draft.columnRatios,
@@ -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,14 +104,20 @@ 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((state) => state.primeMaterialPaintFromSelection)
91
108
  const levelId = useViewer((s) => s.selection.levelId)
92
109
 
93
- const levelNode = useScene((state) =>
94
- levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
95
- )
110
+ // Only subscribe to the primitive `level` number — when walls are added to
111
+ // this level the object ref changes but this number doesn't, so Object.is
112
+ // dedupes and we avoid a re-render.
113
+ const levelIndex = useScene((state) => {
114
+ if (!levelId) return null
115
+ const node = state.nodes[levelId]
116
+ return node?.type === 'level' ? (node as LevelNode).level : null
117
+ })
96
118
 
97
119
  const isSiteEditing = phase === 'site'
98
- const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
120
+ const isGroundFloor = levelIndex === 0
99
121
  const canEnterSiteEdit = isGroundFloor || isSiteEditing
100
122
 
101
123
  const structureLayer = useEditor((state) => state.structureLayer)
@@ -107,6 +129,7 @@ export function ControlModes() {
107
129
  if (id === 'site-edit') return false
108
130
  if (id === 'build')
109
131
  return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
132
+ if (id === 'material-paint') return mode === 'material-paint'
110
133
  if (id === 'furnish') return mode === 'build' && phase === 'furnish'
111
134
  if (id === 'zone')
112
135
  return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
@@ -150,6 +173,15 @@ export function ControlModes() {
150
173
  setStructureLayer('elements')
151
174
  setMode('build')
152
175
  }
176
+ } else if (id === 'material-paint') {
177
+ if (getIsActive('material-paint')) {
178
+ setMode('select')
179
+ } else {
180
+ primeMaterialPaintFromSelection()
181
+ setPhase('structure')
182
+ setStructureLayer('elements')
183
+ setMode('material-paint')
184
+ }
153
185
  } else if (id === 'furnish') {
154
186
  if (getIsActive('furnish')) {
155
187
  setMode('select')
@@ -1,8 +1,14 @@
1
1
  'use client'
2
2
 
3
+ import { useScene } from '@pascal-app/core'
3
4
  import { AnimatePresence, motion } from 'motion/react'
4
- import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
5
+ import { useEffect, useMemo } from 'react'
6
+ import { useViewer } from '@pascal-app/viewer'
5
7
  import { useReducedMotion } from './../../../hooks/use-reduced-motion'
8
+ import { useIsMobile } from './../../../hooks/use-mobile'
9
+ import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
10
+ import { MaterialPicker } from './../../../components/ui/controls/material-picker'
11
+ import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
6
12
  import { cn } from './../../../lib/utils'
7
13
  import useEditor from './../../../store/use-editor'
8
14
  import { ItemCatalog } from '../item-catalog/item-catalog'
@@ -12,12 +18,64 @@ import { FurnishTools } from './furnish-tools'
12
18
  import { StructureTools } from './structure-tools'
13
19
  import { ViewToggles } from './view-toggles'
14
20
 
21
+ function PaintMaterialTray() {
22
+ const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
23
+ const activePaintTarget = useEditor((state) => state.activePaintTarget)
24
+ const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
25
+ const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
26
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
27
+ const nodes = useScene((state) => state.nodes)
28
+ const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
29
+
30
+ useEffect(() => {
31
+ const selectedPaintTarget = resolvePaintTargetFromSelection({
32
+ nodes,
33
+ selectedId,
34
+ })
35
+
36
+ if (selectedPaintTarget) {
37
+ setActivePaintTarget(selectedPaintTarget)
38
+ }
39
+ }, [nodes, selectedId, setActivePaintTarget])
40
+
41
+ return (
42
+ <div className="w-[42rem] max-w-[calc(100vw-2rem)]">
43
+ <MaterialPicker
44
+ onChange={(material) => {
45
+ setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
46
+ }}
47
+ onSelectMaterialPreset={(materialPreset) => {
48
+ setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
49
+ }}
50
+ selectedMaterialPreset={activePaintMaterial?.materialPreset}
51
+ value={activePaintMaterial?.material}
52
+ />
53
+ </div>
54
+ )
55
+ }
56
+
15
57
  export function ActionMenu({ className }: { className?: string }) {
16
58
  const phase = useEditor((state) => state.phase)
17
59
  const mode = useEditor((state) => state.mode)
18
60
  const tool = useEditor((state) => state.tool)
19
61
  const catalogCategory = useEditor((state) => state.catalogCategory)
62
+ const isMobile = useIsMobile()
63
+ const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
64
+ const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
65
+ const CONTEXTUAL_TABS = new Set(['ai', 'items', 'studio'])
66
+ const isContextualPanelOnMobile = useEditor(
67
+ (s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
68
+ )
20
69
  const reducedMotion = useReducedMotion()
70
+ const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
71
+
72
+ // On mobile, defer the bottom rail to the selection bar when something
73
+ // is selected — the contextual actions take priority over mode controls.
74
+ // Also hide on Chat / Items / Studio tabs; those are contextual workflows
75
+ // (composing / picking furniture / generating renders) where the build
76
+ // menu is irrelevant.
77
+ if (hasSelectionOnMobile || hasReferenceOnMobile || isContextualPanelOnMobile) return null
78
+
21
79
  const transition = reducedMotion
22
80
  ? { duration: 0 }
23
81
  : { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
@@ -138,6 +196,38 @@ export function ActionMenu({ className }: { className?: string }) {
138
196
  </motion.div>
139
197
  )}
140
198
  </AnimatePresence>
199
+
200
+ <AnimatePresence>
201
+ {showPaintTray && (
202
+ <motion.div
203
+ animate={{
204
+ opacity: 1,
205
+ maxHeight: 96,
206
+ paddingTop: 8,
207
+ paddingBottom: 8,
208
+ borderBottomWidth: 1,
209
+ }}
210
+ className={cn('overflow-hidden border-border border-b px-3')}
211
+ exit={{
212
+ opacity: 0,
213
+ maxHeight: 0,
214
+ paddingTop: 0,
215
+ paddingBottom: 0,
216
+ borderBottomWidth: 0,
217
+ }}
218
+ initial={{
219
+ opacity: 0,
220
+ maxHeight: 0,
221
+ paddingTop: 0,
222
+ paddingBottom: 0,
223
+ borderBottomWidth: 0,
224
+ }}
225
+ transition={transition}
226
+ >
227
+ <PaintMaterialTray />
228
+ </motion.div>
229
+ )}
230
+ </AnimatePresence>
141
231
  {/* Control Mode Row - Always visible, centered */}
142
232
  <div className="flex items-center justify-center gap-1 px-2 py-1.5">
143
233
  <ControlModes />
@@ -24,12 +24,14 @@ export const tools: ToolConfig[] = [
24
24
  // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
25
25
  { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
26
26
  { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
27
+ { id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
27
28
  { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
28
29
  { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
29
30
  { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
30
31
  { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
31
32
  { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
32
33
  { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
34
+ { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
33
35
  ]
34
36
 
35
37
  export function StructureTools() {