@pascal-app/editor 0.7.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 (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. package/src/components/ui/viewer-toolbar.tsx +0 -436
@@ -192,7 +192,17 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
192
192
 
193
193
  const result: string[] = []
194
194
 
195
- if (phase === 'structure' && structureLayer === 'elements') {
195
+ if (phase === 'structure' && structureLayer === 'zones') {
196
+ for (const childId of levelNode.children) {
197
+ const node = nodes[childId as AnyNodeId]
198
+ if (!node || node.type !== 'zone') continue
199
+ const zone = node as ZoneNode
200
+ if (polygonIntersectsBounds(zone.polygon, bounds)) {
201
+ result.push(zone.id)
202
+ }
203
+ }
204
+ } else {
205
+ // structure (elements) and furnish: collect all node types
196
206
  for (const childId of levelNode.children) {
197
207
  const node = nodes[childId as AnyNodeId]
198
208
  if (!node) continue
@@ -240,22 +250,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
240
250
  if (objectBoundsIntersectsBounds(node.id, bounds)) {
241
251
  result.push(node.id)
242
252
  }
243
- }
244
- }
245
- } else if (phase === 'structure' && structureLayer === 'zones') {
246
- for (const childId of levelNode.children) {
247
- const node = nodes[childId as AnyNodeId]
248
- if (!node || node.type !== 'zone') continue
249
- const zone = node as ZoneNode
250
- if (polygonIntersectsBounds(zone.polygon, bounds)) {
251
- result.push(zone.id)
252
- }
253
- }
254
- } else if (phase === 'furnish') {
255
- for (const childId of levelNode.children) {
256
- const node = nodes[childId as AnyNodeId]
257
- if (!node) continue
258
- if (node.type === 'item') {
253
+ } else if (node.type === 'item') {
259
254
  const item = node as ItemNode
260
255
  if (item.asset.category === 'door' || item.asset.category === 'window') continue
261
256
  const xz = getNodeWorldXZ(item.id)
@@ -145,7 +145,7 @@ export function getSegmentAngleReferenceAtPoint(
145
145
  }
146
146
 
147
147
  const projected = getProjectedPointOnSegment(point, segment)
148
- if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
148
+ if (!(projected && pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE))) {
149
149
  return null
150
150
  }
151
151
 
@@ -45,6 +45,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
45
45
  door: DoorTool,
46
46
  item: ItemTool,
47
47
  zone: ZoneTool,
48
+ spawn: SpawnTool,
48
49
  window: WindowTool,
49
50
  },
50
51
  furnish: {
@@ -63,10 +64,9 @@ export const ToolManager: React.FC = () => {
63
64
  const curvingFence = useEditor((state) => state.curvingFence)
64
65
  const editingHole = useEditor((state) => state.editingHole)
65
66
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
66
- const selectedLevelId = useViewer((state) => state.selection.levelId)
67
- const buildingId = useViewer((state) => state.selection.buildingId)
68
67
  const selectedIds = useViewer((state) => state.selection.selectedIds)
69
- const setSelection = useViewer((state) => state.setSelection)
68
+ const buildingId = useViewer((state) => state.selection.buildingId)
69
+ const activeLevelId = useViewer((state) => state.selection.levelId)
70
70
  const nodes = useScene((state) => state.nodes)
71
71
 
72
72
  // Building transform for the local group — all building-relative tools live inside this group
@@ -128,13 +128,13 @@ export const ToolManager: React.FC = () => {
128
128
 
129
129
  const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
130
130
  const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
131
- setSelection({ selectedIds: [nodeId] })
131
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
132
132
  }
133
133
 
134
134
  return (
135
135
  <>
136
- {showSiteBoundaryEditor && <SiteBoundaryEditor />}
137
136
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
137
+ {showSiteBoundaryEditor && <SiteBoundaryEditor />}
138
138
  {movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
139
139
 
140
140
  {/* Building-local group: all other tools are relative to the selected building.
@@ -162,13 +162,13 @@ export const ToolManager: React.FC = () => {
162
162
  {movingNode && movingNode.type !== 'building' && (
163
163
  <MoveTool onSpawnMoved={handlePlacedNodeSelected} />
164
164
  )}
165
- {!movingNode && showBuildTool && tool === 'spawn' && (
166
- <SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
167
- )}
168
- {!movingNode && showBuildTool && tool === 'column' && (
169
- <ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
170
- )}
171
- {!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
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}
172
172
  </group>
173
173
  </>
174
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 &&
@@ -112,10 +112,12 @@ function getLinkedWallSnapshots(args: {
112
112
  }
113
113
 
114
114
  if (
115
- !samePoint(node.start, originalStart) &&
116
- !samePoint(node.start, originalEnd) &&
117
- !samePoint(node.end, originalStart) &&
118
- !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
+ )
119
121
  ) {
120
122
  continue
121
123
  }
@@ -286,8 +288,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
286
288
  }
287
289
 
288
290
  const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
289
- const hasChanged =
290
- !samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
291
+ const hasChanged = !(
292
+ samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
293
+ )
291
294
 
292
295
  if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
293
296
  wasCommitted = true
@@ -391,7 +394,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
391
394
  >
392
395
  <div className="translate-y-10">
393
396
  <div
394
- 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 ${
395
398
  altPressed
396
399
  ? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
397
400
  : 'border-border bg-background/95 text-muted-foreground'
@@ -415,7 +418,7 @@ function EndpointAngleLabel({
415
418
  }) {
416
419
  return (
417
420
  <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
418
- <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
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">
419
422
  {label}
420
423
  </div>
421
424
  </Html>
@@ -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
  }
File without changes
@@ -156,6 +156,8 @@ export const WallTool: React.FC = () => {
156
156
  const unit = useViewer((state) => state.unit)
157
157
  const cursorRef = useRef<Group>(null)
158
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.
159
161
  const startingPoint = useRef(new Vector3(0, 0, 0))
160
162
  const endingPoint = useRef(new Vector3(0, 0, 0))
161
163
  const buildingState = useRef(0)
@@ -166,8 +168,6 @@ export const WallTool: React.FC = () => {
166
168
  let gridPosition: WallPlanPoint = [0, 0]
167
169
  let previousWallEnd: [number, number] | null = null
168
170
 
169
- // All positions are building-local: this tool is inside the ToolManager building group,
170
- // so local coords are used for both data and visual positioning.
171
171
  const onGridMove = (event: GridEvent) => {
172
172
  if (!(cursorRef.current && wallPreviewRef.current)) return
173
173
 
@@ -324,7 +324,7 @@ function DraftMeasurementLabel({
324
324
  }) {
325
325
  return (
326
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 text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
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
328
  {label}
329
329
  </div>
330
330
  </Html>
@@ -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
  }
File without changes
@@ -104,7 +104,9 @@ export function ControlModes() {
104
104
  const setPhase = useEditor((state) => state.setPhase)
105
105
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
106
106
  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
107
- const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
107
+ const primeMaterialPaintFromSelection = useEditor(
108
+ (state) => state.primeMaterialPaintFromSelection,
109
+ )
108
110
  const levelId = useViewer((s) => s.selection.levelId)
109
111
 
110
112
  // Only subscribe to the primitive `level` number — when walls are added to
@@ -148,6 +150,8 @@ export function ControlModes() {
148
150
  // setPhase('site') calls viewer.resetSelection() which clears levelId,
149
151
  // breaking the 2D floorplan (it needs a level to render the SVG).
150
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: [] })
151
155
  }
152
156
  return
153
157
  }
@@ -188,6 +192,8 @@ export function ControlModes() {
188
192
  } else {
189
193
  setPhase('furnish')
190
194
  setMode('build')
195
+ // Auto-switch sidebar to the items panel so the user can pick furniture
196
+ useEditor.getState().setActiveSidebarPanel('items')
191
197
  }
192
198
  } else if (id === 'zone') {
193
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
- }
@@ -1,22 +1,25 @@
1
1
  'use client'
2
2
 
3
3
  import { useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
4
5
  import { AnimatePresence, motion } from 'motion/react'
5
6
  import { useEffect, useMemo } from 'react'
6
- import { useViewer } from '@pascal-app/viewer'
7
- import { useReducedMotion } from './../../../hooks/use-reduced-motion'
8
- import { useIsMobile } from './../../../hooks/use-mobile'
9
- import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
10
7
  import { MaterialPicker } from './../../../components/ui/controls/material-picker'
8
+ import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
9
+ import { useIsMobile } from './../../../hooks/use-mobile'
10
+ import { useReducedMotion } from './../../../hooks/use-reduced-motion'
11
11
  import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
12
12
  import { cn } from './../../../lib/utils'
13
13
  import useEditor from './../../../store/use-editor'
14
- import { ItemCatalog } from '../item-catalog/item-catalog'
15
14
  import { CameraActions } from './camera-actions'
16
15
  import { ControlModes } from './control-modes'
17
- import { FurnishTools } from './furnish-tools'
18
16
  import { StructureTools } from './structure-tools'
19
- import { ViewToggles } from './view-toggles'
17
+ import { GridSnapControl, SecondaryToggles } from './view-toggles'
18
+
19
+ // Mobile bottom offset matches the viewer's overlap behind the sheet's
20
+ // rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits
21
+ // just above that strip instead of inside it.
22
+ const MOBILE_BOTTOM_OFFSET = 24
20
23
 
21
24
  function PaintMaterialTray() {
22
25
  const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
@@ -84,84 +87,16 @@ export function ActionMenu({ className }: { className?: string }) {
84
87
  <TooltipProvider>
85
88
  <motion.div
86
89
  className={cn(
87
- 'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
90
+ 'left-1/2 z-50 -translate-x-1/2',
91
+ isMobile ? 'absolute origin-bottom scale-90' : 'fixed bottom-6',
88
92
  'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
89
93
  'transition-colors duration-200 ease-out',
90
94
  className,
91
95
  )}
92
96
  layout
97
+ style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
93
98
  transition={transition}
94
99
  >
95
- {/* Item Catalog Row - Only show when in build mode with item tool */}
96
- <AnimatePresence>
97
- {mode === 'build' && tool === 'item' && catalogCategory && (
98
- <motion.div
99
- animate={{
100
- opacity: 1,
101
- maxHeight: 160,
102
- paddingTop: 8,
103
- paddingBottom: 8,
104
- borderBottomWidth: 1,
105
- }}
106
- className={cn('overflow-hidden border-border border-b px-2 py-2')}
107
- exit={{
108
- opacity: 0,
109
- maxHeight: 0,
110
- paddingTop: 0,
111
- paddingBottom: 0,
112
- borderBottomWidth: 0,
113
- }}
114
- initial={{
115
- opacity: 0,
116
- maxHeight: 0,
117
- paddingTop: 0,
118
- paddingBottom: 0,
119
- borderBottomWidth: 0,
120
- }}
121
- transition={transition}
122
- >
123
- <ItemCatalog category={catalogCategory} key={catalogCategory} />
124
- </motion.div>
125
- )}
126
- </AnimatePresence>
127
-
128
- <AnimatePresence>
129
- {phase === 'furnish' && mode === 'build' && (
130
- <motion.div
131
- animate={{
132
- opacity: 1,
133
- maxHeight: 80,
134
- paddingTop: 8,
135
- paddingBottom: 8,
136
- borderBottomWidth: 1,
137
- }}
138
- className={cn(
139
- 'overflow-hidden border-border',
140
- 'max-h-20 border-b px-2 py-2 opacity-100',
141
- )}
142
- exit={{
143
- opacity: 0,
144
- maxHeight: 0,
145
- paddingTop: 0,
146
- paddingBottom: 0,
147
- borderBottomWidth: 0,
148
- }}
149
- initial={{
150
- opacity: 0,
151
- maxHeight: 0,
152
- paddingTop: 0,
153
- paddingBottom: 0,
154
- borderBottomWidth: 0,
155
- }}
156
- transition={transition}
157
- >
158
- <div className="mx-auto w-max">
159
- <FurnishTools />
160
- </div>
161
- </motion.div>
162
- )}
163
- </AnimatePresence>
164
-
165
100
  {/* Structure Tools Row - Animated */}
166
101
  <AnimatePresence>
167
102
  {phase === 'structure' && mode === 'build' && (
@@ -228,14 +163,28 @@ export function ActionMenu({ className }: { className?: string }) {
228
163
  </motion.div>
229
164
  )}
230
165
  </AnimatePresence>
231
- {/* Control Mode Row - Always visible, centered */}
232
- <div className="flex items-center justify-center gap-1 px-2 py-1.5">
233
- <ControlModes />
234
- <div className="mx-1 h-5 w-px bg-border" />
235
- <ViewToggles />
236
- <div className="mx-1 h-5 w-px bg-border" />
237
- <CameraActions />
238
- </div>
166
+ {isMobile ? (
167
+ <div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
168
+ {/* Row 1: control modes only */}
169
+ <div className="flex items-center justify-center gap-1">
170
+ <ControlModes />
171
+ </div>
172
+ {/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */}
173
+ <div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
174
+ <GridSnapControl />
175
+ <SecondaryToggles />
176
+ </div>
177
+ </div>
178
+ ) : (
179
+ <div className="flex items-center justify-center gap-1 px-2 py-1.5">
180
+ <ControlModes />
181
+ <div className="mx-1 h-5 w-px bg-border" />
182
+ <GridSnapControl />
183
+ <SecondaryToggles />
184
+ <div className="mx-1 h-5 w-px bg-border" />
185
+ <CameraActions />
186
+ </div>
187
+ )}
239
188
  </motion.div>
240
189
  </TooltipProvider>
241
190
  )