@pascal-app/editor 0.9.2 → 1.0.0-beta.1

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
4
  import {
5
+ acquireSceneReadOnlyLease,
5
6
  getCatalogMaterialById,
6
7
  getLibraryMaterialIdFromRef,
7
8
  getSceneMaterialIdFromRef,
@@ -31,6 +32,7 @@ import {
31
32
  } from '../../lib/scene'
32
33
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
33
34
  import useEditor from '../../store/use-editor'
35
+ import useFloorplanMode from '../../store/use-floorplan-mode'
34
36
  import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
35
37
  import { CeilingSystem } from '../systems/ceiling/ceiling-system'
36
38
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
@@ -57,12 +59,14 @@ import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
57
59
  import type { SidebarTab } from '../ui/sidebar/tab-bar'
58
60
  import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
59
61
  import { CustomCameraControls } from './custom-camera-controls'
62
+ import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
60
63
  import { EditorLayoutV2 } from './editor-layout-v2'
61
64
  import { ExportManager } from './export-manager'
62
65
  import { FenceTangentLines3D } from './fence-tangent-lines-3d'
63
66
  import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
64
67
  import { FloatingActionMenu } from './floating-action-menu'
65
68
  import { FloatingBuildingActionMenu } from './floating-building-action-menu'
69
+ import { FloorplanModeCoordinator } from './floorplan-mode-coordinator'
66
70
  import { FloorplanPanel } from './floorplan-panel'
67
71
  import { Grid } from './grid'
68
72
  import { GroupFloatingActionMenu } from './group-floating-action-menu'
@@ -152,6 +156,17 @@ export interface EditorProps {
152
156
  * only while a node is selected.
153
157
  */
154
158
  inspectorFooter?: ReactNode
159
+ /**
160
+ * Docked below the multi-selection panel (v2). Hosts mount whole-selection
161
+ * affordances here (e.g. "Save to my catalog"); shows only while more than
162
+ * one node is selected.
163
+ */
164
+ multiSelectionFooter?: ReactNode
165
+
166
+ /** Host-owned content mounted inside the editor's React Three Fiber scene. */
167
+ viewerSceneSlot?: ReactNode
168
+ /** Host-owned SVG content mounted in the transformed floor-plan scene. */
169
+ floorplanSceneSlot?: ReactNode
155
170
 
156
171
  projectId?: string | null
157
172
 
@@ -715,12 +730,14 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
715
730
  isFirstPersonMode,
716
731
  isStudioMode,
717
732
  onThumbnailCapture,
733
+ viewerSceneSlot,
718
734
  }: {
719
735
  isVersionPreviewMode: boolean
720
736
  isLoading: boolean
721
737
  isFirstPersonMode: boolean
722
738
  isStudioMode: boolean
723
739
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
740
+ viewerSceneSlot?: ReactNode
724
741
  }) {
725
742
  // Studio mode is a clean render/snapshot surface — no selection or editing
726
743
  // affordances. It mirrors version-preview's chrome gating on the canvas.
@@ -759,6 +776,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
759
776
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
760
777
  {!isFirstPersonMode && <SiteEdgeLabels />}
761
778
  <InteractiveSystem />
779
+ {!noEditing && viewerSceneSlot}
762
780
  </>
763
781
  )
764
782
  })
@@ -941,6 +959,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
941
959
  sceneReadyKey,
942
960
  onSceneReadyChange,
943
961
  onThumbnailCapture,
