@pascal-app/editor 0.4.0 → 0.5.1
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 +5 -5
- package/src/components/editor/floating-action-menu.tsx +101 -29
- package/src/components/editor/floating-building-action-menu.tsx +69 -0
- package/src/components/editor/floorplan-panel.tsx +31 -13
- package/src/components/editor/index.tsx +219 -167
- package/src/components/editor/node-action-menu.tsx +26 -10
- package/src/components/editor/selection-manager.tsx +38 -2
- package/src/components/editor/thumbnail-generator.tsx +245 -64
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +19 -7
- package/src/components/tools/door/move-door-tool.tsx +17 -8
- package/src/components/tools/fence/fence-drafting.ts +125 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-tool.tsx +223 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +7 -0
- package/src/components/tools/item/placement-strategies.ts +15 -7
- package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
- package/src/components/tools/roof/move-roof-tool.tsx +5 -2
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +2 -2
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +29 -6
- package/src/components/tools/tool-manager.tsx +42 -14
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +17 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +19 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/panels/fence-panel.tsx +184 -0
- package/src/components/ui/panels/panel-manager.tsx +3 -0
- package/src/components/ui/panels/stair-panel.tsx +206 -33
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
- package/src/components/viewer-overlay.tsx +1 -0
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +10 -2
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +4 -0
- package/src/store/use-editor.tsx +7 -0
|
@@ -8,14 +8,7 @@ import {
|
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
10
|
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
|
11
|
-
import {
|
|
12
|
-
type ReactNode,
|
|
13
|
-
type PointerEvent as ReactPointerEvent,
|
|
14
|
-
useCallback,
|
|
15
|
-
useEffect,
|
|
16
|
-
useRef,
|
|
17
|
-
useState,
|
|
18
|
-
} from 'react'
|
|
11
|
+
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
19
12
|
import { ViewerOverlay } from '../../components/viewer-overlay'
|
|
20
13
|
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
|
21
14
|
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
|
@@ -54,15 +47,16 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
|
|
54
47
|
import { CustomCameraControls } from './custom-camera-controls'
|
|
55
48
|
import { EditorLayoutV2 } from './editor-layout-v2'
|
|
56
49
|
import { ExportManager } from './export-manager'
|
|
50
|
+
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
|
57
51
|
import { FloatingActionMenu } from './floating-action-menu'
|
|
52
|
+
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
|
58
53
|
import { FloorplanPanel } from './floorplan-panel'
|
|
59
54
|
import { Grid } from './grid'
|
|
60
55
|
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
|
61
56
|
import { SelectionManager } from './selection-manager'
|
|
62
57
|
import { SiteEdgeLabels } from './site-edge-labels'
|
|
63
|
-
import { ThumbnailGenerator } from './thumbnail-generator'
|
|
58
|
+
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
|
64
59
|
import { WallMeasurementLabel } from './wall-measurement-label'
|
|
65
|
-
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
|
66
60
|
|
|
67
61
|
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
|
68
62
|
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
|
@@ -122,7 +116,7 @@ export interface EditorProps {
|
|
|
122
116
|
isLoading?: boolean
|
|
123
117
|
|
|
124
118
|
// Thumbnail
|
|
125
|
-
onThumbnailCapture?: (blob: Blob) => void
|
|
119
|
+
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
|
126
120
|
|
|
127
121
|
// Version preview overlays (rendered by host app)
|
|
128
122
|
sidebarOverlay?: ReactNode
|
|
@@ -506,6 +500,210 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
|
|
|
506
500
|
)
|
|
507
501
|
}
|
|
508
502
|
|
|
503
|
+
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
|
|
504
|
+
|
|
505
|
+
const ViewerSceneContent = memo(function ViewerSceneContent({
|
|
506
|
+
isVersionPreviewMode,
|
|
507
|
+
isLoading,
|
|
508
|
+
isFirstPersonMode,
|
|
509
|
+
onThumbnailCapture,
|
|
510
|
+
}: {
|
|
511
|
+
isVersionPreviewMode: boolean
|
|
512
|
+
isLoading: boolean
|
|
513
|
+
isFirstPersonMode: boolean
|
|
514
|
+
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
|
515
|
+
}) {
|
|
516
|
+
return (
|
|
517
|
+
<>
|
|
518
|
+
{!isFirstPersonMode && <SelectionManager />}
|
|
519
|
+
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
|
|
520
|
+
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
|
521
|
+
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
|
|
522
|
+
{!isFirstPersonMode && <WallMeasurementLabel />}
|
|
523
|
+
<ExportManager />
|
|
524
|
+
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
|
525
|
+
<CeilingSystem />
|
|
526
|
+
<RoofEditSystem />
|
|
527
|
+
<StairEditSystem />
|
|
528
|
+
{!isLoading && !isFirstPersonMode && (
|
|
529
|
+
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
|
|
530
|
+
)}
|
|
531
|
+
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
|
|
532
|
+
{isFirstPersonMode && <FirstPersonControls />}
|
|
533
|
+
<CustomCameraControls />
|
|
534
|
+
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
|
535
|
+
<PresetThumbnailGenerator />
|
|
536
|
+
{!isFirstPersonMode && <SiteEdgeLabels />}
|
|
537
|
+
{isFirstPersonMode && <InteractiveSystem />}
|
|
538
|
+
</>
|
|
539
|
+
)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
// ── Delete cursor badge: isolated component so cursor moves don't re-render ViewerCanvas ──
|
|
543
|
+
// Subscribes to mode itself and manages cursor position state independently.
|
|
544
|
+
|
|
545
|
+
function DeleteCursorLayer({
|
|
546
|
+
containerRef,
|
|
547
|
+
isVersionPreviewMode,
|
|
548
|
+
}: {
|
|
549
|
+
containerRef: React.RefObject<HTMLDivElement | null>
|
|
550
|
+
isVersionPreviewMode: boolean
|
|
551
|
+
}) {
|
|
552
|
+
const mode = useEditor((s) => s.mode)
|
|
553
|
+
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
|
554
|
+
const active = mode === 'delete' && !isVersionPreviewMode
|
|
555
|
+
|
|
556
|
+
useEffect(() => {
|
|
557
|
+
if (!active) {
|
|
558
|
+
setPosition(null)
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
const el = containerRef.current
|
|
562
|
+
if (!el) return
|
|
563
|
+
const onMove = (e: PointerEvent) => {
|
|
564
|
+
const rect = el.getBoundingClientRect()
|
|
565
|
+
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
|
|
566
|
+
}
|
|
567
|
+
const onLeave = () => setPosition(null)
|
|
568
|
+
el.addEventListener('pointermove', onMove)
|
|
569
|
+
el.addEventListener('pointerleave', onLeave)
|
|
570
|
+
return () => {
|
|
571
|
+
el.removeEventListener('pointermove', onMove)
|
|
572
|
+
el.removeEventListener('pointerleave', onLeave)
|
|
573
|
+
}
|
|
574
|
+
}, [active, containerRef])
|
|
575
|
+
|
|
576
|
+
if (!(active && position)) return null
|
|
577
|
+
return <DeleteCursorBadge position={position} />
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
|
|
581
|
+
// This prevents Editor from re-rendering when those values change.
|
|
582
|
+
|
|
583
|
+
const ViewerCanvas = memo(function ViewerCanvas({
|
|
584
|
+
isVersionPreviewMode,
|
|
585
|
+
isLoading,
|
|
586
|
+
hasLoadedInitialScene,
|
|
587
|
+
showLoader,
|
|
588
|
+
isFirstPersonMode,
|
|
589
|
+
onThumbnailCapture,
|
|
590
|
+
}: {
|
|
591
|
+
isVersionPreviewMode: boolean
|
|
592
|
+
isLoading: boolean
|
|
593
|
+
hasLoadedInitialScene: boolean
|
|
594
|
+
showLoader: boolean
|
|
595
|
+
isFirstPersonMode: boolean
|
|
596
|
+
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
|
597
|
+
}) {
|
|
598
|
+
const viewMode = useEditor((s) => s.viewMode)
|
|
599
|
+
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
|
|
600
|
+
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
|
|
601
|
+
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
602
|
+
|
|
603
|
+
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
|
|
604
|
+
null,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
const viewerAreaRef = useRef<HTMLDivElement>(null)
|
|
608
|
+
const viewer3dRef = useRef<HTMLDivElement>(null)
|
|
609
|
+
const isResizingFloorplan = useRef(false)
|
|
610
|
+
|
|
611
|
+
const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
|
|
612
|
+
e.preventDefault()
|
|
613
|
+
isResizingFloorplan.current = true
|
|
614
|
+
document.body.style.cursor = 'col-resize'
|
|
615
|
+
document.body.style.userSelect = 'none'
|
|
616
|
+
}, [])
|
|
617
|
+
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
620
|
+
if (!isResizingFloorplan.current) return
|
|
621
|
+
if (!viewerAreaRef.current) return
|
|
622
|
+
const rect = viewerAreaRef.current.getBoundingClientRect()
|
|
623
|
+
const newRatio = (e.clientX - rect.left) / rect.width
|
|
624
|
+
setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
|
|
625
|
+
}
|
|
626
|
+
const handlePointerUp = () => {
|
|
627
|
+
isResizingFloorplan.current = false
|
|
628
|
+
document.body.style.cursor = ''
|
|
629
|
+
document.body.style.userSelect = ''
|
|
630
|
+
}
|
|
631
|
+
window.addEventListener('pointermove', handlePointerMove)
|
|
632
|
+
window.addEventListener('pointerup', handlePointerUp)
|
|
633
|
+
return () => {
|
|
634
|
+
window.removeEventListener('pointermove', handlePointerMove)
|
|
635
|
+
window.removeEventListener('pointerup', handlePointerUp)
|
|
636
|
+
}
|
|
637
|
+
}, [setFloorplanPaneRatio])
|
|
638
|
+
|
|
639
|
+
useEffect(() => {
|
|
640
|
+
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
|
|
641
|
+
}, [])
|
|
642
|
+
|
|
643
|
+
const dismissCameraControlsHint = useCallback(() => {
|
|
644
|
+
setIsCameraControlsHintVisible(false)
|
|
645
|
+
writeCameraControlsHintDismissed(true)
|
|
646
|
+
}, [])
|
|
647
|
+
|
|
648
|
+
const show2d = viewMode === '2d' || viewMode === 'split'
|
|
649
|
+
const show3d = viewMode === '3d' || viewMode === 'split'
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
|
653
|
+
<div className="flex h-full" ref={viewerAreaRef}>
|
|
654
|
+
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
|
655
|
+
<div
|
|
656
|
+
className="relative h-full flex-shrink-0"
|
|
657
|
+
style={{
|
|
658
|
+
width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,
|
|
659
|
+
display: show2d ? undefined : 'none',
|
|
660
|
+
}}
|
|
661
|
+
>
|
|
662
|
+
<div className="h-full w-full overflow-hidden">
|
|
663
|
+
<FloorplanPanel />
|
|
664
|
+
</div>
|
|
665
|
+
{viewMode === 'split' && (
|
|
666
|
+
<div
|
|
667
|
+
className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
|
|
668
|
+
onPointerDown={handleFloorplanDividerDown}
|
|
669
|
+
>
|
|
670
|
+
<div className="h-8 w-1 rounded-full bg-neutral-400" />
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
|
676
|
+
<div
|
|
677
|
+
className="relative min-w-0 flex-1 overflow-hidden"
|
|
678
|
+
ref={viewer3dRef}
|
|
679
|
+
style={{ display: show3d ? undefined : 'none' }}
|
|
680
|
+
>
|
|
681
|
+
<DeleteCursorLayer
|
|
682
|
+
containerRef={viewer3dRef}
|
|
683
|
+
isVersionPreviewMode={isVersionPreviewMode}
|
|
684
|
+
/>
|
|
685
|
+
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
|
686
|
+
<ViewerCanvasControlsHint
|
|
687
|
+
isPreviewMode={isPreviewMode}
|
|
688
|
+
onDismiss={dismissCameraControlsHint}
|
|
689
|
+
/>
|
|
690
|
+
) : null}
|
|
691
|
+
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
|
692
|
+
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
|
|
693
|
+
<ViewerSceneContent
|
|
694
|
+
isFirstPersonMode={isFirstPersonMode}
|
|
695
|
+
isLoading={isLoading}
|
|
696
|
+
isVersionPreviewMode={isVersionPreviewMode}
|
|
697
|
+
onThumbnailCapture={onThumbnailCapture}
|
|
698
|
+
/>
|
|
699
|
+
</Viewer>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
|
703
|
+
</ErrorBoundary>
|
|
704
|
+
)
|
|
705
|
+
})
|
|
706
|
+
|
|
509
707
|
export default function Editor({
|
|
510
708
|
layoutVersion = 'v1',
|
|
511
709
|
appMenuButton,
|
|
@@ -542,51 +740,11 @@ export default function Editor({
|
|
|
542
740
|
|
|
543
741
|
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
|
544
742
|
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
|
545
|
-
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
|
|
546
|
-
null,
|
|
547
|
-
)
|
|
548
743
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
549
|
-
const mode = useEditor((s) => s.mode)
|
|
550
744
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
|
551
|
-
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
|
552
|
-
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
|
|
553
|
-
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
|
|
554
|
-
const [viewerCursorPosition, setViewerCursorPosition] = useState<{ x: number; y: number } | null>(
|
|
555
|
-
null,
|
|
556
|
-
)
|
|
557
745
|
|
|
558
746
|
const sidebarWidth = useSidebarStore((s) => s.width)
|
|
559
747
|
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
|
560
|
-
const viewerAreaRef = useRef<HTMLDivElement>(null)
|
|
561
|
-
const isResizingFloorplan = useRef(false)
|
|
562
|
-
|
|
563
|
-
const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
|
|
564
|
-
e.preventDefault()
|
|
565
|
-
isResizingFloorplan.current = true
|
|
566
|
-
document.body.style.cursor = 'col-resize'
|
|
567
|
-
document.body.style.userSelect = 'none'
|
|
568
|
-
}, [])
|
|
569
|
-
|
|
570
|
-
useEffect(() => {
|
|
571
|
-
const handlePointerMove = (e: PointerEvent) => {
|
|
572
|
-
if (!isResizingFloorplan.current) return
|
|
573
|
-
if (!viewerAreaRef.current) return
|
|
574
|
-
const rect = viewerAreaRef.current.getBoundingClientRect()
|
|
575
|
-
const newRatio = (e.clientX - rect.left) / rect.width
|
|
576
|
-
setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
|
|
577
|
-
}
|
|
578
|
-
const handlePointerUp = () => {
|
|
579
|
-
isResizingFloorplan.current = false
|
|
580
|
-
document.body.style.cursor = ''
|
|
581
|
-
document.body.style.userSelect = ''
|
|
582
|
-
}
|
|
583
|
-
window.addEventListener('pointermove', handlePointerMove)
|
|
584
|
-
window.addEventListener('pointerup', handlePointerUp)
|
|
585
|
-
return () => {
|
|
586
|
-
window.removeEventListener('pointermove', handlePointerMove)
|
|
587
|
-
window.removeEventListener('pointerup', handlePointerUp)
|
|
588
|
-
}
|
|
589
|
-
}, [])
|
|
590
748
|
|
|
591
749
|
useEffect(() => {
|
|
592
750
|
const teardown = initializeEditorRuntime()
|
|
@@ -660,38 +818,7 @@ export default function Editor({
|
|
|
660
818
|
}
|
|
661
819
|
}, [])
|
|
662
820
|
|
|
663
|
-
useEffect(() => {
|
|
664
|
-
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
|
|
665
|
-
}, [])
|
|
666
|
-
|
|
667
821
|
const showLoader = isLoading || isSceneLoading
|
|
668
|
-
const dismissCameraControlsHint = useCallback(() => {
|
|
669
|
-
setIsCameraControlsHintVisible(false)
|
|
670
|
-
writeCameraControlsHintDismissed(true)
|
|
671
|
-
}, [])
|
|
672
|
-
|
|
673
|
-
// ── Shared viewer scene content ──
|
|
674
|
-
const viewerSceneContent = (
|
|
675
|
-
<>
|
|
676
|
-
{!isFirstPersonMode && <SelectionManager />}
|
|
677
|
-
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
|
|
678
|
-
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
|
679
|
-
{!isFirstPersonMode && <WallMeasurementLabel />}
|
|
680
|
-
<ExportManager />
|
|
681
|
-
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
|
682
|
-
<CeilingSystem />
|
|
683
|
-
<RoofEditSystem />
|
|
684
|
-
<StairEditSystem />
|
|
685
|
-
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
|
686
|
-
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
|
|
687
|
-
{isFirstPersonMode && <FirstPersonControls />}
|
|
688
|
-
<CustomCameraControls />
|
|
689
|
-
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
|
690
|
-
<PresetThumbnailGenerator />
|
|
691
|
-
{!isFirstPersonMode && <SiteEdgeLabels />}
|
|
692
|
-
{isFirstPersonMode && <InteractiveSystem />}
|
|
693
|
-
</>
|
|
694
|
-
)
|
|
695
822
|
|
|
696
823
|
const previewViewerContent = (
|
|
697
824
|
<Viewer selectionManager="default">
|
|
@@ -707,86 +834,15 @@ export default function Editor({
|
|
|
707
834
|
</Viewer>
|
|
708
835
|
)
|
|
709
836
|
|
|
710
|
-
// ── Shared viewer canvas (handles split/2d/3d) ──
|
|
711
|
-
const viewMode = useEditor((s) => s.viewMode)
|
|
712
|
-
|
|
713
|
-
const show2d = viewMode === '2d' || viewMode === 'split'
|
|
714
|
-
const show3d = viewMode === '3d' || viewMode === 'split'
|
|
715
|
-
const showDeleteCursorBadge = mode === 'delete' && !isVersionPreviewMode
|
|
716
|
-
|
|
717
|
-
useEffect(() => {
|
|
718
|
-
if (!(showDeleteCursorBadge && show3d)) {
|
|
719
|
-
setViewerCursorPosition(null)
|
|
720
|
-
}
|
|
721
|
-
}, [show3d, showDeleteCursorBadge])
|
|
722
|
-
|
|
723
|
-
const handleViewerPointerMove = useCallback(
|
|
724
|
-
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
725
|
-
if (!showDeleteCursorBadge) {
|
|
726
|
-
setViewerCursorPosition(null)
|
|
727
|
-
return
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const rect = event.currentTarget.getBoundingClientRect()
|
|
731
|
-
setViewerCursorPosition({
|
|
732
|
-
x: event.clientX - rect.left,
|
|
733
|
-
y: event.clientY - rect.top,
|
|
734
|
-
})
|
|
735
|
-
},
|
|
736
|
-
[showDeleteCursorBadge],
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
const handleViewerPointerLeave = useCallback(() => {
|
|
740
|
-
setViewerCursorPosition(null)
|
|
741
|
-
}, [])
|
|
742
|
-
|
|
743
837
|
const viewerCanvas = (
|
|
744
|
-
<
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
}}
|
|
753
|
-
>
|
|
754
|
-
<div className="h-full w-full overflow-hidden">
|
|
755
|
-
<FloorplanPanel />
|
|
756
|
-
</div>
|
|
757
|
-
{viewMode === 'split' && (
|
|
758
|
-
<div
|
|
759
|
-
className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
|
|
760
|
-
onPointerDown={handleFloorplanDividerDown}
|
|
761
|
-
>
|
|
762
|
-
<div className="h-8 w-1 rounded-full bg-neutral-400" />
|
|
763
|
-
</div>
|
|
764
|
-
)}
|
|
765
|
-
</div>
|
|
766
|
-
|
|
767
|
-
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
|
768
|
-
<div
|
|
769
|
-
className="relative min-w-0 flex-1 overflow-hidden"
|
|
770
|
-
onPointerEnter={handleViewerPointerMove}
|
|
771
|
-
onPointerLeave={handleViewerPointerLeave}
|
|
772
|
-
onPointerMove={handleViewerPointerMove}
|
|
773
|
-
style={{ display: show3d ? undefined : 'none' }}
|
|
774
|
-
>
|
|
775
|
-
{showDeleteCursorBadge && viewerCursorPosition ? (
|
|
776
|
-
<DeleteCursorBadge position={viewerCursorPosition} />
|
|
777
|
-
) : null}
|
|
778
|
-
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
|
779
|
-
<ViewerCanvasControlsHint
|
|
780
|
-
isPreviewMode={isPreviewMode}
|
|
781
|
-
onDismiss={dismissCameraControlsHint}
|
|
782
|
-
/>
|
|
783
|
-
) : null}
|
|
784
|
-
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
|
785
|
-
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
|
|
786
|
-
</div>
|
|
787
|
-
</div>
|
|
788
|
-
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
|
789
|
-
</ErrorBoundary>
|
|
838
|
+
<ViewerCanvas
|
|
839
|
+
hasLoadedInitialScene={hasLoadedInitialScene}
|
|
840
|
+
isFirstPersonMode={isFirstPersonMode}
|
|
841
|
+
isLoading={isLoading}
|
|
842
|
+
isVersionPreviewMode={isVersionPreviewMode}
|
|
843
|
+
onThumbnailCapture={onThumbnailCapture}
|
|
844
|
+
showLoader={showLoader}
|
|
845
|
+
/>
|
|
790
846
|
)
|
|
791
847
|
|
|
792
848
|
// ── V2 layout ──
|
|
@@ -856,9 +912,7 @@ export default function Editor({
|
|
|
856
912
|
{/* First-person overlay — rendered on top of normal layout */}
|
|
857
913
|
{isFirstPersonMode && (
|
|
858
914
|
<div className="fixed inset-0 z-50 pointer-events-none">
|
|
859
|
-
<FirstPersonOverlay
|
|
860
|
-
onExit={() => useEditor.getState().setFirstPersonMode(false)}
|
|
861
|
-
/>
|
|
915
|
+
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
|
862
916
|
</div>
|
|
863
917
|
)}
|
|
864
918
|
<EditorCommands />
|
|
@@ -904,9 +958,7 @@ export default function Editor({
|
|
|
904
958
|
</SidebarSlot>
|
|
905
959
|
|
|
906
960
|
{/* Viewer area */}
|
|
907
|
-
<div className="relative flex-1 overflow-hidden rounded-xl"
|
|
908
|
-
{viewerCanvas}
|
|
909
|
-
</div>
|
|
961
|
+
<div className="relative flex-1 overflow-hidden rounded-xl">{viewerCanvas}</div>
|
|
910
962
|
|
|
911
963
|
{/* Fixed UI overlays scoped to the viewer area */}
|
|
912
964
|
<ViewerOverlays left={overlayLeft}>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { Icon } from '@iconify/react'
|
|
3
4
|
import { Copy, Move, Trash2 } from 'lucide-react'
|
|
4
5
|
import type { MouseEventHandler, PointerEventHandler } from 'react'
|
|
5
6
|
|
|
6
7
|
type NodeActionMenuProps = {
|
|
7
|
-
|
|
8
|
+
onAddHole?: MouseEventHandler<HTMLButtonElement>
|
|
9
|
+
onDelete?: MouseEventHandler<HTMLButtonElement>
|
|
8
10
|
onDuplicate?: MouseEventHandler<HTMLButtonElement>
|
|
9
11
|
onMove?: MouseEventHandler<HTMLButtonElement>
|
|
10
12
|
onPointerDown?: PointerEventHandler<HTMLDivElement>
|
|
@@ -14,6 +16,7 @@ type NodeActionMenuProps = {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function NodeActionMenu({
|
|
19
|
+
onAddHole,
|
|
17
20
|
onDelete,
|
|
18
21
|
onDuplicate,
|
|
19
22
|
onMove,
|
|
@@ -52,15 +55,28 @@ export function NodeActionMenu({
|
|
|
52
55
|
<Copy className="h-4 w-4" />
|
|
53
56
|
</button>
|
|
54
57
|
)}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
{onAddHole && (
|
|
59
|
+
<button
|
|
60
|
+
aria-label="Cut Out"
|
|
61
|
+
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
62
|
+
onClick={onAddHole}
|
|
63
|
+
title="Cut Out"
|
|
64
|
+
type="button"
|
|
65
|
+
>
|
|
66
|
+
<Icon height={16} icon="carbon:cut-out" width={16} />
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
|
69
|
+
{onDelete && (
|
|
70
|
+
<button
|
|
71
|
+
aria-label="Delete"
|
|
72
|
+
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
73
|
+
onClick={onDelete}
|
|
74
|
+
title="Delete"
|
|
75
|
+
type="button"
|
|
76
|
+
>
|
|
77
|
+
<Trash2 className="h-4 w-4" />
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
64
80
|
</div>
|
|
65
81
|
)
|
|
66
82
|
}
|
|
@@ -26,6 +26,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|
|
26
26
|
|
|
27
27
|
type SelectableNodeType =
|
|
28
28
|
| 'wall'
|
|
29
|
+
| 'fence'
|
|
29
30
|
| 'item'
|
|
30
31
|
| 'building'
|
|
31
32
|
| 'zone'
|
|
@@ -142,7 +143,9 @@ function createHighlightedMaterials(
|
|
|
142
143
|
|
|
143
144
|
function disposeHighlightedMaterials(material: Material | Material[]) {
|
|
144
145
|
if (Array.isArray(material)) {
|
|
145
|
-
material.forEach((entry) =>
|
|
146
|
+
material.forEach((entry) => {
|
|
147
|
+
entry.dispose()
|
|
148
|
+
})
|
|
146
149
|
return
|
|
147
150
|
}
|
|
148
151
|
|
|
@@ -184,6 +187,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
184
187
|
structure: {
|
|
185
188
|
types: [
|
|
186
189
|
'wall',
|
|
190
|
+
'fence',
|
|
187
191
|
'item',
|
|
188
192
|
'zone',
|
|
189
193
|
'slab',
|
|
@@ -236,6 +240,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
236
240
|
}
|
|
237
241
|
if (
|
|
238
242
|
node.type === 'wall' ||
|
|
243
|
+
node.type === 'fence' ||
|
|
239
244
|
node.type === 'slab' ||
|
|
240
245
|
node.type === 'ceiling' ||
|
|
241
246
|
node.type === 'roof' ||
|
|
@@ -297,6 +302,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|
|
297
302
|
|
|
298
303
|
if (
|
|
299
304
|
node.type === 'wall' ||
|
|
305
|
+
node.type === 'fence' ||
|
|
300
306
|
node.type === 'slab' ||
|
|
301
307
|
node.type === 'ceiling' ||
|
|
302
308
|
node.type === 'roof' ||
|
|
@@ -389,7 +395,8 @@ export const SelectionManager = () => {
|
|
|
389
395
|
let currentStructureLayer = useEditor.getState().structureLayer
|
|
390
396
|
|
|
391
397
|
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
|
|
392
|
-
|
|
398
|
+
// Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
|
|
399
|
+
if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
|
|
393
400
|
if (isNodeInCurrentLevel(node)) {
|
|
394
401
|
const target = getSelectionTarget(node)
|
|
395
402
|
if (target) {
|
|
@@ -440,6 +447,7 @@ export const SelectionManager = () => {
|
|
|
440
447
|
|
|
441
448
|
const allTypes = [
|
|
442
449
|
'wall',
|
|
450
|
+
'fence',
|
|
443
451
|
'item',
|
|
444
452
|
'building',
|
|
445
453
|
'zone',
|
|
@@ -534,6 +542,7 @@ export const SelectionManager = () => {
|
|
|
534
542
|
}
|
|
535
543
|
} else if (
|
|
536
544
|
node.type === 'wall' ||
|
|
545
|
+
node.type === 'fence' ||
|
|
537
546
|
node.type === 'slab' ||
|
|
538
547
|
node.type === 'ceiling' ||
|
|
539
548
|
node.type === 'roof' ||
|
|
@@ -583,6 +592,7 @@ export const SelectionManager = () => {
|
|
|
583
592
|
|
|
584
593
|
const allTypes = [
|
|
585
594
|
'wall',
|
|
595
|
+
'fence',
|
|
586
596
|
'item',
|
|
587
597
|
'building',
|
|
588
598
|
'slab',
|
|
@@ -654,6 +664,7 @@ export const SelectionManager = () => {
|
|
|
654
664
|
|
|
655
665
|
const allTypes = [
|
|
656
666
|
'wall',
|
|
667
|
+
'fence',
|
|
657
668
|
'item',
|
|
658
669
|
'slab',
|
|
659
670
|
'ceiling',
|
|
@@ -824,6 +835,31 @@ const SelectionMaterialSync = () => {
|
|
|
824
835
|
})
|
|
825
836
|
}, [syncSelectionMaterials])
|
|
826
837
|
|
|
838
|
+
useEffect(() => {
|
|
839
|
+
const restoreForCapture = () => {
|
|
840
|
+
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|
|
841
|
+
if (mesh.material === entry.highlightedMaterial) {
|
|
842
|
+
mesh.material = entry.originalMaterial
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const reapplyAfterCapture = () => {
|
|
848
|
+
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|
|
849
|
+
if (mesh.material === entry.originalMaterial) {
|
|
850
|
+
mesh.material = entry.highlightedMaterial
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
emitter.on('thumbnail:before-capture', restoreForCapture)
|
|
856
|
+
emitter.on('thumbnail:after-capture', reapplyAfterCapture)
|
|
857
|
+
return () => {
|
|
858
|
+
emitter.off('thumbnail:before-capture', restoreForCapture)
|
|
859
|
+
emitter.off('thumbnail:after-capture', reapplyAfterCapture)
|
|
860
|
+
}
|
|
861
|
+
}, [])
|
|
862
|
+
|
|
827
863
|
useEffect(() => {
|
|
828
864
|
return () => {
|
|
829
865
|
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|