@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.
Files changed (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. 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 && !useRegistryTool && showBuildTool && tool === 'elevator' && (
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
- <div
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
- </div>
435
+ <span className="font-mono tracking-tight">{i18n._({ id: 'editor.controls.sliderControl.mixed', message: 'Mixed' })}</span>
436
+ </button>
427
437
  ) : (
428
- <div
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
- </div>
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
- <div
38
- className="grid gap-1.5"
39
- style={{
40
- gridTemplateColumns: `repeat(${Math.min(option.choices.length, 3)}, minmax(0, 1fr))`,
41
- }}
42
- >
43
- {option.choices.map((choice) => {
44
- const selected = active && choice.value === value
45
- const thumbnail = getChoiceThumbnail?.(option, choice.value)
46
- return (
47
- <button
48
- aria-pressed={selected}
49
- className={cn(
50
- 'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
51
- selected
52
- ? 'bg-primary/10 text-primary ring-1 ring-primary/50'
53
- : 'bg-black/4 text-muted-foreground hover:bg-black/8 hover:text-foreground dark:bg-muted/40 dark:hover:bg-muted',
54
- )}
55
- key={choice.value}
56
- onClick={() => {
57
- triggerSFX('sfx:menu-click')
58
- option.set(choice.value)
59
- onSelect?.(option, choice.value)
60
- }}
61
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
62
- type="button"
63
- >
64
- {thumbnail && (
65
- <Image
66
- alt=""
67
- className={cn(
68
- 'mx-auto mb-1 size-14 object-contain',
69
- !selected && 'opacity-70 grayscale',
70
- )}
71
- height={56}
72
- src={thumbnail}
73
- width={56}
74
- />
75
- )}
76
- {choice.label}
77
- </button>
78
- )
79
- })}
80
- </div>
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