@pascal-app/editor 0.5.1 → 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 (79) 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 +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  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/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -10
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
- import { useCallback, useState } from 'react'
4
+ import { memo, useCallback, useState } from 'react'
5
5
  import useEditor from './../../../../../store/use-editor'
6
6
  import { InlineRenameInput } from './inline-rename-input'
7
7
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -13,7 +13,11 @@ interface SlabTreeNodeProps {
13
13
  isLast?: boolean
14
14
  }
15
15
 
16
- export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
16
+ export const SlabTreeNode = memo(function SlabTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: SlabTreeNodeProps) {
17
21
  const [isEditing, setIsEditing] = useState(false)
18
22
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
19
23
  const polygon = useScene((s) => (s.nodes[nodeId] as SlabNode | undefined)?.polygon ?? [])
@@ -74,7 +78,7 @@ export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
74
78
  onToggle={() => {}}
75
79
  />
76
80
  )
77
- }
81
+ })
78
82
 
79
83
  /**
80
84
  * Calculate the area of a polygon using the shoelace formula
@@ -2,7 +2,7 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { AnimatePresence } from 'motion/react'
4
4
  import Image from 'next/image'
5
- import { useCallback, useEffect, useState } from 'react'
5
+ import { memo, useCallback, useEffect, useState } from 'react'
6
6
  import { useShallow } from 'zustand/react/shallow'
7
7
  import useEditor from '../../../../../store/use-editor'
8
8
  import { InlineRenameInput } from './inline-rename-input'
@@ -16,7 +16,11 @@ interface StairTreeNodeProps {
16
16
  isLast?: boolean
17
17
  }
18
18
 
19
- export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
19
+ export const StairTreeNode = memo(function StairTreeNode({
20
+ nodeId,
21
+ depth,
22
+ isLast,
23
+ }: StairTreeNodeProps) {
20
24
  const [isEditing, setIsEditing] = useState(false)
21
25
  const [expanded, setExpanded] = useState(false)
22
26
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -147,7 +151,7 @@ export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
147
151
  </TreeNodeWrapper>
148
152
  </div>
149
153
  )
150
- }
154
+ })
151
155
 
152
156
  function StairSegmentTreeNode({
153
157
  node,
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
4
- import { useState } from 'react'
4
+ import { memo, useState } from 'react'
5
5
  import {
6
6
  Popover,
7
7
  PopoverContent,
@@ -12,7 +12,7 @@ interface TreeNodeActionsProps {
12
12
  nodeId: AnyNodeId
13
13
  }
14
14
 
15
- export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
15
+ export const TreeNodeActions = memo(function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
16
16
  const [open, setOpen] = useState(false)
17
17
  const updateNode = useScene((state) => state.updateNode)
18
18
  const updateNodes = useScene((state) => state.updateNodes)
@@ -112,4 +112,4 @@ export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
112
112
  </Popover>
113
113
  </div>
114
114
  )
115
- }
115
+ })
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
2
2
  import { ChevronRight } from 'lucide-react'
3
3
  import { AnimatePresence, motion } from 'motion/react'
4
- import { forwardRef, useEffect, useRef } from 'react'
4
+ import { forwardRef, memo, useEffect, useRef } from 'react'
5
5
 
6
6
  export function handleTreeSelection(
7
7
  e: React.MouseEvent,
@@ -73,7 +73,7 @@ interface TreeNodeProps {
73
73
  isLast?: boolean
74
74
  }
75
75
 
76
- export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
76
+ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
77
77
  const nodeType = useScene((state) => state.nodes[nodeId]?.type)
78
78
 
79
79
  if (!nodeType) return null
@@ -106,7 +106,7 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
106
106
  default:
107
107
  return null
108
108
  }
109
- }
109
+ })
110
110
 
111
111
  interface TreeNodeWrapperProps {
112
112
  nodeId?: string
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
- import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
6
6
  import useEditor from './../../../../../store/use-editor'
7
7
  import { InlineRenameInput } from './inline-rename-input'
@@ -14,7 +14,11 @@ interface WallTreeNodeProps {
14
14
  isLast?: boolean
15
15
  }
16
16
 
17
- export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
17
+ export const WallTreeNode = memo(function WallTreeNode({
18
+ nodeId,
19
+ depth,
20
+ isLast,
21
+ }: WallTreeNodeProps) {
18
22
  const [expanded, setExpanded] = useState(false)
19
23
  const [isEditing, setIsEditing] = useState(false)
20
24
  const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
@@ -107,4 +111,4 @@ export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
107
111
  ))}
108
112
  </TreeNodeWrapper>
109
113
  )
110
- }
114
+ })
@@ -3,7 +3,7 @@
3
3
  import { type AnyNodeId, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import Image from 'next/image'
6
- import { useCallback, useState } from 'react'
6
+ import { memo, useCallback, useState } from 'react'
7
7
  import useEditor from './../../../../../store/use-editor'
8
8
  import { InlineRenameInput } from './inline-rename-input'
9
9
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -15,7 +15,11 @@ interface WindowTreeNodeProps {
15
15
  isLast?: boolean
16
16
  }
17
17
 
18
- export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
18
+ export const WindowTreeNode = memo(function WindowTreeNode({
19
+ nodeId,
20
+ depth,
21
+ isLast,
22
+ }: WindowTreeNodeProps) {
19
23
  const [isEditing, setIsEditing] = useState(false)
20
24
  const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
21
25
  const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
@@ -72,4 +76,4 @@ export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
72
76
  onToggle={() => {}}
73
77
  />
74
78
  )
75
- }
79
+ })
@@ -1,6 +1,6 @@
1
1
  import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
- import { useCallback, useState } from 'react'
3
+ import { memo, useCallback, useState } from 'react'
4
4
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
5
5
  import { InlineRenameInput } from './inline-rename-input'
6
6
  import { focusTreeNode, TreeNodeWrapper } from './tree-node'
@@ -12,7 +12,11 @@ interface ZoneTreeNodeProps {
12
12
  isLast?: boolean
13
13
  }
14
14
 
15
- export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
15
+ export const ZoneTreeNode = memo(function ZoneTreeNode({
16
+ nodeId,
17
+ depth,
18
+ isLast,
19
+ }: ZoneTreeNodeProps) {
16
20
  const [isEditing, setIsEditing] = useState(false)
17
21
  const updateNode = useScene((state) => state.updateNode)
18
22
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -61,7 +65,7 @@ export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
61
65
  onToggle={() => {}}
62
66
  />
63
67
  )
64
- }
68
+ })
65
69
 
66
70
  /**
67
71
  * Calculate the area of a polygon using the shoelace formula
@@ -2,11 +2,17 @@
2
2
 
3
3
  import { Icon as IconifyIcon } from '@iconify/react'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react'
5
+ import { Check, ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react'
6
6
  import { useCallback } from 'react'
7
7
  import { cn } from '../../lib/utils'
8
8
  import useEditor from '../../store/use-editor'
9
- import type { ViewMode } from '../../store/use-editor'
9
+ import type { GridSnapStep, ViewMode } from '../../store/use-editor'
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from './primitives/dropdown-menu'
10
16
  import { useSidebarStore } from './primitives/sidebar'
11
17
  import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
12
18
 
@@ -174,6 +180,18 @@ const levelModeLabels: Record<string, string> = {
174
180
  solo: 'Solo',
175
181
  }
176
182
 
183
+ const gridSnapOrder: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
184
+ const gridSnapLabels: Record<GridSnapStep, string> = {
185
+ 0.5: '0.50',
186
+ 0.25: '0.25',
187
+ 0.1: '0.10',
188
+ 0.05: '0.05',
189
+ }
190
+
191
+ function formatGridSnapStep(step: GridSnapStep): string {
192
+ return gridSnapLabels[step]
193
+ }
194
+
177
195
  function LevelModeToggle() {
178
196
  const levelMode = useViewer((s) => s.levelMode)
179
197
  const setLevelMode = useViewer((s) => s.setLevelMode)
@@ -219,6 +237,40 @@ function LevelModeToggle() {
219
237
  )
220
238
  }
221
239
 
240
+ function GridSnapToggle() {
241
+ const gridSnapStep = useEditor((s) => s.gridSnapStep)
242
+ const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
243
+
244
+ return (
245
+ <DropdownMenu>
246
+ <Tooltip>
247
+ <TooltipTrigger asChild>
248
+ <DropdownMenuTrigger asChild>
249
+ <button className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5')} type="button">
250
+ <IconifyIcon height={14} icon="lucide:grid-2x2" width={14} />
251
+ <span className="font-medium text-xs">{formatGridSnapStep(gridSnapStep)}</span>
252
+ </button>
253
+ </DropdownMenuTrigger>
254
+ </TooltipTrigger>
255
+ <TooltipContent side="bottom">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
256
+ </Tooltip>
257
+ <DropdownMenuContent align="center" side="bottom">
258
+ {gridSnapOrder.map((step) => {
259
+ const isActive = step === gridSnapStep
260
+ return (
261
+ <DropdownMenuItem key={step} onSelect={() => setGridSnapStep(step)}>
262
+ <span className="flex min-w-12 items-center justify-between gap-3">
263
+ <span>{formatGridSnapStep(step)}</span>
264
+ {isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
265
+ </span>
266
+ </DropdownMenuItem>
267
+ )
268
+ })}
269
+ </DropdownMenuContent>
270
+ </DropdownMenu>
271
+ )
272
+ }
273
+
222
274
  // ── Wall mode toggle ────────────────────────────────────────────────────────
223
275
 
224
276
  const wallModeOrder = ['cutaway', 'up', 'down'] as const
@@ -330,6 +382,7 @@ export function ViewerToolbarRight() {
330
382
  <div className={TOOLBAR_CONTAINER}>
331
383
  <LevelModeToggle />
332
384
  <WallModeToggle />
385
+ <GridSnapToggle />
333
386
  <div className="my-1.5 w-px bg-border/50" />
334
387
  <UnitToggle />
335
388
  <ThemeToggle />
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
14
14
  import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
15
15
  import { motion } from 'motion/react'
16
16
  import Link from 'next/link'
17
+ import { useShallow } from 'zustand/react/shallow'
17
18
  import { cn } from '../lib/utils'
18
19
  import { ActionButton } from './ui/action-menu/action-button'
19
20
  import { TooltipProvider } from './ui/primitives/tooltip'
@@ -87,7 +88,6 @@ export const ViewerOverlay = ({
87
88
  onBack,
88
89
  }: ViewerOverlayProps) => {
89
90
  const selection = useViewer((s) => s.selection)
90
- const nodes = useScene((s) => s.nodes)
91
91
  const showScans = useViewer((s) => s.showScans)
92
92
  const showGuides = useViewer((s) => s.showGuides)
93
93
  const cameraMode = useViewer((s) => s.cameraMode)
@@ -95,24 +95,30 @@ export const ViewerOverlay = ({
95
95
  const wallMode = useViewer((s) => s.wallMode)
96
96
  const theme = useViewer((s) => s.theme)
97
97
 
98
- const building = selection.buildingId
99
- ? (nodes[selection.buildingId] as BuildingNode | undefined)
100
- : null
101
- const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
102
- const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
103
-
104
- // Get the first selected item (if any)
105
- const selectedNode =
106
- selection.selectedIds.length > 0
107
- ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
108
- : null
109
-
110
- // Get all levels for the selected building
111
- const levels =
112
- building?.children
113
- .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
114
- .filter((n): n is LevelNode => n?.type === 'level')
115
- .sort((a, b) => a.level - b.level) ?? []
98
+ // Subscribe only to the specific nodes we read so that creating an unrelated
99
+ // node elsewhere in the scene doesn't re-render this overlay.
100
+ const firstSelectedId = selection.selectedIds[0] ?? null
101
+ const building = useScene((s) =>
102
+ selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null,
103
+ )
104
+ const level = useScene((s) =>
105
+ selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
106
+ )
107
+ const zone = useScene((s) =>
108
+ selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
109
+ )
110
+ const selectedNode = useScene((s) =>
111
+ firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
112
+ )
113
+ const levels = useScene(
114
+ useShallow((s) => {
115
+ if (!building) return []
116
+ return building.children
117
+ .map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
118
+ .filter((n): n is LevelNode => n?.type === 'level')
119
+ .sort((a, b) => a.level - b.level)
120
+ }),
121
+ )
116
122
 
117
123
  const handleLevelClick = (levelId: LevelNode['id']) => {
118
124
  // When switching levels, deselect zone and items
@@ -1,12 +1,18 @@
1
1
  import { type AnyNodeId, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { useMemo } from 'react'
4
+ import { useShallow } from 'zustand/react/shallow'
4
5
  import useEditor, { type StructureTool } from '../store/use-editor'
5
6
 
6
7
  export function useContextualTools() {
7
8
  const selection = useViewer((s) => s.selection)
8
- const nodes = useScene((s) => s.nodes)
9
- const phase = useEditor((s) => s.phase)
9
+ // Only resubscribe when the *types* of selected nodes change, not when any
10
+ // node in the scene mutates.
11
+ const selectedTypes = useScene(
12
+ useShallow((s) =>
13
+ selection.selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type).filter(Boolean),
14
+ ),
15
+ )
10
16
  const structureLayer = useEditor((s) => s.structureLayer)
11
17
 
12
18
  return useMemo(() => {
@@ -26,35 +32,30 @@ export function useContextualTools() {
26
32
  'window',
27
33
  ]
28
34
 
29
- if (selection.selectedIds.length === 0) {
35
+ if (selectedTypes.length === 0) {
30
36
  return defaultTools
31
37
  }
32
38
 
33
- // Get types of selected nodes
34
- const selectedTypes = new Set(
35
- selection.selectedIds.map((id) => nodes[id as AnyNodeId]?.type).filter(Boolean),
36
- )
37
-
38
39
  // If a wall is selected, prioritize wall-hosted elements
39
- if (selectedTypes.has('wall')) {
40
+ if (selectedTypes.includes('wall')) {
40
41
  return ['window', 'door', 'wall', 'fence'] as StructureTool[]
41
42
  }
42
43
 
43
44
  // If a slab is selected, prioritize slab editing
44
- if (selectedTypes.has('slab')) {
45
+ if (selectedTypes.includes('slab')) {
45
46
  return ['slab', 'wall'] as StructureTool[]
46
47
  }
47
48
 
48
49
  // If a ceiling is selected, prioritize ceiling editing
49
- if (selectedTypes.has('ceiling')) {
50
+ if (selectedTypes.includes('ceiling')) {
50
51
  return ['ceiling'] as StructureTool[]
51
52
  }
52
53
 
53
54
  // If a roof is selected, prioritize roof editing
54
- if (selectedTypes.has('roof')) {
55
+ if (selectedTypes.includes('roof')) {
55
56
  return ['roof'] as StructureTool[]
56
57
  }
57
58
 
58
59
  return defaultTools
59
- }, [selection.selectedIds, nodes, structureLayer])
60
+ }, [selectedTypes, structureLayer])
60
61
  }
@@ -1,6 +1,7 @@
1
1
  import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { useEffect } from 'react'
4
+ import { runRedo, runUndo } from '../lib/history'
4
5
  import { sfxEmitter } from '../lib/sfx-bus'
5
6
  import useEditor from '../store/use-editor'
6
7
 
@@ -91,11 +92,11 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
91
92
  } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
92
93
  if (isVersionPreviewMode) return
93
94
  e.preventDefault()
94
- useScene.temporal.getState().undo()
95
+ runUndo()
95
96
  } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
96
97
  if (isVersionPreviewMode) return
97
98
  e.preventDefault()
98
- useScene.temporal.getState().redo()
99
+ runRedo()
99
100
  } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
100
101
  e.preventDefault()
101
102
  const { buildingId, levelId } = useViewer.getState().selection
package/src/index.tsx CHANGED
@@ -15,11 +15,13 @@ export {
15
15
  } from './components/ui/sidebar/panels/settings-panel'
16
16
  export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
17
17
  export type { SidebarTab } from './components/ui/sidebar/tab-bar'
18
+ export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
18
19
  export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
19
20
  export { PresetsProvider } from './contexts/presets-context'
20
21
  export type { SaveStatus } from './hooks/use-auto-save'
21
22
  export type { SceneGraph } from './lib/scene'
22
23
  export { applySceneGraphToEditor } from './lib/scene'
24
+ export { triggerSFX } from './lib/sfx-bus'
23
25
  export { default as useAudio } from './store/use-audio'
24
26
  export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
25
27
  export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'
@@ -30,4 +32,3 @@ export {
30
32
  usePaletteViewRegistry,
31
33
  } from './store/use-palette-view-registry'
32
34
  export { useUploadStore } from './store/use-upload'
33
- export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
@@ -0,0 +1,20 @@
1
+ import { useLiveTransforms, useScene } from '@pascal-app/core'
2
+
3
+ function refreshSceneAfterHistoryJump() {
4
+ useLiveTransforms.getState().clearAll()
5
+
6
+ const state = useScene.getState()
7
+ for (const node of Object.values(state.nodes)) {
8
+ state.markDirty(node.id)
9
+ }
10
+ }
11
+
12
+ export function runUndo() {
13
+ useScene.temporal.getState().undo()
14
+ refreshSceneAfterHistoryJump()
15
+ }
16
+
17
+ export function runRedo() {
18
+ useScene.temporal.getState().redo()
19
+ refreshSceneAfterHistoryJump()
20
+ }
@@ -1,26 +1,90 @@
1
1
  import { Howl } from 'howler'
2
2
  import useAudio from '../store/use-audio'
3
3
 
4
+ // Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
5
+ // so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones, enough to kill the
6
+ // machine-gun feeling when the same SFX fires in rapid succession.
7
+ type SFXConfig = {
8
+ src: string
9
+ // Random playback-rate range applied per play (1 = unchanged).
10
+ rateRange?: [number, number]
11
+ // Random volume multiplier range applied per play (1 = unchanged).
12
+ volumeRange?: [number, number]
13
+ // Minimum gap between two plays of this SFX. Triggers within this window
14
+ // are silently dropped so bursty sequences don't phase-stack into noise.
15
+ minIntervalMs?: number
16
+ // Random stereo pan per play, max absolute offset (0 = center, 1 = hard
17
+ // right). A small value like 0.15 keeps things centered but adds just enough
18
+ // spread to stop repeats from stacking on the same point in the field.
19
+ panJitter?: number
20
+ }
21
+
22
+ const DEFAULT_MIN_INTERVAL_MS = 30
23
+
4
24
  // SFX sound definitions
5
- export const SFX = {
6
- gridSnap: '/audios/sfx/grid_snap.mp3',
7
- itemDelete: '/audios/sfx/item_delete.mp3',
8
- itemPick: '/audios/sfx/item_pick.mp3',
9
- itemPlace: '/audios/sfx/item_place.mp3',
10
- itemRotate: '/audios/sfx/item_rotate.mp3',
11
- structureBuild: '/audios/sfx/structure_build.mp3',
12
- structureDelete: '/audios/sfx/structure_delete.mp3',
25
+ export const SFX: Record<string, SFXConfig> = {
26
+ gridSnap: {
27
+ src: '/audios/sfx/grid_snap.mp3',
28
+ rateRange: [0.94, 1.06],
29
+ volumeRange: [0.92, 1.0],
30
+ panJitter: 0.15,
31
+ },
32
+ itemDelete: {
33
+ src: '/audios/sfx/item_delete.mp3',
34
+ rateRange: [0.9, 1.1],
35
+ volumeRange: [0.9, 1.0],
36
+ panJitter: 0.15,
37
+ },
38
+ itemPick: {
39
+ src: '/audios/sfx/item_pick.mp3',
40
+ rateRange: [0.92, 1.08],
41
+ volumeRange: [0.92, 1.0],
42
+ panJitter: 0.15,
43
+ },
44
+ itemPlace: {
45
+ src: '/audios/sfx/item_place.mp3',
46
+ rateRange: [0.98, 1.06],
47
+ volumeRange: [0.9, 1.0],
48
+ panJitter: 0.15,
49
+ },
50
+ itemRotate: {
51
+ src: '/audios/sfx/item_rotate.mp3',
52
+ rateRange: [0.94, 1.06],
53
+ volumeRange: [0.92, 1.0],
54
+ panJitter: 0.15,
55
+ },
56
+ structureBuild: {
57
+ src: '/audios/sfx/structure_build.mp3',
58
+ rateRange: [0.95, 1.05],
59
+ volumeRange: [0.88, 1.0],
60
+ panJitter: 0.15,
61
+ },
62
+ structureDelete: {
63
+ src: '/audios/sfx/structure_delete.mp3',
64
+ rateRange: [0.9, 1.1],
65
+ volumeRange: [0.9, 1.0],
66
+ panJitter: 0.15,
67
+ },
68
+ snapshotCapture: {
69
+ // Shutter should sound consistent, no variation.
70
+ src: '/audios/sfx/snapshot_capture.mp3',
71
+ },
13
72
  } as const
14
73
 
15
74
  export type SFXName = keyof typeof SFX
16
75
 
76
+ function randomInRange([min, max]: [number, number]): number {
77
+ return min + Math.random() * (max - min)
78
+ }
79
+
17
80
  // Preload all SFX sounds
18
81
  const sfxCache = new Map<SFXName, Howl>()
82
+ const lastPlayedAt = new Map<SFXName, number>()
19
83
 
20
84
  // Initialize all sounds
21
- Object.entries(SFX).forEach(([name, path]) => {
85
+ Object.entries(SFX).forEach(([name, config]) => {
22
86
  const sound = new Howl({
23
- src: [path],
87
+ src: [config.src],
24
88
  preload: true,
25
89
  volume: 0.5, // Will be adjusted by the bus
26
90
  })
@@ -36,15 +100,34 @@ export function playSFX(name: SFXName) {
36
100
  console.warn(`SFX not found: ${name}`)
37
101
  return
38
102
  }
103
+ const config = SFX[name]!
104
+
105
+ // Drop rapid repeats, two plays of the same SFX within minIntervalMs just
106
+ // smear into noise, they don't add useful information.
107
+ const now = performance.now()
108
+ const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
109
+ const last = lastPlayedAt.get(name)
110
+ if (last !== undefined && now - last < minInterval) return
111
+ lastPlayedAt.set(name, now)
39
112
 
40
113
  const { masterVolume, sfxVolume, muted } = useAudio.getState()
41
114
 
42
115
  if (muted) return
43
116
 
44
117
  // Calculate final volume (masterVolume and sfxVolume are 0-100)
45
- const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
46
- sound.volume(finalVolume)
47
- sound.play()
118
+ const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
119
+ const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
120
+ const rate = config.rateRange ? randomInRange(config.rateRange) : 1
121
+
122
+ // Apply per-play variation using the returned sound id so overlapping plays
123
+ // don't fight over shared properties on the Howl.
124
+ const id = sound.play()
125
+ sound.volume(baseVolume * volumeJitter, id)
126
+ if (rate !== 1) sound.rate(rate, id)
127
+ if (config.panJitter) {
128
+ const pan = (Math.random() * 2 - 1) * config.panJitter
129
+ sound.stereo(pan, id)
130
+ }
48
131
  }
49
132
 
50
133
  /**