@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.
- package/package.json +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/slider-control.tsx +76 -31
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/parametric-inspector.tsx +3 -15
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +42 -29
- package/src/index.tsx +63 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- 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
|
|
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
|
-
|
|
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
|
+
})
|
package/src/store/use-editor.tsx
CHANGED
|
@@ -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:
|
|
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: [],
|