@pascal-app/editor 1.0.0-beta.5 → 1.0.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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -27,6 +27,7 @@ import {
27
27
  prepareFreshPlacementRootDuplicate,
28
28
  } from '../../lib/fresh-planar-placement'
29
29
  import { curveReshapeScope } from '../../lib/interaction/scope'
30
+ import { duplicateWithoutMove, registryMoveDisabled } from '../../lib/node-action-movement'
30
31
  import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
31
32
  import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
32
33
  import { sfxEmitter } from '../../lib/sfx-bus'
@@ -250,7 +251,8 @@ export function FloorplanRegistryActionMenu() {
250
251
  // walls land on their bespoke `MoveWallTool` (perpendicular slide
251
252
  // with linked-wall cascade) via `affordanceTools.move`.
252
253
  const canMove =
253
- !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
254
+ !registryMoveDisabled(node) &&
255
+ (!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move)
254
256
  const canDuplicate = def.capabilities.duplicable !== false
255
257
  const canDelete = def.capabilities.deletable !== false
256
258
  const canAddHole = node.type === 'slab' || node.type === 'ceiling'
@@ -320,6 +322,15 @@ export function FloorplanRegistryActionMenu() {
320
322
  const handleDuplicate = () => {
321
323
  if (!node.parentId) return
322
324
  sfxEmitter.emit('sfx:item-pick')
325
+ if (registryMoveDisabled(node)) {
326
+ try {
327
+ const id = duplicateWithoutMove(node)
328
+ if (id) useViewer.getState().setSelection({ selectedIds: [id] })
329
+ } catch (error) {
330
+ console.error('Failed to duplicate node', error)
331
+ }
332
+ return
333
+ }
323
334
  useScene.temporal.getState().pause()
324
335
  let draftId: AnyNodeId | null = null
325
336
  try {
@@ -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
  })