@pascal-app/editor 0.5.1 → 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 (150) hide show
  1. package/package.json +12 -7
  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 +29 -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 +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -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,6 +30,7 @@ import {
22
30
  } from '../../lib/scene'
23
31
  import { initSFXBus } from '../../lib/sfx-bus'
24
32
  import useEditor from '../../store/use-editor'
33
+ import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
25
34
  import { CeilingSystem } from '../systems/ceiling/ceiling-system'
26
35
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
27
36
  import { StairEditSystem } from '../systems/stair/stair-edit-system'
@@ -62,6 +71,21 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-
62
71
  const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
63
72
  const DELETE_CURSOR_BADGE_OFFSET_X = 14
64
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
+ }
65
89
 
66
90
  /**
67
91
  * Wire up module-level singletons (spatial grid, space detection, SFX) for
@@ -500,6 +524,50 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
500
524
  )
501
525
  }
502
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
+
503
571
  // ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
504
572
 
505
573
  const ViewerSceneContent = memo(function ViewerSceneContent({
@@ -516,19 +584,20 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
516
584
  return (
517
585
  <>
518
586
  {!isFirstPersonMode && <SelectionManager />}
519
- {!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
520
- {!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
521
- {!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
587
+ {!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
588
+ {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
589
+ {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
522
590
  {!isFirstPersonMode && <WallMeasurementLabel />}
523
591
  <ExportManager />
524
592
  {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
525
593
  <CeilingSystem />
594
+ <CeilingSelectionAffordanceSystem />
526
595
  <RoofEditSystem />
527
596
  <StairEditSystem />
528
- {!isLoading && !isFirstPersonMode && (
597
+ {!(isLoading || isFirstPersonMode) && (
529
598
  <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
530
599
  )}
531
- {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
600
+ {!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
532
601
  {isFirstPersonMode && <FirstPersonControls />}
533
602
  <CustomCameraControls />
534
603
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
@@ -550,31 +619,165 @@ function DeleteCursorLayer({
550
619
  isVersionPreviewMode: boolean
551
620
  }) {
552
621
  const mode = useEditor((s) => s.mode)
553
- const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
622
+ const badgeRef = useRef<HTMLDivElement>(null)
554
623
  const active = mode === 'delete' && !isVersionPreviewMode
555
624
 
556
625
  useEffect(() => {
557
626
  if (!active) {
558
- setPosition(null)
627
+ if (badgeRef.current) {
628
+ badgeRef.current.style.display = 'none'
629
+ }
559
630
  return
560
631
  }
561
632
  const el = containerRef.current
562
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
+
563
646
  const onMove = (e: PointerEvent) => {
564
647
  const rect = el.getBoundingClientRect()
565
- 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
+ }
566
663
  }
567
- const onLeave = () => setPosition(null)
568
664
  el.addEventListener('pointermove', onMove)
569
665
  el.addEventListener('pointerleave', onLeave)
570
666
  return () => {
667
+ if (frame !== 0) {
668
+ window.cancelAnimationFrame(frame)
669
+ }
571
670
  el.removeEventListener('pointermove', onMove)
572
671
  el.removeEventListener('pointerleave', onLeave)
573
672
  }
574
673
  }, [active, containerRef])
575
674
 
576
- if (!(active && position)) return null
577
- 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
+ )
578
781
  }
579
782
 
580
783
  // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
@@ -682,6 +885,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
682
885
  containerRef={viewer3dRef}
683
886
  isVersionPreviewMode={isVersionPreviewMode}
684
887
  />
888
+ <PaintCursorLayer
889
+ containerRef={viewer3dRef}
890
+ isVersionPreviewMode={isVersionPreviewMode}
891
+ />
685
892
  {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
686
893
  <ViewerCanvasControlsHint
687
894
  isPreviewMode={isPreviewMode}
@@ -689,7 +896,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
689
896
  />
690
897
  ) : null}
691
898
  <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
692
- <Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
899
+ <Viewer
900
+ hoverStyles={EDITOR_HOVER_STYLES}
901
+ selectionManager={isFirstPersonMode ? 'default' : 'custom'}
902
+ >
693
903
  <ViewerSceneContent
694
904
  isFirstPersonMode={isFirstPersonMode}
695
905
  isLoading={isLoading}
@@ -729,8 +939,8 @@ export default function Editor({
729
939
  presetsAdapter,
730
940
  commandPaletteEmptyAction,
731
941
  }: EditorProps) {
732
- useKeyboard({ isVersionPreviewMode })
733
-
942
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
943
+ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
734
944
  const { isLoadingSceneRef } = useAutoSave({
735
945
  onSave,
736
946
  onDirty,
@@ -741,7 +951,8 @@ export default function Editor({
741
951
  const [isSceneLoading, setIsSceneLoading] = useState(false)
742
952
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
743
953
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
744
- const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
954
+ const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
955
+ const wasFirstPersonModeRef = useRef(isFirstPersonMode)
745
956
 
746
957
  const sidebarWidth = useSidebarStore((s) => s.width)
747
958
  const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -759,6 +970,39 @@ export default function Editor({
759
970
  }
760
971
  }, [projectId])
761
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
+
762
1006
  // Load scene on mount (or when onLoad identity changes, e.g. project switch)
763
1007
  useEffect(() => {
764
1008
  let cancelled = false
@@ -821,7 +1065,7 @@ export default function Editor({
821
1065
  const showLoader = isLoading || isSceneLoading
822
1066
 
823
1067
  const previewViewerContent = (
824
- <Viewer selectionManager="default">
1068
+ <Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
825
1069
  <ExportManager />
826
1070
  <ViewerZoneSystem />
827
1071
  <CeilingSystem />
@@ -864,7 +1108,13 @@ export default function Editor({
864
1108
  return <Component />
865
1109
  }
866
1110
 
867
- 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
+ })) ?? []
868
1118
 
869
1119
  return (
870
1120
  <PresetsProvider adapter={presetsAdapter}>
@@ -911,7 +1161,7 @@ export default function Editor({
911
1161
  />
912
1162
  {/* First-person overlay — rendered on top of normal layout */}
913
1163
  {isFirstPersonMode && (
914
- <div className="fixed inset-0 z-50 pointer-events-none">
1164
+ <div className="pointer-events-none fixed inset-0 z-50">
915
1165
  <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
916
1166
  </div>
917
1167
  )}
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { Copy, Move, Trash2 } from 'lucide-react'
4
+ import { Copy, Move, Spline, Trash2 } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
6
 
7
7
  type NodeActionMenuProps = {
@@ -9,6 +9,7 @@ type NodeActionMenuProps = {
9
9
  onDelete?: MouseEventHandler<HTMLButtonElement>
10
10
  onDuplicate?: MouseEventHandler<HTMLButtonElement>
11
11
  onMove?: MouseEventHandler<HTMLButtonElement>
12
+ onCurve?: MouseEventHandler<HTMLButtonElement>
12
13
  onPointerDown?: PointerEventHandler<HTMLDivElement>
13
14
  onPointerUp?: PointerEventHandler<HTMLDivElement>
14
15
  onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -20,6 +21,7 @@ export function NodeActionMenu({
20
21
  onDelete,
21
22
  onDuplicate,
22
23
  onMove,
24
+ onCurve,
23
25
  onPointerDown,
24
26
  onPointerUp,
25
27
  onPointerEnter,
@@ -44,6 +46,17 @@ export function NodeActionMenu({
44
46
  <Move className="h-4 w-4" />
45
47
  </button>
46
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
+ )}
47
60
  {onDuplicate && (
48
61
  <button
49
62
  aria-label="Duplicate"