@pascal-app/editor 1.0.1 → 1.0.3

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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -19,6 +19,7 @@ import {
19
19
  SceneEnvironment,
20
20
  useViewer,
21
21
  Viewer,
22
+ type ViewerImmersiveSession,
22
23
  ViewerPresentations,
23
24
  } from '@pascal-app/viewer'
24
25
  import {
@@ -202,6 +203,7 @@ export interface EditorProps {
202
203
  projectId?: string | null
203
204
 
204
205
  // Persistence — defaults to localStorage when omitted
206
+ guardAgainstSceneWipe?: boolean
205
207
  onLoad?: () => Promise<SceneGraph | null>
206
208
  onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
207
209
  /**
@@ -234,6 +236,9 @@ export interface EditorProps {
234
236
  */
235
237
  disablePostFx?: boolean
236
238
 
239
+ /** Host-provided immersive XR runtime for the main 3D canvas. */
240
+ immersive?: ViewerImmersiveSession
241
+
237
242
  // Version preview overlays (rendered by host app)
238
243
  sidebarOverlay?: ReactNode
239
244
  viewerBanner?: ReactNode
@@ -783,6 +788,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
783
788
  isVersionPreviewMode,
784
789
  isLoading,
785
790
  isFirstPersonMode,
791
+ isXRMode,
786
792
  isStudioMode,
787
793
  onThumbnailCapture,
788
794
  viewerSceneSlot,
@@ -791,6 +797,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
791
797
  isVersionPreviewMode: boolean
792
798
  isLoading: boolean
793
799
  isFirstPersonMode: boolean
800
+ isXRMode: boolean
794
801
  isStudioMode: boolean
795
802
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
796
803
  viewerSceneSlot?: ReactNode
@@ -807,7 +814,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
807
814
  <>
808
815
  <SceneEnvironment />
809
816
  {!(isFirstPersonMode || isStudioMode || isCaptureMode) && <SelectionManager />}
810
- {!noEditing && <BoxSelectTool />}
817
+ {!(noEditing || isXRMode) && <BoxSelectTool />}
811
818
  {!noEditing && <NodeArrowHandles />}
812
819
  {!noEditing && <GroupRotateHandle />}
813
820
  {!noEditing && <GroupSelectionBox3D />}
@@ -815,10 +822,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
815
822
  {!noEditing && <SlabHoleHighlights />}
816
823
  {!noEditing && <WallMoveSideHandles />}
817
824
  {!noEditing && <FenceTangentLines3D />}
818
- {!noEditing && <FloatingActionMenu />}
819
- {!noEditing && <GroupFloatingActionMenu />}
820
- {!noEditing && <FloatingBuildingActionMenu />}
821
- {!isFirstPersonMode && <WallMeasurementLabel />}
825
+ {!(noEditing || isXRMode) && <FloatingActionMenu />}
826
+ {!(noEditing || isXRMode) && <GroupFloatingActionMenu />}
827
+ {!(noEditing || isXRMode) && <FloatingBuildingActionMenu />}
828
+ {!(isFirstPersonMode || isXRMode) && <WallMeasurementLabel />}
822
829
  <ExportManager />
823
830
  {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
824
831
  <CeilingSystem />
@@ -829,10 +836,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
829
836
  {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
830
837
  {!(isLoading || noEditing) && <ToolManager />}
831
838
  {isFirstPersonMode && <FirstPersonControls />}
832
- {isCaptureMode && <CaptureCameraRig />}
833
- <CustomCameraControls />
834
- <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
835
- {!isFirstPersonMode && <SiteEdgeLabels />}
839
+ {isCaptureMode && !isXRMode && <CaptureCameraRig />}
840
+ {!isXRMode && <CustomCameraControls />}
841
+ {!isXRMode && <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />}
842
+ {!(isFirstPersonMode || isXRMode) && <SiteEdgeLabels />}
836
843
  <InteractiveSystem />
837
844
  {presentationsReady ? <ViewerPresentations /> : null}
838
845
  {!noEditing && viewerSceneSlot}
@@ -1022,6 +1029,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1022
1029
  viewerSceneSlot,
1023
1030
  floorplanSceneSlot,
1024
1031
  disablePostFx = false,
1032
+ immersive,
1025
1033
  }: {
1026
1034
  isVersionPreviewMode: boolean
1027
1035
  isLoading: boolean
@@ -1036,6 +1044,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1036
1044
  viewerSceneSlot?: ReactNode
1037
1045
  floorplanSceneSlot?: ReactNode
1038
1046
  disablePostFx?: boolean
1047
+ immersive?: ViewerImmersiveSession
1039
1048
  }) {
1040
1049
  const viewMode = useEditor((s) => s.viewMode)
1041
1050
  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
@@ -1164,6 +1173,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1164
1173
  renderContext="editor"
1165
1174
  renderPaused={!show3d && !showLoader}
1166
1175
  sceneReadyKey={sceneReadyKey}
1176
+ immersive={immersive}
1167
1177
  // Walk/drone framing during snapshot capture is camera-only: the
1168
1178
  // viewer's default selection manager would hover-highlight whatever
1169
1179
  // the cursor crosses, which orbit capture never does.
@@ -1172,6 +1182,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1172
1182
  <ViewerSceneContent
1173
1183
  isFirstPersonMode={isFirstPersonMode}
1174
1184
  isLoading={showLoader}
1185
+ isXRMode={immersive != null}
1175
1186
  isStudioMode={isStudioMode}
1176
1187
  isVersionPreviewMode={isVersionPreviewMode}
1177
1188
  onThumbnailCapture={onThumbnailCapture}
@@ -1241,6 +1252,7 @@ function PreviewStage({
1241
1252
  }
1242
1253
 
1243
1254
  function EditorContent({
1255
+ guardAgainstSceneWipe,
1244
1256
  layoutVersion = 'v1',
1245
1257
  appMenuButton,
1246
1258
  sidebarTop,
@@ -1265,6 +1277,7 @@ function EditorContent({
1265
1277
  onLoaderChange,
1266
1278
  onThumbnailCapture,
1267
1279
  disablePostFx = false,
1280
+ immersive,
1268
1281
  sidebarOverlay,
1269
1282
  viewerBanner,
1270
1283
  settingsPanelProps,
@@ -1300,6 +1313,7 @@ function EditorContent({
1300
1313
  useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode })
1301
1314
 
1302
1315
  const { isLoadingSceneRef, saveNow } = useAutoSave({
1316
+ guardAgainstSceneWipe,
1303
1317
  onSave,
1304
1318
  onDirty,
1305
1319
  onSaveStatusChange,
@@ -1542,6 +1556,7 @@ function EditorContent({
1542
1556
  showLoader={showLoader}
1543
1557
  viewerSceneSlot={viewerSceneSlot}
1544
1558
  floorplanSceneSlot={floorplanSceneSlot}
1559
+ immersive={immersive}
1545
1560
  />
1546
1561
  )
1547
1562
 
@@ -3,6 +3,7 @@
3
3
  import { Icon } from '@iconify/react'
4
4
  import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
+ import { RegistryActionContributions } from './registry-action-contributions'
6
7
 
7
8
  type NodeActionMenuProps = {
8
9
  onFind?: MouseEventHandler<HTMLButtonElement>
@@ -111,6 +112,7 @@ export function NodeActionMenu({
111
112
  <Spline className="h-4 w-4" />
112
113
  </button>
113
114
  )}
115
+ <RegistryActionContributions />
114
116
  {onDuplicate && (
115
117
  <button
116
118
  aria-label="Duplicate"
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type ArcResizeHandle,
7
+ type CornerRadiusHandle,
7
8
  type Cursor,
8
9
  createSceneApi,
9
10
  DEFAULT_ANGLE_STEP,
@@ -46,7 +47,11 @@ import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/co
46
47
  import { createEditorApi } from '../../lib/editor-api'
47
48
  import { sfxEmitter } from '../../lib/sfx-bus'
48
49
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
49
- import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
50
+ import useEditor, {
51
+ isAngleSnapActive,
52
+ isGridSnapActive,
53
+ isMagneticSnapActive,
54
+ } from '../../store/use-editor'
50
55
  import { useHandleGroup } from '../../store/use-handle-group'
51
56
  import useInteractionScope, {
52
57
  useEndpointReshape,
@@ -574,6 +579,17 @@ function ArrowHandle({
574
579
  />
575
580
  )
576
581
  }
582
+ if (descriptor.kind === 'corner-radius') {
583
+ return (
584
+ <CornerRadiusKnob
585
+ descriptor={descriptor}
586
+ dragControls={dragControls}
587
+ handleIndex={handleIndex}
588
+ node={placementNode}
589
+ rideObject={rideObject}
590
+ />
591
+ )
592
+ }
577
593
  if (descriptor.kind === 'tap-action') {
578
594
  // Tap-action handles (fence side-move arrows, corner pickers) aren't
579
595
  // resize handles, so the freeze-at-pre-drag mechanism — which only
@@ -587,6 +603,119 @@ function ArrowHandle({
587
603
  return null
588
604
  }
589
605
 
606
+ const CORNER_RADIUS_DOT_INSET = 0.2
607
+ const CORNER_RADIUS_POSITION_FACTOR = Math.SQRT2 - 1
608
+
609
+ function CornerRadiusKnob({
610
+ descriptor,
611
+ node,
612
+ handleIndex,
613
+ dragControls,
614
+ rideObject,
615
+ }: {
616
+ descriptor: CornerRadiusHandle<AnyNode>
617
+ node: AnyNode
618
+ handleIndex: number
619
+ dragControls: HandleDragControls
620
+ rideObject: Object3D
621
+ }) {
622
+ const [isHovered, setIsHovered] = useState(false)
623
+ const [isDragging, setIsDragging] = useState(false)
624
+ const { camera } = useThree()
625
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
626
+ const [sx, sy] = descriptor.corner
627
+ const radius = descriptor.currentValue(node)
628
+ const inset = CORNER_RADIUS_DOT_INSET + CORNER_RADIUS_POSITION_FACTOR * radius
629
+ const position: [number, number, number] = [
630
+ sx * (descriptor.width(node) / 2 - inset / Math.SQRT2),
631
+ sy * (descriptor.height(node) / 2 - inset / Math.SQRT2),
632
+ 0,
633
+ ]
634
+
635
+ const activate = useHandleDrag({
636
+ kind: 'drag',
637
+ cursor: 'grabbing',
638
+ dragControls,
639
+ handleIndex,
640
+ node,
641
+ rideObject,
642
+ setIsDragging,
643
+ onStart: ({ initialNode, nodeId, rideObject: dragRideObject, sceneApi: dragSceneApi }) => {
644
+ dragRideObject.updateMatrixWorld(true)
645
+ const worldMatrix = dragRideObject.matrixWorld.clone()
646
+ const inverseWorldMatrix = worldMatrix.clone().invert()
647
+ const center = new Vector3().setFromMatrixPosition(worldMatrix)
648
+ const normal = new Vector3().setFromMatrixColumn(worldMatrix, 2).normalize()
649
+ const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
650
+ const maxRadius = resolveBound(
651
+ descriptor.max,
652
+ Number.POSITIVE_INFINITY,
653
+ initialNode,
654
+ dragSceneApi,
655
+ )
656
+ const preview = descriptor.createPreview?.(initialNode as never)
657
+
658
+ return {
659
+ markDirty: !preview,
660
+ commit: preview ? (patch) => preview.commit(patch as never) : undefined,
661
+ onBegin: () => {
662
+ useInteractionScope.getState().begin({
663
+ kind: 'handle-drag',
664
+ nodeId,
665
+ handle: 'Corner radius',
666
+ })
667
+ },
668
+ onCancel: () => preview?.cancel(),
669
+ onEnd: () => {
670
+ useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag')
671
+ },
672
+ move: ({ event, modifiers, intersectPlane }) => {
673
+ const hit = new Vector3()
674
+ if (!intersectPlane(event.clientX, event.clientY, plane, hit)) return null
675
+ hit.applyMatrix4(inverseWorldMatrix)
676
+ const diagonalDistance =
677
+ (descriptor.width(initialNode) / 2 +
678
+ descriptor.height(initialNode) / 2 -
679
+ sx * hit.x -
680
+ sy * hit.y) /
681
+ Math.SQRT2
682
+ const next = Math.min(
683
+ maxRadius,
684
+ Math.max(
685
+ 0,
686
+ (diagonalDistance - CORNER_RADIUS_DOT_INSET) / CORNER_RADIUS_POSITION_FACTOR,
687
+ ),
688
+ )
689
+ const patch = descriptor.apply(initialNode as never, next, dragSceneApi, modifiers)
690
+ preview?.preview(patch as never)
691
+ return patch as Partial<AnyNode>
692
+ },
693
+ }
694
+ },
695
+ })
696
+
697
+ return (
698
+ <HandleArrow
699
+ activeCursor="grabbing"
700
+ cursor="grab"
701
+ hover={isHovered || isDragging}
702
+ hoverScale={1.3}
703
+ onHoverChange={setIsHovered}
704
+ onPointerDown={activate}
705
+ placement={{ position, rotation: [0, 0, 0], baseScale: zoom * 0.42 }}
706
+ round
707
+ shape="corner-picker"
708
+ >
709
+ {isHovered || isDragging ? (
710
+ <DimensionLabel
711
+ position={[0, 0.22, 0]}
712
+ text={formatDimension(radius, useViewer.getState().unit)}
713
+ />
714
+ ) : null}
715
+ </HandleArrow>
716
+ )
717
+ }
718
+
590
719
  function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>): Cursor {
591
720
  if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor
592
721
  return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize'
@@ -705,7 +834,10 @@ function LinearArrow({
705
834
  initialNode,
706
835
  nodeId,
707
836
  sceneApi,
708
- initialModifiers: { altKey: event.nativeEvent.altKey },
837
+ initialModifiers: {
838
+ altKey: event.nativeEvent.altKey,
839
+ shiftKey: event.nativeEvent.shiftKey,
840
+ },
709
841
  })
710
842
  : null
711
843
  const overrideId = linearBinding?.overrideId ?? nodeId
@@ -1219,10 +1351,7 @@ function ArcArrow({
1219
1351
  }
1220
1352
  const initialAngle = angleOf(hitWorld)
1221
1353
 
1222
- // Advertise the rotate interaction so the contextual HUD can surface the
1223
- // Shift = free-rotation toggle (the angle-step bypass below). Resize
1224
- // handles route a measurement label here; rotate gets a sentinel label so
1225
- // the HUD shows the rotate hint, not a dimension pill.
1354
+ // A distinct label selects rotation snapping instead of resize measurements.
1226
1355
  if (isRotateShape) {
1227
1356
  useInteractionScope
1228
1357
  .getState()
@@ -1244,7 +1373,12 @@ function ArcArrow({
1244
1373
  while (delta > Math.PI) delta -= 2 * Math.PI
1245
1374
  while (delta < -Math.PI) delta += 2 * Math.PI
1246
1375
 
1247
- if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
1376
+ if (
1377
+ !descriptor.continuous &&
1378
+ !moveEvent.altKey &&
1379
+ isAngleSnapActive() &&
1380
+ descriptor.shape === 'rotate'
1381
+ ) {
1248
1382
  delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
1249
1383
  }
1250
1384
 
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ComponentType, lazy, Suspense } from 'react'
6
+ import { useShallow } from 'zustand/react/shallow'
7
+ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
8
+
9
+ type Loader = () => Promise<{ default: ComponentType }>
10
+ const lazyCache = new WeakMap<Loader, ComponentType>()
11
+
12
+ function contribution(kind: string): ComponentType | null {
13
+ const loader = getFloorplanNodeExtension(nodeRegistry.get(kind))?.actionMenu?.actions
14
+ if (!loader) return null
15
+ const cached = lazyCache.get(loader)
16
+ if (cached) return cached
17
+ const component = lazy(loader)
18
+ lazyCache.set(loader, component)
19
+ return component
20
+ }
21
+
22
+ /**
23
+ * The buttons kinds add to the action menu for selections holding them
24
+ * (`extensions['pascal:editor/floorplan'].actionMenu.actions`), in 2D and 3D
25
+ * alike. Each contribution reads the selection and decides its own visibility.
26
+ */
27
+ export function RegistryActionContributions() {
28
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
29
+ const kinds = useScene(
30
+ useShallow((s) =>
31
+ Array.from(
32
+ new Set(
33
+ selectedIds.flatMap((id) => {
34
+ const type = s.nodes[id as AnyNodeId]?.type
35
+ return type ? [type] : []
36
+ }),
37
+ ),
38
+ ).sort(),
39
+ ),
40
+ )
41
+ return (
42
+ <>
43
+ {kinds.map((kind) => {
44
+ const Contribution = contribution(kind)
45
+ return Contribution ? (
46
+ <Suspense fallback={null} key={kind}>
47
+ <Contribution />
48
+ </Suspense>
49
+ ) : null
50
+ })}
51
+ </>
52
+ )
53
+ }
@@ -49,6 +49,8 @@ import {
49
49
  import { isHistoryShortcut } from '../../lib/history'
50
50
  import { endpointReshapeScope } from '../../lib/interaction/scope'
51
51
  import { sfxEmitter } from '../../lib/sfx-bus'
52
+ import { intersectSpatialDragPlane, spatialDragLocalY } from '../../lib/spatial-drag-plane'
53
+ import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input'
52
54
  import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
53
55
  import useInteractionScope, {
54
56
  useEndpointReshape,
@@ -769,7 +771,9 @@ function WallBaseElevationHandle({
769
771
  const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4(
770
772
  levelObject.matrixWorld,
771
773
  )
772
- const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
774
+ const planeNormal = new Vector3()
775
+ .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld)
776
+ .setY(0)
773
777
  if (planeNormal.lengthSq() === 0) return null
774
778
  planeNormal.normalize()
775
779
  const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
@@ -908,16 +912,12 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
908
912
  const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
909
913
  if (!levelObject) return
910
914
 
911
- // Vertical plane through the wall midpoint whose normal points toward
912
- // the camera (projected to horizontal). Raycasting against it converts
913
- // pointer movement into a world-space Y value.
915
+ levelObject.updateWorldMatrix(true, false)
916
+ const worldToLocal = levelObject.matrixWorld.clone().invert()
914
917
  const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld)
915
- const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
916
- if (planeNormal.lengthSq() === 0) return
917
- planeNormal.normalize()
918
- const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
919
-
920
918
  const ndc = new Vector2()
919
+ const spatialPointerId = getSpatialPointerId(event.nativeEvent)
920
+ const spatialRay = spatialPointerId ? event.ray.clone() : null
921
921
  const setNDC = (clientX: number, clientY: number) => {
922
922
  const rect = gl.domElement.getBoundingClientRect()
923
923
  ndc.set(
@@ -926,17 +926,39 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
926
926
  )
927
927
  }
928
928
 
929
- setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
930
- raycaster.setFromCamera(ndc, camera)
931
- const hit = new Vector3()
932
- if (!raycaster.ray.intersectPlane(plane, hit)) return
929
+ let plane: Plane | null = null
930
+ let initialY: number
931
+ if (spatialRay) {
932
+ const worldUp = new Vector3(0, 1, 0).transformDirection(levelObject.matrixWorld)
933
+ const axisPoint = new Vector3()
934
+ const spatialRayLocalY = (ray: Ray) => {
935
+ const axisParameter = closestAxisParameterToRay(midpointWorld, worldUp, ray)
936
+ axisPoint.copy(midpointWorld).addScaledVector(worldUp, axisParameter)
937
+ return spatialDragLocalY(axisPoint, worldToLocal)
938
+ }
939
+ initialY = spatialRayLocalY(spatialRay)
940
+ } else {
941
+ // A camera-facing vertical plane gives desktop pointer movement a stable
942
+ // world-space height. Spatial pointers use the closest point on the
943
+ // wall's vertical axis instead, which remains defined for vertical rays.
944
+ const planeNormal = new Vector3()
945
+ .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld)
946
+ .setY(0)
947
+ if (planeNormal.lengthSq() === 0) return
948
+ plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal.normalize(), midpointWorld)
949
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
950
+ raycaster.setFromCamera(ndc, camera)
951
+ const hit = new Vector3()
952
+ if (!intersectSpatialDragPlane(raycaster.ray, plane, hit)) return
953
+ initialY = spatialDragLocalY(hit, worldToLocal)
954
+ }
933
955
 
934
956
  // Dragging the top makes the wall custom-height; seed from the resolved
935
957
  // effective height so a plane-bound wall's drag starts at its real top.
936
958
  const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
937
- const initialY = hit.y
938
959
  const wallId = wall.id as AnyNodeId
939
960
  let pendingHeight = initialHeight
961
+ let releaseSpatialCapture: (() => void) | null = null
940
962
 
941
963
  document.body.style.cursor = 'ns-resize'
942
964
  sfxEmitter.emit('sfx:item-pick')
@@ -954,9 +976,23 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
954
976
  const onMove = (e: PointerEvent) => {
955
977
  setNDC(e.clientX, e.clientY)
956
978
  raycaster.setFromCamera(ndc, camera)
957
- const intersection = new Vector3()
958
- if (!raycaster.ray.intersectPlane(plane, intersection)) return
959
- const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY))
979
+ applyRay(raycaster.ray)
980
+ }
981
+ const applyRay = (ray: Ray) => {
982
+ let currentY: number
983
+ if (spatialRay) {
984
+ const worldUp = new Vector3(0, 1, 0).transformDirection(levelObject.matrixWorld)
985
+ const axisParameter = closestAxisParameterToRay(midpointWorld, worldUp, ray)
986
+ const axisPoint = midpointWorld.clone().addScaledVector(worldUp, axisParameter)
987
+ currentY = spatialDragLocalY(axisPoint, worldToLocal)
988
+ } else {
989
+ if (!plane) return
990
+ const intersection = new Vector3()
991
+ if (!intersectSpatialDragPlane(ray, plane, intersection)) return
992
+ currentY = spatialDragLocalY(intersection, worldToLocal)
993
+ }
994
+ const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (currentY - initialY))
995
+ if (Math.abs(newHeight - pendingHeight) < 1e-6) return
960
996
  pendingHeight = newHeight
961
997
  useLiveNodeOverrides.getState().set(wallId, { height: newHeight })
962
998
  useScene.getState().markDirty(wallId)
@@ -967,6 +1003,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
967
1003
  window.removeEventListener('pointerup', onUp)
968
1004
  window.removeEventListener('pointercancel', onCancel)
969
1005
  window.removeEventListener('keydown', onKeyDown, true)
1006
+ releaseSpatialCapture?.()
1007
+ releaseSpatialCapture = null
970
1008
  if (document.body.style.cursor === 'ns-resize') {
971
1009
  document.body.style.cursor = ''
972
1010
  }
@@ -981,6 +1019,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
981
1019
  // Commit: write the final override-merged value to zustand once
982
1020
  // (tracked, undoable), then drop the override so the renderer
983
1021
  // falls back to the scene store.
1022
+ useScene.temporal.getState().resume()
984
1023
  if (pendingHeight !== initialHeight) {
985
1024
  useScene.getState().updateNode(wallId, { height: pendingHeight })
986
1025
  }
@@ -1006,10 +1045,21 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
1006
1045
  onCancel()
1007
1046
  }
1008
1047
 
1009
- dragCleanupRef.current = cleanup
1010
- window.addEventListener('pointermove', onMove)
1011
- window.addEventListener('pointerup', onUp)
1012
- window.addEventListener('pointercancel', onCancel)
1048
+ dragCleanupRef.current = onCancel
1049
+ if (spatialPointerId && spatialRay) {
1050
+ releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
1051
+ onMove: (ray) => {
1052
+ spatialRay.copy(ray)
1053
+ applyRay(spatialRay)
1054
+ },
1055
+ onRelease: onUp,
1056
+ onCancel,
1057
+ })
1058
+ } else {
1059
+ window.addEventListener('pointermove', onMove)
1060
+ window.addEventListener('pointerup', onUp)
1061
+ window.addEventListener('pointercancel', onCancel)
1062
+ }
1013
1063
  window.addEventListener('keydown', onKeyDown, true)
1014
1064
  }
1015
1065
 
@@ -11,10 +11,10 @@ import {
11
11
  type Group,
12
12
  type Mesh,
13
13
  MeshBasicMaterial,
14
- Vector3,
15
14
  } from 'three'
16
15
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
17
16
  import { EDITOR_LAYER } from '../../lib/constants'
17
+ import { copyOverlayWorldTransform } from '../../lib/overlay-transform'
18
18
 
19
19
  // How far the outline sits outside the opening's own extents, so it reads as
20
20
  // a frame *around* the opening rather than coinciding with its edges.
@@ -23,7 +23,6 @@ const PAD = 0.05
23
23
  const ACCENT = 0x83_81_ed
24
24
 
25
25
  const NO_RAYCAST = () => null
26
- const scratchScale = new Vector3()
27
26
 
28
27
  // Indigo accent matching the resize-arrow handles — deliberately distinct
29
28
  // from the white selection outline so the highlight reads as "editable child
@@ -182,7 +181,7 @@ function OpeningHighlight({ openingId, depth }: { openingId: string; depth: numb
182
181
  return
183
182
  }
184
183
  group.visible = true
185
- obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale)
184
+ copyOverlayWorldTransform(obj, group)
186
185
  })
187
186
 
188
187
  if (!isOpening || !outlineGeometry || !fillGeometry) return null