@pascal-app/editor 1.0.1 → 1.0.3

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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -0,0 +1,19 @@
1
+ import { type Matrix4, Plane, type Ray, Vector3 } from 'three'
2
+
3
+ export function createSpatialDragPlane(point: Vector3, ray: Ray, localToWorld?: Matrix4) {
4
+ const normal = new Vector3(0, 1, 0)
5
+ if (localToWorld) normal.transformDirection(localToWorld)
6
+ // A nearly horizontal controller ray cannot reliably intersect a floor plane.
7
+ if (Math.abs(normal.dot(ray.direction)) < 0.2) normal.copy(ray.direction)
8
+ return new Plane().setFromNormalAndCoplanarPoint(normal, point)
9
+ }
10
+
11
+ export function intersectSpatialDragPlane(ray: Ray, plane: Plane, target: Vector3) {
12
+ if (Math.abs(plane.normal.dot(ray.direction)) < 0.05) return null
13
+ return ray.intersectPlane(plane, target)
14
+ }
15
+
16
+ // Freeze this frame at grab time; world metres differ from model metres in God view.
17
+ export function spatialDragLocalY(worldPoint: Vector3, worldToLocal: Matrix4) {
18
+ return worldPoint.clone().applyMatrix4(worldToLocal).y
19
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Ray, Vector3 } from 'three'
3
+ import { getSpatialPointerId, SpatialPointerInput } from './spatial-pointer-input'
4
+
5
+ describe('SpatialPointerInput', () => {
6
+ test('recognizes current and legacy XR native event shapes', () => {
7
+ const source = {}
8
+ expect(getSpatialPointerId({ inputSource: source })).toBe(source)
9
+ expect(getSpatialPointerId({ pointerState: { inputSource: source } })).toBe(source)
10
+ expect(getSpatialPointerId({ pointerType: 'mouse' })).toBeNull()
11
+ })
12
+
13
+ test('keeps move and release bound to the pointer that captured a handle', () => {
14
+ const input = new SpatialPointerInput()
15
+ const moves: Ray[] = []
16
+ let releases = 0
17
+ input.capture(42, {
18
+ onMove: (ray) => moves.push(ray.clone()),
19
+ onRelease: () => releases++,
20
+ onCancel: () => undefined,
21
+ })
22
+ const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 1, 0))
23
+ expect(input.move(7, ray)).toBe(false)
24
+ expect(input.move(42, ray)).toBe(true)
25
+ expect(input.release(7)).toBe(false)
26
+ expect(input.release(42)).toBe(true)
27
+ expect(moves).toHaveLength(1)
28
+ expect(moves[0]?.origin.toArray()).toEqual([1, 2, 3])
29
+ expect(releases).toBe(1)
30
+ expect(input.move(42, ray)).toBe(false)
31
+ })
32
+
33
+ test('cancels a captured handle without releasing it', () => {
34
+ const input = new SpatialPointerInput()
35
+ let cancels = 0
36
+ let releases = 0
37
+
38
+ input.capture(9, {
39
+ onMove: () => undefined,
40
+ onRelease: () => releases++,
41
+ onCancel: () => cancels++,
42
+ })
43
+
44
+ expect(input.cancel(9)).toBe(true)
45
+ expect(cancels).toBe(1)
46
+ expect(releases).toBe(0)
47
+ })
48
+
49
+ test('cleans up a replaced capture without letting its stale disposer clear the replacement', () => {
50
+ const input = new SpatialPointerInput()
51
+ const ray = new Ray(new Vector3(), new Vector3(0, 0, -1))
52
+ let replacements = 0
53
+ let replacementMoves = 0
54
+
55
+ const disposeOriginal = input.capture(12, {
56
+ onMove: () => undefined,
57
+ onRelease: () => undefined,
58
+ onCancel: () => undefined,
59
+ onReplace: () => replacements++,
60
+ })
61
+ input.capture(12, {
62
+ onMove: () => replacementMoves++,
63
+ onRelease: () => undefined,
64
+ onCancel: () => undefined,
65
+ })
66
+
67
+ expect(replacements).toBe(1)
68
+ disposeOriginal()
69
+ expect(input.move(12, ray)).toBe(true)
70
+ expect(replacementMoves).toBe(1)
71
+ })
72
+ })
@@ -0,0 +1,62 @@
1
+ import type { Ray } from 'three'
2
+
3
+ type SpatialPointerCapture = {
4
+ onMove: (ray: Ray) => void
5
+ onRelease: () => void
6
+ onCancel: () => void
7
+ onReplace?: () => void
8
+ }
9
+
10
+ export type SpatialPointerId = object | number | string
11
+
12
+ export function getSpatialPointerId(nativeEvent: unknown): SpatialPointerId | null {
13
+ if (!nativeEvent || typeof nativeEvent !== 'object') return null
14
+ const event = nativeEvent as {
15
+ inputSource?: object
16
+ pointerState?: { inputSource?: object }
17
+ }
18
+ return event.inputSource ?? event.pointerState?.inputSource ?? null
19
+ }
20
+
21
+ export class SpatialPointerInput {
22
+ private readonly captures = new Map<SpatialPointerId, SpatialPointerCapture>()
23
+
24
+ capture(pointerId: SpatialPointerId, capture: SpatialPointerCapture): () => void {
25
+ const previous = this.captures.get(pointerId)
26
+ if (previous && previous !== capture) {
27
+ this.captures.delete(pointerId)
28
+ ;(previous.onReplace ?? previous.onCancel)()
29
+ }
30
+ this.captures.set(pointerId, capture)
31
+ return () => {
32
+ if (this.captures.get(pointerId) === capture) {
33
+ this.captures.delete(pointerId)
34
+ }
35
+ }
36
+ }
37
+
38
+ move(pointerId: SpatialPointerId, ray: Ray): boolean {
39
+ const capture = this.captures.get(pointerId)
40
+ if (!capture) return false
41
+ capture.onMove(ray)
42
+ return true
43
+ }
44
+
45
+ release(pointerId: SpatialPointerId): boolean {
46
+ const capture = this.captures.get(pointerId)
47
+ if (!capture) return false
48
+ this.captures.delete(pointerId)
49
+ capture.onRelease()
50
+ return true
51
+ }
52
+
53
+ cancel(pointerId: SpatialPointerId): boolean {
54
+ const capture = this.captures.get(pointerId)
55
+ if (!capture) return false
56
+ this.captures.delete(pointerId)
57
+ capture.onCancel()
58
+ return true
59
+ }
60
+ }
61
+
62
+ export const spatialPointerInput = new SpatialPointerInput()
@@ -0,0 +1,47 @@
1
+ import { test } from 'bun:test'
2
+ import assert from 'node:assert/strict'
3
+ import { snapLocalXZInWorld } from '@pascal-app/core'
4
+ import { Group, Vector3 } from 'three'
5
+
6
+ const close = (a: number, b: number) => assert.ok(Math.abs(a - b) < 1e-8)
7
+
8
+ test('free placement stays under the same hit through God-mode zoom and rotation', () => {
9
+ const pose = { position: [5, 0, -3] as [number, number, number], rotationY: 0.7 }
10
+ const world = new Group()
11
+ const building = new Group()
12
+ building.position.fromArray(pose.position)
13
+ building.rotation.y = pose.rotationY
14
+ world.add(building)
15
+ for (const scale of [0.01, 0.1, 1, 4]) {
16
+ world.scale.setScalar(scale)
17
+ world.position.set(8, 1, -4)
18
+ world.rotation.y = 1.2
19
+ world.updateWorldMatrix(true, true)
20
+ const hit = building.localToWorld(new Vector3(2, 0, -6))
21
+ const local = building.worldToLocal(hit.clone())
22
+ const snapped = snapLocalXZInWorld([local.x, local.z], pose, (value) => value)
23
+ const placed = building.localToWorld(new Vector3(snapped[0], 0, snapped[1]))
24
+ close(placed.distanceTo(hit), 0)
25
+ }
26
+ })
27
+
28
+ test('grid snapping still uses model units and the item footprint', () => {
29
+ const dimensions: number[] = []
30
+ const result = snapLocalXZInWorld([1.23, -2.16], null, (value, axis) => {
31
+ dimensions.push([1.8, 0.6][axis]!)
32
+ return Math.round(value / 0.1) * 0.1
33
+ })
34
+ close(result[0], 1.2)
35
+ close(result[1], -2.2)
36
+ assert.deepEqual(dimensions, [1.8, 0.6])
37
+ })
38
+
39
+ test('rotated buildings snap in model-world axes then return building-local coordinates', () => {
40
+ const result = snapLocalXZInWorld(
41
+ [1.2, 2.3],
42
+ { position: [10, 0, 20], rotationY: Math.PI / 2 },
43
+ Math.round,
44
+ )
45
+ close(result[0], 1)
46
+ close(result[1], 2)
47
+ })
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { type SiteNode, type TerrainVerb, useScene } from '@pascal-app/core'
4
+ import useEditor from '../store/use-editor'
5
+ import type { PanelRow } from './panel-rows'
6
+ import { brushRadiusRange, flattenSite, resetSiteTerrain } from './terrain-sculpt'
7
+
8
+ const TERRAIN_VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth']
9
+
10
+ export function useTerrainPanelRows(): PanelRow[] {
11
+ const verb = useEditor((state) => state.terrainVerb)
12
+ const setVerb = useEditor((state) => state.setTerrainVerb)
13
+ const brush = useEditor((state) => state.terrainBrush)
14
+ const setBrush = useEditor((state) => state.setTerrainBrush)
15
+ const flattenTarget = useEditor((state) => state.terrainFlattenTarget)
16
+ const setFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget)
17
+ const sampling = useEditor((state) => state.terrainSampling)
18
+ const setSampling = useEditor((state) => state.setTerrainSampling)
19
+ const site = useScene((state) => {
20
+ const root = state.rootNodeIds[0]
21
+ const node = root ? state.nodes[root] : undefined
22
+ return node?.type === 'site' ? (node as SiteNode) : null
23
+ })
24
+ const [minRadius, maxRadius] = brushRadiusRange(site)
25
+ const verbIndex = TERRAIN_VERBS.indexOf(verb)
26
+ const cycleVerb = (direction: -1 | 1) => {
27
+ const next =
28
+ TERRAIN_VERBS[(verbIndex + direction + TERRAIN_VERBS.length) % TERRAIN_VERBS.length]
29
+ if (next) setVerb(next)
30
+ }
31
+
32
+ const rows: PanelRow[] = [
33
+ {
34
+ id: 'terrain-verb',
35
+ kind: 'cycle',
36
+ label: 'Brush',
37
+ next: () => cycleVerb(1),
38
+ previous: () => cycleVerb(-1),
39
+ value: verb.charAt(0).toUpperCase() + verb.slice(1),
40
+ },
41
+ {
42
+ id: 'terrain-radius',
43
+ kind: 'stepper',
44
+ label: 'Size',
45
+ max: maxRadius,
46
+ min: minRadius,
47
+ onChange: (radius) => setBrush({ radius }),
48
+ step: 0.5,
49
+ unit: 'm',
50
+ value: Math.max(minRadius, Math.min(maxRadius, brush.radius)),
51
+ },
52
+ {
53
+ id: 'terrain-strength',
54
+ kind: 'stepper',
55
+ label: 'Strength',
56
+ max: 1,
57
+ min: 0.05,
58
+ onChange: (strength) => setBrush({ strength }),
59
+ step: 0.05,
60
+ value: brush.strength,
61
+ },
62
+ {
63
+ id: 'terrain-softness',
64
+ kind: 'stepper',
65
+ label: 'Softness',
66
+ max: 1,
67
+ min: 0,
68
+ onChange: (falloff) => setBrush({ falloff }),
69
+ step: 0.05,
70
+ value: brush.falloff,
71
+ },
72
+ {
73
+ id: 'terrain-shape',
74
+ kind: 'choice',
75
+ label: 'Shape',
76
+ onSelect: () => setBrush({ shape: brush.shape === 'round' ? 'square' : 'round' }),
77
+ value: brush.shape === 'round' ? 'Round' : 'Square',
78
+ },
79
+ ...(verb === 'flatten'
80
+ ? ([
81
+ {
82
+ id: 'terrain-target',
83
+ kind: 'stepper',
84
+ label: 'Target',
85
+ max: 50,
86
+ min: -50,
87
+ onChange: setFlattenTarget,
88
+ step: 0.1,
89
+ unit: 'm',
90
+ value: flattenTarget ?? 0,
91
+ },
92
+ {
93
+ id: 'terrain-sampling',
94
+ kind: 'choice',
95
+ label: 'Pick height',
96
+ onSelect: () => setSampling(!sampling),
97
+ value: sampling ? 'Armed' : 'Off',
98
+ },
99
+ ] satisfies PanelRow[])
100
+ : []),
101
+ {
102
+ id: 'terrain-level-lot',
103
+ kind: 'choice',
104
+ label: 'Whole lot',
105
+ onSelect: site ? () => flattenSite(site, flattenTarget ?? 0) : undefined,
106
+ value: 'Level',
107
+ },
108
+ {
109
+ id: 'terrain-clear',
110
+ kind: 'choice',
111
+ label: 'Terrain',
112
+ onSelect: site?.terrain ? () => resetSiteTerrain(site) : undefined,
113
+ value: site?.terrain ? 'Clear' : 'Empty',
114
+ },
115
+ ]
116
+ return rows
117
+ }
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { BuildingNode, LevelNode, useScene } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { getActiveBuildingId } from './world-grid-snap'
5
+
6
+ const originalNodes = useScene.getState().nodes
7
+ const originalSelection = useViewer.getState().selection
8
+
9
+ afterEach(() => {
10
+ useScene.setState({ nodes: originalNodes } as never)
11
+ useViewer.setState({ selection: originalSelection } as never)
12
+ })
13
+
14
+ describe('getActiveBuildingId', () => {
15
+ test('prefers the active level owner over a stale selected building', () => {
16
+ const staleBuilding = BuildingNode.parse({})
17
+ const activeBuilding = BuildingNode.parse({})
18
+ const level = LevelNode.parse({ parentId: activeBuilding.id })
19
+ useScene.setState({
20
+ nodes: {
21
+ [staleBuilding.id]: staleBuilding,
22
+ [activeBuilding.id]: activeBuilding,
23
+ [level.id]: level,
24
+ },
25
+ } as never)
26
+ useViewer.setState({
27
+ selection: {
28
+ buildingId: staleBuilding.id,
29
+ levelId: level.id,
30
+ zoneId: null,
31
+ selectedIds: [],
32
+ },
33
+ } as never)
34
+
35
+ expect(getActiveBuildingId()).toBe(activeBuilding.id)
36
+ })
37
+
38
+ test('resolves legacy level ownership from the building children list', () => {
39
+ const level = LevelNode.parse({})
40
+ const building = BuildingNode.parse({ children: [level.id] })
41
+ useScene.setState({ nodes: { [building.id]: building, [level.id]: level } } as never)
42
+ useViewer.setState({
43
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
44
+ } as never)
45
+
46
+ expect(getActiveBuildingId()).toBe(building.id)
47
+ })
48
+ })
@@ -15,6 +15,7 @@ import {
15
15
  type BuildingPose,
16
16
  type ResolveAlignmentInBuildingResult,
17
17
  resolveAlignmentInBuildingWorld,
18
+ resolveBuildingForLevel,
18
19
  snapWorldXZToBuildingLocal,
19
20
  useLiveTransforms,
20
21
  useScene,
@@ -39,7 +40,7 @@ import { useViewer } from '@pascal-app/viewer'
39
40
  * guides drift off the visible grid mid-drag (and through any post-drag
40
41
  * frame where the live override is still set).
41
42
  */
42
- export function getActiveBuildingPose(): BuildingPose | null {
43
+ export function getActiveBuildingId(): AnyNodeId | null {
43
44
  const sel = useViewer.getState().selection
44
45
  const nodes = useScene.getState().nodes
45
46
  // Match `use-floorplan-scene-data.ts`: prefer the active level's
@@ -48,12 +49,15 @@ export function getActiveBuildingPose(): BuildingPose | null {
48
49
  // different level, the level path is the authoritative one.
49
50
  let buildingId: AnyNodeId | null = null
50
51
  if (sel.levelId) {
51
- const level = nodes[sel.levelId]
52
- if (level && level.type === 'level' && level.parentId) {
53
- buildingId = level.parentId as AnyNodeId
54
- }
52
+ buildingId = resolveBuildingForLevel(sel.levelId as AnyNodeId, nodes)
55
53
  }
56
54
  if (!buildingId) buildingId = sel.buildingId ?? null
55
+ return buildingId
56
+ }
57
+
58
+ export function getActiveBuildingPose(): BuildingPose | null {
59
+ const buildingId = getActiveBuildingId()
60
+ const nodes = useScene.getState().nodes
57
61
  const building = buildingId ? nodes[buildingId] : null
58
62
  if (building?.type !== 'building') return null
59
63
  const live = useLiveTransforms.getState().transforms.get(buildingId as string)
@@ -0,0 +1,53 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { GROUP_ROTATE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../lib/contextual-help'
3
+ import useEditor, {
4
+ getActiveSnapContext,
5
+ isAngleSnapActive,
6
+ normalizePersistedEditorLayoutState,
7
+ } from './use-editor'
8
+ import useInteractionScope from './use-interaction-scope'
9
+
10
+ const initialEditor = useEditor.getState()
11
+ const initialScope = useInteractionScope.getState()
12
+ afterEach(() => {
13
+ useEditor.setState(initialEditor)
14
+ useInteractionScope.setState(initialScope)
15
+ })
16
+
17
+ for (const handle of [ROTATE_HANDLE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL]) {
18
+ test(`${handle} reads and cycles the shared mode without changing placement modes`, () => {
19
+ useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: 'item_test', handle })
20
+ const placementModes = useEditor.getState().snappingModeByContext
21
+ expect(getActiveSnapContext()).toBe('rotation')
22
+ expect(isAngleSnapActive()).toBe(true)
23
+ expect(useEditor.getState().cycleSnappingMode()).toBe('off')
24
+ expect(isAngleSnapActive()).toBe(false)
25
+ expect(useEditor.getState().snappingModeByContext.wall).toBe(placementModes.wall)
26
+ expect(useEditor.getState().snappingModeByContext.item).toBe(placementModes.item)
27
+ expect(useEditor.getState().cycleSnappingMode()).toBe('angles')
28
+ expect(isAngleSnapActive()).toBe(true)
29
+ })
30
+ }
31
+
32
+ test('older persisted preferences receive the rotation default and retain other modes', () => {
33
+ const merged = normalizePersistedEditorLayoutState({
34
+ snappingModeByContext: { wall: 'off', item: 'lines', polygon: 'grid' },
35
+ })
36
+ expect(merged?.snappingModeByContext).toEqual({
37
+ wall: 'off',
38
+ item: 'lines',
39
+ polygon: 'grid',
40
+ rotation: 'angles',
41
+ })
42
+ })
43
+
44
+ test('persisted rotation mode is retained when valid and reset when invalid', () => {
45
+ expect(
46
+ normalizePersistedEditorLayoutState({ snappingModeByContext: { rotation: 'off' } })
47
+ .snappingModeByContext.rotation,
48
+ ).toBe('off')
49
+ expect(
50
+ normalizePersistedEditorLayoutState({ snappingModeByContext: { rotation: 'grid' } })
51
+ .snappingModeByContext.rotation,
52
+ ).toBe('angles')
53
+ })
@@ -570,6 +570,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
570
570
  wall: defaultSnappingModeFor('wall'),
571
571
  item: defaultSnappingModeFor('item'),
572
572
  polygon: defaultSnappingModeFor('polygon'),
573
+ rotation: defaultSnappingModeFor('rotation'),
573
574
  },
574
575
  continuationByContext: {
575
576
  wall: CONTINUATION_PROFILES.wall.default,
@@ -800,8 +801,14 @@ function normalizeContinuationByContext(
800
801
  }
801
802
  }
802
803
 
803
- function normalizePersistedEditorLayoutState(
804
- state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
804
+ export function normalizePersistedEditorLayoutState(
805
+ state:
806
+ | (Omit<Partial<PersistedEditorLayoutState>, 'snappingModeByContext'> &
807
+ LegacyContinuationState & {
808
+ snappingModeByContext?: Partial<Record<SnapContext, unknown>>
809
+ })
810
+ | null
811
+ | undefined,
805
812
  ): PersistedEditorLayoutState {
806
813
  return {
807
814
  activeSidebarPanel:
@@ -821,6 +828,7 @@ function normalizePersistedEditorLayoutState(
821
828
  wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
822
829
  item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
823
830
  polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
831
+ rotation: migrateSnappingMode(state?.snappingModeByContext?.rotation, 'rotation'),
824
832
  },
825
833
  continuationByContext: normalizeContinuationByContext(state),
826
834
  showReferenceFloor: state?.showReferenceFloor === true,
@@ -40,6 +40,9 @@ type FloorplanDraftPreviewState = {
40
40
  wallDraftStart: WallPlanPoint | null
41
41
  fenceDraftStart: WallPlanPoint | null
42
42
  roofDraftStart: WallPlanPoint | null
43
+ /** First corner of an open rectangle-wall draft; the live cursor is the
44
+ * opposite corner, so the rectangle tool publishes only per click. */
45
+ wallRectangleDraftStart: WallPlanPoint | null
43
46
  roofDraftQuarterTurn: boolean
44
47
  polygonDraftType: FloorplanPolygonDraftType | null
45
48
  polygonDraftPoints: WallPlanPoint[]
@@ -55,6 +58,7 @@ type FloorplanDraftPreviewState = {
55
58
  setWallDraftStart(point: WallPlanPoint | null): void
56
59
  setFenceDraftStart(point: WallPlanPoint | null): void
57
60
  setRoofDraftStart(point: WallPlanPoint | null): void
61
+ setWallRectangleDraftStart(point: WallPlanPoint | null): void
58
62
  setRoofDraftQuarterTurn(quarterTurn: boolean): void
59
63
  setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void
60
64
  reset(): void
@@ -74,7 +78,8 @@ function setPlanPointField(
74
78
  | 'roofDraftEnd'
75
79
  | 'roofDraftStart'
76
80
  | 'wallDraftEnd'
77
- | 'wallDraftStart',
81
+ | 'wallDraftStart'
82
+ | 'wallRectangleDraftStart',
78
83
  point: WallPlanPoint | null,
79
84
  ) {
80
85
  return (
@@ -96,6 +101,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
96
101
  wallDraftStart: null,
97
102
  fenceDraftStart: null,
98
103
  roofDraftStart: null,
104
+ wallRectangleDraftStart: null,
99
105
  roofDraftQuarterTurn: false,
100
106
  polygonDraftType: null,
101
107
  polygonDraftPoints: [],
@@ -119,6 +125,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
119
125
  setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)),
120
126
  setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)),
121
127
  setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)),
128
+ setWallRectangleDraftStart: (point) => set(setPlanPointField('wallRectangleDraftStart', point)),
122
129
  setRoofDraftQuarterTurn: (quarterTurn) =>
123
130
  set((state) =>
124
131
  state.roofDraftQuarterTurn === quarterTurn ? state : { roofDraftQuarterTurn: quarterTurn },
@@ -139,6 +146,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
139
146
  state.wallDraftStart === null &&
140
147
  state.fenceDraftStart === null &&
141
148
  state.roofDraftStart === null &&
149
+ state.wallRectangleDraftStart === null &&
142
150
  state.roofDraftQuarterTurn === false &&
143
151
  state.polygonDraftType === null &&
144
152
  state.polygonDraftPoints.length === 0
@@ -152,6 +160,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
152
160
  wallDraftStart: null,
153
161
  fenceDraftStart: null,
154
162
  roofDraftStart: null,
163
+ wallRectangleDraftStart: null,
155
164
  roofDraftQuarterTurn: false,
156
165
  polygonDraftType: null,
157
166
  polygonDraftPoints: [],