@pascal-app/editor 0.6.0 → 0.7.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 (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -7,8 +7,16 @@ import {
7
7
  spatialGridManager,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
- import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
11
- import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
10
+ import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
11
+ import {
12
+ memo,
13
+ type ReactNode,
14
+ useCallback,
15
+ useEffect,
16
+ useLayoutEffect,
17
+ useRef,
18
+ useState,
19
+ } from 'react'
12
20
  import { ViewerOverlay } from '../../components/viewer-overlay'
13
21
  import { ViewerZoneSystem } from '../../components/viewer-zone-system'
14
22
  import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -22,8 +30,8 @@ import {
22
30
  } from '../../lib/scene'
23
31
  import { initSFXBus } from '../../lib/sfx-bus'
24
32
  import useEditor from '../../store/use-editor'
25
- import { CeilingSystem } from '../systems/ceiling/ceiling-system'
26
33
  import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
34
+ import { CeilingSystem } from '../systems/ceiling/ceiling-system'
27
35
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
28
36
  import { StairEditSystem } from '../systems/stair/stair-edit-system'
29
37
  import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
@@ -63,6 +71,21 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-
63
71
  const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
64
72
  const DELETE_CURSOR_BADGE_OFFSET_X = 14
65
73
  const DELETE_CURSOR_BADGE_OFFSET_Y = 14
74
+ const PAINT_CURSOR_BADGE_COLOR = '#f59e0b'
75
+ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
76
+ const PAINT_CURSOR_BADGE_OFFSET_X = 14
77
+ const PAINT_CURSOR_BADGE_OFFSET_Y = 14
78
+ const EDITOR_HOVER_STYLES: HoverStyles = {
79
+ default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
80
+ delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
81
+ 'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
82
+ 'paint-disabled': {
83
+ visibleColor: 0x94_a3b8,
84
+ hiddenColor: 0x47_5569,
85
+ strength: 4,
86
+ pulse: false,
87
+ },
88
+ }
66
89
 
67
90
  /**
68
91
  * Wire up module-level singletons (spatial grid, space detection, SFX) for
@@ -501,6 +524,50 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
501
524
  )
502
525
  }
503
526
 
527
+ function PaintCursorBadge({
528
+ position,
529
+ label,
530
+ disabled,
531
+ icon,
532
+ }: {
533
+ position: { x: number; y: number }
534
+ label: string
535
+ disabled: boolean
536
+ icon: string
537
+ }) {
538
+ const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
539
+
540
+ return (
541
+ <div
542
+ aria-hidden="true"
543
+ className="pointer-events-none absolute z-40"
544
+ style={{
545
+ left: position.x + PAINT_CURSOR_BADGE_OFFSET_X,
546
+ top: position.y + PAINT_CURSOR_BADGE_OFFSET_Y,
547
+ }}
548
+ >
549
+ <div
550
+ className="flex items-center gap-2 rounded-xl border border-white/5 bg-zinc-900/95 px-3 py-2 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
551
+ style={{
552
+ boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${accentColor}22`,
553
+ }}
554
+ >
555
+ <Icon
556
+ aria-hidden="true"
557
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
558
+ color={accentColor}
559
+ height={16}
560
+ icon={icon}
561
+ width={16}
562
+ />
563
+ <span className="font-medium text-[11px]" style={{ color: accentColor }}>
564
+ {label}
565
+ </span>
566
+ </div>
567
+ </div>
568
+ )
569
+ }
570
+
504
571
  // ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
505
572
 
506
573
  const ViewerSceneContent = memo(function ViewerSceneContent({
@@ -517,9 +584,9 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
517
584
  return (
518
585
  <>
519
586
  {!isFirstPersonMode && <SelectionManager />}
520
- {!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
521
- {!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
522
- {!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
587
+ {!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
588
+ {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
589
+ {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
523
590
  {!isFirstPersonMode && <WallMeasurementLabel />}
524
591
  <ExportManager />
525
592
  {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
@@ -527,10 +594,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
527
594
  <CeilingSelectionAffordanceSystem />
528
595
  <RoofEditSystem />
529
596
  <StairEditSystem />
530
- {!isLoading && !isFirstPersonMode && (
597
+ {!(isLoading || isFirstPersonMode) && (
531
598
  <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
532
599
  )}
533
- {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
600
+ {!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
534
601
  {isFirstPersonMode && <FirstPersonControls />}
535
602
  <CustomCameraControls />
536
603
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
@@ -552,31 +619,165 @@ function DeleteCursorLayer({
552
619
  isVersionPreviewMode: boolean
553
620
  }) {
554
621
  const mode = useEditor((s) => s.mode)
555
- const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
622
+ const badgeRef = useRef<HTMLDivElement>(null)
556
623
  const active = mode === 'delete' && !isVersionPreviewMode
557
624
 
558
625
  useEffect(() => {
559
626
  if (!active) {
560
- setPosition(null)
627
+ if (badgeRef.current) {
628
+ badgeRef.current.style.display = 'none'
629
+ }
561
630
  return
562
631
  }
563
632
  const el = containerRef.current
564
633
  if (!el) return
634
+ let frame = 0
635
+ let nextX = 0
636
+ let nextY = 0
637
+ const badge = badgeRef.current
638
+
639
+ const flushPosition = () => {
640
+ frame = 0
641
+ if (!badge) return
642
+ badge.style.display = 'block'
643
+ badge.style.transform = `translate(${nextX + DELETE_CURSOR_BADGE_OFFSET_X}px, ${nextY + DELETE_CURSOR_BADGE_OFFSET_Y}px)`
644
+ }
645
+
565
646
  const onMove = (e: PointerEvent) => {
566
647
  const rect = el.getBoundingClientRect()
567
- setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
648
+ nextX = e.clientX - rect.left
649
+ nextY = e.clientY - rect.top
650
+
651
+ if (frame === 0) {
652
+ frame = window.requestAnimationFrame(flushPosition)
653
+ }
654
+ }
655
+ const onLeave = () => {
656
+ if (frame !== 0) {
657
+ window.cancelAnimationFrame(frame)
658
+ frame = 0
659
+ }
660
+ if (badge) {
661
+ badge.style.display = 'none'
662
+ }
568
663
  }
569
- const onLeave = () => setPosition(null)
570
664
  el.addEventListener('pointermove', onMove)
571
665
  el.addEventListener('pointerleave', onLeave)
572
666
  return () => {
667
+ if (frame !== 0) {
668
+ window.cancelAnimationFrame(frame)
669
+ }
573
670
  el.removeEventListener('pointermove', onMove)
574
671
  el.removeEventListener('pointerleave', onLeave)
575
672
  }
576
673
  }, [active, containerRef])
577
674
 
578
- if (!(active && position)) return null
579
- return <DeleteCursorBadge position={position} />
675
+ if (!active) return null
676
+
677
+ return (
678
+ <div
679
+ className="pointer-events-none"
680
+ ref={badgeRef}
681
+ style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
682
+ >
683
+ <DeleteCursorBadge position={{ x: 0, y: 0 }} />
684
+ </div>
685
+ )
686
+ }
687
+
688
+ function PaintCursorLayer({
689
+ containerRef,
690
+ isVersionPreviewMode,
691
+ }: {
692
+ containerRef: React.RefObject<HTMLDivElement | null>
693
+ isVersionPreviewMode: boolean
694
+ }) {
695
+ const mode = useEditor((s) => s.mode)
696
+ const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
697
+ const activePaintTarget = useEditor((s) => s.activePaintTarget)
698
+ const badgeRef = useRef<HTMLDivElement>(null)
699
+ const active = mode === 'material-paint' && !isVersionPreviewMode
700
+
701
+ useEffect(() => {
702
+ if (!active) {
703
+ if (badgeRef.current) {
704
+ badgeRef.current.style.display = 'none'
705
+ }
706
+ return
707
+ }
708
+ const el = containerRef.current
709
+ if (!el) return
710
+ let frame = 0
711
+ let nextX = 0
712
+ let nextY = 0
713
+ const badge = badgeRef.current
714
+
715
+ const flushPosition = () => {
716
+ frame = 0
717
+ if (!badge) return
718
+ badge.style.display = 'block'
719
+ badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
720
+ }
721
+
722
+ const onMove = (e: PointerEvent) => {
723
+ const rect = el.getBoundingClientRect()
724
+ nextX = e.clientX - rect.left
725
+ nextY = e.clientY - rect.top
726
+
727
+ if (frame === 0) {
728
+ frame = window.requestAnimationFrame(flushPosition)
729
+ }
730
+ }
731
+ const onLeave = () => {
732
+ if (frame !== 0) {
733
+ window.cancelAnimationFrame(frame)
734
+ frame = 0
735
+ }
736
+ if (badge) {
737
+ badge.style.display = 'none'
738
+ }
739
+ }
740
+ el.addEventListener('pointermove', onMove)
741
+ el.addEventListener('pointerleave', onLeave)
742
+ return () => {
743
+ if (frame !== 0) {
744
+ window.cancelAnimationFrame(frame)
745
+ }
746
+ el.removeEventListener('pointermove', onMove)
747
+ el.removeEventListener('pointerleave', onLeave)
748
+ }
749
+ }, [active, containerRef])
750
+
751
+ const hasMaterial = Boolean(
752
+ activePaintMaterial &&
753
+ (activePaintMaterial.material !== undefined ||
754
+ activePaintMaterial.materialPreset !== undefined),
755
+ )
756
+ const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
757
+ const icon = 'mdi:format-color-fill'
758
+
759
+ useLayoutEffect(() => {
760
+ if (!active && badgeRef.current) {
761
+ badgeRef.current.style.display = 'none'
762
+ }
763
+ }, [active])
764
+
765
+ if (!active) return null
766
+
767
+ return (
768
+ <div
769
+ className="pointer-events-none"
770
+ ref={badgeRef}
771
+ style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
772
+ >
773
+ <PaintCursorBadge
774
+ disabled={!hasMaterial}
775
+ icon={icon}
776
+ label={label}
777
+ position={{ x: 0, y: 0 }}
778
+ />
779
+ </div>
780
+ )
580
781
  }
581
782
 
582
783
  // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
@@ -684,6 +885,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
684
885
  containerRef={viewer3dRef}
685
886
  isVersionPreviewMode={isVersionPreviewMode}
686
887
  />
888
+ <PaintCursorLayer
889
+ containerRef={viewer3dRef}
890
+ isVersionPreviewMode={isVersionPreviewMode}
891
+ />
687
892
  {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
688
893
  <ViewerCanvasControlsHint
689
894
  isPreviewMode={isPreviewMode}
@@ -691,7 +896,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
691
896
  />
692
897
  ) : null}
693
898
  <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
694
- <Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
899
+ <Viewer
900
+ hoverStyles={EDITOR_HOVER_STYLES}
901
+ selectionManager={isFirstPersonMode ? 'default' : 'custom'}
902
+ >
695
903
  <ViewerSceneContent
696
904
  isFirstPersonMode={isFirstPersonMode}
697
905
  isLoading={isLoading}
@@ -731,8 +939,8 @@ export default function Editor({
731
939
  presetsAdapter,
732
940
  commandPaletteEmptyAction,
733
941
  }: EditorProps) {
734
- useKeyboard({ isVersionPreviewMode })
735
-
942
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
943
+ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
736
944
  const { isLoadingSceneRef } = useAutoSave({
737
945
  onSave,
738
946
  onDirty,
@@ -743,7 +951,8 @@ export default function Editor({
743
951
  const [isSceneLoading, setIsSceneLoading] = useState(false)
744
952
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
745
953
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
746
- const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
954
+ const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
955
+ const wasFirstPersonModeRef = useRef(isFirstPersonMode)
747
956
 
748
957
  const sidebarWidth = useSidebarStore((s) => s.width)
749
958
  const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -761,6 +970,39 @@ export default function Editor({
761
970
  }
762
971
  }, [projectId])
763
972
 
973
+ useEffect(() => {
974
+ const wasFirstPersonMode = wasFirstPersonModeRef.current
975
+ wasFirstPersonModeRef.current = isFirstPersonMode
976
+
977
+ if (isFirstPersonMode && !wasFirstPersonMode) {
978
+ const viewer = useViewer.getState()
979
+ firstPersonPreviousLevelRef.current = viewer.selection.levelId
980
+ viewer.setCameraMode('perspective')
981
+ viewer.setWallMode('up')
982
+ viewer.setWalkthroughMode(true)
983
+ viewer.setSelection({ selectedIds: [], zoneId: null })
984
+ return
985
+ }
986
+
987
+ if (!(wasFirstPersonMode && !isFirstPersonMode)) return
988
+
989
+ const viewer = useViewer.getState()
990
+ const previousLevelId = firstPersonPreviousLevelRef.current
991
+ firstPersonPreviousLevelRef.current = null
992
+ viewer.setWalkthroughMode(false)
993
+
994
+ if (!previousLevelId) return
995
+
996
+ const previousLevelNode = useScene.getState().nodes[previousLevelId]
997
+ if (previousLevelNode?.type === 'level') {
998
+ viewer.setSelection({
999
+ levelId: previousLevelId,
1000
+ zoneId: null,
1001
+ selectedIds: [],
1002
+ })
1003
+ }
1004
+ }, [isFirstPersonMode])
1005
+
764
1006
  // Load scene on mount (or when onLoad identity changes, e.g. project switch)
765
1007
  useEffect(() => {
766
1008
  let cancelled = false
@@ -823,7 +1065,7 @@ export default function Editor({
823
1065
  const showLoader = isLoading || isSceneLoading
824
1066
 
825
1067
  const previewViewerContent = (
826
- <Viewer selectionManager="default">
1068
+ <Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
827
1069
  <ExportManager />
828
1070
  <ViewerZoneSystem />
829
1071
  <CeilingSystem />
@@ -866,7 +1108,13 @@ export default function Editor({
866
1108
  return <Component />
867
1109
  }
868
1110
 
869
- const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
1111
+ const tabBarTabs =
1112
+ sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({
1113
+ id,
1114
+ label,
1115
+ mobileDefaultSnap,
1116
+ mobileIcon,
1117
+ })) ?? []
870
1118
 
871
1119
  return (
872
1120
  <PresetsProvider adapter={presetsAdapter}>
@@ -913,7 +1161,7 @@ export default function Editor({
913
1161
  />
914
1162
  {/* First-person overlay — rendered on top of normal layout */}
915
1163
  {isFirstPersonMode && (
916
- <div className="fixed inset-0 z-50 pointer-events-none">
1164
+ <div className="pointer-events-none fixed inset-0 z-50">
917
1165
  <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
918
1166
  </div>
919
1167
  )}