diff --git a/.changeset/tidy-steaks-care.md b/.changeset/tidy-steaks-care.md new file mode 100644 index 0000000000..9b332066cd --- /dev/null +++ b/.changeset/tidy-steaks-care.md @@ -0,0 +1,5 @@ +--- +'@viamrobotics/visualization': minor +--- + +Add object snapping to build-mode moves: key points, surfaces, ground plane, and surface alignment. diff --git a/src/lib/components/SelectedTransformControls.svelte b/src/lib/components/SelectedTransformControls.svelte index 56f844e935..25652cf3d1 100644 --- a/src/lib/components/SelectedTransformControls.svelte +++ b/src/lib/components/SelectedTransformControls.svelte @@ -3,7 +3,6 @@ import { T, useThrelte } from '@threlte/core' import { TransformControls } from '@threlte/extras' - import { onDestroy } from 'svelte' import { Group, Matrix4, Vector3 } from 'three' import { @@ -13,6 +12,7 @@ resizeBoundsObstacle, } from '$lib/boundsObstacleInterior' import { complexShapeOwner } from '$lib/complexShapeOwner' + import SnapIndicator from '$lib/components/SnapIndicator.svelte' import { relations, traits, useQuery, useTrait } from '$lib/ecs' import { FrameEditor } from '$lib/editing/FrameEditor' import { shapeOffsetFromGizmo, withObstacleShapePatch } from '$lib/editing/obstacleShapeGizmo' @@ -33,6 +33,7 @@ simpleObstacleShape, withSimpleObstacleShape, } from '$lib/simpleObstacleShape' + import { useObjectSnapDrag } from '$lib/snapping/useObjectSnapDrag.svelte' import { isolateTransformControls } from '$lib/three/renderLayers' const { invalidate } = useThrelte() @@ -46,7 +47,13 @@ const selected = useQuery(traits.Selected) const mode = $derived(settings.current.transformMode) - const snaps = $derived(transformSnaps(settings.current)) + const objectSnap = useObjectSnapDrag() + const snaps = $derived( + transformSnaps({ + ...settings.current, + snapping: settings.current.snapping !== objectSnap.isInverted, + }) + ) const isBuildMode = $derived(environment.current.mode === 'build') const entity = $derived(selected.current[0]) const editable = useTrait(() => entity, traits.Editable) @@ -110,6 +117,8 @@ } target.decompose(anchor.position, anchor.quaternion, anchor.scale) + // An aligned move stages a turn, which would otherwise tilt the gizmo mid-drag. + if (objectSnap.isDragging) anchor.quaternion.copy(objectSnap.startOrientation) anchor.updateMatrixWorld() invalidate() }) @@ -124,6 +133,15 @@ return mode }) + /** The orientation a drag stages: the gizmo's own, or during a move, the snap's. */ + const stagedOrientation = (gizmo: Group) => + activeMode === 'translate' && objectSnap.isDragging ? objectSnap.orientation : gizmo.quaternion + + const stagesPosition = $derived(activeMode === 'translate') + // Align-to-surface turns the object during a move, so a move writes orientation too. + const stagesOrientation = $derived( + activeMode === 'rotate' || (activeMode === 'translate' && objectSnap.hasTurned) + ) const isSphereScale = $derived( activeMode === 'scale' && (sphere.current !== undefined || obstacleShape?.type === 'sphere') ) @@ -164,6 +182,7 @@ | { type: 'capsule'; r: number; l: number } | undefined let frameHistoryEntryOpen = false + let isGizmoDragging = false let controls = $state.raw() $effect(() => { @@ -182,7 +201,29 @@ frameHistoryEntryOpen = false } - onDestroy(endFrameHistoryEntry) + const releaseGizmo = () => { + if (!isGizmoDragging) return + isGizmoDragging = false + transformControls.setActive(false) + } + + // The gizmo can unmount mid-drag, on a mode hotkey or the entity hiding, and then never sees + // the pointer up. Close the history entry and release the camera with it, or undo stays blocked + // behind the open entry and the camera stays parked. + $effect(() => { + if (!transforming || !entity) return + return () => { + endFrameHistoryEntry() + releaseGizmo() + } + }) + + // Same unmount, plus a switch out of translate mid-drag. End the snap drag with them, or the + // next drag starts from a stale one. + $effect(() => { + if (!transforming || activeMode !== 'translate' || !entity) return + return () => objectSnap.end() + }) const captureScaleStart = () => { if (!entity || activeMode !== 'scale') { @@ -244,31 +285,34 @@ const world = worldMatrix.current if (!ref || !world) return - tempShapeWorld.compose(ref.position, ref.quaternion, UNIT_SCALE) + tempShapeWorld.compose(ref.position, stagedOrientation(ref), UNIT_SCALE) const offset = shapeOffsetFromGizmo(world, tempShapeWorld) - if (activeMode === 'translate') { - patchComplexShape({ translation: { x: offset.x, y: offset.y, z: offset.z } }) - } else { - patchComplexShape({ + patchComplexShape({ + ...(stagesPosition && { translation: { x: offset.x, y: offset.y, z: offset.z } }), + ...(stagesOrientation && { orientation: { type: 'ov_degrees', value: { x: offset.oX, y: offset.oY, z: offset.oZ, th: offset.theta }, }, - }) - } + }), + }) } const onMouseDown = () => { captureScaleStart() beginFrameHistoryEntry() + if (activeMode === 'translate' && entity && ref) objectSnap.begin(entity, ref) + isGizmoDragging = true transformControls.setActive(true) } const onChange = () => { if (!ref || !entity || !activeMode) return + if (activeMode === 'translate' && controls?.axis) objectSnap.apply(ref, controls.axis) + if (activeMode === 'translate' || activeMode === 'rotate') { if (shapeOwner) { stageComplexShapeTransform() @@ -337,7 +381,8 @@ const onMouseUp = () => { scaleStart = undefined - transformControls.setActive(false) + objectSnap.end() + releaseGizmo() endFrameHistoryEntry() } @@ -355,7 +400,7 @@ const computeLocalDragTarget = (out: Matrix4) => { if (!ref || !entity) return - out.makeRotationFromQuaternion(ref.quaternion) + out.makeRotationFromQuaternion(stagedOrientation(ref)) out.setPosition(ref.position) const parentWorld = entity.targetFor(relations.ChildOf)?.get(traits.WorldMatrix) @@ -383,20 +428,15 @@ const config = configMatrix.current if (!live || !config) { - if (activeMode === 'translate') { - frameEditor.setPose(entity, { - x: refPose.x, - y: refPose.y, - z: refPose.z, - }) - } else if (activeMode === 'rotate') { - frameEditor.setPose(entity, { + frameEditor.setPose(entity, { + ...(stagesPosition && { x: refPose.x, y: refPose.y, z: refPose.z }), + ...(stagesOrientation && { oX: refPose.oX, oY: refPose.oY, oZ: refPose.oZ, theta: refPose.theta, - }) - } + }), + }) return } @@ -424,11 +464,12 @@ tempPose.setFromMatrix4(matrix) refPose.setFromMatrix4(tempRefMatrix) - if (activeMode === 'translate') { + if (stagesPosition) { tempPose.x = refPose.x tempPose.y = refPose.y tempPose.z = refPose.z - } else { + } + if (stagesOrientation) { tempPose.oX = refPose.oX tempPose.oY = refPose.oY tempPose.oZ = refPose.oZ @@ -461,4 +502,8 @@ onmouseUp={onMouseUp} /> {/key} + + {#if objectSnap.indicator} + + {/if} {/if} diff --git a/src/lib/components/SnapIndicator.svelte b/src/lib/components/SnapIndicator.svelte new file mode 100644 index 0000000000..871e664ed7 --- /dev/null +++ b/src/lib/components/SnapIndicator.svelte @@ -0,0 +1,60 @@ + + + + + + + + + +{#if normalLine} + null} + bvh={{ enabled: false }} + renderOrder={1} + > + + + +{/if} diff --git a/src/lib/hooks/__tests__/transformSnaps.spec.ts b/src/lib/hooks/__tests__/transformSnaps.spec.ts index 6823f4ea93..af27a620eb 100644 --- a/src/lib/hooks/__tests__/transformSnaps.spec.ts +++ b/src/lib/hooks/__tests__/transformSnaps.spec.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest' import { transformSnaps } from '../transformSnaps' -const snapSettings = { snapping: true, snapTranslate: 0.25, snapRotate: 90, snapScale: 0.1 } +const snapSettings = { + snapping: true, + snapIncrement: true, + snapTranslate: 0.25, + snapRotate: 90, + snapScale: 0.1, +} describe('transformSnaps', () => { it('passes a move step through in the metres the gizmo snaps in', () => { @@ -25,6 +31,14 @@ describe('transformSnaps', () => { }) }) + it('snaps nothing while increment snapping is off', () => { + expect(transformSnaps({ ...snapSettings, snapIncrement: false })).toEqual({ + translation: null, + rotation: null, + scale: null, + }) + }) + it('leaves an axis free when its step is 0', () => { expect(transformSnaps({ ...snapSettings, snapTranslate: 0 }).translation).toBeNull() }) diff --git a/src/lib/hooks/transformSnaps.ts b/src/lib/hooks/transformSnaps.ts index 1c9ab24a97..0fa34c98d3 100644 --- a/src/lib/hooks/transformSnaps.ts +++ b/src/lib/hooks/transformSnaps.ts @@ -10,14 +10,17 @@ export interface TransformSnaps { } /** - * The snap steps to hand a Three.js transform gizmo. Snapping off, or a step of 0, - * leaves that axis free. + * The snap steps to hand a Three.js transform gizmo. Snapping off, increment snapping off, or a + * step of 0 leaves that axis free. */ export const transformSnaps = ( - settings: Pick + settings: Pick< + Settings, + 'snapping' | 'snapIncrement' | 'snapTranslate' | 'snapRotate' | 'snapScale' + > ): TransformSnaps => { const snap = (value: number, convert: (v: number) => number = (v) => v) => - settings.snapping && value > 0 ? convert(value) : null + settings.snapping && settings.snapIncrement && value > 0 ? convert(value) : null return { translation: snap(settings.snapTranslate), diff --git a/src/lib/hooks/useSettings.svelte.ts b/src/lib/hooks/useSettings.svelte.ts index 9fa37015cf..9878c35a6e 100644 --- a/src/lib/hooks/useSettings.svelte.ts +++ b/src/lib/hooks/useSettings.svelte.ts @@ -29,7 +29,16 @@ export interface Settings { disabledCameras: Record disabledVisionServices: Record + /** The master switch. The four flags below choose what snaps while it is on. */ snapping: boolean + /** Step moves, turns, and resizes by `snapTranslate`, `snapRotate`, and `snapScale`. */ + snapIncrement: boolean + /** Snap moves to the key points and surfaces of other geometries. */ + snapObjects: boolean + /** Treat the ground, z = 0, as a surface to snap to. Applies with `snapObjects`. */ + snapGroundPlane: boolean + /** Tilt a surface-snapped object so its +Z follows the surface. Applies with `snapObjects`. */ + snapAlignRotation: boolean /** Move snap step, in metres. Unlike pose fields, it never reaches a Viam API, so it skips mm. */ snapTranslate: number snapRotate: number @@ -124,6 +133,10 @@ const defaults = (): Settings => ({ disabledVisionServices: {}, snapping: false, + snapIncrement: true, + snapObjects: false, + snapGroundPlane: false, + snapAlignRotation: false, snapTranslate: 0.1, snapRotate: 7.5, snapScale: 0.1, diff --git a/src/lib/plugins/BuildFrames/TransformDashboard.svelte b/src/lib/plugins/BuildFrames/TransformDashboard.svelte index f9d29ea1b8..a7a0c52b5e 100644 --- a/src/lib/plugins/BuildFrames/TransformDashboard.svelte +++ b/src/lib/plugins/BuildFrames/TransformDashboard.svelte @@ -1,5 +1,5 @@