@pascal-app/editor 0.7.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 (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. package/src/components/ui/viewer-toolbar.tsx +0 -436
@@ -4092,7 +4092,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
4092
4092
  d={path}
4093
4093
  fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
4094
4094
  fillRule="evenodd"
4095
- opacity={slabFillOpacity}
4096
4095
  onClick={
4097
4096
  canSelectSlabs
4098
4097
  ? (event) => {
@@ -4111,9 +4110,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
4111
4110
  }
4112
4111
  onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined}
4113
4112
  onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined}
4113
+ opacity={slabFillOpacity}
4114
4114
  pointerEvents={canSelectSlabs ? undefined : 'none'}
4115
- style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
4116
4115
  stroke="none"
4116
+ style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
4117
4117
  />
4118
4118
  {isSelected && !isDeleteHovered ? (
4119
4119
  <path
@@ -4168,7 +4168,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
4168
4168
  d={path}
4169
4169
  fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
4170
4170
  fillRule="evenodd"
4171
- opacity={ceilingFillOpacity}
4172
4171
  onClick={
4173
4172
  canSelectCeilings
4174
4173
  ? (event) => {
@@ -4189,9 +4188,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
4189
4188
  canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
4190
4189
  }
4191
4190
  onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
4191
+ opacity={ceilingFillOpacity}
4192
4192
  pointerEvents={canSelectCeilings ? undefined : 'none'}
4193
- style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
4194
4193
  stroke="none"
4194
+ style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
4195
4195
  />
4196
4196
  {isSelected && !isDeleteHovered ? (
4197
4197
  <path
@@ -4298,8 +4298,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
4298
4298
  <polygon
4299
4299
  fill={`url(#${wallSelectionHatchId})`}
4300
4300
  opacity={1}
4301
- points={points}
4302
4301
  pointerEvents="none"
4302
+ points={points}
4303
4303
  />
4304
4304
  ) : null}
4305
4305
  </g>
@@ -4427,8 +4427,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
4427
4427
  {canSelectGeometry && (
4428
4428
  <polygon
4429
4429
  fill="transparent"
4430
- points={points}
4431
4430
  pointerEvents="all"
4431
+ points={points}
4432
4432
  stroke="transparent"
4433
4433
  strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
4434
4434
  vectorEffect="non-scaling-stroke"
@@ -4538,8 +4538,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
4538
4538
  {canSelectGeometry && (
4539
4539
  <polygon
4540
4540
  fill="transparent"
4541
- points={points}
4542
4541
  pointerEvents="all"
4542
+ points={points}
4543
4543
  stroke="transparent"
4544
4544
  strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
4545
4545
  vectorEffect="non-scaling-stroke"
@@ -5218,8 +5218,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
5218
5218
  {canSelectGeometry && (
5219
5219
  <polygon
5220
5220
  fill="transparent"
5221
- points={points}
5222
5221
  pointerEvents="all"
5222
+ points={points}
5223
5223
  stroke="transparent"
5224
5224
  strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
5225
5225
  vectorEffect="non-scaling-stroke"
@@ -5290,8 +5290,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
5290
5290
  d={foldingPath}
5291
5291
  fill="none"
5292
5292
  stroke={doorStroke}
5293
- strokeLinejoin="round"
5294
5293
  strokeLinecap="round"
5294
+ strokeLinejoin="round"
5295
5295
  strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
5296
5296
  vectorEffect="non-scaling-stroke"
5297
5297
  />
@@ -5963,6 +5963,12 @@ function FloorplanItemImage({
5963
5963
  }) {
5964
5964
  const resolvedUrl = useResolvedAssetUrl(url)
5965
5965
  if (!resolvedUrl) return null
5966
+ // The PNG is captured with the modal's top-down camera (default up = +Y),
5967
+ // so its pixel-right is world +X and pixel-up is world -Z. The plan SVG
5968
+ // negates both axes (`toSvgX(v) = -v`, `toSvgY(v) = -v`), which together
5969
+ // are a 180° rotation — so the captured image lands upside-down /
5970
+ // mirrored when overlaid as-is. Bake that 180° into the image transform
5971
+ // here; the panel / modal previews use the PNG directly and stay correct.
5966
5972
  const rotationDeg = (-rotation * 180) / Math.PI + 180
5967
5973
  return (
5968
5974
  <g
@@ -6168,6 +6174,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
6168
6174
  }
6169
6175
  points={points}
6170
6176
  stroke={stroke}
6177
+ strokeOpacity={1}
6171
6178
  strokeWidth={
6172
6179
  isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
6173
6180
  }
@@ -6217,8 +6224,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
6217
6224
  <polygon
6218
6225
  fill={`url(#${wallSelectionHatchId})`}
6219
6226
  opacity={1}
6220
- points={points}
6221
6227
  pointerEvents="none"
6228
+ points={points}
6222
6229
  />
6223
6230
  ) : null}
6224
6231
  {itemDimensionMeasurements.length > 0 ? (
@@ -6308,8 +6315,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
6308
6315
  <polygon
6309
6316
  fill={fill}
6310
6317
  fillOpacity={isDeleteHovered ? 0.82 : 0.92}
6311
- points={FLOORPLAN_SPAWN_ARROW_POINTS}
6312
6318
  pointerEvents="none"
6319
+ points={FLOORPLAN_SPAWN_ARROW_POINTS}
6313
6320
  stroke={stroke}
6314
6321
  strokeLinejoin="round"
6315
6322
  strokeWidth={0.055}
@@ -7224,6 +7231,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
7224
7231
  y2={endSvg.y}
7225
7232
  />
7226
7233
  <line
7234
+ onPointerDown={
7235
+ onEdgePointerDown
7236
+ ? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
7237
+ : undefined
7238
+ }
7227
7239
  pointerEvents="stroke"
7228
7240
  stroke="transparent"
7229
7241
  strokeLinecap="round"
@@ -7234,11 +7246,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
7234
7246
  x2={endSvg.x}
7235
7247
  y1={startSvg.y}
7236
7248
  y2={endSvg.y}
7237
- onPointerDown={
7238
- onEdgePointerDown
7239
- ? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
7240
- : undefined
7241
- }
7242
7249
  />
7243
7250
  </g>
7244
7251
  )
@@ -9838,7 +9845,7 @@ export function FloorplanPanel() {
9838
9845
  zoneVertexDragState != null ||
9839
9846
  isPolygonDraftBuildActive
9840
9847
 
9841
- if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
9848
+ if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
9842
9849
  setViewport((current) =>
9843
9850
  floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
9844
9851
  )
@@ -10970,6 +10977,14 @@ export function FloorplanPanel() {
10970
10977
  }
10971
10978
  }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
10972
10979
 
10980
+ useEffect(() => {
10981
+ if (!hasPendingItemMeshFootprints) {
10982
+ return
10983
+ }
10984
+
10985
+ scheduleMovingFloorplanNodeRefresh()
10986
+ }, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
10987
+
10973
10988
  // Subscribe to the live-transforms store so rotation/position changes that
10974
10989
  // *don't* go through pointer events still refresh the floorplan — e.g. R/T
10975
10990
  // keyboard rotation during placement updates `useLiveTransforms` but emits
@@ -10985,14 +11000,6 @@ export function FloorplanPanel() {
10985
11000
  return unsubscribe
10986
11001
  }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
10987
11002
 
10988
- useEffect(() => {
10989
- if (!hasPendingItemMeshFootprints) {
10990
- return
10991
- }
10992
-
10993
- scheduleMovingFloorplanNodeRefresh()
10994
- }, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
10995
-
10996
11003
  useEffect(() => {
10997
11004
  if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
10998
11005
  return
@@ -16132,17 +16139,13 @@ export function FloorplanPanel() {
16132
16139
  onDuplicate: handleSelectedItemDuplicate,
16133
16140
  onMove: handleSelectedItemMove,
16134
16141
  }}
16142
+ offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
16135
16143
  opening={{
16136
16144
  position: selectedOpeningActionMenuPosition,
16137
16145
  onDelete: handleSelectedOpeningDelete,
16138
16146
  onDuplicate: handleSelectedOpeningDuplicate,
16139
16147
  onMove: handleSelectedOpeningMove,
16140
16148
  }}
16141
- spawn={{
16142
- position: selectedSpawnActionMenuPosition,
16143
- onDelete: handleSelectedSpawnDelete,
16144
- onMove: handleSelectedSpawnMove,
16145
- }}
16146
16149
  roof={{
16147
16150
  position: selectedRoofActionMenuPosition,
16148
16151
  onDelete: handleSelectedRoofDelete,
@@ -16157,6 +16160,11 @@ export function FloorplanPanel() {
16157
16160
  : handleSelectedSlabDelete,
16158
16161
  onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
16159
16162
  }}
16163
+ spawn={{
16164
+ position: selectedSpawnActionMenuPosition,
16165
+ onDelete: handleSelectedSpawnDelete,
16166
+ onMove: handleSelectedSpawnMove,
16167
+ }}
16160
16168
  stair={{
16161
16169
  position: selectedStairActionMenuPosition,
16162
16170
  onDelete: handleSelectedStairDelete,
@@ -16168,7 +16176,6 @@ export function FloorplanPanel() {
16168
16176
  onDelete: handleSelectedWallDelete,
16169
16177
  onMove: handleSelectedWallMove,
16170
16178
  }}
16171
- offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
16172
16179
  />
16173
16180
 
16174
16181
  {referenceScaleDraft && (
@@ -16201,7 +16208,7 @@ export function FloorplanPanel() {
16201
16208
  </div>
16202
16209
 
16203
16210
  <div className="mb-3 rounded-xl border border-border/70 bg-white/5 px-3 py-2">
16204
- <div className="text-muted-foreground text-[11px] uppercase tracking-wide">
16211
+ <div className="text-[11px] text-muted-foreground uppercase tracking-wide">
16205
16212
  Drawn line
16206
16213
  </div>
16207
16214
  <div className="mt-1 font-medium text-sm">
@@ -16363,8 +16370,8 @@ export function FloorplanPanel() {
16363
16370
  <FloorplanGuideLayer
16364
16371
  activeGuideInteractionGuideId={activeGuideInteractionGuideId}
16365
16372
  activeGuideInteractionMode={activeGuideInteractionMode}
16366
- guideUi={guideUi}
16367
16373
  guides={displayGuides}
16374
+ guideUi={guideUi}
16368
16375
  isInteractive={canInteractWithGuides}
16369
16376
  onGuideSelect={handleGuideSelect}
16370
16377
  onGuideTranslateStart={handleGuideTranslateStart}
@@ -16385,6 +16392,8 @@ export function FloorplanPanel() {
16385
16392
  hoveredSlabId={hoveredSlabId}
16386
16393
  hoveredWallId={hoveredWallId}
16387
16394
  isDeleteMode={isDeleteMode}
16395
+ isGuideTraceVisible={isGuideTraceVisible}
16396
+ metersPerUnit={calibratedMetersPerUnit}
16388
16397
  onCeilingDoubleClick={handleCeilingDoubleClick}
16389
16398
  onCeilingHoverChange={handleCeilingHoverChange}
16390
16399
  onCeilingSelect={handleCeilingSelect}
@@ -16401,11 +16410,9 @@ export function FloorplanPanel() {
16401
16410
  openingsPolygons={openingsPolygons}
16402
16411
  palette={palette}
16403
16412
  selectedIdSet={selectedIdSet}
16404
- slabSelectionHatchId={slabSelectionHatchId}
16405
16413
  slabPolygons={displaySlabPolygons}
16414
+ slabSelectionHatchId={slabSelectionHatchId}
16406
16415
  unit={unit}
16407
- metersPerUnit={calibratedMetersPerUnit}
16408
- isGuideTraceVisible={isGuideTraceVisible}
16409
16416
  wallPolygons={displayWallPolygons}
16410
16417
  wallSelectionHatchId={wallSelectionHatchId}
16411
16418
  />
@@ -16483,8 +16490,8 @@ export function FloorplanPanel() {
16483
16490
 
16484
16491
  <FloorplanReferenceScaleLayer
16485
16492
  draft={referenceScaleDraft}
16486
- guideUi={guideUi}
16487
16493
  guides={displayGuides}
16494
+ guideUi={guideUi}
16488
16495
  palette={palette}
16489
16496
  unit={unit}
16490
16497
  unitsPerPixel={floorplanUnitsPerPixel}
@@ -20,6 +20,7 @@ import {
20
20
  import { ViewerOverlay } from '../../components/viewer-overlay'
21
21
  import { ViewerZoneSystem } from '../../components/viewer-zone-system'
22
22
  import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
23
+ import { useAutoFrame } from '../../hooks/use-auto-frame'
23
24
  import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
24
25
  import { useKeyboard } from '../../hooks/use-keyboard'
25
26
  import {
@@ -64,6 +65,7 @@ import { Grid } from './grid'
64
65
  import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
65
66
  import { SelectionManager } from './selection-manager'
66
67
  import { SiteEdgeLabels } from './site-edge-labels'
68
+ import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
67
69
  import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
68
70
  import { WallMeasurementLabel } from './wall-measurement-label'
69
71
 
@@ -76,12 +78,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
76
78
  const PAINT_CURSOR_BADGE_OFFSET_X = 14
77
79
  const PAINT_CURSOR_BADGE_OFFSET_Y = 14
78
80
  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 },
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 },
82
84
  'paint-disabled': {
83
- visibleColor: 0x94_a3b8,
84
- hiddenColor: 0x47_5569,
85
+ visibleColor: 0x94_a3_b8,
86
+ hiddenColor: 0x47_55_69,
85
87
  strength: 4,
86
88
  pulse: false,
87
89
  },
@@ -462,7 +464,7 @@ function ViewerCanvasControlsHint({
462
464
  <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
463
465
  <section
464
466
  aria-label="Camera controls hint"
465
- 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"
466
468
  >
467
469
  <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
468
470
  {hints.map((hint) => (
@@ -753,7 +755,7 @@ function PaintCursorLayer({
753
755
  (activePaintMaterial.material !== undefined ||
754
756
  activePaintMaterial.materialPreset !== undefined),
755
757
  )
756
- const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
758
+ const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
757
759
  const icon = 'mdi:format-color-fill'
758
760
 
759
761
  useLayoutEffect(() => {
@@ -786,16 +788,16 @@ function PaintCursorLayer({
786
788
  const ViewerCanvas = memo(function ViewerCanvas({
787
789
  isVersionPreviewMode,
788
790
  isLoading,
791
+ isFirstPersonMode,
789
792
  hasLoadedInitialScene,
790
793
  showLoader,
791
- isFirstPersonMode,
792
794
  onThumbnailCapture,
793
795
  }: {
794
796
  isVersionPreviewMode: boolean
795
797
  isLoading: boolean
798
+ isFirstPersonMode: boolean
796
799
  hasLoadedInitialScene: boolean
797
800
  showLoader: boolean
798
- isFirstPersonMode: boolean
799
801
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
800
802
  }) {
801
803
  const viewMode = useEditor((s) => s.viewMode)
@@ -837,7 +839,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
837
839
  window.removeEventListener('pointermove', handlePointerMove)
838
840
  window.removeEventListener('pointerup', handlePointerUp)
839
841
  }
840
- }, [setFloorplanPaneRatio])
842
+ }, [])
841
843
 
842
844
  useEffect(() => {
843
845
  setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
@@ -940,7 +942,9 @@ export default function Editor({
940
942
  commandPaletteEmptyAction,
941
943
  }: EditorProps) {
942
944
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
945
+
943
946
  useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
947
+
944
948
  const { isLoadingSceneRef } = useAutoSave({
945
949
  onSave,
946
950
  onDirty,
@@ -951,8 +955,7 @@ export default function Editor({
951
955
  const [isSceneLoading, setIsSceneLoading] = useState(false)
952
956
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
953
957
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
954
- const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
955
- const wasFirstPersonModeRef = useRef(isFirstPersonMode)
958
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
956
959
 
957
960
  const sidebarWidth = useSidebarStore((s) => s.width)
958
961
  const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -970,39 +973,6 @@ export default function Editor({
970
973
  }
971
974
  }, [projectId])
972
975
 
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
-
1006
976
  // Load scene on mount (or when onLoad identity changes, e.g. project switch)
1007
977
  useEffect(() => {
1008
978
  let cancelled = false
@@ -1064,6 +1034,42 @@ export default function Editor({
1064
1034
 
1065
1035
  const showLoader = isLoading || isSceneLoading
1066
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
+
1067
1073
  const previewViewerContent = (
1068
1074
  <Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
1069
1075
  <ExportManager />
@@ -1135,21 +1141,24 @@ export default function Editor({
1135
1141
  navbarSlot={navbarSlot}
1136
1142
  overlays={
1137
1143
  <>
1138
- <FloatingLevelSelector />
1139
- {!isVersionPreviewMode && (
1144
+ {!isCaptureMode && <FloatingLevelSelector />}
1145
+ {!(isVersionPreviewMode || isCaptureMode) && (
1140
1146
  <div className="pointer-events-auto">
1141
1147
  <ActionMenu />
1142
1148
  </div>
1143
1149
  )}
1144
- {!isVersionPreviewMode && (
1150
+ {!(isVersionPreviewMode || isCaptureMode) && (
1145
1151
  <div className="pointer-events-auto">
1146
1152
  <PanelManager />
1147
1153
  </div>
1148
1154
  )}
1149
- <div className="pointer-events-auto">
1150
- <HelperManager />
1151
- </div>
1155
+ {!isCaptureMode && (
1156
+ <div className="pointer-events-auto">
1157
+ <HelperManager />
1158
+ </div>
1159
+ )}
1152
1160
  {viewerBanner}
1161
+ {projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
1153
1162
  </>
1154
1163
  }
1155
1164
  renderTabContent={renderTabContent}
@@ -1159,14 +1168,14 @@ export default function Editor({
1159
1168
  viewerToolbarLeft={viewerToolbarLeft}
1160
1169
  viewerToolbarRight={viewerToolbarRight}
1161
1170
  />
1171
+ <EditorCommands />
1172
+ <CommandPalette emptyAction={commandPaletteEmptyAction} />
1162
1173
  {/* First-person overlay — rendered on top of normal layout */}
1163
1174
  {isFirstPersonMode && (
1164
1175
  <div className="pointer-events-none fixed inset-0 z-50">
1165
1176
  <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1166
1177
  </div>
1167
1178
  )}
1168
- <EditorCommands />
1169
- <CommandPalette emptyAction={commandPaletteEmptyAction} />
1170
1179
  </>
1171
1180
  )}
1172
1181
  </PresetsProvider>
@@ -1222,6 +1231,12 @@ export default function Editor({
1222
1231
  <HelperManager />
1223
1232
  </div>
1224
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
+ )}
1225
1240
  </>
1226
1241
  )}
1227
1242
  </div>
@@ -341,7 +341,7 @@ function applySingleSurfacePaintPreview(
341
341
  if (node.type === 'ceiling') {
342
342
  const root = getRegisteredMesh(node.id)
343
343
  const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
344
- if (!root || !overlay) return null
344
+ if (!(root && overlay)) return null
345
345
 
346
346
  const previewColor =
347
347
  getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
@@ -999,7 +999,6 @@ export const SelectionManager = () => {
999
999
  'roof-segment',
1000
1000
  'stair',
1001
1001
  'stair-segment',
1002
- 'spawn',
1003
1002
  'window',
1004
1003
  'door',
1005
1004
  'zone',
@@ -1392,6 +1391,7 @@ export const SelectionManager = () => {
1392
1391
  'roof-segment',
1393
1392
  'stair',
1394
1393
  'stair-segment',
1394
+ 'spawn',
1395
1395
  'window',
1396
1396
  'door',
1397
1397
  'zone',