@pascal-app/editor 1.0.7 → 1.1.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 +7 -7
- package/src/components/editor/custom-camera-controls.tsx +90 -34
- package/src/components/editor/duplicate-node.ts +2 -1
- package/src/components/editor/first-person-controls.tsx +5 -7
- package/src/components/editor/fit-scene-framing.test.ts +318 -0
- package/src/components/editor/fit-scene-framing.ts +142 -0
- package/src/components/editor/floating-action-menu.tsx +4 -2
- package/src/components/editor/floor-openings-overlay.tsx +1 -1
- package/src/components/editor/floor-region-controls.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +173 -8
- package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
- package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
- package/src/components/editor/footprint-height-handle.tsx +1 -1
- package/src/components/editor/group-transform-shared.ts +3 -1
- package/src/components/editor/index.tsx +20 -0
- package/src/components/editor/measurement-pill.tsx +1 -1
- package/src/components/editor/node-arrow-handles.tsx +1 -1
- package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
- package/src/components/editor/open-wall-ends-hint.tsx +1 -1
- package/src/components/editor/quick-measurement-card.tsx +1 -1
- package/src/components/editor/room-controls.tsx +3 -2
- package/src/components/editor/room-handles-2d.tsx +2 -1
- package/src/components/editor/room-handles.tsx +3 -3
- package/src/components/editor/room-labels.tsx +4 -2
- package/src/components/editor/site-edge-labels.tsx +6 -2
- package/src/components/editor/wall-measurement-label.tsx +30 -14
- package/src/components/editor/wall-move-side-handles.tsx +10 -6
- package/src/components/editor/wall-region-handles.tsx +2 -2
- package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
- package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
- package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
- package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
- package/src/components/systems/roof/roof-edit-system.tsx +30 -19
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
- package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
- package/src/components/tools/item/move-tool.tsx +1 -6
- package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
- package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
- package/src/components/tools/shared/placement-box.tsx +1 -5
- package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
- package/src/components/tools/shared/segment-angle.ts +0 -4
- package/src/components/tools/tool-manager.tsx +1 -24
- package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
- package/src/components/tools/wall/wall-typed-entry.ts +70 -0
- package/src/components/ui/controls/slider-control.tsx +18 -6
- package/src/components/ui/controls/tool-options-panel.tsx +74 -44
- package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
- package/src/components/ui/helpers/helper-manager.tsx +54 -0
- package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
- package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
- package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
- package/src/components/ui/panels/multi-height-mode.tsx +3 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
- package/src/components/ui/panels/panel-manager.tsx +4 -2
- package/src/components/ui/panels/panel-wrapper.tsx +41 -18
- package/src/components/ui/panels/room-construction-rows.tsx +1 -1
- package/src/components/ui/panels/room-openings-field.tsx +1 -1
- package/src/components/ui/panels/room-panel.tsx +1 -1
- package/src/components/ui/right-stack.test.ts +13 -6
- package/src/components/ui/right-stack.tsx +21 -10
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
- package/src/components/viewer/viewer-rooms.tsx +1 -1
- package/src/i18n/locales/en/messages.po +856 -124
- package/src/i18n/locales/en/messages.ts +1 -1
- package/src/i18n/locales/en-XA/messages.ts +1 -1
- package/src/i18n/locales/es/messages.po +2087 -257
- package/src/i18n/locales/fr/messages.po +2087 -257
- package/src/i18n/locales/ja/messages.po +2087 -257
- package/src/i18n/locales/zh-CN/messages.po +2087 -257
- package/src/index.tsx +74 -47
- package/src/lib/contextual-help.ts +6 -0
- package/src/lib/elevation-guides.ts +21 -6
- package/src/lib/elevator-support.ts +1 -72
- package/src/lib/floor-footprints.test.ts +1 -1
- package/src/lib/floor-footprints.ts +1 -9
- package/src/lib/floor-foundation-support.test.ts +8 -3
- package/src/lib/floor-opening-draft.ts +1 -1
- package/src/lib/floor-region-session.ts +1 -1
- package/src/lib/floorplan/open-wall-ends.ts +2 -1
- package/src/lib/gesture-lifecycle.ts +3 -1
- package/src/lib/interaction/scope.ts +1 -0
- package/src/lib/layer-thickness.ts +1 -1
- package/src/lib/level-selection.test.ts +18 -2
- package/src/lib/level-selection.ts +31 -8
- package/src/lib/linear-display.ts +1 -1
- package/src/lib/live-gesture-round-trip.test.ts +1 -1
- package/src/lib/measurement-label.test.ts +1 -1
- package/src/lib/measurements.test.ts +8 -7
- package/src/lib/measurements.ts +1 -218
- package/src/lib/node-action-movement.test.tsx +115 -0
- package/src/lib/node-action-movement.ts +2 -0
- package/src/lib/placement-metadata.ts +4 -0
- package/src/lib/point-ask/describe-target.ts +6 -8
- package/src/lib/point-ask/region.ts +1 -1
- package/src/lib/print-shell-compiler.ts +12 -1
- package/src/lib/print-wall-solids.test.ts +11 -0
- package/src/lib/print-wall-solids.ts +9 -0
- package/src/lib/room-assembly-overlay.ts +1 -1
- package/src/lib/room-built-on.test.ts +2 -5
- package/src/lib/room-built-on.ts +1 -15
- package/src/lib/room-construction-commands.ts +3 -2
- package/src/lib/room-controls-phase6.test.ts +1 -1
- package/src/lib/room-handle-drag.ts +6 -25
- package/src/lib/room-push-dimensions.test.ts +1 -1
- package/src/lib/scene-bounds.test.ts +144 -0
- package/src/lib/scene-bounds.ts +79 -15
- package/src/lib/scene-clipboard.ts +2 -1
- package/src/lib/snapping-mode.test.ts +23 -1
- package/src/lib/viewer-selection.ts +1 -1
- package/src/lib/wall-region-list.ts +3 -1
- package/src/lib/window-interaction.test.ts +4 -2
- package/src/store/use-editor.tsx +5 -0
- package/src/store/use-placement-typing.test.ts +165 -0
- package/src/store/use-placement-typing.ts +186 -0
- package/src/components/tools/elevator/elevator-defaults.ts +0 -8
- package/src/components/tools/elevator/elevator-tool.tsx +0 -372
- package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
- package/src/lib/door-interaction.ts +0 -102
- package/src/lib/measurement-label.ts +0 -68
- package/src/lib/window-interaction.ts +0 -142
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { usePascalI18n } from '@pascal-app/core'
|
|
4
|
+
import { type PlacementTypingField, usePlacementTyping } from '../../../store/use-placement-typing'
|
|
5
|
+
|
|
6
|
+
export type PlacementCoordinateInputProps = {
|
|
7
|
+
/** What each empty field stands for; defaults to the session's `fieldDefaults`. */
|
|
8
|
+
placeholders?: readonly string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The typed-entry HUD, one box per field of the session. It only displays the
|
|
13
|
+
* session: keys reach the active tool's own handler (`routePlacementTypingKey`),
|
|
14
|
+
* so the same HUD can sit in the 3D view and the plan at once without
|
|
15
|
+
* competing for focus. Clicking a field makes it the active one.
|
|
16
|
+
*/
|
|
17
|
+
export function PlacementCoordinateInput({ placeholders }: PlacementCoordinateInputProps) {
|
|
18
|
+
const i18n = usePascalI18n()
|
|
19
|
+
const isActive = usePlacementTyping((state) => state.isActive)
|
|
20
|
+
const labels = usePlacementTyping((state) => state.labels)
|
|
21
|
+
const fields = usePlacementTyping((state) => state.fields)
|
|
22
|
+
const fieldDefaults = usePlacementTyping((state) => state.fieldDefaults)
|
|
23
|
+
const activeField = usePlacementTyping((state) => state.activeField)
|
|
24
|
+
|
|
25
|
+
if (!isActive) return null
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
data-placement-coordinate-entry
|
|
30
|
+
onClick={(event) => event.stopPropagation()}
|
|
31
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
32
|
+
style={{
|
|
33
|
+
alignItems: 'center',
|
|
34
|
+
background: 'rgba(15, 23, 42, 0.94)',
|
|
35
|
+
border: '1px solid rgba(148, 163, 184, 0.55)',
|
|
36
|
+
borderRadius: 6,
|
|
37
|
+
boxShadow: '0 4px 14px rgba(15, 23, 42, 0.28)',
|
|
38
|
+
color: '#f8fafc',
|
|
39
|
+
display: 'flex',
|
|
40
|
+
gap: 6,
|
|
41
|
+
padding: '5px 7px',
|
|
42
|
+
pointerEvents: 'auto',
|
|
43
|
+
userSelect: 'none',
|
|
44
|
+
whiteSpace: 'nowrap',
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{labels.map((descriptor, index) => {
|
|
48
|
+
const field = index as PlacementTypingField
|
|
49
|
+
const label = i18n._(descriptor)
|
|
50
|
+
const active = field === activeField
|
|
51
|
+
const value = fields[field]
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
data-placement-coordinate-field={descriptor.id}
|
|
55
|
+
key={descriptor.id}
|
|
56
|
+
onPointerDown={(event) => {
|
|
57
|
+
event.stopPropagation()
|
|
58
|
+
usePlacementTyping.getState().setActiveField(field)
|
|
59
|
+
}}
|
|
60
|
+
style={{
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
display: 'flex',
|
|
63
|
+
flexDirection: 'column',
|
|
64
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
65
|
+
fontSize: 9,
|
|
66
|
+
gap: 2,
|
|
67
|
+
textTransform: 'uppercase',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<span style={{ color: active ? '#a5b4fc' : '#94a3b8' }}>{label}</span>
|
|
71
|
+
<span
|
|
72
|
+
aria-label={label}
|
|
73
|
+
role="status"
|
|
74
|
+
style={{
|
|
75
|
+
background: active ? '#eef2ff' : '#e2e8f0',
|
|
76
|
+
borderRadius: 3,
|
|
77
|
+
color: value ? '#0f172a' : '#64748b',
|
|
78
|
+
fontSize: 12,
|
|
79
|
+
fontWeight: 600,
|
|
80
|
+
minWidth: 76,
|
|
81
|
+
outline: active ? '2px solid #818cf8' : 'none',
|
|
82
|
+
padding: '3px 5px',
|
|
83
|
+
textAlign: 'right',
|
|
84
|
+
textTransform: 'none',
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{value || placeholders?.[field] || fieldDefaults[field] || '\u00a0'}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -83,10 +83,6 @@ function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): Pl
|
|
|
83
83
|
return best.tangent
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
export function formatAngleRadians(angle: number) {
|
|
87
|
-
return `${Math.round((angle * 180) / Math.PI)}°`
|
|
88
|
-
}
|
|
89
|
-
|
|
90
86
|
export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null {
|
|
91
87
|
const firstLength = Math.hypot(first[0], first[1])
|
|
92
88
|
const secondLength = Math.hypot(second[0], second[1])
|
|
@@ -41,7 +41,6 @@ import { FloorRegionControls3D } from '../editor/floor-region-controls'
|
|
|
41
41
|
import { OpenWallEnds3DLayer } from '../editor/open-wall-ends-3d-layer'
|
|
42
42
|
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
|
|
43
43
|
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
|
|
44
|
-
import { ElevatorTool } from './elevator/elevator-tool'
|
|
45
44
|
import { MoveTool } from './item/move-tool'
|
|
46
45
|
import { WallPaintRegionTool } from './paint-region/wall-paint-region-tool'
|
|
47
46
|
import { RegistryToolProvider } from './registry-tool-context'
|
|
@@ -305,21 +304,6 @@ export const ToolManager: React.FC = () => {
|
|
|
305
304
|
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
|
|
306
305
|
setSelection({ selectedIds: [nodeId] })
|
|
307
306
|
}
|
|
308
|
-
const handlePlacedElevatorSelected = (
|
|
309
|
-
nodeId: AnyNodeId,
|
|
310
|
-
elevatorBuildingId: BuildingNode['id'],
|
|
311
|
-
) => {
|
|
312
|
-
// Preserve the active level. `setSelection`'s hierarchy guard nulls
|
|
313
|
-
// `levelId` whenever `buildingId` is passed without an explicit
|
|
314
|
-
// `levelId` — which deselected the current floor plan the moment an
|
|
315
|
-
// elevator was placed. Pass the current level through so the floor
|
|
316
|
-
// plan stays selected.
|
|
317
|
-
setSelection({
|
|
318
|
-
buildingId: elevatorBuildingId,
|
|
319
|
-
levelId: activeLevelId ?? null,
|
|
320
|
-
selectedIds: [nodeId],
|
|
321
|
-
})
|
|
322
|
-
}
|
|
323
307
|
|
|
324
308
|
return (
|
|
325
309
|
<RegistryToolProvider value={registryToolContext}>
|
|
@@ -463,14 +447,7 @@ export const ToolManager: React.FC = () => {
|
|
|
463
447
|
<RegistryToolComponent />
|
|
464
448
|
</Suspense>
|
|
465
449
|
)}
|
|
466
|
-
{!movingNode &&
|
|
467
|
-
<ElevatorTool
|
|
468
|
-
buildingId={buildingId as BuildingNode['id'] | null}
|
|
469
|
-
levelId={activeLevelId ?? null}
|
|
470
|
-
onPlaced={handlePlacedElevatorSelected}
|
|
471
|
-
/>
|
|
472
|
-
)}
|
|
473
|
-
{!movingNode && BuildToolComponent && tool !== 'elevator' ? <BuildToolComponent /> : null}
|
|
450
|
+
{!movingNode && BuildToolComponent ? <BuildToolComponent /> : null}
|
|
474
451
|
{/* Figma-style alignment guides published by the move / placement
|
|
475
452
|
tools above. Lives inside the building-local group so the
|
|
476
453
|
building-local guide coords render at the right world position. */}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
formatWallDraftHeading,
|
|
4
|
+
resolveTypedWallEnd,
|
|
5
|
+
wallDraftHeadingDegrees,
|
|
6
|
+
} from './wall-typed-entry'
|
|
7
|
+
|
|
8
|
+
const metric = { bareUnit: 'm', system: 'metric' } as const
|
|
9
|
+
|
|
10
|
+
function expectPoint(actual: [number, number] | null, expected: [number, number]) {
|
|
11
|
+
expect(actual).not.toBeNull()
|
|
12
|
+
expect(actual![0]).toBeCloseTo(expected[0], 10)
|
|
13
|
+
expect(actual![1]).toBeCloseTo(expected[1], 10)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('resolveTypedWallEnd — length only', () => {
|
|
17
|
+
test('keeps the pointer direction and replaces the distance', () => {
|
|
18
|
+
expectPoint(resolveTypedWallEnd([0, 0], [3, 4], ['1', ''], metric), [0.6, 0.8])
|
|
19
|
+
expectPoint(resolveTypedWallEnd([0, 0], [-30, -40], ['5', ''], metric), [-3, -4])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('reads the whole field, not the length the pointer last showed', () => {
|
|
23
|
+
expectPoint(resolveTypedWallEnd([0, 0], [1, 0], ['12', ''], metric), [12, 0])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('honours unit suffixes and the bare unit', () => {
|
|
27
|
+
expectPoint(resolveTypedWallEnd([1, 1], [11, 1], ['250cm', ''], metric), [3.5, 1])
|
|
28
|
+
expectPoint(
|
|
29
|
+
resolveTypedWallEnd([0, 0], [0, 9], ['2500', ''], { bareUnit: 'mm', system: 'metric' }),
|
|
30
|
+
[0, 2.5],
|
|
31
|
+
)
|
|
32
|
+
expectPoint(
|
|
33
|
+
resolveTypedWallEnd([0, 0], [9, 0], ["10'", ''], { bareUnit: 'in', system: 'imperial' }),
|
|
34
|
+
[3.048, 0],
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('has no endpoint without a usable length or a direction', () => {
|
|
39
|
+
expect(resolveTypedWallEnd([0, 0], [1, 0], ['', ''], metric)).toBeNull()
|
|
40
|
+
expect(resolveTypedWallEnd([0, 0], [1, 0], ['abc', ''], metric)).toBeNull()
|
|
41
|
+
expect(resolveTypedWallEnd([0, 0], [1, 0], ['0', ''], metric)).toBeNull()
|
|
42
|
+
expect(resolveTypedWallEnd([2, 2], [2, 2], ['5', ''], metric)).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('resolveTypedWallEnd — angle', () => {
|
|
47
|
+
test('angle only locks the heading; the pointer projection sets the length', () => {
|
|
48
|
+
// 90° runs along +Z; a pointer at (1, 3) projects to 3 m along it.
|
|
49
|
+
expectPoint(resolveTypedWallEnd([0, 0], [1, 3], ['', '90'], metric), [0, 3])
|
|
50
|
+
// (3, 4) projects to 7/√2 along 45°, landing 3.5 out on each axis.
|
|
51
|
+
expectPoint(resolveTypedWallEnd([1, 1], [4, 5], ['', '45'], metric), [4.5, 4.5])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('angle only quantises the projected length to the step', () => {
|
|
55
|
+
expectPoint(resolveTypedWallEnd([0, 0], [0.2, 2.37], ['', '90'], metric, 0.5), [0, 2.5])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('angle only never flips the wall behind its start', () => {
|
|
59
|
+
expectPoint(resolveTypedWallEnd([0, 0], [-4, 0.1], ['', '0'], metric), [0, 0])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('length and angle fix the endpoint whatever the pointer does', () => {
|
|
63
|
+
expectPoint(resolveTypedWallEnd([2, 1], [-50, 80], ['4', '180'], metric), [-2, 1])
|
|
64
|
+
expectPoint(resolveTypedWallEnd([0, 0], [0, 0], ['2', '270'], metric), [0, -2])
|
|
65
|
+
expectPoint(resolveTypedWallEnd([0, 0], [5, 5], ['1', '-90'], metric), [0, -1])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('accepts degree and radian suffixes', () => {
|
|
69
|
+
expectPoint(resolveTypedWallEnd([0, 0], [9, 9], ['2', '90°'], metric), [0, 2])
|
|
70
|
+
expectPoint(resolveTypedWallEnd([0, 0], [9, 9], ['2', `${Math.PI}rad`], metric), [-2, 0])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('an unreadable angle leaves the direction to the pointer', () => {
|
|
74
|
+
expectPoint(resolveTypedWallEnd([0, 0], [0, 4], ['2', 'x'], metric), [0, 2])
|
|
75
|
+
expect(resolveTypedWallEnd([0, 0], [0, 4], ['', 'x'], metric)).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('wallDraftHeadingDegrees', () => {
|
|
80
|
+
test('measures from +X towards +Z in [0, 360)', () => {
|
|
81
|
+
expect(wallDraftHeadingDegrees([0, 0], [1, 0])).toBeCloseTo(0, 10)
|
|
82
|
+
expect(wallDraftHeadingDegrees([0, 0], [0, 1])).toBeCloseTo(90, 10)
|
|
83
|
+
expect(wallDraftHeadingDegrees([0, 0], [-1, 0])).toBeCloseTo(180, 10)
|
|
84
|
+
expect(wallDraftHeadingDegrees([0, 0], [0, -1])).toBeCloseTo(270, 10)
|
|
85
|
+
expect(wallDraftHeadingDegrees([1, 1], [1, 1])).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('round-trips through a typed angle', () => {
|
|
89
|
+
const heading = wallDraftHeadingDegrees([0, 0], [-3, -4])!
|
|
90
|
+
expectPoint(resolveTypedWallEnd([0, 0], [0, 0], ['5', String(heading)], metric), [-3, -4])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('labels the live heading in the same convention, wrapping 360° to 0°', () => {
|
|
94
|
+
expect(formatWallDraftHeading([0, 0], [0, 1])).toBe('90°')
|
|
95
|
+
expect(formatWallDraftHeading([0, 0], [1, -1])).toBe('315°')
|
|
96
|
+
expect(formatWallDraftHeading([0, 0], [1, -0.001])).toBe('0°')
|
|
97
|
+
expect(formatWallDraftHeading([0, 0], [0, 0])).toBe('')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { formatAngleRadians } from '@pascal-app/core'
|
|
2
|
+
import { type ParseMeasurementOptions, parseMeasurement } from '../../../lib/measurement-parser'
|
|
3
|
+
import type { PlacementTypingValues } from '../../../store/use-placement-typing'
|
|
4
|
+
import type { WallPlanPoint } from './wall-drafting'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Heading of a wall draft in degrees, the way the 15° angle snap measures it:
|
|
8
|
+
* from the building's +X axis towards +Z (`atan2(dz, dx)`), in [0, 360).
|
|
9
|
+
* On an unrotated plan +Z points down the screen, so 90 runs straight down.
|
|
10
|
+
*/
|
|
11
|
+
export function wallDraftHeadingDegrees(start: WallPlanPoint, end: WallPlanPoint): number | null {
|
|
12
|
+
const dx = end[0] - start[0]
|
|
13
|
+
const dz = end[1] - start[1]
|
|
14
|
+
if (Math.hypot(dx, dz) <= 1e-6) return null
|
|
15
|
+
const degrees = (Math.atan2(dz, dx) * 180) / Math.PI
|
|
16
|
+
return ((degrees % 360) + 360) % 360
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The draft's live heading as the angle field's placeholder, e.g. `90°`. */
|
|
20
|
+
export function formatWallDraftHeading(start: WallPlanPoint, end: WallPlanPoint): string {
|
|
21
|
+
const degrees = wallDraftHeadingDegrees(start, end)
|
|
22
|
+
if (degrees === null) return ''
|
|
23
|
+
return formatAngleRadians(((Math.round(degrees) % 360) * Math.PI) / 180)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The endpoint a typed wall draft commits to. Field 0 is the length, field 1
|
|
28
|
+
* the heading (see `wallDraftHeadingDegrees`); an empty field is left to the
|
|
29
|
+
* pointer:
|
|
30
|
+
* - length only: the pointer steers the direction, the length is exact;
|
|
31
|
+
* - angle only: the direction is locked, the pointer's projection onto it sets
|
|
32
|
+
* the length (quantised to `lengthStep` when one is given);
|
|
33
|
+
* - both: the endpoint is fully determined and the pointer is ignored.
|
|
34
|
+
*
|
|
35
|
+
* Returns null when no field holds a usable value or the pointer gives no
|
|
36
|
+
* direction to follow, so the caller keeps the pointer's own endpoint.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveTypedWallEnd(
|
|
39
|
+
start: WallPlanPoint,
|
|
40
|
+
pointer: WallPlanPoint,
|
|
41
|
+
fields: Readonly<PlacementTypingValues>,
|
|
42
|
+
lengthOptions: ParseMeasurementOptions,
|
|
43
|
+
lengthStep = 0,
|
|
44
|
+
): WallPlanPoint | null {
|
|
45
|
+
const typedLength = fields[0].trim()
|
|
46
|
+
? parseMeasurement(fields[0], { kind: 'length', unitId: 'm' }, lengthOptions)
|
|
47
|
+
: null
|
|
48
|
+
const length = typedLength !== null && typedLength > 0 ? typedLength : null
|
|
49
|
+
const typedAngle = fields[1].trim()
|
|
50
|
+
? parseMeasurement(fields[1], { kind: 'angle', unitId: 'rad' }, { bareUnit: 'deg' })
|
|
51
|
+
: null
|
|
52
|
+
|
|
53
|
+
const dx = pointer[0] - start[0]
|
|
54
|
+
const dz = pointer[1] - start[1]
|
|
55
|
+
if (typedAngle === null) {
|
|
56
|
+
if (length === null) return null
|
|
57
|
+
const pointerLength = Math.hypot(dx, dz)
|
|
58
|
+
if (pointerLength <= 1e-6) return null
|
|
59
|
+
return [start[0] + (dx / pointerLength) * length, start[1] + (dz / pointerLength) * length]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const dirX = Math.cos(typedAngle)
|
|
63
|
+
const dirZ = Math.sin(typedAngle)
|
|
64
|
+
let distance = length
|
|
65
|
+
if (distance === null) {
|
|
66
|
+
const projected = Math.max(0, dx * dirX + dz * dirZ)
|
|
67
|
+
distance = lengthStep > 0 ? Math.round(projected / lengthStep) * lengthStep : projected
|
|
68
|
+
}
|
|
69
|
+
return [start[0] + dirX * distance, start[1] + dirZ * distance]
|
|
70
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { formatNumber, parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core'
|
|
3
|
+
import { formatNumber, parseLocalizedNumber, useLocale, useScene, usePascalI18n } from '@pascal-app/core'
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
5
|
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
|
|
6
6
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
|
@@ -71,6 +71,7 @@ export function SliderControl({
|
|
|
71
71
|
restoreOnCommit = true,
|
|
72
72
|
mixed = false,
|
|
73
73
|
}: SliderControlProps) {
|
|
74
|
+
const i18n = usePascalI18n()
|
|
74
75
|
const locale = useLocale()
|
|
75
76
|
// Values and bounds stay in meters; gestures and input use the displayed unit.
|
|
76
77
|
const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } =
|
|
@@ -260,6 +261,12 @@ export function SliderControl({
|
|
|
260
261
|
[],
|
|
261
262
|
)
|
|
262
263
|
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
const cancelOnBlur = () => { if (dragRef.current || editingRef.current) cancelEdit() }
|
|
266
|
+
window.addEventListener('blur', cancelOnBlur)
|
|
267
|
+
return () => window.removeEventListener('blur', cancelOnBlur)
|
|
268
|
+
}, [cancelEdit])
|
|
269
|
+
|
|
263
270
|
useEffect(() => {
|
|
264
271
|
if (!isDragging) return
|
|
265
272
|
const escape = (event: KeyboardEvent) => {
|
|
@@ -392,6 +399,7 @@ export function SliderControl({
|
|
|
392
399
|
</span>
|
|
393
400
|
)}
|
|
394
401
|
<input
|
|
402
|
+
aria-label={typeof label === 'string' ? label : undefined}
|
|
395
403
|
autoFocus
|
|
396
404
|
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
|
397
405
|
onBlur={submitValue}
|
|
@@ -418,14 +426,18 @@ export function SliderControl({
|
|
|
418
426
|
)}
|
|
419
427
|
</>
|
|
420
428
|
) : mixed && !isDragging ? (
|
|
421
|
-
<
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
aria-label={typeof label === 'string' ? i18n._({ id: 'editor.controls.sliderControl.editLabel', message: 'Edit {label}', values: { label } }) : undefined}
|
|
422
432
|
className="flex cursor-text items-center text-muted-foreground transition-colors hover:text-foreground"
|
|
423
433
|
onClick={handleValueClick}
|
|
424
434
|
>
|
|
425
|
-
<span className="font-mono tracking-tight">Mixed</span>
|
|
426
|
-
</
|
|
435
|
+
<span className="font-mono tracking-tight">{i18n._({ id: 'editor.controls.sliderControl.mixed', message: 'Mixed' })}</span>
|
|
436
|
+
</button>
|
|
427
437
|
) : (
|
|
428
|
-
<
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
aria-label={typeof label === 'string' ? i18n._({ id: 'editor.controls.sliderControl.editLabel', message: 'Edit {label}', values: { label } }) : undefined}
|
|
429
441
|
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
|
|
430
442
|
onClick={handleValueClick}
|
|
431
443
|
>
|
|
@@ -433,7 +445,7 @@ export function SliderControl({
|
|
|
433
445
|
{formatDisplay(displayValue)}
|
|
434
446
|
</span>
|
|
435
447
|
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
|
436
|
-
</
|
|
448
|
+
</button>
|
|
437
449
|
)}
|
|
438
450
|
</div>
|
|
439
451
|
</div>
|
|
@@ -34,50 +34,80 @@ function ToolOptionRow({
|
|
|
34
34
|
return (
|
|
35
35
|
<div className="flex flex-col gap-2">
|
|
36
36
|
<div className="px-0.5 font-medium text-muted-foreground text-xs">{option.label}</div>
|
|
37
|
-
|
|
38
|
-
className="
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
37
|
+
{option.input ? (
|
|
38
|
+
<label className="flex items-center gap-2 rounded-lg border border-border px-2 py-1.5">
|
|
39
|
+
<input
|
|
40
|
+
key={value}
|
|
41
|
+
aria-label={option.label}
|
|
42
|
+
className="min-w-0 flex-1 bg-transparent text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
43
|
+
type="number"
|
|
44
|
+
defaultValue={value}
|
|
45
|
+
min={option.input.min}
|
|
46
|
+
max={option.input.max}
|
|
47
|
+
step={option.input.step ?? 'any'}
|
|
48
|
+
onBlur={(event) => {
|
|
49
|
+
const next = event.target.valueAsNumber
|
|
50
|
+
if (
|
|
51
|
+
Number.isFinite(next) &&
|
|
52
|
+
!event.target.validity.rangeUnderflow &&
|
|
53
|
+
!event.target.validity.rangeOverflow
|
|
54
|
+
) {
|
|
55
|
+
option.set(String(next))
|
|
56
|
+
onSelect?.(option, String(next))
|
|
57
|
+
} else event.target.value = value
|
|
58
|
+
}}
|
|
59
|
+
onKeyDown={(event) => {
|
|
60
|
+
if (event.key === 'Enter') event.currentTarget.blur()
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
<span className="text-xs text-muted-foreground">{option.input.unit}</span>
|
|
64
|
+
</label>
|
|
65
|
+
) : (
|
|
66
|
+
<div
|
|
67
|
+
className="grid gap-1.5"
|
|
68
|
+
style={{
|
|
69
|
+
gridTemplateColumns: `repeat(${Math.min(option.choices.length, 3)}, minmax(0, 1fr))`,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{option.choices.map((choice) => {
|
|
73
|
+
const selected = active && choice.value === value
|
|
74
|
+
const thumbnail = getChoiceThumbnail?.(option, choice.value)
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
aria-pressed={selected}
|
|
78
|
+
className={cn(
|
|
79
|
+
'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
|
|
80
|
+
selected
|
|
81
|
+
? 'bg-primary/10 text-primary ring-1 ring-primary/50'
|
|
82
|
+
: 'bg-black/4 text-muted-foreground hover:bg-black/8 hover:text-foreground dark:bg-muted/40 dark:hover:bg-muted',
|
|
83
|
+
)}
|
|
84
|
+
key={choice.value}
|
|
85
|
+
onClick={() => {
|
|
86
|
+
triggerSFX('sfx:menu-click')
|
|
87
|
+
option.set(choice.value)
|
|
88
|
+
onSelect?.(option, choice.value)
|
|
89
|
+
}}
|
|
90
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
91
|
+
type="button"
|
|
92
|
+
>
|
|
93
|
+
{thumbnail && (
|
|
94
|
+
<Image
|
|
95
|
+
alt=""
|
|
96
|
+
className={cn(
|
|
97
|
+
'mx-auto mb-1 size-14 object-contain',
|
|
98
|
+
!selected && 'opacity-70 grayscale',
|
|
99
|
+
)}
|
|
100
|
+
height={56}
|
|
101
|
+
src={thumbnail}
|
|
102
|
+
width={56}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
{choice.label}
|
|
106
|
+
</button>
|
|
107
|
+
)
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
81
111
|
{activeChoice && activeChoice.description ? (
|
|
82
112
|
<p className="px-0.5 text-[11px] text-muted-foreground leading-relaxed">
|
|
83
113
|
{activeChoice.description}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import {
|
|
4
|
+
AXIS_MOVE_DRAG_LABEL,
|
|
5
|
+
PATH_POINT_DRAG_LABEL,
|
|
6
|
+
ROTATE_HANDLE_DRAG_LABEL,
|
|
7
|
+
} from '../../../lib/contextual-help'
|
|
3
8
|
import { SELECT_HUD_TITLE, toolHudTitle } from '../../../lib/hud-title'
|
|
4
9
|
import { snapContextOf } from '../../../lib/snapping-mode'
|
|
5
10
|
import { cornerHintRows } from '../../../store/use-corner-snap-hint'
|
|
6
11
|
import { TooltipProvider } from '../primitives/tooltip'
|
|
7
12
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
|
8
13
|
import {
|
|
14
|
+
mepHandleGesture,
|
|
15
|
+
mepHandleGestureHints,
|
|
9
16
|
mezzanineHints,
|
|
10
17
|
openingHints,
|
|
11
18
|
ROOM_DIVIDE_HINTS,
|
|
@@ -128,6 +135,52 @@ describe('HUD header', () => {
|
|
|
128
135
|
})
|
|
129
136
|
})
|
|
130
137
|
|
|
138
|
+
describe('MEP handle HUD', () => {
|
|
139
|
+
const english = (hints: ReturnType<typeof mepHandleGestureHints>) =>
|
|
140
|
+
hints.map((hint) => (typeof hint.label === 'string' ? hint.label : hint.label.message))
|
|
141
|
+
const drag = (label: string) => ({ nodeId: 'pipe_a', label })
|
|
142
|
+
|
|
143
|
+
test('names a run point drag and an axis slide, and leaves rotate and resize to their own HUDs', () => {
|
|
144
|
+
expect(mepHandleGesture(drag(PATH_POINT_DRAG_LABEL))).toBe('edit-path')
|
|
145
|
+
expect(mepHandleGesture(drag(AXIS_MOVE_DRAG_LABEL))).toBe('move')
|
|
146
|
+
expect(mepHandleGesture(drag(ROTATE_HANDLE_DRAG_LABEL))).toBeNull()
|
|
147
|
+
expect(mepHandleGesture(drag('resize-diameter'))).toBeNull()
|
|
148
|
+
expect(mepHandleGesture(null)).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('keeps "Alt: Detach the joint" on screen for the whole run point drag', () => {
|
|
152
|
+
const idle = mepHandleGestureHints('edit-path', false)
|
|
153
|
+
expect(english(idle)).toEqual(['Detach the joint', 'Cancel'])
|
|
154
|
+
expect(idle.map((hint) => hint.keys)).toEqual([['Alt'], ['Esc']])
|
|
155
|
+
expect(idle[0]?.active).toBe(false)
|
|
156
|
+
expect(mepHandleGestureHints('edit-path', true)[0]?.active).toBe(true)
|
|
157
|
+
expect(english(mepHandleGestureHints('move', false))).toEqual(['Cancel'])
|
|
158
|
+
|
|
159
|
+
const html = renderToStaticMarkup(
|
|
160
|
+
<TooltipProvider>
|
|
161
|
+
<ContextualHelperPanel hints={idle} snapContext="polygon" />
|
|
162
|
+
</TooltipProvider>,
|
|
163
|
+
)
|
|
164
|
+
for (const label of ['Detach the joint', 'Cancel', 'Snapping: Grid']) expect(html).toContain(label)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('a handle drag snaps in its kind context, and a rotate arc in angles', () => {
|
|
168
|
+
const contextOf = (handle: string, profile: 'item' | 'structural') =>
|
|
169
|
+
snapContextOf({
|
|
170
|
+
scope: { kind: 'handle-drag', nodeId: 'pipe_a', handle },
|
|
171
|
+
mode: 'select',
|
|
172
|
+
tool: null,
|
|
173
|
+
profileOf: () => undefined,
|
|
174
|
+
profileOfNode: () => profile,
|
|
175
|
+
})
|
|
176
|
+
expect(contextOf(PATH_POINT_DRAG_LABEL, 'structural')).toBe('polygon')
|
|
177
|
+
expect(contextOf(AXIS_MOVE_DRAG_LABEL, 'structural')).toBe('polygon')
|
|
178
|
+
expect(contextOf(AXIS_MOVE_DRAG_LABEL, 'item')).toBe('item')
|
|
179
|
+
expect(contextOf('resize-diameter', 'item')).toBe('item')
|
|
180
|
+
expect(contextOf(ROTATE_HANDLE_DRAG_LABEL, 'item')).toBe('rotation')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
131
184
|
describe('Cut opening HUD', () => {
|
|
132
185
|
test('names the drag gesture "Draw the opening", not the mezzanine', () => {
|
|
133
186
|
// The drag row folds under "More shortcuts", so read the rows the HUD is given.
|
|
@@ -13,9 +13,11 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
|
13
13
|
import { useShallow } from 'zustand/react/shallow'
|
|
14
14
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
|
15
15
|
import {
|
|
16
|
+
AXIS_MOVE_DRAG_LABEL,
|
|
16
17
|
type ContextualShortcutHint,
|
|
17
18
|
GROUP_MOVE_DRAG_LABEL,
|
|
18
19
|
GROUP_ROTATE_DRAG_LABEL,
|
|
20
|
+
PATH_POINT_DRAG_LABEL,
|
|
19
21
|
ROTATE_HANDLE_DRAG_LABEL,
|
|
20
22
|
resolveRotateHandleHelpHints,
|
|
21
23
|
resolveSelectModeHelpHints,
|
|
@@ -294,6 +296,40 @@ export const MEZZANINE_GESTURE_HINTS: Record<MezzanineGesture, ContextualShortcu
|
|
|
294
296
|
],
|
|
295
297
|
}
|
|
296
298
|
|
|
299
|
+
export type MepHandleGesture = 'edit-path' | 'move'
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A drag on an MEP handle — a run point, where Alt detaches the joint, or a
|
|
303
|
+
* fitting, run or run segment slid along an axis — or null. Its HUD names the
|
|
304
|
+
* kind and the gesture, not "Resize".
|
|
305
|
+
*/
|
|
306
|
+
export function mepHandleGesture(
|
|
307
|
+
drag: { nodeId: string; label: string } | null,
|
|
308
|
+
): MepHandleGesture | null {
|
|
309
|
+
if (drag?.label === PATH_POINT_DRAG_LABEL) return 'edit-path'
|
|
310
|
+
if (drag?.label === AXIS_MOVE_DRAG_LABEL) return 'move'
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function mepHandleGestureHints(
|
|
315
|
+
gesture: MepHandleGesture,
|
|
316
|
+
altPressed: boolean,
|
|
317
|
+
): ContextualShortcutHint[] {
|
|
318
|
+
const cancel: ContextualShortcutHint = {
|
|
319
|
+
keys: ['Esc'],
|
|
320
|
+
label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' },
|
|
321
|
+
}
|
|
322
|
+
if (gesture === 'move') return [cancel]
|
|
323
|
+
return [
|
|
324
|
+
{
|
|
325
|
+
keys: ['Alt'],
|
|
326
|
+
label: /* i18n */ { id: 'editor.helpers.helperManager.detachTheJoint', message: 'Detach the joint' },
|
|
327
|
+
active: altPressed,
|
|
328
|
+
},
|
|
329
|
+
cancel,
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
|
|
297
333
|
/**
|
|
298
334
|
* Whether the armed tool's own hints (`toolHints`) win over the node in hand: a registry tool that
|
|
299
335
|
* holds its draft in a placing scope (the roof's footprint, a fitting, a block) says what its clicks
|
|
@@ -340,6 +376,10 @@ export function HelperManager() {
|
|
|
340
376
|
const reshapingNode = useReshapingNode()
|
|
341
377
|
const activeHandleDrag = useActiveHandleDrag()
|
|
342
378
|
const mezzanineHandle = useScene((s) => mezzanineGesture(activeHandleDrag, s.nodes))
|
|
379
|
+
const mepHandle = mepHandleGesture(activeHandleDrag)
|
|
380
|
+
const mepHandleKind = useScene((s) =>
|
|
381
|
+
mepHandle && activeHandleDrag ? s.nodes[activeHandleDrag.nodeId as AnyNodeId]?.type : undefined,
|
|
382
|
+
)
|
|
343
383
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
344
384
|
const isMobile = useIsMobile()
|
|
345
385
|
const modifiers = useActiveModifierKeys()
|
|
@@ -518,6 +558,20 @@ export function HelperManager() {
|
|
|
518
558
|
)
|
|
519
559
|
}
|
|
520
560
|
|
|
561
|
+
if (mepHandle) {
|
|
562
|
+
return (
|
|
563
|
+
<ContextualHelperPanel
|
|
564
|
+
hints={mepHandleGestureHints(mepHandle, modifiers.alt)}
|
|
565
|
+
snapContext={snapContext}
|
|
566
|
+
title={
|
|
567
|
+
mepHandleKind
|
|
568
|
+
? nodeKindHudTitle(mepHandleKind, mepHandle === 'move' ? 'move' : 'edit')
|
|
569
|
+
: null
|
|
570
|
+
}
|
|
571
|
+
/>
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
521
575
|
// A single-node resize arrow is still an active snapping interaction. Roof
|
|
522
576
|
// width/depth handles opt into grid snapping, so keep the mode and grid-step
|
|
523
577
|
// controls visible for the whole drag instead of falling through to idle
|