@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.
- package/package.json +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/slider-control.tsx +76 -31
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/parametric-inspector.tsx +3 -15
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +42 -29
- package/src/index.tsx +63 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- 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, {
|
|
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: {
|
|
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
|
-
//
|
|
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 (
|
|
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()
|
|
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
|
-
|
|
912
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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 =
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
|
184
|
+
copyOverlayWorldTransform(obj, group)
|
|
186
185
|
})
|
|
187
186
|
|
|
188
187
|
if (!isOpening || !outlineGeometry || !fillGeometry) return null
|