@pascal-app/editor 0.9.1 → 0.9.2
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 +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
package/src/lib/history.ts
CHANGED
|
@@ -19,3 +19,12 @@ export function runRedo() {
|
|
|
19
19
|
useScene.temporal.getState().redo()
|
|
20
20
|
refreshSceneAfterHistoryJump()
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ⌘Z / ⌘⇧Z (undo/redo). Pointer-drag sessions intercept these in the capture
|
|
25
|
+
* phase and cancel the gesture instead — mid-drag, "undo" means "abort what my
|
|
26
|
+
* mouse is doing", never a history jump under a live pointer.
|
|
27
|
+
*/
|
|
28
|
+
export function isHistoryShortcut(e: KeyboardEvent) {
|
|
29
|
+
return (e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')
|
|
30
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
type AttachClass,
|
|
5
|
+
attachClassOf,
|
|
6
|
+
type HotSetCandidate,
|
|
7
|
+
isCandidateInHotSet,
|
|
8
|
+
isPickableForAttach,
|
|
9
|
+
} from './hot-set'
|
|
10
|
+
|
|
11
|
+
const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
|
|
12
|
+
|
|
13
|
+
const floor: HotSetCandidate = {
|
|
14
|
+
type: 'level',
|
|
15
|
+
isFloorLike: true,
|
|
16
|
+
exposesTop: false,
|
|
17
|
+
attachClass: 'surface',
|
|
18
|
+
}
|
|
19
|
+
const wall: HotSetCandidate = {
|
|
20
|
+
type: 'wall',
|
|
21
|
+
isFloorLike: false,
|
|
22
|
+
exposesTop: false,
|
|
23
|
+
attachClass: 'surface',
|
|
24
|
+
}
|
|
25
|
+
const ceiling: HotSetCandidate = {
|
|
26
|
+
type: 'ceiling',
|
|
27
|
+
isFloorLike: false,
|
|
28
|
+
exposesTop: false,
|
|
29
|
+
attachClass: 'surface',
|
|
30
|
+
}
|
|
31
|
+
const table: HotSetCandidate = {
|
|
32
|
+
type: 'item',
|
|
33
|
+
isFloorLike: false,
|
|
34
|
+
exposesTop: true,
|
|
35
|
+
attachClass: 'surface',
|
|
36
|
+
}
|
|
37
|
+
const wallShelf: HotSetCandidate = {
|
|
38
|
+
type: 'shelf',
|
|
39
|
+
isFloorLike: false,
|
|
40
|
+
exposesTop: true,
|
|
41
|
+
attachClass: 'wall',
|
|
42
|
+
}
|
|
43
|
+
const ceilingFan: HotSetCandidate = {
|
|
44
|
+
type: 'item',
|
|
45
|
+
isFloorLike: false,
|
|
46
|
+
exposesTop: true,
|
|
47
|
+
attachClass: 'ceiling',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('attachClassOf', () => {
|
|
51
|
+
test('wall and wall-side collapse to wall', () => {
|
|
52
|
+
expect(attachClassOf('wall')).toBe('wall')
|
|
53
|
+
expect(attachClassOf('wall-side')).toBe('wall')
|
|
54
|
+
})
|
|
55
|
+
test('ceiling maps to ceiling', () => {
|
|
56
|
+
expect(attachClassOf('ceiling')).toBe('ceiling')
|
|
57
|
+
})
|
|
58
|
+
test('undefined/null/unknown is surface-resting', () => {
|
|
59
|
+
expect(attachClassOf(undefined)).toBe('surface')
|
|
60
|
+
expect(attachClassOf(null)).toBe('surface')
|
|
61
|
+
expect(attachClassOf('')).toBe('surface')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('isPickableForAttach — wall-mounted (window)', () => {
|
|
66
|
+
test('only walls are eligible; floor/ceiling/tops are not', () => {
|
|
67
|
+
expect(isPickableForAttach('wall', wall)).toBe(true)
|
|
68
|
+
expect(isPickableForAttach('wall', floor)).toBe(false)
|
|
69
|
+
expect(isPickableForAttach('wall', ceiling)).toBe(false)
|
|
70
|
+
expect(isPickableForAttach('wall', table)).toBe(false)
|
|
71
|
+
expect(isPickableForAttach('wall', wallShelf)).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('isPickableForAttach — ceiling-mounted', () => {
|
|
76
|
+
test('only ceilings are eligible', () => {
|
|
77
|
+
expect(isPickableForAttach('ceiling', ceiling)).toBe(true)
|
|
78
|
+
expect(isPickableForAttach('ceiling', wall)).toBe(false)
|
|
79
|
+
expect(isPickableForAttach('ceiling', floor)).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('isPickableForAttach — surface-resting (sofa / cactus)', () => {
|
|
84
|
+
test('floor is always eligible', () => {
|
|
85
|
+
expect(isPickableForAttach('surface', floor)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
test('host tops (table, wall-shelf top) are eligible', () => {
|
|
88
|
+
expect(isPickableForAttach('surface', table)).toBe(true)
|
|
89
|
+
expect(isPickableForAttach('surface', wallShelf)).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
test('a wall (no top surface) is not eligible', () => {
|
|
92
|
+
expect(isPickableForAttach('surface', wall)).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
test('a ceiling-mounted host (ceiling fan) is never eligible — Track E', () => {
|
|
95
|
+
expect(isPickableForAttach('surface', ceilingFan)).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('isCandidateInHotSet — by scope', () => {
|
|
100
|
+
const surfaceClass: AttachClass = 'surface'
|
|
101
|
+
test('idle: everything is in the hot-set (selection filtering lives elsewhere)', () => {
|
|
102
|
+
expect(isCandidateInHotSet({ kind: 'idle' }, null, ceilingFan)).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
test('placing a surface item: derives from attach class', () => {
|
|
105
|
+
const scope = {
|
|
106
|
+
kind: 'placing' as const,
|
|
107
|
+
node: mockNode('i1', 'item'),
|
|
108
|
+
nodeId: 'i1',
|
|
109
|
+
nodeType: 'item',
|
|
110
|
+
view: '3d' as const,
|
|
111
|
+
pressDrag: false,
|
|
112
|
+
}
|
|
113
|
+
expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true)
|
|
114
|
+
expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
test('moving a wall-mounted item: only walls', () => {
|
|
117
|
+
const scope = {
|
|
118
|
+
kind: 'moving' as const,
|
|
119
|
+
node: mockNode('w1', 'window'),
|
|
120
|
+
nodeId: 'w1',
|
|
121
|
+
nodeType: 'window',
|
|
122
|
+
view: '2d' as const,
|
|
123
|
+
}
|
|
124
|
+
expect(isCandidateInHotSet(scope, 'wall', wall)).toBe(true)
|
|
125
|
+
expect(isCandidateInHotSet(scope, 'wall', table)).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
test('non-placement active scopes target nothing in the scene', () => {
|
|
128
|
+
expect(isCandidateInHotSet({ kind: 'box-select' }, null, floor)).toBe(false)
|
|
129
|
+
expect(
|
|
130
|
+
isCandidateInHotSet({ kind: 'handle-drag', nodeId: 'x', handle: 'h' }, null, floor),
|
|
131
|
+
).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The hot-set: which scene objects are raycast-eligible during an interaction.
|
|
2
|
+
//
|
|
3
|
+
// It is never hand-authored per interaction. It falls out of the node's
|
|
4
|
+
// `asset.attachTo` plus whether a candidate exposes a top surface. "Floor item"
|
|
5
|
+
// really means surface-resting: it rests on the floor *or* any host's top
|
|
6
|
+
// surface. Walls and ceilings are the special attach modes. Adding a node kind
|
|
7
|
+
// = set `attachTo` (or leave blank); the hot-set follows with zero per-kind
|
|
8
|
+
// wiring.
|
|
9
|
+
|
|
10
|
+
import type { InteractionScope } from './scope'
|
|
11
|
+
|
|
12
|
+
// What a node attaches to, collapsed to the three classes the hot-set cares
|
|
13
|
+
// about. `wall-side` is a wall attachment; everything without an explicit
|
|
14
|
+
// `attachTo` is surface-resting.
|
|
15
|
+
export type AttachClass = 'wall' | 'ceiling' | 'surface'
|
|
16
|
+
|
|
17
|
+
export function attachClassOf(attachTo: string | undefined | null): AttachClass {
|
|
18
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
|
|
19
|
+
if (attachTo === 'ceiling') return 'ceiling'
|
|
20
|
+
return 'surface'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The metadata the hot-set needs about a candidate host/surface. Derived from
|
|
24
|
+
// the candidate node + its registry definition by the caller, so this module
|
|
25
|
+
// stays pure and unit-testable without the scene or registry.
|
|
26
|
+
export type HotSetCandidate = {
|
|
27
|
+
type: string
|
|
28
|
+
// The level floor plane / ground a surface-resting node can always rest on.
|
|
29
|
+
isFloorLike: boolean
|
|
30
|
+
// The candidate exposes a usable top surface (registry
|
|
31
|
+
// `capabilities.surfaces.top`) — a table, a shelf, a slab.
|
|
32
|
+
exposesTop: boolean
|
|
33
|
+
// The candidate's own attach class. A ceiling fan is `ceiling`: it hangs from
|
|
34
|
+
// the ceiling and must never act as a host top (Track E).
|
|
35
|
+
attachClass: AttachClass
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// For a node whose attach class is `placed`, is `candidate` a valid
|
|
39
|
+
// host/surface to pick during placement or move?
|
|
40
|
+
export function isPickableForAttach(placed: AttachClass, candidate: HotSetCandidate): boolean {
|
|
41
|
+
if (placed === 'wall') return candidate.type === 'wall'
|
|
42
|
+
if (placed === 'ceiling') return candidate.type === 'ceiling'
|
|
43
|
+
// Surface-resting: the floor, or any host that exposes a top surface — but
|
|
44
|
+
// never a ceiling-mounted host (a floor lamp must not land on a ceiling fan).
|
|
45
|
+
if (candidate.isFloorLike) return true
|
|
46
|
+
if (!candidate.exposesTop) return false
|
|
47
|
+
if (candidate.attachClass === 'ceiling') return false
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The hot-set predicate for a whole scope. For placing/moving it derives from
|
|
52
|
+
// the moving node's attach class; for every other active scope nothing in the
|
|
53
|
+
// scene is a placement target, so the body's own raycast owns the pointer.
|
|
54
|
+
// `idle` returns true here — selection/phase filtering stays in the selection
|
|
55
|
+
// manager; this only narrows what an *active* interaction can target.
|
|
56
|
+
export function isCandidateInHotSet(
|
|
57
|
+
scope: InteractionScope,
|
|
58
|
+
placedAttachClass: AttachClass | null,
|
|
59
|
+
candidate: HotSetCandidate,
|
|
60
|
+
): boolean {
|
|
61
|
+
if (scope.kind === 'idle') return true
|
|
62
|
+
if (scope.kind === 'placing' || scope.kind === 'moving') {
|
|
63
|
+
if (placedAttachClass === null) return true
|
|
64
|
+
return isPickableForAttach(placedAttachClass, candidate)
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
3
|
+
import { resolveOverlayPolicy } from './overlay-policy'
|
|
4
|
+
import type { ActiveInteractionScope } from './scope'
|
|
5
|
+
|
|
6
|
+
const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
|
|
7
|
+
|
|
8
|
+
const ACTIVE_SCOPES: ActiveInteractionScope[] = [
|
|
9
|
+
{
|
|
10
|
+
kind: 'placing',
|
|
11
|
+
node: mockNode('i1', 'item'),
|
|
12
|
+
nodeId: 'i1',
|
|
13
|
+
nodeType: 'item',
|
|
14
|
+
view: '3d',
|
|
15
|
+
pressDrag: false,
|
|
16
|
+
},
|
|
17
|
+
{ kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
|
|
18
|
+
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
|
|
19
|
+
{ kind: 'drafting', tool: 'wall' },
|
|
20
|
+
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
|
|
21
|
+
{ kind: 'box-select' },
|
|
22
|
+
{ kind: 'painting' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
describe('resolveOverlayPolicy', () => {
|
|
26
|
+
test('idle keeps everything shown and pickable', () => {
|
|
27
|
+
const p = resolveOverlayPolicy({ kind: 'idle' })
|
|
28
|
+
expect(p.zoneLabels).toBe('shown')
|
|
29
|
+
expect(p.contextBadges).toBe('shown')
|
|
30
|
+
expect(p.conflictingControls).toBe('shown')
|
|
31
|
+
expect(p.sceneObjectsPickable).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('every active scope hides zone labels, fades badges, hides conflicting controls', () => {
|
|
35
|
+
for (const scope of ACTIVE_SCOPES) {
|
|
36
|
+
const p = resolveOverlayPolicy(scope)
|
|
37
|
+
expect(p.zoneLabels).toBe('hidden')
|
|
38
|
+
expect(p.contextBadges).toBe('faded')
|
|
39
|
+
expect(p.conflictingControls).toBe('hidden')
|
|
40
|
+
expect(p.sceneObjectsPickable).toBe(false)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('active affordances and the contextual HUD always stay interactive', () => {
|
|
45
|
+
for (const scope of [{ kind: 'idle' } as const, ...ACTIVE_SCOPES]) {
|
|
46
|
+
const p = resolveOverlayPolicy(scope)
|
|
47
|
+
expect(p.activeAffordances).toBe('shown')
|
|
48
|
+
expect(p.contextualHudInteractive).toBe(true)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The overlay scope matrix — the "Sims-light" feel. During any non-idle
|
|
2
|
+
// interaction, two layers behave differently:
|
|
3
|
+
//
|
|
4
|
+
// - 3D scene objects stay VISIBLE but become NON-pickable (the hot-set owns
|
|
5
|
+
// what the active interaction can target). Context is preserved; you just
|
|
6
|
+
// can't grab the wrong thing.
|
|
7
|
+
// - DOM/HUD overlays step back, differentiated by how distracting they are:
|
|
8
|
+
// zone labels -> hidden (not a primary editing concern)
|
|
9
|
+
// context badges -> faded + pointer-events:none (hover name pills)
|
|
10
|
+
// other controls -> hard-hidden (other objects' handles, the floating
|
|
11
|
+
// action menu, conflicting controls)
|
|
12
|
+
//
|
|
13
|
+
// The active interaction's own affordances (ghost, snap guides, dimension
|
|
14
|
+
// labels, the active handle) always stay — "default-off, opt-in for the active
|
|
15
|
+
// action". The contextual control HUD is exempt from the pointer-events
|
|
16
|
+
// step-back because it *is* the active interaction's own controls.
|
|
17
|
+
|
|
18
|
+
import { type InteractionScope, isActive } from './scope'
|
|
19
|
+
|
|
20
|
+
export type OverlayVisibility = 'shown' | 'faded' | 'hidden'
|
|
21
|
+
|
|
22
|
+
export type OverlayPolicy = {
|
|
23
|
+
zoneLabels: OverlayVisibility
|
|
24
|
+
// Hover name pills / context badges.
|
|
25
|
+
contextBadges: OverlayVisibility
|
|
26
|
+
// Other objects' handles + the floating action menu — anything whose action
|
|
27
|
+
// would conflict with the active interaction.
|
|
28
|
+
conflictingControls: OverlayVisibility
|
|
29
|
+
// Non-active scene objects: visible always, pickable only when idle.
|
|
30
|
+
sceneObjectsPickable: boolean
|
|
31
|
+
// The active interaction's own ghost/guides/dimension labels/handle. Always
|
|
32
|
+
// shown; this field exists so consumers can assert the contract.
|
|
33
|
+
activeAffordances: 'shown'
|
|
34
|
+
// The contextual control HUD keeps pointer events even while everything else
|
|
35
|
+
// steps back, because it is the active interaction's own controls.
|
|
36
|
+
contextualHudInteractive: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const IDLE_POLICY: OverlayPolicy = {
|
|
40
|
+
zoneLabels: 'shown',
|
|
41
|
+
contextBadges: 'shown',
|
|
42
|
+
conflictingControls: 'shown',
|
|
43
|
+
sceneObjectsPickable: true,
|
|
44
|
+
activeAffordances: 'shown',
|
|
45
|
+
contextualHudInteractive: true,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ACTIVE_POLICY: OverlayPolicy = {
|
|
49
|
+
zoneLabels: 'hidden',
|
|
50
|
+
contextBadges: 'faded',
|
|
51
|
+
conflictingControls: 'hidden',
|
|
52
|
+
sceneObjectsPickable: false,
|
|
53
|
+
activeAffordances: 'shown',
|
|
54
|
+
contextualHudInteractive: true,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy {
|
|
58
|
+
return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY
|
|
59
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// The authoritative description of "what the user is currently doing".
|
|
2
|
+
//
|
|
3
|
+
// Before this, that question was answered by re-deriving from 7+ independent
|
|
4
|
+
// `useEditor` flags (`mode`, `tool`, `movingNode`, `placementDragMode`,
|
|
5
|
+
// `activeHandleDrag`, `curvingWall`, `curvingFence`, `editingHole`,
|
|
6
|
+
// `movingWallEndpoint`, `movingFenceEndpoint`, …). Every overlay and pick site
|
|
7
|
+
// re-derived its behaviour from a different subset, so the flags could drift
|
|
8
|
+
// into illegal combinations (moving + curving at once; a stale `movingNode`
|
|
9
|
+
// after a drag ended). Collapsing them into one discriminated union makes those
|
|
10
|
+
// combinations unrepresentable: a scope is exactly one interaction at a time,
|
|
11
|
+
// and `idle` carries no interaction payload at all.
|
|
12
|
+
|
|
13
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
14
|
+
|
|
15
|
+
export type InteractionView = '2d' | '3d'
|
|
16
|
+
|
|
17
|
+
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
|
|
18
|
+
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
|
19
|
+
// (rather than four sibling scopes) keeps the union small while still making
|
|
20
|
+
// "curving and hole-editing at once" unrepresentable.
|
|
21
|
+
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
|
|
22
|
+
|
|
23
|
+
export type InteractionScope =
|
|
24
|
+
| { kind: 'idle' }
|
|
25
|
+
// Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
|
|
26
|
+
// gizmo press-drag flavour (commit on release) vs click-to-place.
|
|
27
|
+
| {
|
|
28
|
+
kind: 'placing'
|
|
29
|
+
// The node being placed, carried inline: a fresh-placement / duplicate
|
|
30
|
+
// draft is not in the scene yet, so it cannot be recovered by id. Set once
|
|
31
|
+
// at `begin` and never mutated, so it is a stable reference for the gesture.
|
|
32
|
+
node: AnyNode
|
|
33
|
+
nodeId: string
|
|
34
|
+
nodeType: string
|
|
35
|
+
view: InteractionView
|
|
36
|
+
pressDrag: boolean
|
|
37
|
+
}
|
|
38
|
+
// Moving an existing node.
|
|
39
|
+
| { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView }
|
|
40
|
+
// Dragging a resize/translate/rotate handle of a selected node.
|
|
41
|
+
| { kind: 'handle-drag'; nodeId: string; handle: string }
|
|
42
|
+
// Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
|
|
43
|
+
| { kind: 'drafting'; tool: string }
|
|
44
|
+
// Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set
|
|
45
|
+
// only for `reshape: 'hole'`; `endpoint` only for `reshape: 'endpoint'`.
|
|
46
|
+
| {
|
|
47
|
+
kind: 'reshaping'
|
|
48
|
+
nodeId: string
|
|
49
|
+
reshape: ReshapeKind
|
|
50
|
+
holeIndex?: number
|
|
51
|
+
endpoint?: 'start' | 'end'
|
|
52
|
+
index?: number
|
|
53
|
+
side?: 'in' | 'out'
|
|
54
|
+
}
|
|
55
|
+
// Marquee selection drag.
|
|
56
|
+
| { kind: 'box-select' }
|
|
57
|
+
// Material paint application.
|
|
58
|
+
| { kind: 'painting' }
|
|
59
|
+
|
|
60
|
+
export type InteractionKind = InteractionScope['kind']
|
|
61
|
+
|
|
62
|
+
export type ActiveInteractionScope = Exclude<InteractionScope, { kind: 'idle' }>
|
|
63
|
+
|
|
64
|
+
export const IDLE_SCOPE: InteractionScope = { kind: 'idle' }
|
|
65
|
+
|
|
66
|
+
export function isIdle(scope: InteractionScope): scope is { kind: 'idle' } {
|
|
67
|
+
return scope.kind === 'idle'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isActive(scope: InteractionScope): scope is ActiveInteractionScope {
|
|
71
|
+
return scope.kind !== 'idle'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The node a scope is acting on, if any. Drafting/box-select/painting/idle
|
|
75
|
+
// target no single existing node.
|
|
76
|
+
export function scopeNodeId(scope: InteractionScope): string | null {
|
|
77
|
+
switch (scope.kind) {
|
|
78
|
+
case 'placing':
|
|
79
|
+
case 'moving':
|
|
80
|
+
case 'handle-drag':
|
|
81
|
+
case 'reshaping':
|
|
82
|
+
return scope.nodeId
|
|
83
|
+
default:
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The node a placing/moving scope is acting on, carried inline (see the
|
|
89
|
+
// `placing` variant comment). Null for every other scope. Replaces the legacy
|
|
90
|
+
// `useEditor.movingNode` flag: the node lives inside the discriminated union, so
|
|
91
|
+
// it cannot survive past the interaction's `end()`.
|
|
92
|
+
export function movingNodeOf(scope: InteractionScope): AnyNode | null {
|
|
93
|
+
return scope.kind === 'placing' || scope.kind === 'moving' ? scope.node : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Selection/hover picking is only meaningful while idle. During any active
|
|
97
|
+
// interaction the pointer belongs to that interaction's body, not to selecting
|
|
98
|
+
// a different object — the picking choke point should not route a hover/click
|
|
99
|
+
// to selection while this is false.
|
|
100
|
+
export function selectionEnabled(scope: InteractionScope): boolean {
|
|
101
|
+
return scope.kind === 'idle'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Derived views of the scope that mirror the legacy `useEditor` flags they
|
|
105
|
+
// replaced. Each returns null unless that exact interaction is active, so a
|
|
106
|
+
// stale payload is unrepresentable: the value is a pure function of the single
|
|
107
|
+
// authoritative scope, not an independent flag that can drift out of sync.
|
|
108
|
+
|
|
109
|
+
// The legacy `activeHandleDrag` flag. `label` keeps the legacy field name so
|
|
110
|
+
// downstream `=== ROTATE_HANDLE_DRAG_LABEL` / `=== 'height'` checks are unchanged.
|
|
111
|
+
export function handleDragInfo(scope: InteractionScope): { nodeId: string; label: string } | null {
|
|
112
|
+
return scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The legacy `editingHole` flag (`SurfaceHoleTarget`).
|
|
116
|
+
export function editingHoleInfo(
|
|
117
|
+
scope: InteractionScope,
|
|
118
|
+
): { nodeId: string; holeIndex: number } | null {
|
|
119
|
+
return scope.kind === 'reshaping' && scope.reshape === 'hole' && scope.holeIndex !== undefined
|
|
120
|
+
? { nodeId: scope.nodeId, holeIndex: scope.holeIndex }
|
|
121
|
+
: null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build the scope payload for a hole reshape, so producers don't re-spell the
|
|
125
|
+
// discriminator at every call site.
|
|
126
|
+
export function holeEditScope(target: {
|
|
127
|
+
nodeId: string
|
|
128
|
+
holeIndex: number
|
|
129
|
+
}): ActiveInteractionScope {
|
|
130
|
+
return {
|
|
131
|
+
kind: 'reshaping',
|
|
132
|
+
nodeId: target.nodeId,
|
|
133
|
+
reshape: 'hole',
|
|
134
|
+
holeIndex: target.holeIndex,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// True while the selected node's geometry is being curved (legacy
|
|
139
|
+
// `curvingWall` / `curvingFence` — now one scope; the wall-vs-fence kind is
|
|
140
|
+
// recovered from the reshaped node's type, looked up from the scene by nodeId).
|
|
141
|
+
export function isCurveReshape(scope: InteractionScope): boolean {
|
|
142
|
+
return scope.kind === 'reshaping' && scope.reshape === 'curve'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
|
|
146
|
+
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for
|
|
147
|
+
// the duration of the drag).
|
|
148
|
+
export function endpointReshapeInfo(
|
|
149
|
+
scope: InteractionScope,
|
|
150
|
+
): { nodeId: string; endpoint: 'start' | 'end' } | null {
|
|
151
|
+
return scope.kind === 'reshaping' && scope.reshape === 'endpoint' && scope.endpoint !== undefined
|
|
152
|
+
? { nodeId: scope.nodeId, endpoint: scope.endpoint }
|
|
153
|
+
: null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function controlPointReshapeInfo(
|
|
157
|
+
scope: InteractionScope,
|
|
158
|
+
): { nodeId: string; index: number } | null {
|
|
159
|
+
return scope.kind === 'reshaping' &&
|
|
160
|
+
scope.reshape === 'control-point' &&
|
|
161
|
+
scope.index !== undefined
|
|
162
|
+
? { nodeId: scope.nodeId, index: scope.index }
|
|
163
|
+
: null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function tangentReshapeInfo(
|
|
167
|
+
scope: InteractionScope,
|
|
168
|
+
): { nodeId: string; index: number; side: 'in' | 'out' } | null {
|
|
169
|
+
return scope.kind === 'reshaping' &&
|
|
170
|
+
scope.reshape === 'tangent' &&
|
|
171
|
+
scope.index !== undefined &&
|
|
172
|
+
scope.side !== undefined
|
|
173
|
+
? { nodeId: scope.nodeId, index: scope.index, side: scope.side }
|
|
174
|
+
: null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The id of the node being reshaped (any reshape kind), for the scene lookup
|
|
178
|
+
// that recovers the full node payload a few consumers still need.
|
|
179
|
+
export function reshapingNodeId(scope: InteractionScope): string | null {
|
|
180
|
+
return scope.kind === 'reshaping' ? scope.nodeId : null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Builders so producers don't re-spell the discriminator at every call site.
|
|
184
|
+
export function curveReshapeScope(nodeId: string): ActiveInteractionScope {
|
|
185
|
+
return { kind: 'reshaping', nodeId, reshape: 'curve' }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function endpointReshapeScope(
|
|
189
|
+
nodeId: string,
|
|
190
|
+
endpoint: 'start' | 'end',
|
|
191
|
+
): ActiveInteractionScope {
|
|
192
|
+
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope {
|
|
196
|
+
return { kind: 'reshaping', nodeId, reshape: 'control-point', index }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function tangentReshapeScope(
|
|
200
|
+
nodeId: string,
|
|
201
|
+
index: number,
|
|
202
|
+
side: 'in' | 'out',
|
|
203
|
+
): ActiveInteractionScope {
|
|
204
|
+
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
|
|
208
|
+
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
|
|
209
|
+
export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
|
|
210
|
+
return { kind: 'reshaping', nodeId, reshape: 'boundary' }
|
|
211
|
+
}
|
|
@@ -5,10 +5,10 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
5
5
|
function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {
|
|
6
6
|
const { nodes } = useScene.getState()
|
|
7
7
|
const level = nodes[levelId]
|
|
8
|
-
if (
|
|
8
|
+
if (level?.type !== 'level' || !level.parentId) return null
|
|
9
9
|
|
|
10
10
|
const building = nodes[level.parentId as AnyNodeId]
|
|
11
|
-
if (
|
|
11
|
+
if (building?.type !== 'building') return null
|
|
12
12
|
|
|
13
13
|
const siblingLevelIds = (building as BuildingNode).children.filter(
|
|
14
14
|
(childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',
|