@pascal-app/editor 0.4.0 → 0.6.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 (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -90,12 +90,17 @@ export function ControlModes() {
90
90
  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
91
91
  const levelId = useViewer((s) => s.selection.levelId)
92
92
 
93
- const levelNode = useScene((state) =>
94
- levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
95
- )
93
+ // Only subscribe to the primitive `level` number — when walls are added to
94
+ // this level the object ref changes but this number doesn't, so Object.is
95
+ // dedupes and we avoid a re-render.
96
+ const levelIndex = useScene((state) => {
97
+ if (!levelId) return null
98
+ const node = state.nodes[levelId]
99
+ return node?.type === 'level' ? (node as LevelNode).level : null
100
+ })
96
101
 
97
102
  const isSiteEditing = phase === 'site'
98
- const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
103
+ const isGroundFloor = levelIndex === 0
99
104
  const canEnterSiteEdit = isGroundFloor || isSiteEditing
100
105
 
101
106
  const structureLayer = useEditor((state) => state.structureLayer)
@@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [
28
28
  { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
29
29
  { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
30
30
  { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
31
+ { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
31
32
  { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
32
33
  ]
33
34
 
@@ -35,6 +35,7 @@ import {
35
35
  } from 'lucide-react'
36
36
  import { useEffect } from 'react'
37
37
  import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
38
+ import { runRedo, runUndo } from '../../../lib/history'
38
39
  import { useCommandRegistry } from '../../../store/use-command-registry'
39
40
  import type { StructureTool } from '../../../store/use-editor'
40
41
  import useEditor from '../../../store/use-editor'
@@ -44,8 +45,12 @@ export function EditorCommands() {
44
45
  const register = useCommandRegistry((s) => s.register)
45
46
  const { navigateTo, setInputValue, setOpen } = useCommandPalette()
46
47
 
47
- const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
48
- useEditor()
48
+ const setPhase = useEditor((s) => s.setPhase)
49
+ const setMode = useEditor((s) => s.setMode)
50
+ const setTool = useEditor((s) => s.setTool)
51
+ const setStructureLayer = useEditor((s) => s.setStructureLayer)
52
+ const isPreviewMode = useEditor((s) => s.isPreviewMode)
53
+ const setPreviewMode = useEditor((s) => s.setPreviewMode)
49
54
 
50
55
  const exportScene = useViewer((s) => s.exportScene)
51
56
 
@@ -309,7 +314,7 @@ export function EditorCommands() {
309
314
  group: 'History',
310
315
  icon: <Undo2 className="h-4 w-4" />,
311
316
  keywords: ['undo', 'revert', 'back'],
312
- execute: () => run(() => useScene.temporal.getState().undo()),
317
+ execute: () => run(() => runUndo()),
313
318
  },
314
319
  {
315
320
  id: 'editor.history.redo',
@@ -317,7 +322,7 @@ export function EditorCommands() {
317
322
  group: 'History',
318
323
  icon: <Redo2 className="h-4 w-4" />,
319
324
  keywords: ['redo', 'forward', 'repeat'],
320
- execute: () => run(() => useScene.temporal.getState().redo()),
325
+ execute: () => run(() => runRedo()),
321
326
  },
322
327
 
323
328
  // ── Export & Share ───────────────────────────────────────────────────
@@ -219,7 +219,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
219
219
  const views = usePaletteViewRegistry((s) => s.views)
220
220
 
221
221
  const activeLevelId = useViewer((s) => s.selection.levelId)
222
- const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
223
222
 
224
223
  const wallMode = useViewer((s) => s.wallMode)
225
224
  const setWallMode = useViewer((s) => s.setWallMode)
@@ -1,73 +1,79 @@
1
1
  'use client'
2
2
 
3
- import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core'
4
- import { useState } from 'react'
5
-
6
- const PRESET_COLORS: Record<MaterialPreset, string> = {
7
- white: '#ffffff',
8
- brick: '#8b4513',
9
- concrete: '#808080',
10
- wood: '#deb887',
11
- glass: '#87ceeb',
12
- metal: '#c0c0c0',
13
- plaster: '#f5f5dc',
14
- tile: '#d3d3d3',
15
- marble: '#fafafa',
16
- custom: '#ffffff',
17
- }
18
-
19
- const PRESET_LABELS: Record<MaterialPreset, string> = {
20
- white: 'White',
21
- brick: 'Brick',
22
- concrete: 'Concrete',
23
- wood: 'Wood',
24
- glass: 'Glass',
25
- metal: 'Metal',
26
- plaster: 'Plaster',
27
- tile: 'Tile',
28
- marble: 'Marble',
29
- custom: 'Custom',
30
- }
3
+ import {
4
+ getMaterialsForTarget,
5
+ toLibraryMaterialRef,
6
+ type MaterialSchema,
7
+ type MaterialTarget,
8
+ } from '@pascal-app/core'
9
+ import { useEffect, useState } from 'react'
31
10
 
32
11
  type MaterialPickerProps = {
12
+ nodeType?: MaterialTarget
33
13
  value?: MaterialSchema
34
- onChange: (material: MaterialSchema) => void
14
+ selectedMaterialPreset?: string
15
+ onChange?: (material: MaterialSchema) => void
16
+ onSelectMaterialPreset?: (materialPreset: string) => void
17
+ hideSideControl?: boolean
18
+ disabled?: boolean
35
19
  }
36
20
 
37
- export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
38
- const [showCustom, setShowCustom] = useState<boolean>(
39
- value?.preset === 'custom' || !!value?.properties,
40
- )
21
+ export function MaterialPicker({
22
+ nodeType,
23
+ value,
24
+ selectedMaterialPreset,
25
+ onChange,
26
+ onSelectMaterialPreset,
27
+ hideSideControl = false,
28
+ disabled = false,
29
+ }: MaterialPickerProps) {
30
+ const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
31
+ const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
41
32
 
42
- const currentPreset = value?.preset || 'white'
43
- const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]
33
+ useEffect(() => {
34
+ setShowCustom(!!value?.properties && !selectedMaterialPreset)
35
+ }, [selectedMaterialPreset, value?.properties])
44
36
 
45
- const handlePresetChange = (preset: MaterialPreset) => {
46
- if (preset === 'custom') {
47
- setShowCustom(true)
48
- onChange({
49
- preset: 'custom',
50
- properties: {
51
- color: value?.properties?.color || '#ffffff',
52
- roughness: value?.properties?.roughness ?? 0.5,
53
- metalness: value?.properties?.metalness ?? 0,
54
- opacity: value?.properties?.opacity ?? 1,
55
- transparent: value?.properties?.transparent ?? false,
56
- side: value?.properties?.side ?? 'front',
57
- },
58
- })
59
- } else {
60
- setShowCustom(false)
61
- onChange({ preset })
62
- }
37
+ const currentProps = value?.properties || {
38
+ color: '#ffffff',
39
+ roughness: 0.5,
40
+ metalness: 0,
41
+ opacity: 1,
42
+ transparent: false,
43
+ side: 'front' as const,
44
+ }
45
+ const selectedCatalogId =
46
+ selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
47
+
48
+ const handleCatalogSelect = (materialId: string) => {
49
+ if (disabled) return
50
+ setShowCustom(false)
51
+ onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
52
+ }
53
+
54
+ const handleCustomOpen = () => {
55
+ if (disabled) return
56
+ setShowCustom(true)
57
+ onChange?.({
58
+ preset: 'custom',
59
+ properties: {
60
+ color: value?.properties?.color || '#ffffff',
61
+ roughness: value?.properties?.roughness ?? 0.5,
62
+ metalness: value?.properties?.metalness ?? 0,
63
+ opacity: value?.properties?.opacity ?? 1,
64
+ transparent: value?.properties?.transparent ?? false,
65
+ side: value?.properties?.side ?? 'front',
66
+ },
67
+ })
63
68
  }
64
69
 
65
70
  const handlePropertyChange = (
66
71
  prop: keyof typeof currentProps,
67
72
  val: (typeof currentProps)[keyof typeof currentProps],
68
73
  ) => {
69
- onChange({
70
- preset: showCustom ? 'custom' : currentPreset,
74
+ if (disabled) return
75
+ onChange?.({
76
+ preset: 'custom',
71
77
  properties: {
72
78
  ...currentProps,
73
79
  [prop]: val,
@@ -76,32 +82,57 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
76
82
  }
77
83
 
78
84
  return (
79
- <div className="space-y-3">
80
- <div className="grid grid-cols-5 gap-1.5">
81
- {(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => (
82
- <button
83
- className={`h-8 w-8 rounded border-2 transition-all ${
84
- currentPreset === preset
85
- ? 'border-blue-500 ring-2 ring-blue-500/30'
86
- : 'border-gray-300 hover:border-gray-400'
87
- }`}
88
- key={preset}
89
- onClick={() => handlePresetChange(preset)}
90
- style={{
91
- backgroundColor: PRESET_COLORS[preset],
92
- backgroundImage:
93
- preset === 'glass'
94
- ? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)'
95
- : undefined,
96
- backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
97
- }}
98
- title={PRESET_LABELS[preset]}
99
- type="button"
100
- />
101
- ))}
102
- </div>
85
+ <div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
86
+ {(catalogItems.length > 0 || onChange) && (
87
+ <div className="space-y-2">
88
+ {catalogItems.length > 0 ? (
89
+ <div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
90
+ ) : null}
91
+ <div className="flex flex-wrap gap-1.5">
92
+ {catalogItems.map((item) => (
93
+ <button
94
+ className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
95
+ selectedCatalogId === toLibraryMaterialRef(item.id)
96
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
97
+ : 'border-gray-300 hover:border-gray-400'
98
+ }`}
99
+ key={item.id}
100
+ onClick={() => handleCatalogSelect(item.id)}
101
+ title={item.label}
102
+ type="button"
103
+ >
104
+ {item.previewThumbnailUrl ? (
105
+ <img
106
+ alt={item.label}
107
+ className="h-full w-full object-cover"
108
+ src={item.previewThumbnailUrl}
109
+ />
110
+ ) : item.previewColor ? (
111
+ <div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
112
+ ) : (
113
+ <div className="h-full w-full bg-gray-100" />
114
+ )}
115
+ </button>
116
+ ))}
117
+ {onChange ? (
118
+ <button
119
+ className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
120
+ showCustom
121
+ ? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
122
+ : 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
123
+ }`}
124
+ onClick={handleCustomOpen}
125
+ title="Custom"
126
+ type="button"
127
+ >
128
+ Custom
129
+ </button>
130
+ ) : null}
131
+ </div>
132
+ </div>
133
+ )}
103
134
 
104
- {showCustom && (
135
+ {showCustom && onChange && (
105
136
  <div className="space-y-2 pt-2">
106
137
  <div className="flex items-center gap-2">
107
138
  <label className="w-16 text-gray-500 text-xs">Color</label>
@@ -173,20 +204,22 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
173
204
  </span>
174
205
  </div>
175
206
 
176
- <div className="flex items-center gap-2">
177
- <label className="w-16 text-gray-500 text-xs">Side</label>
178
- <select
179
- className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
180
- onChange={(e) =>
181
- handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
182
- }
183
- value={currentProps.side}
184
- >
185
- <option value="front">Front</option>
186
- <option value="back">Back</option>
187
- <option value="double">Double</option>
188
- </select>
189
- </div>
207
+ {!hideSideControl && (
208
+ <div className="flex items-center gap-2">
209
+ <label className="w-16 text-gray-500 text-xs">Side</label>
210
+ <select
211
+ className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
212
+ onChange={(e) =>
213
+ handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
214
+ }
215
+ value={currentProps.side}
216
+ >
217
+ <option value="front">Front</option>
218
+ <option value="back">Back</option>
219
+ <option value="double">Double</option>
220
+ </select>
221
+ </div>
222
+ )}
190
223
  </div>
191
224
  )}
192
225
  </div>
@@ -21,6 +21,20 @@ function stepPrecision(s: number): number {
21
21
  return Math.max(0, Math.ceil(-Math.log10(s)))
22
22
  }
23
23
 
24
+ function getAdjustedStep(
25
+ baseStep: number,
26
+ modifiers: {
27
+ shiftKey?: boolean
28
+ metaKey?: boolean
29
+ ctrlKey?: boolean
30
+ altKey?: boolean
31
+ },
32
+ ): number {
33
+ if (modifiers.shiftKey) return baseStep * 10
34
+ if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
35
+ return baseStep
36
+ }
37
+
24
38
  export function SliderControl({
25
39
  label,
26
40
  value,
@@ -58,16 +72,14 @@ export function SliderControl({
58
72
  if (isEditing) return
59
73
  e.preventDefault()
60
74
  const direction = e.deltaY < 0 ? 1 : -1
61
- let s = step
62
- if (e.shiftKey) s = step * 10
63
- else if (e.altKey) s = step * 0.1
75
+ const s = getAdjustedStep(step, e)
64
76
  const newValue = clamp(valueRef.current + direction * s)
65
77
  const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
66
78
  if (final !== valueRef.current) onChange(final)
67
79
  }
68
80
  el.addEventListener('wheel', handleWheel, { passive: false })
69
81
  return () => el.removeEventListener('wheel', handleWheel)
70
- }, [isEditing, step, clamp, onChange, precision])
82
+ }, [isEditing, step, clamp, onChange])
71
83
 
72
84
  // Arrow key support while hovered
73
85
  useEffect(() => {
@@ -78,9 +90,7 @@ export function SliderControl({
78
90
  else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
79
91
  if (direction !== 0) {
80
92
  e.preventDefault()
81
- let s = step
82
- if (e.shiftKey) s = step * 10
83
- else if (e.metaKey || e.ctrlKey) s = step * 0.1
93
+ const s = getAdjustedStep(step, e)
84
94
  const newValue = clamp(valueRef.current + direction * s)
85
95
  const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
86
96
  if (final !== valueRef.current) onChange(final)
@@ -88,7 +98,7 @@ export function SliderControl({
88
98
  }
89
99
  window.addEventListener('keydown', handleKeyDown)
90
100
  return () => window.removeEventListener('keydown', handleKeyDown)
91
- }, [isHovered, isEditing, step, clamp, onChange, precision])
101
+ }, [isHovered, isEditing, step, clamp, onChange])
92
102
 
93
103
  const handleLabelPointerDown = useCallback(
94
104
  (e: React.PointerEvent<HTMLDivElement>) => {
@@ -107,16 +117,14 @@ export function SliderControl({
107
117
  if (!dragRef.current) return
108
118
  const { startX, startValue } = dragRef.current
109
119
  const dx = e.clientX - startX
110
- let s = step
111
- if (e.shiftKey) s = step * 10
112
- else if (e.metaKey || e.ctrlKey) s = step * 0.1
120
+ const s = getAdjustedStep(step, e)
113
121
  // 4 px per step at default sensitivity
114
122
  const newValue = clamp(
115
123
  Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
116
124
  )
117
125
  onChange(newValue)
118
126
  },
119
- [step, precision, clamp, onChange],
127
+ [step, clamp, onChange],
120
128
  )
121
129
 
122
130
  const handleLabelPointerUp = useCallback(
@@ -163,12 +171,18 @@ export function SliderControl({
163
171
  setIsEditing(false)
164
172
  } else if (e.key === 'ArrowUp') {
165
173
  e.preventDefault()
166
- const newV = clamp(value + step)
174
+ const adjustedStep = getAdjustedStep(step, e)
175
+ const newV = clamp(
176
+ Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
177
+ )
167
178
  onChange(newV)
168
179
  setInputValue(newV.toFixed(precision))
169
180
  } else if (e.key === 'ArrowDown') {
170
181
  e.preventDefault()
171
- const newV = clamp(value - step)
182
+ const adjustedStep = getAdjustedStep(step, e)
183
+ const newV = clamp(
184
+ Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
185
+ )
172
186
  onChange(newV)
173
187
  setInputValue(newV.toFixed(precision))
174
188
  }
@@ -0,0 +1,32 @@
1
+ import { ShortcutToken } from '../primitives/shortcut-token'
2
+
3
+ interface BuildingHelperProps {
4
+ showRotate?: boolean
5
+ }
6
+
7
+ export function BuildingHelper({ showRotate }: BuildingHelperProps) {
8
+ return (
9
+ <div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
10
+ <div className="flex items-center gap-2 text-sm">
11
+ <ShortcutToken value="Left click" />
12
+ <span className="text-muted-foreground">Place building</span>
13
+ </div>
14
+ {showRotate && (
15
+ <>
16
+ <div className="flex items-center gap-2 text-sm">
17
+ <ShortcutToken value="R" />
18
+ <span className="text-muted-foreground">Rotate counterclockwise</span>
19
+ </div>
20
+ <div className="flex items-center gap-2 text-sm">
21
+ <ShortcutToken value="T" />
22
+ <span className="text-muted-foreground">Rotate clockwise</span>
23
+ </div>
24
+ </>
25
+ )}
26
+ <div className="flex items-center gap-2 text-sm">
27
+ <ShortcutToken value="Esc" />
28
+ <span className="text-muted-foreground">Cancel</span>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import useEditor from '../../../store/use-editor'
4
+ import { BuildingHelper } from './building-helper'
4
5
  import { CeilingHelper } from './ceiling-helper'
5
6
  import { ItemHelper } from './item-helper'
6
7
  import { RoofHelper } from './roof-helper'
@@ -12,6 +13,7 @@ export function HelperManager() {
12
13
  const movingNode = useEditor((state) => state.movingNode)
13
14
 
14
15
  if (movingNode) {
16
+ if (movingNode.type === 'building') return <BuildingHelper showRotate />
15
17
  return <ItemHelper showEsc />
16
18
  }
17
19
 
@@ -1578,3 +1578,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
1578
1578
  },
1579
1579
  },
1580
1580
  ]
1581
+
1582
+ export function getDefaultCatalogItem(category: string | null | undefined): AssetInput | null {
1583
+ if (!category) return null
1584
+ return CATALOG_ITEMS.find((item) => item.category === category) ?? null
1585
+ }
@@ -2,27 +2,27 @@
2
2
 
3
3
  import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Edit, Plus, Trash2 } from 'lucide-react'
5
+ import { Edit, Move, Plus, Trash2 } from 'lucide-react'
6
6
  import { useCallback, useEffect } from 'react'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
7
8
  import useEditor from '../../../store/use-editor'
8
- import { ActionButton } from '../controls/action-button'
9
+ import { ActionButton, ActionGroup } from '../controls/action-button'
9
10
  import { MaterialPicker } from '../controls/material-picker'
10
11
  import { PanelSection } from '../controls/panel-section'
11
12
  import { SliderControl } from '../controls/slider-control'
12
13
  import { PanelWrapper } from './panel-wrapper'
13
14
 
14
15
  export function CeilingPanel() {
15
- const selectedIds = useViewer((s) => s.selection.selectedIds)
16
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
16
17
  const setSelection = useViewer((s) => s.setSelection)
17
- const nodes = useScene((s) => s.nodes)
18
18
  const updateNode = useScene((s) => s.updateNode)
19
19
  const editingHole = useEditor((s) => s.editingHole)
20
20
  const setEditingHole = useEditor((s) => s.setEditingHole)
21
+ const setMovingNode = useEditor((s) => s.setMovingNode)
21
22
 
22
- const selectedId = selectedIds[0]
23
- const node = selectedId
24
- ? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
25
- : undefined
23
+ const node = useScene((s) =>
24
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
25
+ )
26
26
 
27
27
  const handleUpdate = useCallback(
28
28
  (updates: Partial<CeilingNode>) => {
@@ -34,7 +34,14 @@ export function CeilingPanel() {
34
34
 
35
35
  const handleMaterialChange = useCallback(
36
36
  (material: MaterialSchema) => {
37
- handleUpdate({ material })
37
+ handleUpdate({ material, materialPreset: undefined })
38
+ },
39
+ [handleUpdate],
40
+ )
41
+
42
+ const handleMaterialPresetChange = useCallback(
43
+ (materialPreset: string) => {
44
+ handleUpdate({ materialPreset, material: undefined })
38
45
  },
39
46
  [handleUpdate],
40
47
  )
@@ -77,7 +84,13 @@ export function CeilingPanel() {
77
84
  [cx - holeSize, cz + holeSize],
78
85
  ]
79
86
  const currentHoles = node?.holes || []
80
- handleUpdate({ holes: [...currentHoles, newHole] })
87
+ const currentMetadata = currentHoles.map(
88
+ (_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
89
+ )
90
+ handleUpdate({
91
+ holes: [...currentHoles, newHole],
92
+ holeMetadata: [...currentMetadata, { source: 'manual' }],
93
+ })
81
94
  setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
82
95
  }, [node, selectedId, handleUpdate, setEditingHole])
83
96
 
@@ -93,16 +106,28 @@ export function CeilingPanel() {
93
106
  (index: number) => {
94
107
  if (!selectedId) return
95
108
  const currentHoles = node?.holes || []
109
+ if (node?.holeMetadata?.[index]?.source === 'stair') return
96
110
  const newHoles = currentHoles.filter((_, i) => i !== index)
97
- handleUpdate({ holes: newHoles })
111
+ const currentMetadata = currentHoles.map(
112
+ (_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
113
+ )
114
+ const newMetadata = currentMetadata.filter((_, i) => i !== index)
115
+ handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
98
116
  if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
99
117
  setEditingHole(null)
100
118
  }
101
119
  },
102
- [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
120
+ [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
103
121
  )
104
122
 
105
- if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
123
+ const handleMove = useCallback(() => {
124
+ if (!node) return
125
+ sfxEmitter.emit('sfx:item-pick')
126
+ setMovingNode(node)
127
+ setSelection({ selectedIds: [] })
128
+ }, [node, setMovingNode, setSelection])
129
+
130
+ if (!(node && node.type === 'ceiling' && selectedId)) return null
106
131
 
107
132
  const calculateArea = (polygon: Array<[number, number]>): number => {
108
133
  if (polygon.length < 3) return 0
@@ -110,8 +135,11 @@ export function CeilingPanel() {
110
135
  const n = polygon.length
111
136
  for (let i = 0; i < n; i++) {
112
137
  const j = (i + 1) % n
113
- area += polygon[i]?.[0] * polygon[j]?.[1]
114
- area -= polygon[j]?.[0] * polygon[i]?.[1]
138
+ const current = polygon[i]
139
+ const next = polygon[j]
140
+ if (!(current && next)) continue
141
+ area += current[0] * next[1]
142
+ area -= next[0] * current[1]
115
143
  }
116
144
  return Math.abs(area) / 2
117
145
  }
@@ -158,6 +186,8 @@ export function CeilingPanel() {
158
186
  const holeArea = calculateArea(hole)
159
187
  const isEditing =
160
188
  editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
189
+ const source = node.holeMetadata?.[index]?.source ?? 'manual'
190
+ const isAutoHole = source === 'stair'
161
191
  return (
162
192
  <div
163
193
  className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -174,7 +204,8 @@ export function CeilingPanel() {
174
204
  Hole {index + 1} {isEditing && '(Editing)'}
175
205
  </p>
176
206
  <p className="text-[10px] text-muted-foreground">
177
- {holeArea.toFixed(2)} m² · {hole.length} pts
207
+ {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
208
+ {isAutoHole ? 'Auto stair cutout' : 'Manual'}
178
209
  </p>
179
210
  </div>
180
211
  <div className="flex items-center gap-1">
@@ -184,6 +215,10 @@ export function CeilingPanel() {
184
215
  label="Done"
185
216
  onClick={() => setEditingHole(null)}
186
217
  />
218
+ ) : isAutoHole ? (
219
+ <div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
220
+ Auto
221
+ </div>
187
222
  ) : (
188
223
  <>
189
224
  <button
@@ -223,8 +258,17 @@ export function CeilingPanel() {
223
258
  </PanelSection>
224
259
 
225
260
  <PanelSection title="Material">
226
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
261
+ <MaterialPicker
262
+ nodeType="ceiling"
263
+ onChange={handleMaterialChange}
264
+ onSelectMaterialPreset={handleMaterialPresetChange}
265
+ selectedMaterialPreset={node.materialPreset}
266
+ value={node.material}
267
+ />
227
268
  </PanelSection>
269
+ <ActionGroup>
270
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
271
+ </ActionGroup>
228
272
  </PanelWrapper>
229
273
  )
230
274
  }