@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.
Files changed (61) hide show
  1. package/package.json +5 -5
  2. package/src/components/editor/floating-action-menu.tsx +101 -29
  3. package/src/components/editor/floating-building-action-menu.tsx +69 -0
  4. package/src/components/editor/floorplan-panel.tsx +31 -13
  5. package/src/components/editor/index.tsx +219 -167
  6. package/src/components/editor/node-action-menu.tsx +26 -10
  7. package/src/components/editor/selection-manager.tsx +38 -2
  8. package/src/components/editor/thumbnail-generator.tsx +245 -64
  9. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  10. package/src/components/tools/building/move-building-tool.tsx +157 -0
  11. package/src/components/tools/door/door-math.ts +1 -1
  12. package/src/components/tools/door/door-tool.tsx +19 -7
  13. package/src/components/tools/door/move-door-tool.tsx +17 -8
  14. package/src/components/tools/fence/fence-drafting.ts +125 -0
  15. package/src/components/tools/fence/fence-tool.tsx +190 -0
  16. package/src/components/tools/fence/move-fence-tool.tsx +223 -0
  17. package/src/components/tools/item/item-tool.tsx +3 -3
  18. package/src/components/tools/item/move-tool.tsx +7 -0
  19. package/src/components/tools/item/placement-strategies.ts +15 -7
  20. package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
  21. package/src/components/tools/roof/move-roof-tool.tsx +5 -2
  22. package/src/components/tools/roof/roof-tool.tsx +6 -6
  23. package/src/components/tools/select/box-select-tool.tsx +2 -2
  24. package/src/components/tools/shared/polygon-editor.tsx +2 -2
  25. package/src/components/tools/slab/slab-tool.tsx +4 -4
  26. package/src/components/tools/stair/stair-defaults.ts +10 -0
  27. package/src/components/tools/stair/stair-tool.tsx +29 -6
  28. package/src/components/tools/tool-manager.tsx +42 -14
  29. package/src/components/tools/wall/wall-tool.tsx +19 -29
  30. package/src/components/tools/window/move-window-tool.tsx +17 -8
  31. package/src/components/tools/window/window-math.ts +1 -1
  32. package/src/components/tools/window/window-tool.tsx +19 -7
  33. package/src/components/tools/zone/zone-tool.tsx +7 -7
  34. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  35. package/src/components/ui/helpers/building-helper.tsx +32 -0
  36. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  37. package/src/components/ui/panels/fence-panel.tsx +184 -0
  38. package/src/components/ui/panels/panel-manager.tsx +3 -0
  39. package/src/components/ui/panels/stair-panel.tsx +206 -33
  40. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
  41. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
  42. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
  43. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
  44. package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
  45. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  46. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
  47. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
  48. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
  49. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
  50. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
  51. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
  52. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
  53. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
  54. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
  55. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
  56. package/src/components/viewer-overlay.tsx +1 -0
  57. package/src/hooks/use-auto-save.ts +3 -6
  58. package/src/hooks/use-contextual-tools.ts +10 -2
  59. package/src/hooks/use-grid-events.ts +13 -1
  60. package/src/hooks/use-keyboard.ts +4 -0
  61. 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
- <ErrorBoundary fallback={<EditorSceneCrashFallback />}>
745
- <div className="flex h-full" ref={viewerAreaRef}>
746
- {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
747
- <div
748
- className="relative h-full flex-shrink-0"
749
- style={{
750
- width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,
751
- display: show2d ? undefined : 'none',
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" ref={viewerAreaRef}>
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
- onDelete: MouseEventHandler<HTMLButtonElement>
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
- <button
56
- aria-label="Delete"
57
- className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
58
- onClick={onDelete}
59
- title="Delete"
60
- type="button"
61
- >
62
- <Trash2 className="h-4 w-4" />
63
- </button>
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) => entry.dispose())
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
- if (currentPhase === 'structure' || currentPhase === 'furnish') {
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()) {