@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,64 @@
1
+ export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet'
2
+ export type ContinuationMode = string
3
+
4
+ export const CONTINUATION_PROFILES: Record<
5
+ ContinuationContext,
6
+ {
7
+ options: ContinuationMode[]
8
+ default: ContinuationMode
9
+ labels: Record<string, string>
10
+ icons: Record<string, string>
11
+ }
12
+ > = {
13
+ wall: {
14
+ options: ['room', 'single'],
15
+ default: 'room',
16
+ labels: { room: 'Room (auto-close)', single: 'Single wall' },
17
+ icons: { room: 'lucide:square', single: 'lucide:minus' },
18
+ },
19
+ fence: {
20
+ options: ['single', 'continuous', 'curved'],
21
+ default: 'continuous',
22
+ labels: {
23
+ continuous: 'Continuous',
24
+ single: 'Single fence',
25
+ curved: 'Curved fence',
26
+ },
27
+ icons: {
28
+ continuous: 'lucide:waypoints',
29
+ single: 'lucide:minus',
30
+ curved: 'lucide:spline',
31
+ },
32
+ },
33
+ point: {
34
+ options: ['once', 'repeat'],
35
+ default: 'once',
36
+ labels: { once: 'Place once', repeat: 'Place multiple' },
37
+ icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' },
38
+ },
39
+ cabinet: {
40
+ options: ['single', 'continuous'],
41
+ default: 'single',
42
+ labels: { single: 'Single cabinet', continuous: 'Continuous run' },
43
+ icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' },
44
+ },
45
+ }
46
+
47
+ const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column'])
48
+
49
+ export function nextContinuation(
50
+ context: ContinuationContext,
51
+ current: ContinuationMode,
52
+ ): ContinuationMode {
53
+ const profile = CONTINUATION_PROFILES[context]
54
+ const index = profile.options.indexOf(current)
55
+ if (index === -1) return profile.default
56
+ return profile.options[(index + 1) % profile.options.length] ?? profile.default
57
+ }
58
+
59
+ export function continuationContextOf(kind: string): ContinuationContext | null {
60
+ if (kind === 'wall') return 'wall'
61
+ if (kind === 'fence') return 'fence'
62
+ if (kind === 'cabinet') return 'cabinet'
63
+ return POINT_KINDS.has(kind) ? 'point' : null
64
+ }
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ DEFAULT_ANGLE_STEP,
6
+ nodeRegistry,
7
+ registerNode,
8
+ } from '@pascal-app/core'
9
+ import { z } from 'zod'
10
+ import {
11
+ canDirectMoveNode,
12
+ resolveDirectManipulationNode,
13
+ resolveDirectRotationDragDelta,
14
+ snapDirectRotationDelta,
15
+ } from './direct-manipulation'
16
+
17
+ function registerTestDefinition(kind: string, overrides: Partial<AnyNodeDefinition>) {
18
+ if (nodeRegistry.has(kind)) return
19
+ registerNode({
20
+ kind,
21
+ schemaVersion: 1,
22
+ schema: z.object({ type: z.literal(kind) }) as never,
23
+ category: 'structure',
24
+ defaults: () => ({ type: kind }) as never,
25
+ capabilities: {},
26
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
27
+ ...overrides,
28
+ } as AnyNodeDefinition)
29
+ }
30
+
31
+ describe('snapDirectRotationDelta', () => {
32
+ test('snaps rotation deltas to the default angle increment', () => {
33
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0)
34
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo(
35
+ DEFAULT_ANGLE_STEP,
36
+ )
37
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo(
38
+ -DEFAULT_ANGLE_STEP,
39
+ )
40
+ })
41
+
42
+ test('keeps the raw rotation delta while free-rotating', () => {
43
+ const rawDelta = DEFAULT_ANGLE_STEP * 0.42
44
+ expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta)
45
+ })
46
+ })
47
+
48
+ describe('resolveDirectRotationDragDelta', () => {
49
+ test('maps horizontal pointer motion to the direct rotation delta direction', () => {
50
+ const radiansPerPixel = DEFAULT_ANGLE_STEP / 12
51
+
52
+ expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo(
53
+ -DEFAULT_ANGLE_STEP,
54
+ )
55
+ expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo(
56
+ DEFAULT_ANGLE_STEP,
57
+ )
58
+ })
59
+
60
+ test('keeps unsnapped drag deltas while free-rotating', () => {
61
+ expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3)
62
+ })
63
+ })
64
+
65
+ describe('canDirectMoveNode', () => {
66
+ // Accepts kinds with a 3D-mountable move tool (`movable` or
67
+ // `affordanceTools.move`); floorplan-only movers (zone) are excluded.
68
+ test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
69
+ const kind = 'direct-move-floorplan-only-test'
70
+ registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
71
+
72
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
73
+ })
74
+
75
+ test('rejects MEP kinds that own move through bespoke selection affordances', () => {
76
+ for (const kind of [
77
+ 'duct-segment',
78
+ 'duct-fitting',
79
+ 'pipe-segment',
80
+ 'pipe-fitting',
81
+ 'lineset',
82
+ 'liquid-line',
83
+ ]) {
84
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
85
+ }
86
+ })
87
+
88
+ test('accepts kinds with a bespoke move tool', () => {
89
+ const kind = 'direct-move-bespoke-tool-test'
90
+ registerTestDefinition(kind, {
91
+ affordanceTools: {
92
+ move: async () => ({ default: () => null }),
93
+ } as never,
94
+ })
95
+
96
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
97
+ })
98
+
99
+ test('accepts nodes with the generic movable capability', () => {
100
+ const kind = 'direct-move-movable-test'
101
+ registerTestDefinition(kind, {
102
+ capabilities: {
103
+ movable: { axes: ['x', 'z'], gridSnap: true },
104
+ },
105
+ } as Partial<AnyNodeDefinition>)
106
+
107
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
108
+ })
109
+
110
+ test('rejects kinds with no registered move path', () => {
111
+ const kind = 'direct-move-none-test'
112
+ registerTestDefinition(kind, {})
113
+
114
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
115
+ })
116
+ })
117
+
118
+ describe('resolveDirectManipulationNode', () => {
119
+ test('routes proxied members to their assembly for direct transforms', () => {
120
+ const group = {
121
+ id: 'direct_manipulation_group',
122
+ type: 'direct-manipulation-group-test',
123
+ } as unknown as AnyNode
124
+ const member = {
125
+ id: 'direct_manipulation_member',
126
+ type: 'direct-manipulation-member-test',
127
+ metadata: { nodeSelectionProxyId: group.id },
128
+ } as unknown as AnyNode
129
+
130
+ expect(
131
+ resolveDirectManipulationNode(member, {
132
+ [group.id]: group,
133
+ [member.id]: member,
134
+ }),
135
+ ).toBe(group)
136
+ })
137
+
138
+ test('falls back to the selected node when the proxy target is missing', () => {
139
+ const member = {
140
+ id: 'direct_manipulation_orphan_member',
141
+ type: 'direct-manipulation-member-test',
142
+ metadata: { nodeSelectionProxyId: 'missing_group' },
143
+ } as unknown as AnyNode
144
+
145
+ expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member)
146
+ })
147
+
148
+ test('routes parent-frame children to their rotatable parent', () => {
149
+ const parentKind = 'direct-manipulation-parent-frame-parent-test'
150
+ const childKind = 'direct-manipulation-parent-frame-child-test'
151
+ registerTestDefinition(parentKind, {
152
+ capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
153
+ })
154
+ registerTestDefinition(childKind, {
155
+ capabilities: {
156
+ movable: {
157
+ axes: ['x', 'z'],
158
+ gridSnap: true,
159
+ parentFrame: {
160
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
161
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
162
+ parentRotationY: () => 0,
163
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
164
+ local[0],
165
+ local[1],
166
+ local[2],
167
+ ],
168
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
169
+ planX,
170
+ localY,
171
+ planZ,
172
+ ],
173
+ },
174
+ },
175
+ },
176
+ })
177
+
178
+ const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode
179
+ const child = {
180
+ id: 'direct_manipulation_child',
181
+ type: childKind,
182
+ parentId: parent.id,
183
+ } as unknown as AnyNode
184
+
185
+ expect(
186
+ resolveDirectManipulationNode(child, {
187
+ [parent.id]: parent,
188
+ [child.id]: child,
189
+ }),
190
+ ).toBe(parent)
191
+ })
192
+ })
@@ -0,0 +1,109 @@
1
+ import {
2
+ type AnyNode,
3
+ type ArcResizeHandle,
4
+ createSceneApi,
5
+ DEFAULT_ANGLE_STEP,
6
+ type HandleDescriptor,
7
+ hasRegistry3DMoveTool,
8
+ isMovable,
9
+ nodeRegistry,
10
+ resolveSelectionProxyId,
11
+ type SceneApi,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+
15
+ function resolveHandles(node: AnyNode): HandleDescriptor<AnyNode>[] {
16
+ const handles = nodeRegistry.get(node.type)?.handles
17
+ if (!handles) return []
18
+ return (
19
+ typeof handles === 'function' ? handles(node as never) : handles
20
+ ) as HandleDescriptor<AnyNode>[]
21
+ }
22
+
23
+ export function getDirectRotateHandle(node: AnyNode): ArcResizeHandle<AnyNode> | null {
24
+ for (const handle of resolveHandles(node)) {
25
+ if (handle.kind === 'arc-resize' && handle.shape === 'rotate') {
26
+ return handle as ArcResizeHandle<AnyNode>
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
32
+ export function canDirectRotateNode(node: AnyNode): boolean {
33
+ return (
34
+ getDirectRotateHandle(node) !== null ||
35
+ nodeRegistry.get(node.type)?.capabilities?.rotatable !== undefined
36
+ )
37
+ }
38
+
39
+ const BESPOKE_SELECTION_MOVE_KINDS = new Set([
40
+ 'duct-segment',
41
+ 'duct-fitting',
42
+ 'pipe-segment',
43
+ 'pipe-fitting',
44
+ 'lineset',
45
+ 'liquid-line',
46
+ ])
47
+
48
+ export function canDirectMoveNode(node: AnyNode): boolean {
49
+ // These MEP kinds own move through bespoke selection rigs (latch cubes,
50
+ // directional arrows, grid-driven previews). Sending body drags/clicks
51
+ // through the generic direct-move handoff conflicts with that path and can
52
+ // leave the editor appearing frozen while their mover waits for the wrong
53
+ // gesture stream.
54
+ if (BESPOKE_SELECTION_MOVE_KINDS.has(node.type)) return false
55
+ // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
56
+ // mounts in 3D — distinct from `isRegistryMovable`, which also accepts
57
+ // floorplan-only movers (zone) for the 2D plan.
58
+ if (!hasRegistry3DMoveTool(node.type)) return false
59
+ // Bespoke movers (`affordanceTools.move`) own their constraints and often
60
+ // deliberately omit `capabilities.movable` — only gate registry-movable
61
+ // kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet
62
+ // run locked behind its selection proxy) can opt out of direct move.
63
+ if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true
64
+ return isMovable(node)
65
+ }
66
+
67
+ export function resolveDirectManipulationNode(
68
+ node: AnyNode,
69
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
70
+ ): AnyNode {
71
+ const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node
72
+ const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
73
+ const parent = parentFrame?.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
74
+ return parent && canDirectRotateNode(parent) ? parent : target
75
+ }
76
+
77
+ export function snapDirectRotationDelta(delta: number, free: boolean): number {
78
+ return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
79
+ }
80
+
81
+ export function resolveDirectRotationDragDelta(
82
+ startX: number,
83
+ clientX: number,
84
+ radiansPerPixel: number,
85
+ free: boolean,
86
+ ): number {
87
+ return snapDirectRotationDelta((startX - clientX) * radiansPerPixel, free)
88
+ }
89
+
90
+ export function resolveDirectRotationPatch(
91
+ node: AnyNode,
92
+ delta: number,
93
+ sceneApi: SceneApi = createSceneApi(useScene),
94
+ ): Partial<AnyNode> | null {
95
+ const rotateHandle = getDirectRotateHandle(node)
96
+ if (rotateHandle) {
97
+ return rotateHandle.apply(node, delta, sceneApi) as Partial<AnyNode>
98
+ }
99
+
100
+ const rotation = (node as { rotation?: unknown }).rotation
101
+ if (typeof rotation === 'number') {
102
+ return { rotation: rotation - delta } as Partial<AnyNode>
103
+ }
104
+ if (Array.isArray(rotation)) {
105
+ const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?]
106
+ return { rotation: [rx, ry - delta, rz] } as Partial<AnyNode>
107
+ }
108
+ return null
109
+ }
@@ -1,33 +1,20 @@
1
- import type { AnyNode, EditorApi, FenceNode, WallNode } from '@pascal-app/core'
1
+ import type { AnyNode, EditorApi } from '@pascal-app/core'
2
2
  import useEditor from '../store/use-editor'
3
-
4
- type EditorState = ReturnType<typeof useEditor.getState>
5
- type EndpointEngager = (node: AnyNode, endpoint: 'start' | 'end', editor: EditorState) => void
3
+ import useInteractionScope from '../store/use-interaction-scope'
4
+ import {
5
+ controlPointReshapeScope,
6
+ endpointReshapeScope,
7
+ tangentReshapeScope,
8
+ } from './interaction/scope'
6
9
 
7
10
  /**
8
- * Per-kind endpoint-move engagement. Kinds whose 2D endpoint drag
9
- * needs its own store field (wall `movingWallEndpoint`, fence
10
- * `movingFenceEndpoint`) register their bridge here. The dispatcher
11
- * is a table lookup rather than an `if (type === 'wall')` chain so
12
- * adding a new endpoint-draggable kind is a one-line entry instead
13
- * of a new branch. Each entry casts the generic `AnyNode` to its
14
- * concrete kind — the lookup key already guarantees the type.
15
- */
16
- const endpointEngagers: Record<string, EndpointEngager> = {
17
- wall: (node, endpoint, editor) =>
18
- editor.setMovingWallEndpoint({ wall: node as WallNode, endpoint }),
19
- fence: (node, endpoint, editor) =>
20
- editor.setMovingFenceEndpoint({ fence: node as FenceNode, endpoint }),
21
- }
22
-
23
- /**
24
- * Concrete {@link EditorApi} backed by `useEditor`. Descriptors call into
25
- * editor state through this interface; the editor owns the actual setter
26
- * names so core stays decoupled.
11
+ * Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
12
+ * Descriptors call into editor state through this interface; the editor owns
13
+ * the actual store wiring so core stays decoupled.
27
14
  *
28
- * `engageMove` clears any in-progress endpoint drag or curve gesture so
29
- * the move tool takes over cleanly mirrors the legacy bookkeeping that
30
- * lived inside `WallMoveArrowHandle.activateWallMove` / `FenceMoveArrowHandle`.
15
+ * `engageMove` no longer clears any in-progress endpoint drag or curve gesture:
16
+ * `setMovingNode` begins the `moving` scope, and the scope is single-owner, so
17
+ * it atomically replaces any prior reshape — there is no separate flag to reset.
31
18
  */
32
19
  export function createEditorApi(): EditorApi {
33
20
  return {
@@ -39,10 +26,6 @@ export function createEditorApi(): EditorApi {
39
26
  // cast lets registry-driven move kinds through without forcing a
40
27
  // schema-level type widening.
41
28
  editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
42
- editor.setMovingWallEndpoint(null)
43
- editor.setMovingFenceEndpoint(null)
44
- editor.setCurvingWall(null)
45
- editor.setCurvingFence(null)
46
29
  },
47
30
  engageMoveDrag(node: AnyNode) {
48
31
  const editor = useEditor.getState()
@@ -50,13 +33,18 @@ export function createEditorApi(): EditorApi {
50
33
  // it at setup and wires its commit-on-release listener.
51
34
  editor.setPlacementDragMode(true)
52
35
  editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
53
- editor.setMovingWallEndpoint(null)
54
- editor.setMovingFenceEndpoint(null)
55
- editor.setCurvingWall(null)
56
- editor.setCurvingFence(null)
57
36
  },
58
37
  engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
59
- endpointEngagers[node.type]?.(node, endpoint, useEditor.getState())
38
+ // Endpoint reshape is kind-agnostic: the scope carries the node id + which
39
+ // endpoint, and consumers recover the node from the scene. Adding a new
40
+ // endpoint-draggable kind needs no entry here.
41
+ useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
42
+ },
43
+ engageControlPointMove(node: AnyNode, index: number) {
44
+ useInteractionScope.getState().begin(controlPointReshapeScope(node.id, index))
45
+ },
46
+ engageTangentMove(node: AnyNode, index: number, side: 'in' | 'out') {
47
+ useInteractionScope.getState().begin(tangentReshapeScope(node.id, index, side))
60
48
  },
61
49
  }
62
50
  }
@@ -0,0 +1,25 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import useAlignmentGuides from '../../store/use-alignment-guides'
3
+ import { applyFloorplanAlignment } from './apply-alignment'
4
+
5
+ describe('applyFloorplanAlignment', () => {
6
+ afterEach(() => {
7
+ useAlignmentGuides.getState().clear()
8
+ })
9
+
10
+ test('can publish passive guides without applying snap', () => {
11
+ useAlignmentGuides.getState().clear()
12
+
13
+ const result = applyFloorplanAlignment(
14
+ [0.04, 2],
15
+ [{ nodeId: 'draft', kind: 'corner', x: 0.04, z: 2 }],
16
+ [{ nodeId: 'wall_a', kind: 'corner', x: 0, z: 0 }],
17
+ { applySnap: false },
18
+ )
19
+
20
+ expect(result.point).toEqual([0.04, 2])
21
+ expect(result.snapped).toBe(false)
22
+ expect(result.guides).toHaveLength(1)
23
+ expect(useAlignmentGuides.getState().guides).toHaveLength(1)
24
+ })
25
+ })
@@ -2,10 +2,10 @@ import {
2
2
  type AlignmentAnchor,
3
3
  type AlignmentGuide,
4
4
  collectAlignmentAnchors,
5
- resolveAlignment,
6
5
  useScene,
7
6
  } from '@pascal-app/core'
8
- import { useAlignmentGuides } from '@pascal-app/editor'
7
+ import useAlignmentGuides from '../../store/use-alignment-guides'
8
+ import { resolveAlignmentForFloorplanView } from '../world-grid-snap'
9
9
 
10
10
  /**
11
11
  * Fixed Figma-style alignment threshold (meters) for floor-plan placement /
@@ -30,9 +30,9 @@ export type FloorplanAlignmentResult = {
30
30
  *
31
31
  * Publishes guides to the `useAlignmentGuides` store as a side effect — set
32
32
  * on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
33
- * renders them. Returns the adjusted point. When `bypass` is true (Alt held)
34
- * the point is returned unchanged and guides are cleared, matching the
35
- * "No snap" affordance the placement tools advertise.
33
+ * renders them. Returns the adjusted point. When `bypass` is true (Alt for
34
+ * alignment-only bypass, or Shift for the full guided-constraint bypass) the
35
+ * point is returned unchanged and guides are cleared.
36
36
  *
37
37
  * `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
38
38
  * the scene is stable during a single drag, so re-collecting per pointer-move
@@ -42,14 +42,14 @@ export function applyFloorplanAlignment(
42
42
  point: readonly [number, number],
43
43
  movingAnchors: AlignmentAnchor[],
44
44
  candidates: AlignmentAnchor[],
45
- opts?: { bypass?: boolean; threshold?: number },
45
+ opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
46
46
  ): FloorplanAlignmentResult {
47
47
  if (opts?.bypass) {
48
48
  useAlignmentGuides.getState().clear()
49
49
  return { point: [point[0], point[1]], snapped: false, guides: [] }
50
50
  }
51
51
 
52
- const result = resolveAlignment({
52
+ const result = resolveAlignmentForFloorplanView({
53
53
  moving: movingAnchors,
54
54
  candidates,
55
55
  threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
@@ -57,7 +57,7 @@ export function applyFloorplanAlignment(
57
57
 
58
58
  useAlignmentGuides.getState().set(result.guides)
59
59
 
60
- if (!result.snap) {
60
+ if (!result.snap || opts?.applySnap === false) {
61
61
  return { point: [point[0], point[1]], snapped: false, guides: result.guides }
62
62
  }
63
63
  return {
@@ -81,8 +81,9 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
81
81
  *
82
82
  * Used by BOTH the move-preview branch and the click-commit handler so the
83
83
  * committed vertex lands exactly where the preview showed it. Caller owns the
84
- * per-kind precedence (existing-wall endpoint/join snap wins; angle-snap
85
- * suppresses alignment) and only calls this when alignment should apply.
84
+ * per-kind precedence: existing-wall endpoint/join snap can still win, while
85
+ * angle-locked segments can pass `applySnap: false` to publish passive guide
86
+ * feedback without pulling the endpoint off its constrained ray.
86
87
  *
87
88
  * `excludeIds` drops those nodes' anchors from the candidate pool — used when
88
89
  * dragging a wall / fence endpoint so the moving endpoint doesn't try to
@@ -90,7 +91,12 @@ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
90
91
  */
91
92
  export function alignFloorplanDraftPoint(
92
93
  point: readonly [number, number],
93
- opts?: { bypass?: boolean; threshold?: number; excludeIds?: readonly string[] },
94
+ opts?: {
95
+ applySnap?: boolean
96
+ bypass?: boolean
97
+ threshold?: number
98
+ excludeIds?: readonly string[]
99
+ },
94
100
  ): [number, number] {
95
101
  if (opts?.bypass) {
96
102
  useAlignmentGuides.getState().clear()
@@ -105,7 +111,7 @@ export function alignFloorplanDraftPoint(
105
111
  point,
106
112
  [{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
107
113
  candidates,
108
- { threshold: opts?.threshold },
114
+ { applySnap: opts?.applySnap, threshold: opts?.threshold },
109
115
  )
110
116
  return snapped
111
117
  }