@pascal-app/editor 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -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'
@@ -30,6 +23,7 @@ import {
30
23
  import { initSFXBus } from '../../lib/sfx-bus'
31
24
  import useEditor from '../../store/use-editor'
32
25
  import { CeilingSystem } from '../systems/ceiling/ceiling-system'
26
+ import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
33
27
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
34
28
  import { StairEditSystem } from '../systems/stair/stair-edit-system'
35
29
  import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
@@ -54,15 +48,16 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
54
48
  import { CustomCameraControls } from './custom-camera-controls'
55
49
  import { EditorLayoutV2 } from './editor-layout-v2'
56
50
  import { ExportManager } from './export-manager'
51
+ import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
57
52
  import { FloatingActionMenu } from './floating-action-menu'
53
+ import { FloatingBuildingActionMenu } from './floating-building-action-menu'
58
54
  import { FloorplanPanel } from './floorplan-panel'
59
55
  import { Grid } from './grid'
60
56
  import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
61
57
  import { SelectionManager } from './selection-manager'
62
58
  import { SiteEdgeLabels } from './site-edge-labels'
63
- import { ThumbnailGenerator } from './thumbnail-generator'
59
+ import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
64
60
  import { WallMeasurementLabel } from './wall-measurement-label'
65
- import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
66
61
 
67
62
  const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
68
63
  const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
@@ -122,7 +117,7 @@ export interface EditorProps {
122
117
  isLoading?: boolean
123
118
 
124
119
  // Thumbnail
125
- onThumbnailCapture?: (blob: Blob) => void
120
+ onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
126
121
 
127
122
  // Version preview overlays (rendered by host app)
128
123
  sidebarOverlay?: ReactNode
@@ -506,6 +501,211 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
506
501
  )
507
502
  }
508
503
 