962
+ viewerSceneSlot,
963
+ floorplanSceneSlot,
944
964
  }: {
945
965
  isVersionPreviewMode: boolean
946
966
  isLoading: boolean
@@ -951,6 +971,8 @@ const ViewerCanvas = memo(function ViewerCanvas({
951
971
  sceneReadyKey: number
952
972
  onSceneReadyChange: (ready: boolean) => void
953
973
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
974
+ viewerSceneSlot?: ReactNode
975
+ floorplanSceneSlot?: ReactNode
954
976
  }) {
955
977
  const viewMode = useEditor((s) => s.viewMode)
956
978
  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
@@ -1018,6 +1040,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1018
1040
  2d / 3d / split alike) can anchor to this container's bottom-left. */}
1019
1041
  <div className="relative flex h-full" ref={setViewerAreaNode}>
1020
1042
  <QuickMeasurementHud />
1043
+ <DeleteConfirmationDialog />
1021
1044
  {/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
1022
1045
  <div
1023
1046
  className="relative h-full flex-shrink-0"
@@ -1027,7 +1050,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1027
1050
  }}
1028
1051
  >
1029
1052
  <div className="h-full w-full overflow-hidden">
1030
- <FloorplanPanel compassHost={viewerAreaEl} />
1053
+ <FloorplanPanel compassHost={viewerAreaEl} floorplanSceneSlot={floorplanSceneSlot} />
1031
1054
  </div>
1032
1055
  {viewMode === 'split' && (
1033
1056
  <div
@@ -1075,6 +1098,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1075
1098
  isStudioMode={isStudioMode}
1076
1099
  isVersionPreviewMode={isVersionPreviewMode}
1077
1100
  onThumbnailCapture={onThumbnailCapture}
1101
+ viewerSceneSlot={viewerSceneSlot}
1078
1102
  />
1079
1103
  </Viewer>
1080
1104
  </div>
@@ -1094,6 +1118,9 @@ export default function Editor({
1094
1118
  viewerToolbarRight,
1095
1119
  stageOverlay,
1096
1120
  inspectorFooter,
1121
+ multiSelectionFooter,
1122
+ viewerSceneSlot,
1123
+ floorplanSceneSlot,
1097
1124
  projectId,
1098
1125
  onLoad,
1099
1126
  onSave,
@@ -1151,9 +1178,11 @@ export default function Editor({
1151
1178
 
1152
1179
  useEffect(() => {
1153
1180
  useViewer.getState().setProjectId(projectId ?? null)
1181
+ useFloorplanMode.getState().setProjectId(projectId ?? null)
1154
1182
 
1155
1183
  return () => {
1156
1184
  useViewer.getState().setProjectId(null)
1185
+ useFloorplanMode.getState().setProjectId(null)
1157
1186
  }
1158
1187
  }, [projectId])
1159
1188
 
@@ -1209,13 +1238,10 @@ export default function Editor({
1209
1238
 
1210
1239
  // Lock scene graph and reset to select mode when entering version preview
1211
1240
  useEffect(() => {
1212
- useScene.getState().setReadOnly(isVersionPreviewMode)
1213
- if (isVersionPreviewMode) {
1214
- useEditor.getState().setMode('select')
1215
- }
1216
- return () => {
1217
- useScene.getState().setReadOnly(false)
1218
- }
1241
+ if (!isVersionPreviewMode) return
1242
+ const releaseReadOnly = acquireSceneReadOnlyLease()
1243
+ useEditor.getState().setMode('select')
1244
+ return releaseReadOnly
1219
1245
  }, [isVersionPreviewMode])
1220
1246
 
1221
1247
  useEffect(() => {
@@ -1314,6 +1340,8 @@ export default function Editor({
1314
1340
  onThumbnailCapture={onThumbnailCapture}
1315
1341
  sceneReadyKey={sceneReadyKey}
1316
1342
  showLoader={showLoader}
1343
+ viewerSceneSlot={viewerSceneSlot}
1344
+ floorplanSceneSlot={floorplanSceneSlot}
1317
1345
  />
1318
1346
  )
1319
1347
 
@@ -1366,6 +1394,7 @@ export default function Editor({
1366
1394
 
1367
1395
  return (
1368
1396
  <>
1397
+ <FloorplanModeCoordinator />
1369
1398
  {showLoader && (
1370
1399
  <div className="fixed inset-0 z-60">
1371
1400
  <SceneLoader className="bg-background" />
@@ -1397,7 +1426,10 @@ export default function Editor({
1397
1426
  )}
1398
1427
  {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
1399
1428
  <div className="pointer-events-auto">
1400
- <PanelManager inspectorFooter={inspectorFooter} />
1429
+ <PanelManager
1430
+ inspectorFooter={inspectorFooter}
1431
+ multiSelectionFooter={multiSelectionFooter}
1432
+ />
1401
1433
  </div>
1402
1434
  )}
1403
1435
  {!isCaptureMode && (
@@ -1438,6 +1470,7 @@ export default function Editor({
1438
1470
 
1439
1471
  return (
1440
1472
  <div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
1473
+ <FloorplanModeCoordinator />
1441
1474
  {showLoader && (
1442
1475
  <div className="fixed inset-0 z-60">
1443
1476
  <SceneLoader className="bg-background" />
@@ -4,7 +4,11 @@ import { type ForwardedRef, Fragment, forwardRef } from 'react'
4
4
 
5
5
  // Canonical in-world dimension formatter — metric metres or imperial
6
6
  // feet/inches. Shared by every measurement readout so they read the same.
7
- export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string {
7
+ export function formatMeasurement(
8
+ value: number,
9
+ unit: 'metric' | 'imperial',
10
+ metricNotation: 'meters' | 'millimeters' = 'meters',
11
+ ): string {
8
12
  if (unit === 'imperial') {
9
13
  const feet = value * 3.280_84
10
14
  const wholeFeet = Math.floor(feet)
@@ -12,6 +16,7 @@ export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): s
12
16
  if (inches === 12) return `${wholeFeet + 1}'0"`
13
17
  return `${wholeFeet}'${inches}"`
14
18
  }
19
+ if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm`
15
20
  return `${Number.parseFloat(value.toFixed(2))}m`
16
21
  }
17
22
 
@@ -14,7 +14,6 @@ import {
14
14
  nodeRegistry,
15
15
  type RadialResizeHandle,
16
16
  sceneRegistry,
17
- snapScalar,
18
17
  type TapActionHandle,
19
18
  useLiveNodeOverrides,
20
19
  useScene,
@@ -44,11 +43,10 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
44
43
  import { MeshBasicNodeMaterial } from 'three/webgpu'
45
44
  import { EDITOR_LAYER } from '../../lib/constants'
46
45
  import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
47
- import { resolveDirectManipulationNode } from '../../lib/direct-manipulation'
48
46
  import { createEditorApi } from '../../lib/editor-api'
49
47
  import { sfxEmitter } from '../../lib/sfx-bus'
50
48
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
51
- import useEditor from '../../store/use-editor'
49
+ import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
52
50
  import useInteractionScope, {
53
51
  useEndpointReshape,
54
52
  useIsCurveReshape,
@@ -64,6 +62,8 @@ import {
64
62
  HandleArrow,
65
63
  NO_RAYCAST,
66
64
  } from './handles/handle-arrow'
65
+ import { replacePreviewOverrideIds } from './handles/preview-overrides'
66
+ import { resolveResizeSnapValue } from './handles/resize-snap'
67
67
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
68
68
 
69
69
  // Pooled scratch for the handle rig's world-relative pose mapping.
@@ -213,8 +213,7 @@ export function NodeArrowHandles() {
213
213
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
214
214
  const rawNode = useScene((state) => {
215
215
  if (!selectedId) return null
216
- const selectedNode = state.nodes[selectedId as AnyNodeId]
217
- return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null
216
+ return state.nodes[selectedId as AnyNodeId] ?? null
218
217
  })
219
218
 
220
219
  // Merge any live drag override so the arrows themselves (positions,
@@ -234,7 +233,7 @@ export function NodeArrowHandles() {
234
233
  if (!(node && def?.handles)) return null
235
234
  const all =
236
235
  typeof def.handles === 'function'
237
- ? def.handles(node as never)
236
+ ? def.handles(node as never, descriptorSceneApi)
238
237
  : (def.handles as HandleDescriptor[])
239
238
  // The whole-node move-cross gizmo is gone: moving is now click-to-move on
240
239
  // the selected node body (see selection-manager). Drop both flavours — the
@@ -718,10 +717,6 @@ function LinearArrow({
718
717
  const initialValue = descriptor.currentValue(initialNode)
719
718
  const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
720
719
  const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
721
- const gridSnapStep =
722
- descriptor.kind === 'linear-resize' && descriptor.gridSnap
723
- ? useEditor.getState().gridSnapStep
724
- : null
725
720
  const factor =
726
721
  descriptor.kind === 'radial-resize'
727
722
  ? 1
@@ -735,6 +730,7 @@ function LinearArrow({
735
730
  // when the (snapped + clamped) value actually changes, so the cue
736
731
  // tracks real size steps instead of every sub-pixel pointer jitter.
737
732
  let lastTickValue = initialValue
733
+ let previewOverrideIds = new Set<AnyNodeId>()
738
734
 
739
735
  return {
740
736
  overrideId,
@@ -755,7 +751,14 @@ function LinearArrow({
755
751
  },
756
752
  onEnd: () => {
757
753
  useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
754
+ if (descriptor.kind === 'linear-resize') {
755
+ descriptor.onDragEnd?.(initialNode as never, sceneApi)
756
+ }
758
757
  if (onDrag) useOpeningGuides.getState().clear()
758
+ for (const previewId of previewOverrideIds) {
759
+ useLiveNodeOverrides.getState().clear(previewId)
760
+ useScene.getState().markDirty(previewId)
761
+ }
759
762
  },
760
763
  move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
761
764
  const currentPointer =
@@ -766,16 +769,46 @@ function LinearArrow({
766
769
  ) / localToWorldScale
767
770
  const delta = currentPointer - initialPointer
768
771
  const rawNext = initialValue + delta * factor
769
- const snappedNext =
770
- !moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0
771
- ? snapScalar(rawNext, gridSnapStep)
772
- : rawNext
772
+ const linearDescriptor = descriptor.kind === 'linear-resize' ? descriptor : null
773
+ const snappedNext = resolveResizeSnapValue({
774
+ rawValue: rawNext,
775
+ gridSnapEnabled: linearDescriptor?.gridSnap === true,
776
+ gridSnapActive: isGridSnapActive(),
777
+ gridSnapStep: useEditor.getState().gridSnapStep,
778
+ magneticSnapActive: isMagneticSnapActive(),
779
+ magneticSnap: linearDescriptor?.magneticSnap
780
+ ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value
781
+ : undefined,
782
+ })
773
783
  const next = Math.min(maxBound, Math.max(minBound, snappedNext))
774
784
  if (next !== lastTickValue) {
775
785
  lastTickValue = next
776
786
  sfxEmitter.emit('sfx:resize')
777
787
  }
778
788
  const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
789
+ if (descriptor.kind === 'linear-resize' && descriptor.previewOverrides) {
790
+ const previewEntries = descriptor.previewOverrides(initialNode as never, next, sceneApi)
791
+ const nextPreviewOverrideIds = replacePreviewOverrideIds(
792
+ previewOverrideIds,
793
+ previewEntries,
794
+ (previewId) => {
795
+ useLiveNodeOverrides.getState().clear(previewId)
796
+ useScene.getState().markDirty(previewId)
797
+ },
798
+ )
799
+ useLiveNodeOverrides
800
+ .getState()
801
+ .setMany(
802
+ previewEntries.map(([id, previewPatch]) => [
803
+ id,
804
+ previewPatch as Record<string, unknown>,
805
+ ]),
806
+ )
807
+ for (const [previewId] of previewEntries) {
808
+ useScene.getState().markDirty(previewId)
809
+ }
810
+ previewOverrideIds = nextPreviewOverrideIds
811
+ }
779
812
  // Let the kind publish live guides for the edge being resized.
780
813
  onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
781
814
  return patch
@@ -1550,7 +1583,10 @@ function CornerPickerShape({
1550
1583
  position={position}
1551
1584
  renderOrder={1001}
1552
1585
  />
1553
- <group position={[position[0], CORNER_FLOOR_OFFSET, position[2]]} ref={billboardRef}>
1586
+ <group
1587
+ position={[position[0], position[1] + CORNER_FLOOR_OFFSET, position[2]]}
1588
+ ref={billboardRef}
1589
+ >
1554
1590
  <HandleArrow
1555
1591
  cursor={cursor}
1556
1592
  hover={hover}
@@ -5,19 +5,25 @@ import { Crosshair, MapPin } from 'lucide-react'
5
5
  import { memo } from 'react'
6
6
  import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements'
7
7
 
8
- function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string {
8
+ function formatMetric(
9
+ metric: QuickMeasurementMetric,
10
+ unit: 'metric' | 'imperial',
11
+ metricNotation: 'meters' | 'millimeters',
12
+ ): string {
9
13
  if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2)
10
14
  if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2)
11
- return formatLinearMeasurement(metric.value, unit)
15
+ return formatLinearMeasurement(metric.value, unit, metricNotation)
12
16
  }
13
17
 
14
18
  export const QuickMeasurementCard = memo(function QuickMeasurementCard({
15
19
  report,
16
20
  unit,
21
+ metricNotation,
17
22
  lensState,
18
23
  }: {
19
24
  report: QuickMeasurementReport
20
25
  unit: 'metric' | 'imperial'
26
+ metricNotation: 'meters' | 'millimeters'
21
27
  lensState: 'live' | 'pinned'
22
28
  }) {
23
29
  const pinned = lensState === 'pinned'
@@ -53,7 +59,7 @@ export const QuickMeasurementCard = memo(function QuickMeasurementCard({
53
59
  <span className="truncate">{metric.label}</span>
54
60
  </div>
55
61
  <div className="mt-0.5 truncate font-mono font-medium text-xs tabular-nums">
56
- {formatMetric(metric, unit)}
62
+ {formatMetric(metric, unit, metricNotation)}
57
63
  </div>
58
64
  </div>
59
65
  ))}
@@ -12,6 +12,7 @@ export function QuickMeasurementHud() {
12
12
  const viewMode = useEditor((state) => state.viewMode)
13
13
  const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode))
14
14
  const unit = useViewer((state) => state.unit)
15
+ const metricNotation = useViewer((state) => state.metricNotation)
15
16
 
16
17
  if (!entry) return null
17
18
 
@@ -21,7 +22,12 @@ export function QuickMeasurementHud() {
21
22
  data-quick-measure-hud
22
23
  >
23
24
  <div className="w-full max-w-[34rem]">
24
- <QuickMeasurementCard lensState={entry.lensState} report={entry.report} unit={unit} />
25
+ <QuickMeasurementCard
26
+ lensState={entry.lensState}
27
+ metricNotation={metricNotation}
28
+ report={entry.report}
29
+ unit={unit}
30
+ />
25
31
  </div>
26
32
  </div>
27
33
  )
@@ -60,6 +60,7 @@ import {
60
60
  } from '../../lib/paint-scope'
61
61
  import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
62
62
  import {
63
+ emitCanvasNodeSelection,
63
64
  resolveCanvasSelectionNode,
64
65
  resolveNodeSelectionTarget,
65
66
  resolveSelectedIdsForNodeClick,
@@ -1365,7 +1366,7 @@ export const SelectionManager = () => {
1365
1366
  useEffect(() => {
1366
1367
  if (mode !== 'select') return
1367
1368
  let owns = false
1368
- let prevKey = ''
1369
+ let prevKey = '\0'
1369
1370
  const applyCursor = () => {
1370
1371
  const { selection, hoveredId } = useViewer.getState()
1371
1372
  const selectedIds = selection.selectedIds
@@ -1648,6 +1649,7 @@ export const SelectionManager = () => {
1648
1649
  modifierKeysRef.current,
1649
1650
  selectedIdsBeforeRouting,
1650
1651
  )
1652
+ emitCanvasNodeSelection(nodeToSelect)
1651
1653
 
1652
1654
  let nextMaterialTargetHandled = false
1653
1655
 
@@ -7,6 +7,7 @@ import { Html } from '@react-three/drei'
7
7
  import { createPortal, useFrame, useThree } from '@react-three/fiber'
8
8
  import { useCallback, useMemo, useRef, useState } from 'react'
9
9
  import { type Camera, type Object3D, Vector3 } from 'three'
10
+ import { groundHeightAt } from '../../lib/ground-surface'
10
11
  import { formatLinearMeasurement } from '../../lib/measurements'
11
12
  import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
12
13
  import { useActiveHandleDrag } from '../../store/use-interaction-scope'
@@ -16,6 +17,16 @@ type ViewportSize = {
16
17
  height: number
17
18
  }
18
19
 
20
+ /**
21
+ * How far the label floats above the ground under it.
22
+ *
23
+ * Was a bare `0.5` in the position literal, which on flat ground is the same thing.
24
+ * Named now because it is no longer the label's *height* but its clearance: on a
25
+ * sculpted lot the number underneath moves, and half a metre is what keeps the text
26
+ * clear of a rim the boundary line is draped over.
27
+ */
28
+ const LABEL_LIFT = 0.5
29
+
19
30
  const htmlPosition = new Vector3()
20
31
 
21
32
  function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize) {
@@ -78,14 +89,25 @@ export function SiteEdgeLabels() {
78
89
  if (obj) setSiteObj(obj)
79
90
  })
80
91
 
92
+ // No live-terrain subscription here, deliberately: activating a site-boundary
93
+ // handle exits sculpt mode before the handle-drag scope begins, so the ground
94
+ // cannot move while these labels are on screen. The drag itself rewrites
95
+ // `polygon` every frame, which re-runs this memo against the current field.
81
96
  const edges = useMemo(() => {
82
97
  if (polygon.length < 2) return []
83
98
  return polygon.map(([x1, z1], i) => {
84
99
  const [x2, z2] = polygon[(i + 1) % polygon.length]!
85
100
  const midX = (x1! + x2) / 2
86
101
  const midZ = (z1! + z2) / 2
102
+ // Length stays the *plan* length. A property line is a horizontal measurement
103
+ // — a deed says 30 m whether the lot is flat or a hillside — and reporting the
104
+ // slope distance would make the same lot appear to grow as it is sculpted.
105
+ // Only the label's Y follows the ground.
87
106
  const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
88
- return { midX, midZ, dist }
107
+ // `?? 0` covers both no-terrain cases, and 0 *is* the datum, so this reduces
108
+ // to the flat constant it replaced whenever the lot is unsculpted.
109
+ const groundY = groundHeightAt(midX, midZ, 0) ?? 0
110
+ return { midX, midY: groundY + LABEL_LIFT, midZ, dist }
89
111
  })
90
112
  }, [polygon])
91
113
 
@@ -99,7 +121,7 @@ export function SiteEdgeLabels() {
99
121
  calculatePosition={calculateLabelPosition}
100
122
  key={`${cameraMode}-${camera.uuid}-edge-${i}`}
101
123
  occlude
102
- position={[edge.midX, 0.5, edge.midZ]}
124
+ position={[edge.midX, edge.midY, edge.midZ]}
103
125
  style={{ pointerEvents: 'none', userSelect: 'none' }}
104
126
  zIndexRange={[10, 0]}
105
127
  >
@@ -109,6 +109,9 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
109
109
  const isPreset = captureMode.mode === 'preset'
110
110
  const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined
111
111
  const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined
112
+ // A host-preselected crop means the host needs that exact output shape
113
+ // (e.g. the publish-cover capture) — hide the crop/aspect switcher.
114
+ const isCropLocked = isPreset || requestedCrop !== undefined
112
115
 
113
116
  const [mode, setMode] = useState<CropMode>('standard')
114
117
  const [standardAspect, setStandardAspect] = useState<StandardAspect>('16:9')
@@ -538,7 +541,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
538
541
 
539
542
  {/* Bottom-center: crop switcher, caption + shutter */}
540
543
  <div className="pointer-events-none absolute right-0 bottom-5 left-0 flex flex-col items-center gap-2.5">
541
- {!isPreset && (
544
+ {!isCropLocked && (
542
545
  <div className="pointer-events-auto relative flex items-center gap-1 rounded-full border border-white/10 bg-neutral-950/85 px-1.5 py-1.5 shadow-xl backdrop-blur-md">
543
546
  {/* Clicking Standard while it's active opens the aspect picker */}
544
547
  <ModeButton