@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -7,11 +7,20 @@ 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'
23
+ import { useAutoFrame } from '../../hooks/use-auto-frame'
15
24
  import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
16
25
  import { useKeyboard } from '../../hooks/use-keyboard'
17
26
  import {
@@ -22,8 +31,8 @@ import {
22
31
  } from '../../lib/scene'
23
32
  import { initSFXBus } from '../../lib/sfx-bus'
24
33
  import useEditor from '../../store/use-editor'
25
- import { CeilingSystem } from '../systems/ceiling/ceiling-system'
26
34
  import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
35
+ import { CeilingSystem } from '../systems/ceiling/ceiling-system'
27
36
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
28
37
  import { StairEditSystem } from '../systems/stair/stair-edit-system'
29
38
  import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
@@ -56,6 +65,7 @@ import { Grid } from './grid'
56
65
  import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
57
66
  import { SelectionManager } from './selection-manager'
58
67
  import { SiteEdgeLabels } from './site-edge-labels'
68
+ import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
59
69
  import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
60
70
  import { WallMeasurementLabel } from './wall-measurement-label'
61
71
 
@@ -63,6 +73,21 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-
63
73
  const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
64
74
  const DELETE_CURSOR_BADGE_OFFSET_X = 14
65
75
  const DELETE_CURSOR_BADGE_OFFSET_Y = 14
76
+ const PAINT_CURSOR_BADGE_COLOR = '#f59e0b'
77
+ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
78
+ const PAINT_CURSOR_BADGE_OFFSET_X = 14
79
+ const PAINT_CURSOR_BADGE_OFFSET_Y = 14
80
+ const EDITOR_HOVER_STYLES: HoverStyles = {
81
+ default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
82
+ delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
83
+ 'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true },
84
+ 'paint-disabled': {
85
+ visibleColor: 0x94_a3_b8,
86
+ hiddenColor: 0x47_55_69,
87
+ strength: 4,
88
+ pulse: false,
89
+ },
90
+ }
66
91
 
67
92
  /**
68
93
  * Wire up module-level singletons (spatial grid, space detection, SFX) for
@@ -439,7 +464,7 @@ function ViewerCanvasControlsHint({
439
464
  <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
440
465
  <section
441
466
  aria-label="Camera controls hint"
442
- className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
467
+ className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
443
468
  >
444
469
  <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
445
470
  {hints.map((hint) => (
@@ -501,6 +526,50 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
501
526
  )
502
527
  }
503
528
 
529
+ function PaintCursorBadge({
530
+ position,
531
+ label,
532
+ disabled,
533
+ icon,
534
+ }: {
535
+ position: { x: number; y: number }
536
+ label: string
537
+ disabled: boolean
538
+ icon: string
539
+ }) {
540
+ const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
541
+
542
+ return (
543
+ <div
544
+ aria-hidden="true"
545
+ className="pointer-events-none absolute z-40"
546
+ style={{
547
+ left: position.x + PAINT_CURSOR_BADGE_OFFSET_X,
548
+ top: position.y + PAINT_CURSOR_BADGE_OFFSET_Y,
549
+ }}
550
+ >
551
+ <div
552
+ 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)]"
553
+ style={{
554
+ 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`,
555
+ }}
556
+ >
557
+ <Icon
558
+ aria-hidden="true"
559
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
560
+ color={accentColor}
561
+ height={16}
562
+ icon={icon}
563
+ width={16}
564
+ />
565
+ <span className="font-medium text-[11px]" style={{ color: accentColor }}>
566
+ {label}
567
+ </span>
568
+ </div>
569
+ </div>
570
+ )
571
+ }
572
+
504
573
  // ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
505
574
 
506
575
  const ViewerSceneContent = memo(function ViewerSceneContent({
@@ -517,9 +586,9 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
517
586
  return (
518
587
  <>
519
588
  {!isFirstPersonMode && <SelectionManager />}
520
- {!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
521
- {!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
522
- {!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
589
+ {!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
590
+ {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
591
+ {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
523
592
  {!isFirstPersonMode && <WallMeasurementLabel />}
524
593
  <ExportManager />
525
594
  {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
@@ -527,10 +596,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
527
596
  <CeilingSelectionAffordanceSystem />
528
597
  <RoofEditSystem />
529
598
  <StairEditSystem />
530
- {!isLoading && !isFirstPersonMode && (
599
+ {!(isLoading || isFirstPersonMode) && (
531
600
  <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
532
601
  )}
533
- {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
602
+ {!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
534
603
  {isFirstPersonMode && <FirstPersonControls />}
535
604
  <CustomCameraControls />
536
605
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
@@ -552,31 +621,165 @@ function DeleteCursorLayer({
552
621
  isVersionPreviewMode: boolean
553
622
  }) {
554
623
  const mode = useEditor((s) => s.mode)
555
- const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
624
+ const badgeRef = useRef<HTMLDivElement>(null)
556
625
  const active = mode === 'delete' && !isVersionPreviewMode
557
626
 
558
627
  useEffect(() => {
559
628
  if (!active) {
560
- setPosition(null)
629
+ if (badgeRef.current) {
630
+ badgeRef.current.style.display = 'none'
631
+ }
561
632
  return
562
633
  }
563
634
  const el = containerRef.current
564
635
  if (!el) return
636
+ let frame = 0
637
+ let nextX = 0
638
+ let nextY = 0
639
+ const badge = badgeRef.current
640
+
641
+ const flushPosition = () => {
642
+ frame = 0
643
+ if (!badge) return
644
+ badge.style.display = 'block'
645
+ badge.style.transform = `translate(${nextX + DELETE_CURSOR_BADGE_OFFSET_X}px, ${nextY + DELETE_CURSOR_BADGE_OFFSET_Y}px)`
646
+ }
647
+
565
648
  const onMove = (e: PointerEvent) => {
566
649
  const rect = el.getBoundingClientRect()
567
- setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
650
+ nextX = e.clientX - rect.left
651
+ nextY = e.clientY - rect.top
652
+
653
+ if (frame === 0) {
654
+ frame = window.requestAnimationFrame(flushPosition)
655
+ }
656
+ }
657
+ const onLeave = () => {
658
+ if (frame !== 0) {
659
+ window.cancelAnimationFrame(frame)
660
+ frame = 0
661
+ }
662
+ if (badge) {
663
+ badge.style.display = 'none'
664
+ }
568
665
  }
569
- const onLeave = () => setPosition(null)
570
666
  el.addEventListener('pointermove', onMove)
571
667
  el.addEventListener('pointerleave', onLeave)
572
668
  return () => {
669
+ if (frame !== 0) {
670
+ window.cancelAnimationFrame(frame)
671
+ }
573
672
  el.removeEventListener('pointermove', onMove)
574
673
  el.removeEventListener('pointerleave', onLeave)
575
674
  }
576
675
  }, [active, containerRef])
577
676
 
578
- if (!(active && position)) return null
579
- return <DeleteCursorBadge position={position} />
677
+ if (!active) return null
678
+
679
+ return (
680
+ <div
681
+ className="pointer-events-none"
682
+ ref={badgeRef}
683
+ style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
684
+ >
685
+ <DeleteCursorBadge position={{ x: 0, y: 0 }} />
686
+ </div>
687
+ )
688
+ }
689
+
690
+ function PaintCursorLayer({
691
+ containerRef,
692
+ isVersionPreviewMode,
693
+ }: {
694
+ containerRef: React.RefObject<HTMLDivElement | null>
695
+ isVersionPreviewMode: boolean
696
+ }) {
697
+ const mode = useEditor((s) => s.mode)
698
+ const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
699
+ const activePaintTarget = useEditor((s) => s.activePaintTarget)
700
+ const badgeRef = useRef<HTMLDivElement>(null)
701
+ const active = mode === 'material-paint' && !isVersionPreviewMode
702
+
703
+ useEffect(() => {
704
+ if (!active) {
705
+ if (badgeRef.current) {
706
+ badgeRef.current.style.display = 'none'
707
+ }
708
+ return
709
+ }
710
+ const el = containerRef.current
711
+ if (!el) return
712
+ let frame = 0
713
+ let nextX = 0
714
+ let nextY = 0
715
+ const badge = badgeRef.current
716
+
717
+ const flushPosition = () => {
718
+ frame = 0
719
+ if (!badge) return
720
+ badge.style.display = 'block'
721
+ badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
722
+ }
723
+
724
+ const onMove = (e: PointerEvent) => {
725
+ const rect = el.getBoundingClientRect()
726
+ nextX = e.clientX - rect.left
727
+ nextY = e.clientY - rect.top
728
+
729
+ if (frame === 0) {
730
+ frame = window.requestAnimationFrame(flushPosition)
731
+ }
732
+ }
733
+ const onLeave = () => {
734
+ if (frame !== 0) {
735
+ window.cancelAnimationFrame(frame)
736
+ frame = 0
737
+ }
738
+ if (badge) {
739
+ badge.style.display = 'none'
740
+ }
741
+ }
742
+ el.addEventListener('pointermove', onMove)
743
+ el.addEventListener('pointerleave', onLeave)
744
+ return () => {
745
+ if (frame !== 0) {
746
+ window.cancelAnimationFrame(frame)
747
+ }
748
+ el.removeEventListener('pointermove', onMove)
749
+ el.removeEventListener('pointerleave', onLeave)
750
+ }
751
+ }, [active, containerRef])
752
+
753
+ const hasMaterial = Boolean(
754
+ activePaintMaterial &&
755
+ (activePaintMaterial.material !== undefined ||
756
+ activePaintMaterial.materialPreset !== undefined),
757
+ )
758
+ const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
759
+ const icon = 'mdi:format-color-fill'
760
+
761
+ useLayoutEffect(() => {
762
+ if (!active && badgeRef.current) {
763
+ badgeRef.current.style.display = 'none'
764
+ }
765
+ }, [active])
766
+
767
+ if (!active) return null
768
+
769
+ return (
770
+ <div
771
+ className="pointer-events-none"
772
+ ref={badgeRef}
773
+ style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
774
+ >
775
+ <PaintCursorBadge
776
+ disabled={!hasMaterial}
777
+ icon={icon}
778
+ label={label}
779
+ position={{ x: 0, y: 0 }}
780
+ />
781
+ </div>
782
+ )
580
783
  }
581
784
 
582
785
  // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
@@ -585,16 +788,16 @@ function DeleteCursorLayer({
585
788
  const ViewerCanvas = memo(function ViewerCanvas({
586
789
  isVersionPreviewMode,
587
790
  isLoading,
791
+ isFirstPersonMode,
588
792
  hasLoadedInitialScene,
589
793
  showLoader,
590
- isFirstPersonMode,
591
794
  onThumbnailCapture,
592
795
  }: {
593
796
  isVersionPreviewMode: boolean
594
797
  isLoading: boolean
798
+ isFirstPersonMode: boolean
595
799
  hasLoadedInitialScene: boolean
596
800
  showLoader: boolean
597
- isFirstPersonMode: boolean
598
801
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
599
802
  }) {
600
803
  const viewMode = useEditor((s) => s.viewMode)
@@ -636,7 +839,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
636
839
  window.removeEventListener('pointermove', handlePointerMove)
637
840
  window.removeEventListener('pointerup', handlePointerUp)
638
841
  }
639
- }, [setFloorplanPaneRatio])
842
+ }, [])
640
843
 
641
844
  useEffect(() => {
642
845
  setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
@@ -684,6 +887,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
684
887
  containerRef={viewer3dRef}
685
888
  isVersionPreviewMode={isVersionPreviewMode}
686
889
  />
890
+ <PaintCursorLayer
891
+ containerRef={viewer3dRef}
892
+ isVersionPreviewMode={isVersionPreviewMode}
893
+ />
687
894
  {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
688
895
  <ViewerCanvasControlsHint
689
896
  isPreviewMode={isPreviewMode}
@@ -691,7 +898,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
691
898
  />
692
899
  ) : null}
693
900
  <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
694
- <Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
901
+ <Viewer
902
+ hoverStyles={EDITOR_HOVER_STYLES}
903
+ selectionManager={isFirstPersonMode ? 'default' : 'custom'}
904
+ >
695
905
  <ViewerSceneContent
696
906
  isFirstPersonMode={isFirstPersonMode}
697
907
  isLoading={isLoading}
@@ -731,7 +941,9 @@ export default function Editor({
731
941
  presetsAdapter,
732
942
  commandPaletteEmptyAction,
733
943
  }: EditorProps) {
734
- useKeyboard({ isVersionPreviewMode })
944
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
945
+
946
+ useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
735
947
 
736
948
  const { isLoadingSceneRef } = useAutoSave({
737
949
  onSave,
@@ -743,7 +955,7 @@ export default function Editor({
743
955
  const [isSceneLoading, setIsSceneLoading] = useState(false)
744
956
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
745
957
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
746
- const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
958
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
747
959
 
748
960
  const sidebarWidth = useSidebarStore((s) => s.width)
749
961
  const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -822,8 +1034,44 @@ export default function Editor({
822
1034
 
823
1035
  const showLoader = isLoading || isSceneLoading
824
1036
 
1037
+ const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
1038
+ const wasFirstPersonModeRef = useRef(isFirstPersonMode)
1039
+
1040
+ useEffect(() => {
1041
+ const wasFirstPersonMode = wasFirstPersonModeRef.current
1042
+ wasFirstPersonModeRef.current = isFirstPersonMode
1043
+
1044
+ if (isFirstPersonMode && !wasFirstPersonMode) {
1045
+ const viewer = useViewer.getState()
1046
+ firstPersonPreviousLevelRef.current = viewer.selection.levelId
1047
+ viewer.setCameraMode('perspective')
1048
+ viewer.setWallMode('up')
1049
+ viewer.setWalkthroughMode(true)
1050
+ viewer.setSelection({ selectedIds: [], zoneId: null })
1051
+ return
1052
+ }
1053
+
1054
+ if (!(wasFirstPersonMode && !isFirstPersonMode)) return
1055
+
1056
+ const viewer = useViewer.getState()
1057
+ const previousLevelId = firstPersonPreviousLevelRef.current
1058
+ firstPersonPreviousLevelRef.current = null
1059
+ viewer.setWalkthroughMode(false)
1060
+
1061
+ if (!previousLevelId) return
1062
+
1063
+ const previousLevelNode = useScene.getState().nodes[previousLevelId]
1064
+ if (previousLevelNode?.type === 'level') {
1065
+ viewer.setSelection({
1066
+ levelId: previousLevelId,
1067
+ zoneId: null,
1068
+ selectedIds: [],
1069
+ })
1070
+ }
1071
+ }, [isFirstPersonMode])
1072
+
825
1073
  const previewViewerContent = (
826
- <Viewer selectionManager="default">
1074
+ <Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
827
1075
  <ExportManager />
828
1076
  <ViewerZoneSystem />
829
1077
  <CeilingSystem />
@@ -866,7 +1114,13 @@ export default function Editor({
866
1114
  return <Component />
867
1115
  }
868
1116
 
869
- const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
1117
+ const tabBarTabs =
1118
+ sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({
1119
+ id,
1120
+ label,
1121
+ mobileDefaultSnap,
1122
+ mobileIcon,
1123
+ })) ?? []
870
1124
 
871
1125
  return (
872
1126
  <PresetsProvider adapter={presetsAdapter}>
@@ -887,21 +1141,24 @@ export default function Editor({
887
1141
  navbarSlot={navbarSlot}
888
1142
  overlays={
889
1143
  <>
890
- <FloatingLevelSelector />
891
- {!isVersionPreviewMode && (
1144
+ {!isCaptureMode && <FloatingLevelSelector />}
1145
+ {!(isVersionPreviewMode || isCaptureMode) && (
892
1146
  <div className="pointer-events-auto">
893
1147
  <ActionMenu />
894
1148
  </div>
895
1149
  )}
896
- {!isVersionPreviewMode && (
1150
+ {!(isVersionPreviewMode || isCaptureMode) && (
897
1151
  <div className="pointer-events-auto">
898
1152
  <PanelManager />
899
1153
  </div>
900
1154
  )}
901
- <div className="pointer-events-auto">
902
- <HelperManager />
903
- </div>
1155
+ {!isCaptureMode && (
1156
+ <div className="pointer-events-auto">
1157
+ <HelperManager />
1158
+ </div>
1159
+ )}
904
1160
  {viewerBanner}
1161
+ {projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
905
1162
  </>
906
1163
  }
907
1164
  renderTabContent={renderTabContent}
@@ -911,14 +1168,14 @@ export default function Editor({
911
1168
  viewerToolbarLeft={viewerToolbarLeft}
912
1169
  viewerToolbarRight={viewerToolbarRight}
913
1170
  />
1171
+ <EditorCommands />
1172
+ <CommandPalette emptyAction={commandPaletteEmptyAction} />
914
1173
  {/* First-person overlay — rendered on top of normal layout */}
915
1174
  {isFirstPersonMode && (
916
- <div className="fixed inset-0 z-50 pointer-events-none">
1175
+ <div className="pointer-events-none fixed inset-0 z-50">
917
1176
  <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
918
1177
  </div>
919
1178
  )}
920
- <EditorCommands />
921
- <CommandPalette emptyAction={commandPaletteEmptyAction} />
922
1179
  </>
923
1180
  )}
924
1181
  </PresetsProvider>
@@ -974,6 +1231,12 @@ export default function Editor({
974
1231
  <HelperManager />
975
1232
  </div>
976
1233
  </ViewerOverlays>
1234
+ {/* First-person overlay — rendered on top of normal layout */}
1235
+ {isFirstPersonMode && (
1236
+ <div className="pointer-events-none fixed inset-0 z-50">
1237
+ <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1238
+ </div>
1239
+ )}
977
1240
  </>
978
1241
  )}
979
1242
  </div>