Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-steaks-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@viamrobotics/visualization': minor
---

Add object snapping to build-mode moves: key points, surfaces, ground plane, and surface alignment.
93 changes: 69 additions & 24 deletions src/lib/components/SelectedTransformControls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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()
})
Expand All @@ -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')
)
Expand Down Expand Up @@ -164,6 +182,7 @@
| { type: 'capsule'; r: number; l: number }
| undefined
let frameHistoryEntryOpen = false
let isGizmoDragging = false
let controls = $state.raw<ThreeTransformControls>()

$effect(() => {
Expand All @@ -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') {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -337,7 +381,8 @@

const onMouseUp = () => {
scaleStart = undefined
transformControls.setActive(false)
objectSnap.end()
releaseGizmo()
endFrameHistoryEntry()
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -461,4 +502,8 @@
onmouseUp={onMouseUp}
/>
{/key}

{#if objectSnap.indicator}
<SnapIndicator indicator={objectSnap.indicator} />
{/if}
{/if}
60 changes: 60 additions & 0 deletions src/lib/components/SnapIndicator.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script lang="ts">
import { T } from '@threlte/core'
import { HTML, MeshLineGeometry, MeshLineMaterial } from '@threlte/extras'
import { Vector3 } from 'three'

import type { SnapIndicator } from '$lib/snapping/snapQuery'

interface Props {
indicator: SnapIndicator
}

const { indicator }: Props = $props()

// Long enough to read the surface's tilt at a glance, short enough to stay out of the way.
const NORMAL_LENGTH_M = 0.1

const normalLine = $derived(
indicator.kind === 'surface'
? [
indicator.point,
new Vector3().copy(indicator.point).addScaledVector(indicator.normal, NORMAL_LENGTH_M),
]
: undefined
)
</script>

<T.Group position={indicator.point.toArray()}>
<!-- `HTML` hands its own wrapper only `class` and `style`, so the decorative dot is a child that can carry `aria-hidden`. -->
<HTML
center
zIndexRange={[3, 0]}
class="pointer-events-none"
>
<span
aria-hidden="true"
class={[
'block h-3 w-3 rounded-full border-2 border-black/70',
indicator.kind === 'surface' && 'bg-black/70',
]}
></span>
</HTML>
</T.Group>

{#if normalLine}
<T.Mesh
raycast={() => null}
bvh={{ enabled: false }}
renderOrder={1}
>
<MeshLineGeometry points={normalLine} />
<MeshLineMaterial
width={2.5}
depthTest={false}
color="black"
opacity={0.7}
attenuate={false}
transparent
/>
</T.Mesh>
{/if}
16 changes: 15 additions & 1 deletion src/lib/hooks/__tests__/transformSnaps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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()
})
Expand Down
11 changes: 7 additions & 4 deletions src/lib/hooks/transformSnaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, 'snapping' | 'snapTranslate' | 'snapRotate' | 'snapScale'>
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),
Expand Down
13 changes: 13 additions & 0 deletions src/lib/hooks/useSettings.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ export interface Settings {
disabledCameras: Record<string, boolean>
disabledVisionServices: Record<string, boolean>

/** 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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading