@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
@@ -6,8 +6,11 @@ import {
6
6
  type AnyNodeId,
7
7
  bboxAnchors,
8
8
  bboxCornerAnchors,
9
+ createSceneApi,
9
10
  emitter,
10
11
  type FloorplanMoveTargetSession,
12
+ type GroupMoveSnapResult,
13
+ type MovableConfig,
11
14
  nodeRegistry,
12
15
  pauseSceneHistory,
13
16
  resumeSceneHistory,
@@ -20,7 +23,11 @@ import { useEffect } from 'react'
20
23
  import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
21
24
  import { isHistoryShortcut } from '../../lib/history'
22
25
  import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
23
- import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
26
+ import { resolvePrioritizedPlanarCursorPosition } from '../../lib/planar-cursor-placement'
27
+ import {
28
+ resolveAttachmentPreviewRotation,
29
+ rigidPlanSvgTransform,
30
+ } from '../../lib/rigid-plan-svg-transform'
24
31
  import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
25
32
  import { sfxEmitter } from '../../lib/sfx-bus'
26
33
  import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
@@ -104,12 +111,14 @@ export function FloorplanRegistryMoveOverlay() {
104
111
  // ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
105
112
  if (hasMoveTarget && def?.floorplanMoveTarget) {
106
113
  const sceneNodes = useScene.getState().nodes
114
+ const sceneApi = createSceneApi(useScene)
107
115
  const session: FloorplanMoveTargetSession = (
108
116
  def.floorplanMoveTarget as (a: {
109
117
  node: AnyNode
110
118
  nodes: Record<AnyNodeId, AnyNode>
119
+ sceneApi: ReturnType<typeof createSceneApi>
111
120
  }) => FloorplanMoveTargetSession
112
- )({ node: movingNode, nodes: sceneNodes })
121
+ )({ node: movingNode, nodes: sceneNodes, sceneApi })
113
122
 
114
123
  // Capture snapshots of every affected node BEFORE the first apply
115
124
  // so the single-undo dance has a clean baseline to revert to.
@@ -544,8 +553,19 @@ export function FloorplanRegistryMoveOverlay() {
544
553
  candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
545
554
  }
546
555
 
547
- let lastSnapped: [number, number] | null = null
556
+ const storedRotation = (movingNode as { rotation?: unknown }).rotation
557
+ const originalRotation =
558
+ typeof storedRotation === 'number'
559
+ ? storedRotation
560
+ : Array.isArray(storedRotation)
561
+ ? ((storedRotation as [number?, number?, number?])[1] ?? 0)
562
+ : 0
563
+ let currentRotation = originalRotation
564
+ let lastSnapped: { point: [number, number]; rotation: number } | null = null
548
565
  let dragAnchor: [number, number] | null = null
566
+ let lastPositionValid = true
567
+ let forcePlace = false
568
+ const movableValidityConfig = (def?.capabilities?.movable as MovableConfig | undefined) ?? null
549
569
 
550
570
  // Footprint bounding box drawn around the dragged entry — the 2D
551
571
  // counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
@@ -572,22 +592,74 @@ export function FloorplanRegistryMoveOverlay() {
572
592
  if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
573
593
  const m = toMeters(event.clientX, event.clientY)
574
594
  if (!m) return
595
+ forcePlace = event.altKey
575
596
 
576
- // 1) Grid snap baseline. Fresh catalog placement is absolute under
577
- // the cursor; existing moves preserve the cursor's grab offset. Grid
578
- // follows the active snapping mode (Shift cycles it); raw cursor in
579
- // any non-grid mode.
597
+ // 1) Wall attachment gets the raw proposal before grid/alignment. If no
598
+ // attachment is available, fresh placement is absolute under the cursor
599
+ // and existing moves preserve the cursor's grab offset before grid snap.
580
600
  const gridStep = useEditor.getState().gridSnapStep
581
601
  const snap = (value: number) =>
582
602
  isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
583
- const resolved = resolvePlanarCursorPosition({
603
+ const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
604
+ const groupMoveSnapPose = def?.capabilities?.movable?.groupMoveSnapPose
605
+ const gridSnapPosition = def?.capabilities?.movable?.gridSnapPosition
606
+ const attachmentEnabled = isGridSnapActive() || isMagneticSnapActive()
607
+ let attachmentRotation: number | null = null
608
+ const resolved = resolvePrioritizedPlanarCursorPosition({
584
609
  cursor: [m[0], m[1]],
585
610
  original: [originalPosition[0], originalPosition[2]],
586
611
  anchor: dragAnchor,
587
612
  mode: isFreshPlacement ? 'absolute' : 'relative',
588
- snap,
613
+ snap: gridSnapPosition ? undefined : snap,
614
+ snapPoint:
615
+ isGridSnapActive() && gridSnapPosition
616
+ ? ([planX, planZ]) => {
617
+ const snappedPosition = gridSnapPosition({
618
+ node: movingNode,
619
+ candidatePosition: [planX, originalPosition[1], planZ],
620
+ candidateRotation: originalRotation,
621
+ movingIds: [movingNode.id as AnyNodeId],
622
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
623
+ levelId:
624
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
625
+ (movingNode.parentId as AnyNodeId | undefined) ??
626
+ null,
627
+ gridStep,
628
+ })
629
+ return [snappedPosition[0], snappedPosition[2]]
630
+ }
631
+ : undefined,
632
+ resolveAttachment:
633
+ attachmentEnabled && (groupMoveSnapPose || groupMoveSnap)
634
+ ? ([planX, planZ]) => {
635
+ const snapArgs: Parameters<NonNullable<typeof groupMoveSnapPose>>[0] = {
636
+ node: movingNode,
637
+ candidatePosition: [planX, originalPosition[1], planZ],
638
+ candidateRotation: currentRotation,
639
+ movingIds: [movingNode.id as AnyNodeId],
640
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
641
+ levelId:
642
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
643
+ (movingNode.parentId as AnyNodeId | undefined) ??
644
+ null,
645
+ }
646
+ const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPose
647
+ ? groupMoveSnapPose(snapArgs)
648
+ : (() => {
649
+ const position = groupMoveSnap?.(snapArgs)
650
+ return position ? { position } : null
651
+ })()
652
+ if (!snappedPosition) return null
653
+ attachmentRotation = snappedPosition.rotation ?? null
654
+ return [snappedPosition.position[0], snappedPosition.position[2]]
655
+ }
656
+ : undefined,
589
657
  })
590
658
  dragAnchor = resolved.anchor
659
+ currentRotation = resolveAttachmentPreviewRotation(
660
+ originalRotation,
661
+ resolved.attachmentSnapped ? attachmentRotation : null,
662
+ )
591
663
  const [gridX, gridZ] = resolved.point
592
664
 
593
665
  // 2) Alignment snap layered on top. Treat the grid-snapped point
@@ -598,7 +670,7 @@ export function FloorplanRegistryMoveOverlay() {
598
670
  // force-place, not a snap bypass.
599
671
  let finalX = gridX
600
672
  let finalZ = gridZ
601
- if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
673
+ if (!resolved.attachmentSnapped && isAlignmentGuideActive() && candidateAnchors.length > 0) {
602
674
  // Translate the cached local bbox to the proposed pos to get the
603
675
  // moving anchors at that location. The entry's untransformed
604
676
  // bbox is in world meters relative to the node's origin, so a
@@ -635,35 +707,35 @@ export function FloorplanRegistryMoveOverlay() {
635
707
  useAlignmentGuides.getState().clear()
636
708
  }
637
709
 
638
- // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
639
- // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
640
- // alignment guide, so it runs in every snapping mode except Off.
641
- const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
642
- if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
643
- const snappedPosition = groupMoveSnap({
644
- node: movingNode,
645
- candidatePosition: [finalX, originalPosition[1], finalZ],
646
- movingIds: [movingNode.id as AnyNodeId],
647
- nodes: useScene.getState().nodes as Record<string, AnyNode>,
648
- levelId:
649
- (useViewer.getState().selection.levelId as AnyNodeId | null) ??
650
- (movingNode.parentId as AnyNodeId | undefined) ??
651
- null,
652
- })
653
- if (snappedPosition) {
654
- finalX = snappedPosition[0]
655
- finalZ = snappedPosition[2]
656
- useAlignmentGuides.getState().clear()
657
- }
658
- }
659
-
660
- const dx = finalX - originalPosition[0]
661
- const dz = finalZ - originalPosition[2]
710
+ const transform = rigidPlanSvgTransform({
711
+ from: [originalPosition[0], originalPosition[2]],
712
+ fromRotation: originalRotation,
713
+ to: [finalX, finalZ],
714
+ toRotation: currentRotation,
715
+ })
662
716
  for (const relatedEntry of relatedEntries) {
663
- relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
717
+ relatedEntry.setAttribute('transform', transform)
664
718
  }
665
- boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
666
- lastSnapped = [finalX, finalZ]
719
+ boxEl.setAttribute('transform', transform)
720
+ const oldY = originalPosition[1]
721
+ lastPositionValid = movableValidityConfig?.isValidPosition
722
+ ? movableValidityConfig.isValidPosition({
723
+ node: {
724
+ ...movingNode,
725
+ position: [finalX, oldY, finalZ],
726
+ rotation: currentRotation,
727
+ } as AnyNode,
728
+ position: [finalX, oldY, finalZ],
729
+ rotation: currentRotation,
730
+ levelId:
731
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
732
+ (movingNode.parentId as AnyNodeId | null) ??
733
+ null,
734
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
735
+ })
736
+ : true
737
+ boxEl.setAttribute('stroke', lastPositionValid || forcePlace ? '#22c55e' : '#ef4444')
738
+ lastSnapped = { point: [finalX, finalZ], rotation: currentRotation }
667
739
  }
668
740
 
669
741
  const onPointerUp = (event: PointerEvent) => {
@@ -672,9 +744,26 @@ export function FloorplanRegistryMoveOverlay() {
672
744
 
673
745
  const snapped = lastSnapped
674
746
  if (!snapped) return
675
- const [sx, sz] = snapped
747
+ const [sx, sz] = snapped.point
676
748
  const [, oldY] = originalPosition
749
+ const rotation = Array.isArray(storedRotation)
750
+ ? [
751
+ (storedRotation as [number?, number?, number?])[0] ?? 0,
752
+ snapped.rotation,
753
+ (storedRotation as [number?, number?, number?])[2] ?? 0,
754
+ ]
755
+ : snapped.rotation
756
+ const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
677
757
  setMovingNodeOrigin('2d')
758
+ if (!lastPositionValid && !forcePlace) {
759
+ for (const relatedEntry of relatedEntries) {
760
+ relatedEntry.removeAttribute('transform')
761
+ }
762
+ useAlignmentGuides.getState().clear()
763
+ setMovingNode(null)
764
+ swallowNextClick()
765
+ return
766
+ }
678
767
  let selectedId = movingNode.id as AnyNodeId
679
768
  if (originalPath) {
680
769
  // Polyline kinds: shift every point by the committed delta and
@@ -711,6 +800,7 @@ export function FloorplanRegistryMoveOverlay() {
711
800
  movingNode.id as AnyNodeId,
712
801
  {
713
802
  position: [sx, oldY, sz],
803
+ ...rotationPatch,
714
804
  metadata: stripPlacementMetadataFlags(
715
805
  (movingNode as { metadata?: unknown }).metadata,
716
806
  ),
@@ -722,6 +812,7 @@ export function FloorplanRegistryMoveOverlay() {
722
812
  movingNode.id as AnyNodeId,
723
813
  {
724
814
  position: [sx, oldY, sz],
815
+ ...rotationPatch,
725
816
  } as Partial<AnyNode>,
726
817
  )
727
818
  }
@@ -161,12 +161,14 @@ export function FloorplanDimensionRenderer({
161
161
  stroke = geometry.stroke ?? '#334155',
162
162
  annotationUnitsPerPoint,
163
163
  renderMode = 'screen',
164
+ onSelect,
164
165
  }: {
165
166
  geometry: DimensionGeometry
166
167
  sceneRotationDeg?: number
167
168
  stroke?: string
168
169
  annotationUnitsPerPoint?: number
169
170
  renderMode?: FloorplanDimensionRenderMode
171
+ onSelect?: () => void
170
172
  }): React.ReactElement | null {
171
173
  const layout = computeArchitecturalDimensionLayout(
172
174
  geometry,
@@ -200,7 +202,18 @@ export function FloorplanDimensionRenderer({
200
202
  : undefined
201
203
 
202
204
  return (
203
- <g data-floorplan-dimension="" pointerEvents="none">
205
+ <g
206
+ data-floorplan-dimension=""
207
+ onClick={
208
+ onSelect
209
+ ? (event) => {
210
+ event.stopPropagation()
211
+ onSelect()
212
+ }
213
+ : undefined
214
+ }
215
+ pointerEvents={onSelect ? 'auto' : 'none'}
216
+ >
204
217
  <line
205
218
  {...lineProps}
206
219
  x1={layout.extensionStart[0]}
@@ -366,4 +366,20 @@ describe('FloorplanGeometryRenderer static labels', () => {
366
366
  expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"')
367
367
  expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
368
368
  })
369
+ test('forwards even-odd fill rules for compound plugin paths', () => {
370
+ const geometry = {
371
+ kind: 'path',
372
+ d: 'M0,0H4V4H0ZM1,1H3V3H1Z',
373
+ fill: '#3f6b2f',
374
+ fillRule: 'evenodd',
375
+ } satisfies FloorplanGeometry
376
+
377
+ const markup = renderToStaticMarkup(
378
+ <svg>
379
+ <FloorplanGeometryRenderer geometry={geometry} />
380
+ </svg>,
381
+ )
382
+
383
+ expect(markup).toContain('fill-rule="evenodd"')
384
+ })
369
385
  })
@@ -83,6 +83,7 @@ function styleAttrs(
83
83
  // filtered out by the caller's type bound).
84
84
  const s = g as unknown as {
85
85
  fill?: string
86
+ fillRule?: 'nonzero' | 'evenodd'
86
87
  fillOpacity?: number
87
88
  stroke?: string
88
89
  strokeWidth?: number
@@ -109,6 +110,7 @@ function styleAttrs(
109
110
  'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
110
111
  'data-floorplan-annotation-role': annotationMetadata.annotationRole,
111
112
  fill: documentStyle.fill ?? s.fill ?? 'none',
113
+ fillRule: s.fillRule,
112
114
  fillOpacity: s.fillOpacity,
113
115
  stroke: documentStyle.stroke ?? s.stroke,
114
116
  strokeWidth,
@@ -10,13 +10,16 @@ import {
10
10
  } from '@pascal-app/core'
11
11
  import { useViewer } from '@pascal-app/viewer'
12
12
  import { memo, useMemo } from 'react'
13
+ import { formatLinearMeasurement } from '../../../lib/measurements'
13
14
  import usePlacementPreview from '../../../store/use-placement-preview'
14
- import { useFloorplanRender } from '../floorplan-render-context'
15
+ import { useFloorplanRender, useFloorplanSceneRotation } from '../floorplan-render-context'
16
+ import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
15
17
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
16
18
 
17
19
  export interface FloorplanNodePreviewProps {
18
20
  node: AnyNode
19
21
  parentNode?: AnyNode | null
22
+ contextNodes?: AnyNode[]
20
23
  opacity?: number
21
24
  className?: string
22
25
  selected?: boolean
@@ -33,6 +36,7 @@ export interface FloorplanNodePreviewProps {
33
36
  export const FloorplanNodePreview = memo(function FloorplanNodePreview({
34
37
  node,
35
38
  parentNode = null,
39
+ contextNodes: previewContextNodes = [],
36
40
  opacity = 0.5,
37
41
  className,
38
42
  selected = false,
@@ -53,6 +57,9 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
53
57
  ...(nodes as Record<string, AnyNode>),
54
58
  [node.id]: node,
55
59
  }
60
+ for (const previewNode of previewContextNodes) {
61
+ contextNodes[previewNode.id] = previewNode
62
+ }
56
63
  if (parentNode) contextNodes[parentNode.id] = parentNode
57
64
  const resolvedParent =
58
65
  parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null)
@@ -90,7 +97,18 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
90
97
  }
91
98
 
92
99
  return (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(node, ctx)
93
- }, [highlighted, hovered, moving, node, nodes, parentNode, renderContext, selected, unit])
100
+ }, [
101
+ highlighted,
102
+ hovered,
103
+ moving,
104
+ node,
105
+ nodes,
106
+ parentNode,
107
+ previewContextNodes,
108
+ renderContext,
109
+ selected,
110
+ unit,
111
+ ])
94
112
  if (!geometry) return null
95
113
 
96
114
  return (
@@ -117,11 +135,42 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
117
135
  export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
118
136
  const node = usePlacementPreview((s) => s.node)
119
137
  const parentNode = usePlacementPreview((s) => s.parentNode)
138
+ const contextNodes = usePlacementPreview((s) => s.contextNodes)
139
+ const dimensions = usePlacementPreview((s) => s.dimensions)
140
+ const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId)
141
+ const dimensionInput = usePlacementPreview((s) => s.dimensionInput)
142
+ const unit = useViewer((s) => s.unit)
143
+ const metricNotation = useViewer((s) => s.metricNotation)
144
+ const sceneRotationDeg = useFloorplanSceneRotation()
120
145
  if (!node) return null
121
146
 
122
147
  return (
123
148
  <g data-floorplan-placement-preview>
124
- <FloorplanNodePreview node={node} parentNode={parentNode} />
149
+ <FloorplanNodePreview contextNodes={contextNodes} node={node} parentNode={parentNode} />
150
+ <g data-floorplan-placement-dimensions>
151
+ {dimensions
152
+ .filter((dimension) => dimension.renderInFloorplan !== false)
153
+ .map((dimension) => (
154
+ <FloorplanDimensionRenderer
155
+ geometry={{
156
+ kind: 'dimension',
157
+ start: [dimension.start[0], dimension.start[2]],
158
+ end: [dimension.end[0], dimension.end[2]],
159
+ offsetNormal: dimension.offsetNormal,
160
+ offsetDistance: dimension.offsetDistance,
161
+ extensionOvershoot: 0.04,
162
+ text:
163
+ dimension.id === activeDimensionId && dimensionInput
164
+ ? dimensionInput
165
+ : formatLinearMeasurement(dimension.value, unit, metricNotation),
166
+ stroke: '#6366f1',
167
+ }}
168
+ key={dimension.id}
169
+ onSelect={() => usePlacementPreview.getState().selectDimension(dimension.id)}
170
+ sceneRotationDeg={sceneRotationDeg}
171
+ />
172
+ ))}
173
+ </g>
125
174
  </g>
126
175
  )
127
176
  })
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, mock, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
2
  import type {
3
3
  AnyNode,
4
4
  AnyNodeId,
@@ -16,7 +16,9 @@ import {
16
16
  floorplanGeometryMetadata,
17
17
  } from '../../../lib/floorplan/floorplan-extension'
18
18
  import {
19
+ buildFloorplanEntryGeometry,
19
20
  cancelFloorplanAffordanceDrag,
21
+ collectDirectFloorplanScopeNodes,
20
22
  collectFloorplanDependencyNodes,
21
23
  collectFloorplanLinkedLevelNodes,
22
24
  computeAffectedSiblingIds,
@@ -25,10 +27,185 @@ import {
25
27
  InteractiveGeometry,
26
28
  isFloorplanOpeningPlacementState,
27
29
  resolveFloorplanHandleUnitsPerPixel,
30
+ siteToFloorplanTransform,
28
31
  splitFloorplanOverlay,
29
32
  subscribeFloorplanAffordanceToolCancel,
30
33
  } from './floorplan-registry-layer'
31
34
 
35
+ describe('site-scoped floorplan discovery', () => {
36
+ let restoreRegistry: () => void
37
+
38
+ beforeEach(() => {
39
+ restoreRegistry = nodeRegistry._snapshot()
40
+ nodeRegistry._reset()
41
+ registerNode({
42
+ kind: 'test:site-overlay',
43
+ schemaVersion: 1,
44
+ schema: z.object({ type: z.literal('test:site-overlay') }) as never,
45
+ category: 'utility',
46
+ defaults: () => ({}) as never,
47
+ capabilities: {},
48
+ floorplanScope: 'site',
49
+ floorplan: (node) => {
50
+ const positioned = node as unknown as {
51
+ position?: [number, number, number]
52
+ rotation?: [number, number, number]
53
+ }
54
+ if (!(positioned.position && positioned.rotation)) return null
55
+ return {
56
+ kind: 'group',
57
+ children: [{ kind: 'circle', cx: 0, cy: 0, r: 1 }],
58
+ transform: {
59
+ translate: [positioned.position[0], positioned.position[2]],
60
+ rotate: positioned.rotation[1],
61
+ },
62
+ }
63
+ },
64
+ } as AnyNodeDefinition)
65
+ })
66
+
67
+ afterEach(() => restoreRegistry())
68
+
69
+ test('collects only direct children of the active Site', () => {
70
+ const activeSite = {
71
+ id: 'site_active',
72
+ type: 'site',
73
+ parentId: null,
74
+ children: ['overlay_declared'],
75
+ } as unknown as AnyNode
76
+ const nodes = {
77
+ [activeSite.id]: activeSite,
78
+ overlay_declared: {
79
+ id: 'overlay_declared',
80
+ type: 'test:site-overlay',
81
+ parentId: null,
82
+ } as unknown as AnyNode,
83
+ overlay_parented: {
84
+ id: 'overlay_parented',
85
+ type: 'test:site-overlay',
86
+ parentId: activeSite.id,
87
+ } as unknown as AnyNode,
88
+ overlay_other_site: {
89
+ id: 'overlay_other_site',
90
+ type: 'test:site-overlay',
91
+ parentId: 'site_other',
92
+ } as unknown as AnyNode,
93
+ }
94
+
95
+ expect(
96
+ collectDirectFloorplanScopeNodes(nodes, activeSite, 'site').map((node) => String(node.id)),
97
+ ).toEqual(['overlay_declared', 'overlay_parented'])
98
+ })
99
+ test('projects site-local coordinates through the inverse Three.js building transform', () => {
100
+ const transform = siteToFloorplanTransform([10, 0, 5], Math.PI / 2)
101
+ const [tx, ty] = transform.translate
102
+ const sitePoint = [10, 3] as const
103
+ const cos = Math.cos(transform.rotate)
104
+ const sin = Math.sin(transform.rotate)
105
+
106
+ expect(tx + sitePoint[0] * cos - sitePoint[1] * sin).toBeCloseTo(2)
107
+
108
+ expect(ty + sitePoint[0] * sin + sitePoint[1] * cos).toBeCloseTo(0)
109
+ })
110
+
111
+ test('rebuilds site geometry from a live pose and restores the committed pose when cleared', () => {
112
+ const site = {
113
+ id: 'site_active',
114
+ type: 'site',
115
+ parentId: null,
116
+ children: ['overlay_pond'],
117
+ visible: true,
118
+ } as unknown as AnyNode
119
+ const overlay = {
120
+ id: 'overlay_pond',
121
+ type: 'test:site-overlay',
122
+ parentId: site.id,
123
+ children: [],
124
+ visible: true,
125
+ position: [1, 0, 2],
126
+ rotation: [0, 0.25, 0],
127
+ } as unknown as AnyNode
128
+ const nodes = {
129
+ [site.id]: site,
130
+ [overlay.id]: overlay,
131
+ }
132
+ const geometryCache = new Map()
133
+ const liveOverrides = new Map<string, LiveNodeOverrides>()
134
+ const siteProjection = siteToFloorplanTransform([10, 0, 5], Math.PI / 2)
135
+ const common = {
136
+ automaticDimensions: false,
137
+ ctxOverrides: {
138
+ children: [],
139
+ siblings: [],
140
+ parent: site,
141
+ outputTransform: siteProjection,
142
+ trackAllNodes: true,
143
+ },
144
+ geometryCache,
145
+ highlighted: false,
146
+ hovered: false,
147
+ interactiveElevators: {},
148
+ levelDataCache: new Map(),
149
+ levelNodeIdsByType: new Map(),
150
+ liveOverride: undefined,
151
+ liveOverrides,
152
+ moving: true,
153
+ node: overlay,
154
+ nodeId: overlay.id,
155
+ nodes,
156
+ palette: undefined,
157
+ selected: false,
158
+ siblingEpoch: 0,
159
+ unit: 'metric' as const,
160
+ metricNotation: 'meters' as const,
161
+ wallDimensionReference: 'finished-faces' as const,
162
+ visibilityRootId: site.id,
163
+ }
164
+ const committedSnapshot = structuredClone(overlay)
165
+ const livePose = {
166
+ position: [4, 0, 5] as [number, number, number],
167
+ rotation: Math.PI / 3,
168
+ }
169
+
170
+ const liveEntry = buildFloorplanEntryGeometry({ ...common, live: livePose })
171
+
172
+ expect(liveEntry?.node).toMatchObject({
173
+ position: livePose.position,
174
+ rotation: [0, livePose.rotation, 0],
175
+ parentId: null,
176
+ })
177
+ expect(liveEntry?.base).toEqual({
178
+ kind: 'group',
179
+ children: [
180
+ {
181
+ kind: 'group',
182
+ children: [{ kind: 'circle', cx: 0, cy: 0, r: 1 }],
183
+ transform: { translate: [4, 5], rotate: livePose.rotation },
184
+ },
185
+ ],
186
+ transform: siteProjection,
187
+ })
188
+ expect(overlay).toEqual(committedSnapshot)
189
+
190
+ const committedEntry = buildFloorplanEntryGeometry({ ...common, live: undefined })
191
+
192
+ expect(committedEntry).not.toBe(liveEntry)
193
+ expect(committedEntry?.node).toBe(overlay)
194
+ expect(committedEntry?.base).toEqual({
195
+ kind: 'group',
196
+ children: [
197
+ {
198
+ kind: 'group',
199
+ children: [{ kind: 'circle', cx: 0, cy: 0, r: 1 }],
200
+ transform: { translate: [1, 2], rotate: 0.25 },
201
+ },
202
+ ],
203
+ transform: siteProjection,
204
+ })
205
+ expect(overlay).toEqual(committedSnapshot)
206
+ })
207
+ })
208
+
32
209
  describe('floorplan selection handle sizing', () => {
33
210
  test('caps visual handle growth at extreme zoom-out', () => {
34
211
  expect(resolveFloorplanHandleUnitsPerPixel(0.01)).toBe(0.01)
@@ -447,12 +624,20 @@ describe('floorplan annotation overlay routing', () => {
447
624
  })
448
625
 
449
626
  describe('computeAffectedSiblingIds', () => {
627
+ // The cabinet fixture definitions have no `capabilities` — leaking them
628
+ // past this describe crashes any later test FILE that enumerates the
629
+ // registry (night-8 CI: pointer-support-cap.test.ts, run 32580694134).
630
+ let restoreRegistry: () => void
631
+
450
632
  beforeEach(() => {
633
+ restoreRegistry = nodeRegistry._snapshot()
451
634
  nodeRegistry._reset()
452
635
  registerCabinetFloorplanDefinition('cabinet')
453
636
  registerCabinetFloorplanDefinition('cabinet-module')
454
637
  })
455
638
 
639
+ afterEach(() => restoreRegistry())
640
+
456
641
  test('propagates cabinet live overrides through the cabinet family', () => {
457
642
  const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner'])
458
643
  const module = cabinetModule('cabinet-module_main', run.id)
@@ -545,6 +730,16 @@ describe('collectFloorplanDependencyNodes', () => {
545
730
  })
546
731
 
547
732
  describe('collectFloorplanLinkedLevelNodes', () => {
733
+ // Same containment as computeAffectedSiblingIds above: the fixture
734
+ // definition has no `capabilities`, so it must not outlive this describe.
735
+ let restoreRegistry: () => void
736
+
737
+ beforeEach(() => {
738
+ restoreRegistry = nodeRegistry._snapshot()
739
+ })
740
+
741
+ afterEach(() => restoreRegistry())
742
+
548
743
  test('projects a node onto a linked destination level with its real children', () => {
549
744
  nodeRegistry._reset()
550
745
  registerNode({