@pascal-app/editor 0.8.0 → 0.9.1
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 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5829 -12005
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +178 -149
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +433 -156
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -538
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +10 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +320 -37
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type ChildQuery,
|
|
7
|
+
createDragSession,
|
|
8
|
+
createSceneApi,
|
|
9
|
+
type DragAction,
|
|
10
|
+
type DragSessionInput,
|
|
11
|
+
emitter,
|
|
12
|
+
type GridEvent,
|
|
13
|
+
type Modifiers,
|
|
14
|
+
type SpatialQuery,
|
|
15
|
+
useScene,
|
|
16
|
+
} from '@pascal-app/core'
|
|
17
|
+
import { useEffect, useRef } from 'react'
|
|
18
|
+
|
|
19
|
+
const sceneApi = createSceneApi(useScene)
|
|
20
|
+
|
|
21
|
+
function modifiersFromGridEvent(event: GridEvent): Modifiers {
|
|
22
|
+
const ne = event.nativeEvent?.nativeEvent as Partial<KeyboardEvent> | undefined
|
|
23
|
+
return {
|
|
24
|
+
shift: ne?.shiftKey ?? false,
|
|
25
|
+
alt: ne?.altKey ?? false,
|
|
26
|
+
ctrl: ne?.ctrlKey ?? false,
|
|
27
|
+
meta: ne?.metaKey ?? false,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type UseDragActionArgs<Ctx, Draft> = {
|
|
32
|
+
/** When true the session is live: subscribes to grid events + Esc.
|
|
33
|
+
* Flipping to false (or unmount) cancels and cleans up. */
|
|
34
|
+
active: boolean
|
|
35
|
+
action: DragAction<Ctx, Draft>
|
|
36
|
+
/** Captured once at the moment `active` flips to true. */
|
|
37
|
+
initial: DragSessionInput
|
|
38
|
+
/** Relations cascade plumbing. */
|
|
39
|
+
spatialQuery?: SpatialQuery
|
|
40
|
+
childQuery?: ChildQuery
|
|
41
|
+
/** Fires once after `action.commit` returns true. */
|
|
42
|
+
onCommit?: () => void
|
|
43
|
+
/** Fires once after `action.cancel` (Esc, unmount, or commit-returns-false). */
|
|
44
|
+
onCancel?: () => void
|
|
45
|
+
/**
|
|
46
|
+
* Milliseconds after activation during which `grid:click` is swallowed.
|
|
47
|
+
* Stops the very click that mounted this tool (a DOM button or 3D
|
|
48
|
+
* handle elsewhere) from cascading into the grid and immediately
|
|
49
|
+
* committing the drag. Defaults to 150ms — matches the legacy guard
|
|
50
|
+
* used by every kind-owned tool entered via a click.
|
|
51
|
+
*/
|
|
52
|
+
activationGraceMs?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* React hook wrapping the pure `createDragSession` orchestrator with the
|
|
57
|
+
* editor's grid event emitter and an Esc-to-cancel keyboard binding.
|
|
58
|
+
*
|
|
59
|
+
* - Pauses scene history when active → resumes on commit/cancel/unmount
|
|
60
|
+
* - Per `grid:move` runs preview + snap + apply and cascades dirty marks
|
|
61
|
+
* - `grid:click` triggers commit; Escape triggers cancel
|
|
62
|
+
*
|
|
63
|
+
* For tests of the underlying behavior, drive `createDragSession` directly
|
|
64
|
+
* (no React needed). This hook is the thin glue.
|
|
65
|
+
*/
|
|
66
|
+
export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
|
|
67
|
+
// Stable refs so handlers don't re-bind when callbacks change.
|
|
68
|
+
const argsRef = useRef(args)
|
|
69
|
+
argsRef.current = args
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!args.active) return
|
|
73
|
+
|
|
74
|
+
const session = createDragSession<Ctx, Draft>(argsRef.current.action, sceneApi, {
|
|
75
|
+
spatialQuery: argsRef.current.spatialQuery,
|
|
76
|
+
childQuery: argsRef.current.childQuery,
|
|
77
|
+
onCommit: () => argsRef.current.onCommit?.(),
|
|
78
|
+
onCancel: () => argsRef.current.onCancel?.(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
session.start(argsRef.current.initial)
|
|
82
|
+
|
|
83
|
+
const activatedAt = Date.now()
|
|
84
|
+
const graceMs = argsRef.current.activationGraceMs ?? 150
|
|
85
|
+
|
|
86
|
+
const onMove = (event: GridEvent) => {
|
|
87
|
+
const point: readonly [number, number] = [event.localPosition[0], event.localPosition[2]]
|
|
88
|
+
session.move(point, modifiersFromGridEvent(event))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const onClick = (event: GridEvent) => {
|
|
92
|
+
// Swallow the click that mounted this tool — otherwise the very
|
|
93
|
+
// first grid:click cascades into commit() before any move().
|
|
94
|
+
if (Date.now() - activatedAt < graceMs) {
|
|
95
|
+
event.nativeEvent?.stopPropagation?.()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
session.commit()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
102
|
+
if (e.key === 'Escape') session.cancel()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
emitter.on('grid:move', onMove)
|
|
106
|
+
emitter.on('grid:click', onClick)
|
|
107
|
+
if (typeof window !== 'undefined') {
|
|
108
|
+
window.addEventListener('keydown', onKeyDown)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
emitter.off('grid:move', onMove)
|
|
113
|
+
emitter.off('grid:click', onClick)
|
|
114
|
+
if (typeof window !== 'undefined') {
|
|
115
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
116
|
+
}
|
|
117
|
+
// If the parent flipped `active` to false (or unmounted) while we were
|
|
118
|
+
// still mid-drag, treat it as a cancel — no dangling history pause.
|
|
119
|
+
session.dispose()
|
|
120
|
+
}
|
|
121
|
+
}, [args.active])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type { AnyNode, AnyNodeId, DragAction, Modifiers }
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
1
|
+
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { useEffect } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { toggleDoorOpenState } from '../lib/door-interaction'
|
|
5
5
|
import { runRedo, runUndo } from '../lib/history'
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
copySelectedNodesToEditorClipboard,
|
|
8
|
+
pasteEditorClipboardToLevel,
|
|
9
|
+
} from '../lib/scene-clipboard'
|
|
10
|
+
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
|
|
11
|
+
import { toggleWindowOpenState } from '../lib/window-interaction'
|
|
8
12
|
import useEditor from '../store/use-editor'
|
|
9
13
|
|
|
10
14
|
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
|
@@ -95,7 +99,7 @@ export const useKeyboard = ({
|
|
|
95
99
|
useEditor.getState().setPhase('structure')
|
|
96
100
|
useEditor.getState().setStructureLayer('elements')
|
|
97
101
|
useEditor.getState().setMode('build')
|
|
98
|
-
} else if (e.key === '
|
|
102
|
+
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
|
99
103
|
if (isVersionPreviewMode) return
|
|
100
104
|
e.preventDefault()
|
|
101
105
|
useEditor.getState().setMode('delete')
|
|
@@ -106,6 +110,17 @@ export const useKeyboard = ({
|
|
|
106
110
|
useEditor.getState().setPhase('structure')
|
|
107
111
|
useEditor.getState().setStructureLayer('elements')
|
|
108
112
|
useEditor.getState().setMode('material-paint')
|
|
113
|
+
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
114
|
+
if (isVersionPreviewMode) return
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
copySelectedNodesToEditorClipboard()
|
|
117
|
+
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
118
|
+
if (isVersionPreviewMode) return
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
const result = pasteEditorClipboardToLevel()
|
|
121
|
+
if (result?.pastedIds.length) {
|
|
122
|
+
sfxEmitter.emit('sfx:item-place')
|
|
123
|
+
}
|
|
109
124
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
|
110
125
|
if (isVersionPreviewMode) return
|
|
111
126
|
e.preventDefault()
|
|
@@ -119,13 +134,19 @@ export const useKeyboard = ({
|
|
|
119
134
|
const { buildingId, levelId } = useViewer.getState().selection
|
|
120
135
|
if (buildingId) {
|
|
121
136
|
const building = useScene.getState().nodes[buildingId]
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
const levels =
|
|
138
|
+
building?.type === 'building'
|
|
139
|
+
? building.children.filter(
|
|
140
|
+
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
|
141
|
+
)
|
|
142
|
+
: []
|
|
143
|
+
if (levels.length > 0) {
|
|
144
|
+
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
|
145
|
+
const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
|
|
125
146
|
if (nextIdx !== -1 && nextIdx !== currentIdx) {
|
|
126
|
-
useViewer.getState().setSelection({ levelId:
|
|
147
|
+
useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
|
|
127
148
|
} else if (currentIdx === -1) {
|
|
128
|
-
useViewer.getState().setSelection({ levelId:
|
|
149
|
+
useViewer.getState().setSelection({ levelId: levels[0] as any })
|
|
129
150
|
}
|
|
130
151
|
}
|
|
131
152
|
}
|
|
@@ -134,43 +155,60 @@ export const useKeyboard = ({
|
|
|
134
155
|
const { buildingId, levelId } = useViewer.getState().selection
|
|
135
156
|
if (buildingId) {
|
|
136
157
|
const building = useScene.getState().nodes[buildingId]
|
|
137
|
-
|
|
138
|
-
|
|
158
|
+
const levels =
|
|
159
|
+
building?.type === 'building'
|
|
160
|
+
? building.children.filter(
|
|
161
|
+
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
|
162
|
+
)
|
|
163
|
+
: []
|
|
164
|
+
if (levels.length > 0) {
|
|
165
|
+
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
|
139
166
|
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
|
140
167
|
if (prevIdx !== -1 && prevIdx !== currentIdx) {
|
|
141
|
-
useViewer.getState().setSelection({ levelId:
|
|
168
|
+
useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
|
|
142
169
|
} else if (currentIdx === -1) {
|
|
143
|
-
useViewer
|
|
144
|
-
.getState()
|
|
145
|
-
.setSelection({ levelId: building.children[building.children.length - 1] as any })
|
|
170
|
+
useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
|
|
146
171
|
}
|
|
147
172
|
}
|
|
148
173
|
}
|
|
149
174
|
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
|
150
175
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
|
151
|
-
//
|
|
176
|
+
// Doors use R to flip side (front ↔ back, rotation += π); their
|
|
177
|
+
// open/close toggle lives on E. Windows still use R to toggle
|
|
178
|
+
// their open/closed state.
|
|
152
179
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
153
180
|
if (selectedNodeIds.length === 1) {
|
|
154
181
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
155
182
|
if (node?.type === 'door') {
|
|
156
183
|
e.preventDefault()
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
184
|
+
useScene.getState().updateNode(node.id, {
|
|
185
|
+
side: node.side === 'front' ? 'back' : 'front',
|
|
186
|
+
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
|
187
|
+
})
|
|
188
|
+
if (node.parentId) {
|
|
189
|
+
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
160
190
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
node.windowType === 'casement' ||
|
|
166
|
-
node.windowType === 'awning' ||
|
|
167
|
-
node.windowType === 'hopper' ||
|
|
168
|
-
node.windowType === 'single-hung' ||
|
|
169
|
-
node.windowType === 'double-hung' ||
|
|
170
|
-
node.windowType === 'louvered')
|
|
171
|
-
) {
|
|
191
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
192
|
+
} else if (node?.type === 'window') {
|
|
193
|
+
// Windows: R flips side (front ↔ back, rotation += π). Open/
|
|
194
|
+
// close toggle for operable windows lives on E.
|
|
172
195
|
e.preventDefault()
|
|
173
|
-
|
|
196
|
+
useScene.getState().updateNode(node.id, {
|
|
197
|
+
side: node.side === 'front' ? 'back' : 'front',
|
|
198
|
+
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
|
199
|
+
})
|
|
200
|
+
if (node.parentId) {
|
|
201
|
+
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
202
|
+
}
|
|
203
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
204
|
+
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
|
|
205
|
+
// Registry-driven R action. Skylight uses this for open/
|
|
206
|
+
// close toggling; future kinds with custom R behaviour
|
|
207
|
+
// declare it on their `def.keyboardActions` without
|
|
208
|
+
// touching this hook. Door / window still use the legacy
|
|
209
|
+
// direct calls above (follow-up to migrate).
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
nodeRegistry.get(node.type)?.keyboardActions?.r?.run(node)
|
|
174
212
|
sfxEmitter.emit('sfx:item-rotate')
|
|
175
213
|
} else if (node && 'rotation' in node) {
|
|
176
214
|
e.preventDefault()
|
|
@@ -193,24 +231,17 @@ export const useKeyboard = ({
|
|
|
193
231
|
if (selectedNodeIds.length === 1) {
|
|
194
232
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
195
233
|
if (node?.type === 'door') {
|
|
234
|
+
// Door's open/close moved to E; T is a no-op for doors so
|
|
235
|
+
// it doesn't free-rotate a wall-bound node by π/4.
|
|
196
236
|
e.preventDefault()
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
} else if (
|
|
202
|
-
|
|
203
|
-
node.openingKind !== 'opening' &&
|
|
204
|
-
(node.windowType === 'sliding' ||
|
|
205
|
-
node.windowType === 'casement' ||
|
|
206
|
-
node.windowType === 'awning' ||
|
|
207
|
-
node.windowType === 'hopper' ||
|
|
208
|
-
node.windowType === 'single-hung' ||
|
|
209
|
-
node.windowType === 'double-hung' ||
|
|
210
|
-
node.windowType === 'louvered')
|
|
211
|
-
) {
|
|
237
|
+
} else if (node?.type === 'window') {
|
|
238
|
+
// Window's open/close moved to E; T is a no-op so it doesn't
|
|
239
|
+
// free-rotate a wall-bound node by π/4.
|
|
240
|
+
e.preventDefault()
|
|
241
|
+
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
|
|
242
|
+
// Registry-driven T action. Same shape as the R arm above.
|
|
212
243
|
e.preventDefault()
|
|
213
|
-
|
|
244
|
+
nodeRegistry.get(node.type)?.keyboardActions?.t?.run(node)
|
|
214
245
|
sfxEmitter.emit('sfx:item-rotate')
|
|
215
246
|
} else if (node && 'rotation' in node) {
|
|
216
247
|
e.preventDefault()
|
|
@@ -226,6 +257,32 @@ export const useKeyboard = ({
|
|
|
226
257
|
sfxEmitter.emit('sfx:item-rotate')
|
|
227
258
|
}
|
|
228
259
|
}
|
|
260
|
+
} else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) {
|
|
261
|
+
// Toggle door / operable-window open/closed state. Moved off R,
|
|
262
|
+
// which now flips the opening (side + π rotation).
|
|
263
|
+
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
264
|
+
if (selectedNodeIds.length === 1) {
|
|
265
|
+
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
266
|
+
if (node?.type === 'door' && node.openingKind !== 'opening') {
|
|
267
|
+
e.preventDefault()
|
|
268
|
+
toggleDoorOpenState(node.id)
|
|
269
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
270
|
+
} else if (
|
|
271
|
+
node?.type === 'window' &&
|
|
272
|
+
node.openingKind !== 'opening' &&
|
|
273
|
+
(node.windowType === 'sliding' ||
|
|
274
|
+
node.windowType === 'casement' ||
|
|
275
|
+
node.windowType === 'awning' ||
|
|
276
|
+
node.windowType === 'hopper' ||
|
|
277
|
+
node.windowType === 'single-hung' ||
|
|
278
|
+
node.windowType === 'double-hung' ||
|
|
279
|
+
node.windowType === 'louvered')
|
|
280
|
+
) {
|
|
281
|
+
e.preventDefault()
|
|
282
|
+
toggleWindowOpenState(node.id)
|
|
283
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
229
286
|
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
|
|
230
287
|
e.preventDefault()
|
|
231
288
|
|
|
@@ -241,15 +298,6 @@ export const useKeyboard = ({
|
|
|
241
298
|
}
|
|
242
299
|
}
|
|
243
300
|
|
|
244
|
-
// Delete selected zone
|
|
245
|
-
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
246
|
-
if (selectedZoneId) {
|
|
247
|
-
sfxEmitter.emit('sfx:structure-delete')
|
|
248
|
-
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
|
249
|
-
useViewer.getState().setSelection({ zoneId: null })
|
|
250
|
-
return
|
|
251
|
-
}
|
|
252
|
-
|
|
253
301
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
254
302
|
|
|
255
303
|
if (selectedNodeIds.length > 0) {
|
|
@@ -265,16 +313,21 @@ export const useKeyboard = ({
|
|
|
265
313
|
// Play appropriate SFX based on what's being deleted
|
|
266
314
|
if (selectedNodeIds.length === 1) {
|
|
267
315
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
268
|
-
|
|
269
|
-
sfxEmitter.emit('sfx:item-delete')
|
|
270
|
-
} else {
|
|
271
|
-
sfxEmitter.emit('sfx:structure-delete')
|
|
272
|
-
}
|
|
316
|
+
emitDeleteSFX(node?.type)
|
|
273
317
|
} else {
|
|
274
318
|
sfxEmitter.emit('sfx:structure-delete')
|
|
275
319
|
}
|
|
276
320
|
|
|
277
321
|
useScene.getState().deleteNodes(selectedNodeIds)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Delete selected zone when no explicit element selection is active.
|
|
326
|
+
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
327
|
+
if (selectedZoneId) {
|
|
328
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
329
|
+
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
|
330
|
+
useViewer.getState().setSelection({ zoneId: null })
|
|
278
331
|
}
|
|
279
332
|
}
|
|
280
333
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
4
|
+
import { useScene } from '@pascal-app/core'
|
|
5
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolved current selection — selected node IDs plus a convenience
|
|
9
|
+
* lookup into the live scene store. Returned shape is intentionally
|
|
10
|
+
* narrow: hosts that need richer per-node state can compose this with
|
|
11
|
+
* `useScene()` themselves.
|
|
12
|
+
*/
|
|
13
|
+
export type Selection = {
|
|
14
|
+
/** Multi-select node IDs (drives the rest of the editor's UI). */
|
|
15
|
+
selectedIds: AnyNodeId[]
|
|
16
|
+
/** Currently active building context — surfaces / palettes filter on this. */
|
|
17
|
+
buildingId: AnyNodeId | null
|
|
18
|
+
/** Currently active level context. */
|
|
19
|
+
levelId: AnyNodeId | null
|
|
20
|
+
/** Currently active zone context. */
|
|
21
|
+
zoneId: AnyNodeId | null
|
|
22
|
+
/**
|
|
23
|
+
* Resolved nodes for `selectedIds`. Missing entries (deleted nodes) are
|
|
24
|
+
* filtered out, so the array length may be ≤ `selectedIds.length`.
|
|
25
|
+
*/
|
|
26
|
+
selectedNodes: AnyNode[]
|
|
27
|
+
/**
|
|
28
|
+
* The single selected node, or `null` when zero or multiple nodes are
|
|
29
|
+
* selected. Useful for "save as preset" / inspector gating where the
|
|
30
|
+
* UI only makes sense for a unique selection.
|
|
31
|
+
*/
|
|
32
|
+
selectedNode: AnyNode | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to the current selection. Equivalent to reading from
|
|
37
|
+
* `useViewer().selection` plus a live `useScene()` lookup, packaged as
|
|
38
|
+
* a single hook so consumers building their own shells (community,
|
|
39
|
+
* standalone editor app, embedders) don't have to learn the two
|
|
40
|
+
* separate stores.
|
|
41
|
+
*
|
|
42
|
+
* Selection state intentionally lives in `useViewer` (it tracks the
|
|
43
|
+
* camera / visibility hierarchy: building → level → zone → nodes), not
|
|
44
|
+
* `useScene` — see `wiki/architecture/scene-registry.md`.
|
|
45
|
+
*/
|
|
46
|
+
export function useSelection(): Selection {
|
|
47
|
+
const selection = useViewer((s) => s.selection)
|
|
48
|
+
const nodes = useScene((s) => s.nodes)
|
|
49
|
+
|
|
50
|
+
const selectedIds = selection.selectedIds as AnyNodeId[]
|
|
51
|
+
const selectedNodes = selectedIds
|
|
52
|
+
.map((id) => nodes[id])
|
|
53
|
+
.filter((n): n is AnyNode => n !== undefined)
|
|
54
|
+
const selectedNode = selectedNodes.length === 1 ? (selectedNodes[0] ?? null) : null
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
selectedIds,
|
|
58
|
+
buildingId: (selection.buildingId ?? null) as AnyNodeId | null,
|
|
59
|
+
levelId: (selection.levelId ?? null) as AnyNodeId | null,
|
|
60
|
+
zoneId: (selection.zoneId ?? null) as AnyNodeId | null,
|
|
61
|
+
selectedNodes,
|
|
62
|
+
selectedNode,
|
|
63
|
+
}
|
|
64
|
+
}
|