504
+ // ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
505
+
506
+ const ViewerSceneContent = memo(function ViewerSceneContent({
507
+ isVersionPreviewMode,
508
+ isLoading,
509
+ isFirstPersonMode,
510
+ onThumbnailCapture,
511
+ }: {
512
+ isVersionPreviewMode: boolean
513
+ isLoading: boolean
514
+ isFirstPersonMode: boolean
515
+ onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
516
+ }) {
517
+ return (
518
+ <>
519
+ {!isFirstPersonMode && <SelectionManager />}
520
+ {!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
521
+ {!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
522
+ {!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
523
+ {!isFirstPersonMode && <WallMeasurementLabel />}
524
+ <ExportManager />
525
+ {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
526
+ <CeilingSystem />
527
+ <CeilingSelectionAffordanceSystem />
528
+ <RoofEditSystem />
529
+ <StairEditSystem />
530
+ {!isLoading && !isFirstPersonMode && (
531
+ <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
532
+ )}
533
+ {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
534
+ {isFirstPersonMode && <FirstPersonControls />}
535
+ <CustomCameraControls />
536
+ <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
537
+ <PresetThumbnailGenerator />
538
+ {!isFirstPersonMode && <SiteEdgeLabels />}
539
+ {isFirstPersonMode && <InteractiveSystem />}
540
+ </>
541
+ )
542
+ })
543
+
544
+ // ── Delete cursor badge: isolated component so cursor moves don't re-render ViewerCanvas ──
545
+ // Subscribes to mode itself and manages cursor position state independently.
546
+
547
+ function DeleteCursorLayer({
548
+ containerRef,
549
+ isVersionPreviewMode,
550
+ }: {
551
+ containerRef: React.RefObject<HTMLDivElement | null>
552
+ isVersionPreviewMode: boolean
553
+ }) {
554
+ const mode = useEditor((s) => s.mode)
555
+ const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
556
+ const active = mode === 'delete' && !isVersionPreviewMode
557
+
558
+ useEffect(() => {
559
+ if (!active) {
560
+ setPosition(null)
561
+ return
562
+ }
563
+ const el = containerRef.current
564
+ if (!el) return
565
+ const onMove = (e: PointerEvent) => {
566
+ const rect = el.getBoundingClientRect()
567
+ setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
568
+ }
569
+ const onLeave = () => setPosition(null)
570
+ el.addEventListener('pointermove', onMove)
571
+ el.addEventListener('pointerleave', onLeave)
572
+ return () => {
573
+ el.removeEventListener('pointermove', onMove)
574
+ el.removeEventListener('pointerleave', onLeave)
575
+ }
576
+ }, [active, containerRef])
577
+
578
+ if (!(active && position)) return null
579
+ return <DeleteCursorBadge position={position} />
580
+ }
581
+
582
+ // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
583
+ // This prevents Editor from re-rendering when those values change.
584
+
585
+ const ViewerCanvas = memo(function ViewerCanvas({
586
+ isVersionPreviewMode,
587
+ isLoading,
588
+ hasLoadedInitialScene,
589
+ showLoader,
590
+ isFirstPersonMode,
591
+ onThumbnailCapture,
592
+ }: {
593
+ isVersionPreviewMode: boolean
594
+ isLoading: boolean
595
+ hasLoadedInitialScene: boolean
596
+ showLoader: boolean
597
+ isFirstPersonMode: boolean
598
+ onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
599
+ }) {
600
+ const viewMode = useEditor((s) => s.viewMode)
601
+ const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
602
+ const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
603
+ const isPreviewMode = useEditor((s) => s.isPreviewMode)
604
+
605
+ const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
606
+ null,
607
+ )
608
+
609
+ const viewerAreaRef = useRef<HTMLDivElement>(null)
610
+ const viewer3dRef = useRef<HTMLDivElement>(null)
611
+ const isResizingFloorplan = useRef(false)
612
+
613
+ const handleFloorplanDividerDown = useCallback((e: React.PointerEvent) => {
614
+ e.preventDefault()
615
+ isResizingFloorplan.current = true
616
+ document.body.style.cursor = 'col-resize'
617
+ document.body.style.userSelect = 'none'
618
+ }, [])
619
+
620
+ useEffect(() => {
621
+ const handlePointerMove = (e: PointerEvent) => {
622
+ if (!isResizingFloorplan.current) return
623
+ if (!viewerAreaRef.current) return
624
+ const rect = viewerAreaRef.current.getBoundingClientRect()
625
+ const newRatio = (e.clientX - rect.left) / rect.width
626
+ setFloorplanPaneRatio(Math.max(0.15, Math.min(0.85, newRatio)))
627
+ }
628
+ const handlePointerUp = () => {
629
+ isResizingFloorplan.current = false
630
+ document.body.style.cursor = ''
631
+ document.body.style.userSelect = ''
632
+ }
633
+ window.addEventListener('pointermove', handlePointerMove)
634
+ window.addEventListener('pointerup', handlePointerUp)
635
+ return () => {
636
+ window.removeEventListener('pointermove', handlePointerMove)
637
+ window.removeEventListener('pointerup', handlePointerUp)
638
+ }
639
+ }, [setFloorplanPaneRatio])
640
+
641
+ useEffect(() => {
642
+ setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
643
+ }, [])
644
+
645
+ const dismissCameraControlsHint = useCallback(() => {
646
+ setIsCameraControlsHintVisible(false)
647
+ writeCameraControlsHintDismissed(true)
648
+ }, [])
649
+
650
+ const show2d = viewMode === '2d' || viewMode === 'split'
651
+ const show3d = viewMode === '3d' || viewMode === 'split'
652
+
653
+ return (
654
+ <ErrorBoundary fallback={<EditorSceneCrashFallback />}>
655
+ <div className="flex h-full" ref={viewerAreaRef}>
656
+ {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
657
+ <div
658
+ className="relative h-full flex-shrink-0"
659
+ style={{
660
+ width: viewMode === '2d' ? '100%' : `${floorplanPaneRatio * 100}%`,
661
+ display: show2d ? undefined : 'none',
662
+ }}
663
+ >
664
+ <div className="h-full w-full overflow-hidden">
665
+ <FloorplanPanel />
666
+ </div>
667
+ {viewMode === 'split' && (
668
+ <div
669
+ className="absolute inset-y-0 -right-3 z-10 flex w-6 cursor-col-resize items-center justify-center"
670
+ onPointerDown={handleFloorplanDividerDown}
671
+ >
672
+ <div className="h-8 w-1 rounded-full bg-neutral-400" />
673
+ </div>
674
+ )}
675
+ </div>
676
+
677
+ {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
678
+ <div
679
+ className="relative min-w-0 flex-1 overflow-hidden"
680
+ ref={viewer3dRef}
681
+ style={{ display: show3d ? undefined : 'none' }}
682
+ >
683
+ <DeleteCursorLayer
684
+ containerRef={viewer3dRef}
685
+ isVersionPreviewMode={isVersionPreviewMode}
686
+ />
687
+ {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
688
+ <ViewerCanvasControlsHint
689
+ isPreviewMode={isPreviewMode}
690
+ onDismiss={dismissCameraControlsHint}
691
+ />
692
+ ) : null}
693
+ <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
694
+ <Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
695
+ <ViewerSceneContent
696
+ isFirstPersonMode={isFirstPersonMode}
697
+ isLoading={isLoading}
698
+ isVersionPreviewMode={isVersionPreviewMode}
699
+ onThumbnailCapture={onThumbnailCapture}
700
+ />
701
+ </Viewer>
702
+ </div>
703
+ </div>
704
+ {!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
705
+ </ErrorBoundary>
706
+ )
707
+ })
708
+
509
709
  export default function Editor({
510
710
  layoutVersion = 'v1',
511
711
  appMenuButton,
@@ -542,51 +742,11 @@ export default function Editor({
542
742
 
543
743
  const [isSceneLoading, setIsSceneLoading] = useState(false)
544
744
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
545
- const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
546
- null,
547
- )
548
745
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
549
- const mode = useEditor((s) => s.mode)
550
746
  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
747
 
558
748
  const sidebarWidth = useSidebarStore((s) => s.width)
559
749
  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
750
 
591
751
  useEffect(() => {
592
752
  const teardown = initializeEditorRuntime()
@@ -660,38 +820,7 @@ export default function Editor({
660
820
  }
661
821
  }, [])
662
822
 
663
- useEffect(() => {
664
- setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
665
- }, [])
666
-
667
823
  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
824
 
696
825
  const previewViewerContent = (
697
826
  <Viewer selectionManager="default">
@@ -707,86 +836,15 @@ export default function Editor({
707
836
  </Viewer>
708
837
  )
709
838
 
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
839
  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>
840
+ <ViewerCanvas
841
+ hasLoadedInitialScene={hasLoadedInitialScene}
842
+ isFirstPersonMode={isFirstPersonMode}
843
+ isLoading={isLoading}
844
+ isVersionPreviewMode={isVersionPreviewMode}
845
+ onThumbnailCapture={onThumbnailCapture}
846
+ showLoader={showLoader}
847
+ />
790
848
  )
791
849
 
792
850
  // ── V2 layout ──
@@ -856,9 +914,7 @@ export default function Editor({
856
914
  {/* First-person overlay — rendered on top of normal layout */}
857
915
  {isFirstPersonMode && (
858
916
  <div className="fixed inset-0 z-50 pointer-events-none">
859
- <FirstPersonOverlay
860
- onExit={() => useEditor.getState().setFirstPersonMode(false)}
861
- />
917
+ <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
862
918
  </div>
863
919
  )}
864
920
  <EditorCommands />
@@ -904,9 +960,7 @@ export default function Editor({
904
960
  </SidebarSlot>
905
961
 
906
962
  {/* Viewer area */}
907
- <div className="relative flex-1 overflow-hidden rounded-xl" ref={viewerAreaRef}>
908
- {viewerCanvas}
909
- </div>
963
+ <div className="relative flex-1 overflow-hidden rounded-xl">{viewerCanvas}</div>
910
964
 
911
965
  {/* Fixed UI overlays scoped to the viewer area */}
912
966
  <ViewerOverlays left={overlayLeft}>
@@ -1,12 +1,15 @@
1
1
  'use client'
2
2
 
3
- import { Copy, Move, Trash2 } from 'lucide-react'
3
+ import { Icon } from '@iconify/react'
4
+ import { Copy, Move, Spline, 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>
12
+ onCurve?: MouseEventHandler<HTMLButtonElement>
10
13
  onPointerDown?: PointerEventHandler<HTMLDivElement>
11
14
  onPointerUp?: PointerEventHandler<HTMLDivElement>
12
15
  onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -14,9 +17,11 @@ type NodeActionMenuProps = {
14
17
  }
15
18
 
16
19
  export function NodeActionMenu({
20
+ onAddHole,
17
21
  onDelete,
18
22
  onDuplicate,
19
23
  onMove,
24
+ onCurve,
20
25
  onPointerDown,
21
26
  onPointerUp,
22
27
  onPointerEnter,
@@ -41,6 +46,17 @@ export function NodeActionMenu({
41
46
  <Move className="h-4 w-4" />
42
47
  </button>
43
48
  )}
49
+ {onCurve && (
50
+ <button
51
+ aria-label="Curve"
52
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
53
+ onClick={onCurve}
54
+ title="Curve"
55
+ type="button"
56
+ >
57
+ <Spline className="h-4 w-4" />
58
+ </button>
59
+ )}
44
60
  {onDuplicate && (
45
61
  <button
46
62
  aria-label="Duplicate"
@@ -52,15 +68,28 @@ export function NodeActionMenu({
52
68
  <Copy className="h-4 w-4" />
53
69
  </button>
54
70
  )}
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>
71
+ {onAddHole && (
72
+ <button
73
+ aria-label="Cut Out"
74
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
75
+ onClick={onAddHole}
76
+ title="Cut Out"
77
+ type="button"
78
+ >
79
+ <Icon height={16} icon="carbon:cut-out" width={16} />
80
+ </button>
81
+ )}
82
+ {onDelete && (
83
+ <button
84
+ aria-label="Delete"
85
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
86
+ onClick={onDelete}
87
+ title="Delete"
88
+ type="button"
89
+ >
90
+ <Trash2 className="h-4 w-4" />
91
+ </button>
92
+ )}
64
93
  </div>
65
94
  )
66
95
  }