@pascal-app/editor 1.0.0-beta.5 → 1.0.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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -10,6 +10,7 @@ import {
10
10
  type FloorplanGeometry,
11
11
  type FloorplanPalette,
12
12
  type FloorplanPoint,
13
+ type FloorplanScope,
13
14
  type GeometryContext,
14
15
  isNodeKindEnabled,
15
16
  isRegistryMovable,
@@ -26,7 +27,7 @@ import {
26
27
  useLiveTransforms,
27
28
  useScene,
28
29
  } from '@pascal-app/core'
29
- import { useViewer } from '@pascal-app/viewer'
30
+ import { beginPerfAction, cancelPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer'
30
31
  import {
31
32
  type ComponentProps,
32
33
  memo,
@@ -46,6 +47,7 @@ import {
46
47
  resolveDirectManipulationNode,
47
48
  resolveDirectRotationDragDelta,
48
49
  resolveDirectRotationPatch,
50
+ shouldStartDirectMoveDrag,
49
51
  snapDirectRotationDelta,
50
52
  } from '../../../lib/direct-manipulation'
51
53
  import { createEditorApi } from '../../../lib/editor-api'
@@ -246,6 +248,7 @@ export function cancelFloorplanAffordanceDrag(
246
248
  for (const id of drag.session.affectedIds) effects.clearPreview(id)
247
249
  effects.endReshapeScope(drag)
248
250
  effects.clearDragFeedback?.()
251
+ cancelPerfAction()
249
252
  return true
250
253
  }
251
254
 
@@ -310,6 +313,14 @@ export function floorplanAffordanceReshapeScope(
310
313
  return null
311
314
  }
312
315
 
316
+ function floorplanAffordancePerfAction(node: AnyNode, affordance: string): string {
317
+ if (affordance.includes('endpoint')) return `drag:${node.type}-endpoint`
318
+ if (affordance.includes('resize')) return 'drag:resize'
319
+ if (affordance.includes('rotate')) return 'drag:rotate'
320
+ if (affordance.includes('move')) return 'drag:move'
321
+ return 'drag:reshape'
322
+ }
323
+
313
324
  /**
314
325
  * Transient live-rotation readout state. Rebuilt each pointer-move while a
315
326
  * rotate-arrow is dragged and cleared on release. World-plan coords.
@@ -328,6 +339,8 @@ type FloorplanEntryDescriptor = {
328
339
  node: AnyNode
329
340
  dependsOnSiblingInputs: boolean
330
341
  ctxOverrides?: FloorplanContextOverrides
342
+ scopeRank?: number
343
+ visibilityRootId?: AnyNodeId
331
344
  }
332
345
 
333
346
  type NodeDeps = {
@@ -347,6 +360,7 @@ type NodeDeps = {
347
360
  committedNodes: Record<string, AnyNode> | null
348
361
  dependencyNodes: AnyNode[]
349
362
  interactiveElevators: unknown
363
+ ctxOverrides: FloorplanContextOverrides | undefined
350
364
  }
351
365
 
352
366
  type CacheEntry = {
@@ -367,6 +381,40 @@ type FloorplanContextOverrides = {
367
381
  children: AnyNode[]
368
382
  siblings: AnyNode[]
369
383
  parent: AnyNode | null
384
+ outputTransform?: { translate?: FloorplanPoint; rotate?: number }
385
+ trackAllNodes?: boolean
386
+ }
387
+
388
+ export function collectDirectFloorplanScopeNodes(
389
+ nodes: Record<string, AnyNode>,
390
+ parent: AnyNode,
391
+ scope: FloorplanScope,
392
+ ): AnyNode[] {
393
+ const scopedKinds = new Set(kindsWithFloorplanScope(scope))
394
+ const declaredChildren = new Set(
395
+ Array.isArray((parent as { children?: AnyNodeId[] }).children)
396
+ ? (parent as { children: AnyNodeId[] }).children
397
+ : [],
398
+ )
399
+ return Object.values(nodes).filter(
400
+ (node) =>
401
+ scopedKinds.has(node.type) && (node.parentId === parent.id || declaredChildren.has(node.id)),
402
+ )
403
+ }
404
+
405
+ export function siteToFloorplanTransform(
406
+ buildingPosition: readonly [number, number, number],
407
+ buildingRotationY: number,
408
+ ): { translate: FloorplanPoint; rotate: number } {
409
+ const cos = Math.cos(buildingRotationY)
410
+ const sin = Math.sin(buildingRotationY)
411
+ return {
412
+ translate: [
413
+ -buildingPosition[0] * cos + buildingPosition[2] * sin,
414
+ -buildingPosition[0] * sin - buildingPosition[2] * cos,
415
+ ],
416
+ rotate: buildingRotationY,
417
+ }
370
418
  }
371
419
 
372
420
  type FloorplanLevelDataHook = (args: {
@@ -477,6 +525,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
477
525
 
478
526
  const levelId = selectedLevelId ?? ambientLevelId
479
527
  const isAmbient = !selectedLevelId && !!ambientLevelId
528
+ const activeBuildingId = useMemo(
529
+ () => (levelId ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null),
530
+ [levelId, nodes],
531
+ )
532
+ const activeBuildingLiveTransform = useLiveTransforms((state) =>
533
+ activeBuildingId ? state.transforms.get(activeBuildingId) : undefined,
534
+ )
480
535
  const renderCtx = useFloorplanStaticRender()
481
536
  const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
482
537
  const setMovingNode = useEditor((s) => s.setMovingNode)
@@ -644,16 +699,24 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
644
699
 
645
700
  const startDirectMoveDrag = useCallback(
646
701
  (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
647
- if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
702
+ if (event.button !== 0) return false
648
703
 
649
704
  const node = useScene.getState().nodes[id]
650
705
  if (!node || !isRegistryMovable(node.type)) return false
651
- // Sole selection only: per-node direct manipulation stands down for a
652
- // multi-selection (the group session owns plain drags there, and Cmd is
653
- // the selection-toggle key — a wobbly Cmd+click must not yank one
654
- // member out of the group).
655
706
  const currentSelectedIds = useViewer.getState().selection.selectedIds
656
- if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
707
+ const allowPlainDrag = nodeRegistry.get(node.type)?.capabilities?.movable?.directDrag === true
708
+ const commandModifier = event.metaKey || event.ctrlKey
709
+ if (
710
+ !shouldStartDirectMoveDrag({
711
+ allowPlainDrag,
712
+ commandModifier,
713
+ handleOwnsPointer: false,
714
+ nodeId: id,
715
+ selectedIds: currentSelectedIds,
716
+ })
717
+ ) {
718
+ return false
719
+ }
657
720
 
658
721
  event.preventDefault()
659
722
  event.stopPropagation()
@@ -705,9 +768,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
705
768
  if (endEvent.pointerId !== pointerId) return
706
769
  cleanup()
707
770
  if (!engaged) {
708
- // Cmd/Ctrl+click without drag: toggle member (options object, not bare boolean).
709
771
  applyEntrySelection(id, {
710
- shouldToggle: true,
772
+ shouldToggle: commandModifier,
711
773
  isolateMember: false,
712
774
  })
713
775
  }
@@ -929,7 +991,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
929
991
 
930
992
  collectLevelDataKind(levelId as AnyNodeId)
931
993
 
932
- const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
994
+ const pushEntry = (
995
+ id: AnyNodeId,
996
+ node: AnyNode,
997
+ ctxOverrides?: FloorplanContextOverrides,
998
+ options?: { scopeRank?: number; visibilityRootId?: AnyNodeId },
999
+ ) => {
933
1000
  if (!isNodeKindEnabled(node.type, installedPlugins)) return
934
1001
  const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
935
1002
  if (!drawingNode) return
@@ -942,6 +1009,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
942
1009
  )
943
1010
  const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
944
1011
  if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
1012
+ if (options?.scopeRank !== undefined) descriptor.scopeRank = options.scopeRank
1013
+ if (options?.visibilityRootId) descriptor.visibilityRootId = options.visibilityRootId
945
1014
  out.push(descriptor)
946
1015
  }
947
1016
 
@@ -977,13 +1046,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
977
1046
  // as siblings of the level, not under it — the `visit(levelId)` DFS
978
1047
  // above doesn't reach them. Walk every node of those kinds whose
979
1048
  // parent matches the active level's building, and synthesise a
980
- // `GeometryContext` whose `parent` is the active level (so kind
981
- // builders that gate on the current floor — e.g. elevator service
982
- // range — keep working). Pure registry-driven dispatch: no kind
983
- // name appears in this file.
984
- const activeBuildingId = activeLevelNode
985
- ? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
986
- : null
1049
+ // `GeometryContext` whose `parent` is the active level.
987
1050
  if (activeLevelNode && activeBuildingId) {
988
1051
  const buildingScopedKinds = kindsWithFloorplanScope('building')
989
1052
  const buildingScopedKindSet = new Set(buildingScopedKinds)
@@ -1000,6 +1063,45 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1000
1063
  }
1001
1064
  }
1002
1065
 
1066
+ // Site-scoped kinds are semantic children of the Site and therefore
1067
+ // outside both the active-level DFS and the building sibling scan.
1068
+ // Their builders stay site-local; wrap their output in the inverse active
1069
+ // building transform so it aligns with this building-local plan.
1070
+ const activeBuildingNode = activeBuildingId ? nodes[activeBuildingId] : undefined
1071
+ const activeSiteNode =
1072
+ activeBuildingNode?.type === 'building' && activeBuildingNode.parentId
1073
+ ? nodes[activeBuildingNode.parentId as AnyNodeId]
1074
+ : undefined
1075
+ if (activeSiteNode?.type === 'site' && activeBuildingNode?.type === 'building') {
1076
+ const siteScopedNodes = collectDirectFloorplanScopeNodes(nodes, activeSiteNode, 'site')
1077
+ const siteProjection = siteToFloorplanTransform(
1078
+ activeBuildingLiveTransform?.position ?? activeBuildingNode.position,
1079
+ activeBuildingLiveTransform?.rotation ?? activeBuildingNode.rotation[1],
1080
+ )
1081
+ for (const node of siteScopedNodes) {
1082
+ const children = Array.isArray((node as { children?: AnyNodeId[] }).children)
1083
+ ? (node as { children: AnyNodeId[] }).children
1084
+ .map((id) => nodes[id])
1085
+ .filter((child): child is AnyNode => child !== undefined)
1086
+ : []
1087
+ const siblings = siteScopedNodes.filter(
1088
+ (candidate) => candidate.id !== node.id && candidate.type === node.type,
1089
+ )
1090
+ pushEntry(
1091
+ node.id,
1092
+ node,
1093
+ {
1094
+ children,
1095
+ siblings,
1096
+ parent: activeSiteNode,
1097
+ outputTransform: siteProjection,
1098
+ trackAllNodes: true,
1099
+ },
1100
+ { scopeRank: -1, visibilityRootId: activeSiteNode.id },
1101
+ )
1102
+ }
1103
+ }
1104
+
1003
1105
  // Stable z-order sort. SVG renders in document order — later siblings
1004
1106
  // paint on top of earlier ones — so anything that should sit *under*
1005
1107
  // other floor-plan geometry has to come first in the entries array.
@@ -1007,7 +1109,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1007
1109
  // all belong on top of them. Within a layer bucket we preserve the
1008
1110
  // DFS visit order (stable sort) so siblings keep their relative
1009
1111
  // priority.
1010
- out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
1112
+ out.sort(
1113
+ (a, b) =>
1114
+ (a.scopeRank ?? 0) - (b.scopeRank ?? 0) ||
1115
+ floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type),
1116
+ )
1011
1117
  const entryIds = new Set(out.map((entry) => entry.id))
1012
1118
  for (const id of geometryCacheRef.current.keys()) {
1013
1119
  if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id)
@@ -1016,7 +1122,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1016
1122
  if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
1017
1123
  }
1018
1124
  return { entries: out, levelNodeIdsByType }
1019
- }, [drawingType, installedPlugins, levelId, nodes])
1125
+ }, [activeBuildingId, activeBuildingLiveTransform, drawingType, installedPlugins, levelId, nodes])
1020
1126
 
1021
1127
  // ── Generic 2D affordance dispatch ─────────────────────────────────
1022
1128
  //
@@ -1048,6 +1154,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1048
1154
  nodes: sceneNodes,
1049
1155
  initialPlanPoint,
1050
1156
  gridSnapStep: useEditor.getState().gridSnapStep,
1157
+ sceneApi: createSceneApi(useScene),
1051
1158
  })
1052
1159
  if (!(session.commit && session.canCommit())) return
1053
1160
  session.commit()
@@ -1085,12 +1192,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1085
1192
  event.stopPropagation()
1086
1193
  suppressBoxSelectForPointer(event)
1087
1194
 
1195
+ beginPerfAction(floorplanAffordancePerfAction(node, affordance), `${node.type}:${node.id}`)
1088
1196
  const session = handler.start({
1089
1197
  node,
1090
1198
  payload,
1091
1199
  nodes: sceneNodes,
1092
1200
  initialPlanPoint,
1093
1201
  gridSnapStep: useEditor.getState().gridSnapStep,
1202
+ sceneApi: createSceneApi(useScene),
1094
1203
  })
1095
1204
 
1096
1205
  const snapshots: NodeSnapshot[] = []
@@ -1246,6 +1355,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1246
1355
  drag.historyPaused = false
1247
1356
  }
1248
1357
  drag.session.commit()
1358
+ commitPerfAction()
1249
1359
  sfxEmitter.emit('sfx:structure-build')
1250
1360
  clearSurfacePlanSnapFeedback()
1251
1361
  endReshapeScope(drag)
@@ -1287,6 +1397,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1287
1397
  drag.historyPaused = false
1288
1398
  }
1289
1399
  useScene.getState().updateNodes(finalUpdates)
1400
+ commitPerfAction()
1290
1401
  sfxEmitter.emit('sfx:structure-build')
1291
1402
  } else {
1292
1403
  // Either no net change or canCommit() rejected — revert and
@@ -1300,6 +1411,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1300
1411
  }
1301
1412
  const overrides = useLiveNodeOverrides.getState()
1302
1413
  for (const id of drag.session.affectedIds) overrides.clear(id)
1414
+ cancelPerfAction()
1303
1415
  }
1304
1416
 
1305
1417
  clearSurfacePlanSnapFeedback()
@@ -1425,7 +1537,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1425
1537
  unit={unit}
1426
1538
  metricNotation={metricNotation}
1427
1539
  wallDimensionReference={effectiveWallDimensionReference}
1428
- visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1540
+ visibilityRootId={
1541
+ entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId))
1542
+ }
1429
1543
  ctxOverrides={entry.ctxOverrides}
1430
1544
  />
1431
1545
  ))}
@@ -1471,7 +1585,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1471
1585
  unit={unit}
1472
1586
  metricNotation={metricNotation}
1473
1587
  wallDimensionReference={effectiveWallDimensionReference}
1474
- visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1588
+ visibilityRootId={
1589
+ entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId))
1590
+ }
1475
1591
  ctxOverrides={entry.ctxOverrides}
1476
1592
  />
1477
1593
  ))}
@@ -1943,12 +2059,12 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1943
2059
  // the body-drag gesture — the whole selection slides, not one member.
1944
2060
  if (onGroupMovePointerDown(nodeId, event)) return
1945
2061
  sfxEmitter.emit('sfx:item-pick')
1946
- setMovingNode(currentNode as never)
2062
+ createEditorApi().engageMove(currentNode)
1947
2063
  // Claim 2D ownership of this move at the source. `setMovingNode`
1948
2064
  // resets the origin to null, so this must follow it.
1949
2065
  setMovingNodeOrigin('2d')
1950
2066
  },
1951
- [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
2067
+ [nodeId, onGroupMovePointerDown, setMovingNodeOrigin],
1952
2068
  )
1953
2069
 
1954
2070
  const cacheEntry = buildFloorplanEntryGeometry({
@@ -2078,7 +2194,7 @@ function floorplanEntryReferencedAnnotationRole(
2078
2194
  return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
2079
2195
  }
2080
2196
 
2081
- function buildFloorplanEntryGeometry({
2197
+ export function buildFloorplanEntryGeometry({
2082
2198
  automaticDimensions,
2083
2199
  ctxOverrides,
2084
2200
  geometryCache,
@@ -2123,6 +2239,7 @@ function buildFloorplanEntryGeometry({
2123
2239
  const deps: NodeDeps = {
2124
2240
  automaticDimensions,
2125
2241
  node,
2242
+ ctxOverrides,
2126
2243
  live,
2127
2244
  unit,
2128
2245
  metricNotation,
@@ -2136,7 +2253,7 @@ function buildFloorplanEntryGeometry({
2136
2253
  siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
2137
2254
  // Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
2138
2255
  // committed state via `ctx`, so committed sibling edits still invalidate.
2139
- committedNodes: dependsOnSiblingInputs ? nodes : null,
2256
+ committedNodes: dependsOnSiblingInputs || ctxOverrides?.trackAllNodes ? nodes : null,
2140
2257
  dependencyNodes,
2141
2258
  interactiveElevators,
2142
2259
  }
@@ -2161,7 +2278,12 @@ function buildFloorplanEntryGeometry({
2161
2278
  : r,
2162
2279
  } as AnyNode
2163
2280
  }
2164
- if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
2281
+ if (
2282
+ (def.capabilities?.floorPlaced ||
2283
+ def.floorplanScope === 'building' ||
2284
+ def.floorplanScope === 'site') &&
2285
+ hasPosition
2286
+ ) {
2165
2287
  return applyPositionLiveTransform(sourceNode, live)
2166
2288
  }
2167
2289
  if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') {
@@ -2256,10 +2378,18 @@ function buildFloorplanEntryGeometry({
2256
2378
  const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, {
2257
2379
  annotationRole: 'contextual-dimension',
2258
2380
  })
2259
- const geometry =
2381
+ const unprojectedGeometry =
2260
2382
  modelGeometry && taggedContextualGeometry
2261
2383
  ? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] }
2262
2384
  : (modelGeometry ?? taggedContextualGeometry)
2385
+ const geometry =
2386
+ unprojectedGeometry && ctxOverrides?.outputTransform
2387
+ ? {
2388
+ kind: 'group' as const,
2389
+ children: [unprojectedGeometry],
2390
+ transform: ctxOverrides.outputTransform,
2391
+ }
2392
+ : unprojectedGeometry
2263
2393
  const { base, overlay } = geometry
2264
2394
  ? splitFloorplanOverlay(geometry)
2265
2395
  : { base: null, overlay: null }
@@ -2822,6 +2952,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
2822
2952
  case 'midpoint-handle': {
2823
2953
  if (!palette) return <></>
2824
2954
  const handleId = makeHandleId(nodeId, g.payload)
2955
+ const isAction = g.activation === 'action'
2825
2956
  const isHovered = hoveredHandleId === handleId
2826
2957
  const isActive = activeDragId === handleId
2827
2958
  const stroke = palette.endpointHandleStroke
@@ -2834,7 +2965,12 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
2834
2965
  return (
2835
2966
  <g
2836
2967
  key={keyHint}
2837
- onClick={(e) => e.stopPropagation()}
2968
+ onClick={(event) => {
2969
+ event.stopPropagation()
2970
+ if (!isAction) return
2971
+ event.preventDefault()
2972
+ onHandleDoubleClick(g.affordance, g.payload, event)
2973
+ }}
2838
2974
  onPointerEnter={() => onHandleHoverChange(handleId)}
2839
2975
  onPointerLeave={() => onHandleHoverChange(null)}
2840
2976
  >
@@ -2891,9 +3027,17 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
2891
3027
  cx={g.point[0]}
2892
3028
  cy={g.point[1]}
2893
3029
  fill="transparent"
2894
- onPointerDown={(e) =>
2895
- onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
2896
- }
3030
+ onPointerDown={(event) => {
3031
+ if (isAction) {
3032
+ event.stopPropagation()
3033
+ return
3034
+ }
3035
+ onHandlePointerDown(
3036
+ g.affordance,
3037
+ g.payload,
3038
+ event as ReactPointerEvent<SVGGElement>,
3039
+ )
3040
+ }}
2897
3041
  pointerEvents="all"
2898
3042
  r={radius + unitsPerPixel * 2}
2899
3043
  stroke="transparent"
@@ -3142,12 +3286,15 @@ export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOve
3142
3286
  return (node as { visible?: boolean }).visible !== false
3143
3287
  }
3144
3288
 
3145
- function isFloorplanHierarchyVisible(
3289
+ export function isFloorplanHierarchyVisible(
3146
3290
  node: AnyNode,
3147
3291
  nodes: Record<string, AnyNode>,
3148
3292
  liveOverrides: Map<string, LiveNodeOverrides>,
3149
3293
  rootId: AnyNodeId,
3150
3294
  ): boolean {
3295
+ const root = nodes[rootId]
3296
+ if (root && !isFloorplanNodeVisible(root, liveOverrides.get(root.id))) return false
3297
+
3151
3298
  let current: AnyNode | undefined = node
3152
3299
  const seen = new Set<AnyNodeId>()
3153
3300
  while (current) {
@@ -3427,6 +3574,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
3427
3574
  'liveOverride',
3428
3575
  'palette',
3429
3576
  'siblingEpoch',
3577
+ 'ctxOverrides',
3430
3578
  'committedNodes',
3431
3579
  'dependencyNodes',
3432
3580
  'interactiveElevators',