@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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +377 -50
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +125 -10
|
@@ -1,62 +1,61 @@
|
|
|
1
|
-
import { useScene, type ZoneNode } from '@pascal-app/core'
|
|
1
|
+
import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
-
import { 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'
|
|
7
7
|
import { TreeNodeActions } from './tree-node-actions'
|
|
8
8
|
|
|
9
9
|
interface ZoneTreeNodeProps {
|
|
10
|
-
|
|
10
|
+
nodeId: AnyNodeId
|
|
11
11
|
depth: number
|
|
12
12
|
isLast?: boolean
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export
|
|
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
|
-
const
|
|
19
|
-
const
|
|
22
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
23
|
+
const color = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.color)
|
|
24
|
+
const polygon = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.polygon ?? [])
|
|
25
|
+
const isSelected = useViewer((state) => state.selection.zoneId === nodeId)
|
|
26
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
20
27
|
const setSelection = useViewer((state) => state.setSelection)
|
|
21
28
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
22
29
|
|
|
23
|
-
const handleClick = () => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const handleMouseEnter = () => {
|
|
32
|
-
setHoveredId(node.id)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const handleMouseLeave = () => {
|
|
36
|
-
setHoveredId(null)
|
|
37
|
-
}
|
|
30
|
+
const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
|
|
31
|
+
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
|
32
|
+
const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
|
|
33
|
+
const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
|
|
34
|
+
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
35
|
+
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
38
36
|
|
|
39
37
|
// Calculate approximate area from polygon
|
|
40
|
-
const area = calculatePolygonArea(
|
|
38
|
+
const area = calculatePolygonArea(polygon).toFixed(1)
|
|
41
39
|
const defaultName = `Zone (${area}m²)`
|
|
42
40
|
|
|
43
41
|
return (
|
|
44
42
|
<TreeNodeWrapper
|
|
45
|
-
actions={<TreeNodeActions
|
|
43
|
+
actions={<TreeNodeActions nodeId={nodeId} />}
|
|
46
44
|
depth={depth}
|
|
47
45
|
expanded={false}
|
|
48
46
|
hasChildren={false}
|
|
49
|
-
icon={<ColorDot color={
|
|
47
|
+
icon={<ColorDot color={color} onChange={(c) => updateNode(nodeId, { color: c })} />}
|
|
50
48
|
isHovered={isHovered}
|
|
51
49
|
isLast={isLast}
|
|
52
50
|
isSelected={isSelected}
|
|
51
|
+
isVisible={isVisible}
|
|
53
52
|
label={
|
|
54
53
|
<InlineRenameInput
|
|
55
54
|
defaultName={defaultName}
|
|
56
55
|
isEditing={isEditing}
|
|
57
|
-
|
|
58
|
-
onStartEditing={
|
|
59
|
-
onStopEditing={
|
|
56
|
+
nodeId={nodeId}
|
|
57
|
+
onStartEditing={handleStartEditing}
|
|
58
|
+
onStopEditing={handleStopEditing}
|
|
60
59
|
/>
|
|
61
60
|
}
|
|
62
61
|
onClick={handleClick}
|
|
@@ -66,7 +65,7 @@ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
|
|
66
65
|
onToggle={() => {}}
|
|
67
66
|
/>
|
|
68
67
|
)
|
|
69
|
-
}
|
|
68
|
+
})
|
|
70
69
|
|
|
71
70
|
/**
|
|
72
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'
|
|
@@ -62,6 +63,7 @@ const wallModeConfig = {
|
|
|
62
63
|
const getNodeName = (node: AnyNode): string => {
|
|
63
64
|
if ('name' in node && node.name) return node.name
|
|
64
65
|
if (node.type === 'wall') return 'Wall'
|
|
66
|
+
if (node.type === 'fence') return 'Fence'
|
|
65
67
|
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
|
66
68
|
if (node.type === 'slab') return 'Slab'
|
|
67
69
|
if (node.type === 'ceiling') return 'Ceiling'
|
|
@@ -86,7 +88,6 @@ export const ViewerOverlay = ({
|
|
|
86
88
|
onBack,
|
|
87
89
|
}: ViewerOverlayProps) => {
|
|
88
90
|
const selection = useViewer((s) => s.selection)
|
|
89
|
-
const nodes = useScene((s) => s.nodes)
|
|
90
91
|
const showScans = useViewer((s) => s.showScans)
|
|
91
92
|
const showGuides = useViewer((s) => s.showGuides)
|
|
92
93
|
const cameraMode = useViewer((s) => s.cameraMode)
|
|
@@ -94,24 +95,30 @@ export const ViewerOverlay = ({
|
|
|
94
95
|
const wallMode = useViewer((s) => s.wallMode)
|
|
95
96
|
const theme = useViewer((s) => s.theme)
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
)
|
|
115
122
|
|
|
116
123
|
const handleLevelClick = (levelId: LevelNode['id']) => {
|
|
117
124
|
// When switching levels, deselect zone and items
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useScene } from '@pascal-app/core'
|
|
4
|
-
import { type MutableRefObject, useCallback, useEffect, useRef
|
|
4
|
+
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
|
5
5
|
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
|
|
6
6
|
|
|
7
7
|
const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
@@ -26,9 +26,7 @@ export function useAutoSave({
|
|
|
26
26
|
onDirty,
|
|
27
27
|
onSaveStatusChange,
|
|
28
28
|
isVersionPreviewMode = false,
|
|
29
|
-
}: UseAutoSaveOptions): {
|
|
30
|
-
const [saveStatus, _setSaveStatus] = useState<SaveStatus>('idle')
|
|
31
|
-
|
|
29
|
+
}: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject<boolean> } {
|
|
32
30
|
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
33
31
|
const isSavingRef = useRef(false)
|
|
34
32
|
const isLoadingSceneRef = useRef(false)
|
|
@@ -56,7 +54,6 @@ export function useAutoSave({
|
|
|
56
54
|
}, [isVersionPreviewMode])
|
|
57
55
|
|
|
58
56
|
const setSaveStatus = useCallback((status: SaveStatus) => {
|
|
59
|
-
_setSaveStatus(status)
|
|
60
57
|
onSaveStatusChangeRef.current?.(status)
|
|
61
58
|
}, [])
|
|
62
59
|
|
|
@@ -190,5 +187,5 @@ export function useAutoSave({
|
|
|
190
187
|
setSaveStatus('saved')
|
|
191
188
|
}, [isVersionPreviewMode, setSaveStatus])
|
|
192
189
|
|
|
193
|
-
return {
|
|
190
|
+
return { isLoadingSceneRef }
|
|
194
191
|
}
|
|
@@ -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
|
-
|
|
9
|
-
|
|
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(() => {
|
|
@@ -16,37 +22,40 @@ export function useContextualTools() {
|
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
// Default tools when nothing is selected
|
|
19
|
-
const defaultTools: StructureTool[] = [
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
const defaultTools: StructureTool[] = [
|
|
26
|
+
'wall',
|
|
27
|
+
'fence',
|
|
28
|
+
'slab',
|
|
29
|
+
'ceiling',
|
|
30
|
+
'roof',
|
|
31
|
+
'door',
|
|
32
|
+
'window',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
if (selectedTypes.length === 0) {
|
|
22
36
|
return defaultTools
|
|
23
37
|
}
|
|
24
38
|
|
|
25
|
-
// Get types of selected nodes
|
|
26
|
-
const selectedTypes = new Set(
|
|
27
|
-
selection.selectedIds.map((id) => nodes[id as AnyNodeId]?.type).filter(Boolean),
|
|
28
|
-
)
|
|
29
|
-
|
|
30
39
|
// If a wall is selected, prioritize wall-hosted elements
|
|
31
|
-
if (selectedTypes.
|
|
32
|
-
return ['window', 'door', 'wall'] as StructureTool[]
|
|
40
|
+
if (selectedTypes.includes('wall')) {
|
|
41
|
+
return ['window', 'door', 'wall', 'fence'] as StructureTool[]
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
// If a slab is selected, prioritize slab editing
|
|
36
|
-
if (selectedTypes.
|
|
45
|
+
if (selectedTypes.includes('slab')) {
|
|
37
46
|
return ['slab', 'wall'] as StructureTool[]
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
// If a ceiling is selected, prioritize ceiling editing
|
|
41
|
-
if (selectedTypes.
|
|
50
|
+
if (selectedTypes.includes('ceiling')) {
|
|
42
51
|
return ['ceiling'] as StructureTool[]
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
// If a roof is selected, prioritize roof editing
|
|
46
|
-
if (selectedTypes.
|
|
55
|
+
if (selectedTypes.includes('roof')) {
|
|
47
56
|
return ['roof'] as StructureTool[]
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
return defaultTools
|
|
51
|
-
}, [
|
|
60
|
+
}, [selectedTypes, structureLayer])
|
|
52
61
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
type EventSuffix,
|
|
4
|
+
emitter,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
sceneRegistry,
|
|
7
|
+
} from '@pascal-app/core'
|
|
2
8
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
9
|
import { useThree } from '@react-three/fiber'
|
|
4
10
|
import { useEffect, useRef } from 'react'
|
|
@@ -44,9 +50,15 @@ export function useGridEvents(gridY: number) {
|
|
|
44
50
|
const point = getIntersection(nativeEvent)
|
|
45
51
|
if (!point) return
|
|
46
52
|
|
|
53
|
+
// Convert world-space point to building-local for tools that live inside a building.
|
|
54
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
55
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
56
|
+
const localPoint = buildingMesh ? buildingMesh.worldToLocal(point.clone()) : point
|
|
57
|
+
|
|
47
58
|
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
|
|
48
59
|
const payload: GridEvent = {
|
|
49
60
|
position: [point.x, point.y, point.z],
|
|
61
|
+
localPosition: [localPoint.x, localPoint.y, localPoint.z],
|
|
50
62
|
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
|
|
51
63
|
}
|
|
52
64
|
|
|
@@ -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
|
|
|
@@ -84,14 +85,18 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|
|
84
85
|
useEditor.getState().setPhase('structure')
|
|
85
86
|
useEditor.getState().setStructureLayer('elements')
|
|
86
87
|
useEditor.getState().setMode('build')
|
|
88
|
+
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
|
|
89
|
+
if (isVersionPreviewMode) return
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
useEditor.getState().setMode('delete')
|
|
87
92
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
|
88
93
|
if (isVersionPreviewMode) return
|
|
89
94
|
e.preventDefault()
|
|
90
|
-
|
|
95
|
+
runUndo()
|
|
91
96
|
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
|
92
97
|
if (isVersionPreviewMode) return
|
|
93
98
|
e.preventDefault()
|
|
94
|
-
|
|
99
|
+
runRedo()
|
|
95
100
|
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
|
96
101
|
e.preventDefault()
|
|
97
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
|
+
}
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -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:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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,
|
|
85
|
+
Object.entries(SFX).forEach(([name, config]) => {
|
|
22
86
|
const sound = new Howl({
|
|
23
|
-
src: [
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
/**
|