From 5951b6534b65defc215d27b407b547c0cb26ff9c Mon Sep 17 00:00:00 2001 From: Micheal Parks Date: Fri, 16 Oct 2026 12:51:15 -0400 Subject: [PATCH 1/4] object snapping poc --- .changeset/tidy-steaks-care.md | 5 + .../SelectedTransformControls.svelte | 57 +++--- src/lib/components/SnapIndicator.svelte | 54 ++++++ .../hooks/__tests__/transformSnaps.spec.ts | 16 +- src/lib/hooks/transformSnaps.ts | 11 +- src/lib/hooks/useSettings.svelte.ts | 13 ++ .../BuildFrames/TransformDashboard.svelte | 24 ++- .../__tests__/__fixtures__/snapFixtures.ts | 40 +++++ .../__tests__/alignUpToNormal.spec.ts | 29 +++ .../snapping/__tests__/dragConstraint.spec.ts | 54 ++++++ .../__tests__/geometryKeyPoints.spec.ts | 52 ++++++ src/lib/snapping/__tests__/pointSnap.spec.ts | 76 ++++++++ .../__tests__/raycastSnapShape.spec.ts | 94 ++++++++++ .../__tests__/supportDistance.spec.ts | 45 +++++ .../snapping/__tests__/surfaceSnap.spec.ts | 122 +++++++++++++ src/lib/snapping/alignUpToNormal.ts | 20 +++ src/lib/snapping/dragConstraint.ts | 66 +++++++ src/lib/snapping/geometryKeyPoints.ts | 66 +++++++ src/lib/snapping/pointSnap.ts | 82 +++++++++ src/lib/snapping/raycastSnapShape.ts | 170 ++++++++++++++++++ src/lib/snapping/resolveSnap.ts | 12 ++ src/lib/snapping/snapQuery.ts | 56 ++++++ src/lib/snapping/snapScene.ts | 66 +++++++ src/lib/snapping/snapShape.ts | 53 ++++++ src/lib/snapping/supportDistance.ts | 57 ++++++ src/lib/snapping/surfaceSnap.ts | 93 ++++++++++ src/lib/snapping/useObjectSnapDrag.svelte.ts | 135 ++++++++++++++ 27 files changed, 1541 insertions(+), 27 deletions(-) create mode 100644 .changeset/tidy-steaks-care.md create mode 100644 src/lib/components/SnapIndicator.svelte create mode 100644 src/lib/snapping/__tests__/__fixtures__/snapFixtures.ts create mode 100644 src/lib/snapping/__tests__/alignUpToNormal.spec.ts create mode 100644 src/lib/snapping/__tests__/dragConstraint.spec.ts create mode 100644 src/lib/snapping/__tests__/geometryKeyPoints.spec.ts create mode 100644 src/lib/snapping/__tests__/pointSnap.spec.ts create mode 100644 src/lib/snapping/__tests__/raycastSnapShape.spec.ts create mode 100644 src/lib/snapping/__tests__/supportDistance.spec.ts create mode 100644 src/lib/snapping/__tests__/surfaceSnap.spec.ts create mode 100644 src/lib/snapping/alignUpToNormal.ts create mode 100644 src/lib/snapping/dragConstraint.ts create mode 100644 src/lib/snapping/geometryKeyPoints.ts create mode 100644 src/lib/snapping/pointSnap.ts create mode 100644 src/lib/snapping/raycastSnapShape.ts create mode 100644 src/lib/snapping/resolveSnap.ts create mode 100644 src/lib/snapping/snapQuery.ts create mode 100644 src/lib/snapping/snapScene.ts create mode 100644 src/lib/snapping/snapShape.ts create mode 100644 src/lib/snapping/supportDistance.ts create mode 100644 src/lib/snapping/surfaceSnap.ts create mode 100644 src/lib/snapping/useObjectSnapDrag.svelte.ts diff --git a/.changeset/tidy-steaks-care.md b/.changeset/tidy-steaks-care.md new file mode 100644 index 000000000..9b332066c --- /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 56f844e93..f4ae588e2 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) @@ -124,6 +131,11 @@ return mode }) + 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.alignsRotation) + ) const isSphereScale = $derived( activeMode === 'scale' && (sphere.current !== undefined || obstacleShape?.type === 'sphere') ) @@ -182,7 +194,7 @@ frameHistoryEntryOpen = false } - onDestroy(endFrameHistoryEntry) + $effect(() => endFrameHistoryEntry) const captureScaleStart = () => { if (!entity || activeMode !== 'scale') { @@ -247,21 +259,21 @@ tempShapeWorld.compose(ref.position, ref.quaternion, 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) transformControls.setActive(true) } @@ -269,6 +281,8 @@ 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,6 +351,7 @@ const onMouseUp = () => { scaleStart = undefined + objectSnap.end() transformControls.setActive(false) endFrameHistoryEntry() } @@ -383,20 +398,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 +434,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 +472,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 000000000..1fc68641f --- /dev/null +++ b/src/lib/components/SnapIndicator.svelte @@ -0,0 +1,54 @@ + + + + + + +{#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 6823f4ea9..af27a620e 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 1c9ab24a9..0fa34c98d 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 9fa37015c..9878c35a6 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 f9d29ea1b..a7a0c52b5 100644 --- a/src/lib/plugins/BuildFrames/TransformDashboard.svelte +++ b/src/lib/plugins/BuildFrames/TransformDashboard.svelte @@ -1,5 +1,5 @@ + + class="pointer-events-none" + > + + {#if normalLine} diff --git a/src/lib/snapping/__tests__/isVisibleTurn.spec.ts b/src/lib/snapping/__tests__/isVisibleTurn.spec.ts new file mode 100644 index 000000000..9f1f047c0 --- /dev/null +++ b/src/lib/snapping/__tests__/isVisibleTurn.spec.ts @@ -0,0 +1,26 @@ +import { Quaternion, Vector3 } from 'three' +import { describe, expect, it } from 'vitest' + +import { alignUpToNormal } from '../alignUpToNormal' +import { isVisibleTurn } from '../isVisibleTurn' + +const UP = new Vector3(0, 0, 1) +const headings = Array.from({ length: 2000 }, (_, index) => + new Quaternion().setFromAxisAngle(UP, (index / 2000) * Math.PI * 2) +) + +describe('isVisibleTurn', () => { + it('ignores the rounding left by aligning a level object to the ground at any heading', () => { + const turnedHeadings = headings.filter((heading) => + isVisibleTurn(heading, alignUpToNormal(heading, UP, new Quaternion())) + ) + + expect(turnedHeadings).toEqual([]) + }) + + it('counts a one-degree tilt as a turn', () => { + const tilt = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 180) + + expect(isVisibleTurn(new Quaternion(), tilt)).toBe(true) + }) +}) diff --git a/src/lib/snapping/isVisibleTurn.ts b/src/lib/snapping/isVisibleTurn.ts new file mode 100644 index 000000000..d574392f5 --- /dev/null +++ b/src/lib/snapping/isVisibleTurn.ts @@ -0,0 +1,10 @@ +import type { Quaternion } from 'three' + +// Re-deriving an unchanged orientation leaves the quaternion dot product a rounding error short +// of 1, which `angleTo`'s acos magnifies to up to about 1e-7 rad. A real turn onto a surface is +// orders of magnitude past this. +const MIN_TURN_RADIANS = 1e-5 + +/** Whether `to` is turned from `from` by more than rounding noise. */ +export const isVisibleTurn = (from: Quaternion, to: Quaternion): boolean => + from.angleTo(to) > MIN_TURN_RADIANS diff --git a/src/lib/snapping/useObjectSnapDrag.svelte.ts b/src/lib/snapping/useObjectSnapDrag.svelte.ts index 80978efaa..7067500ad 100644 --- a/src/lib/snapping/useObjectSnapDrag.svelte.ts +++ b/src/lib/snapping/useObjectSnapDrag.svelte.ts @@ -7,14 +7,12 @@ import { useWorld } from '$lib/ecs' import { useSettings } from '$lib/hooks/useSettings.svelte' import { dragConstraintFor } from './dragConstraint' +import { isVisibleTurn } from './isVisibleTurn' import { resolveSnap } from './resolveSnap' import { type SnapIndicator } from './snapQuery' import { posedTargets, type SnapScene, snapSceneFor } from './snapScene' const UNIT_SCALE = new Vector3(1, 1, 1) -// Aligning to a surface the object already sits level on still rounds, by about 1e-16 rad. Below -// this, nothing visibly turned, so staging orientation would only write noise into the config. -const MIN_TURN_RADIANS = 1e-9 /** Object snapping for one translate gizmo, from pointer down to pointer up. */ export interface ObjectSnapDrag { @@ -145,7 +143,8 @@ export const useObjectSnapDrag = (): ObjectSnapDrag => { if (result) { anchor.position.copy(result.position) orientation.copy(result.orientation) - if (orientation.angleTo(startOrientation) > MIN_TURN_RADIANS) hasTurned = true + // Staging an unturned orientation would only write rounding noise into the config. + if (isVisibleTurn(startOrientation, orientation)) hasTurned = true indicator = result.indicator } } From 01b6728e79944e74a3eb92d928d6cdde5f8f91d3 Mon Sep 17 00:00:00 2001 From: Micheal Parks Date: Tue, 20 Oct 2026 12:37:20 -0400 Subject: [PATCH 4/4] occlusion fixes --- .../__tests__/__fixtures__/snapFixtures.ts | 11 ++++-- src/lib/snapping/__tests__/pointSnap.spec.ts | 29 +++++++++++++++ src/lib/snapping/nearestSurfaceHit.ts | 36 +++++++++++++++++++ src/lib/snapping/pointSnap.ts | 25 ++++++++++--- src/lib/snapping/snapQuery.ts | 2 ++ src/lib/snapping/surfaceSnap.ts | 29 ++------------- src/lib/snapping/useObjectSnapDrag.svelte.ts | 13 ++++++- 7 files changed, 111 insertions(+), 34 deletions(-) create mode 100644 src/lib/snapping/nearestSurfaceHit.ts diff --git a/src/lib/snapping/__tests__/__fixtures__/snapFixtures.ts b/src/lib/snapping/__tests__/__fixtures__/snapFixtures.ts index 69221fc61..4bf088563 100644 --- a/src/lib/snapping/__tests__/__fixtures__/snapFixtures.ts +++ b/src/lib/snapping/__tests__/__fixtures__/snapFixtures.ts @@ -15,9 +15,12 @@ export const box = (x: number, y: number, z: number): SnapShape => ({ export const placed = (x: number, y: number, z: number, orientation = new Quaternion()) => new Matrix4().compose(new Vector3(x, y, z), orientation, new Vector3(1, 1, 1)) +/** The camera height the top-down view looks down from. */ +const CAMERA_Z = 10 + /** - * A query seen from straight above: the ray drops from the cursor, and a world point lands at - * its x and y scaled to pixels. + * A query seen from straight above: rays drop from `CAMERA_Z`, and a world point lands at its x + * and y scaled to pixels. */ export const topDownQuery = ( cursor: { x: number; y: number }, @@ -28,12 +31,14 @@ export const topDownQuery = ( dragged: [], targets: [], constraint: { kind: 'free' }, - ray: new Ray(new Vector3(cursor.x, cursor.y, 10), new Vector3(0, 0, -1)), + ray: new Ray(new Vector3(cursor.x, cursor.y, CAMERA_Z), new Vector3(0, 0, -1)), cursor: new Vector2(cursor.x, cursor.y).multiplyScalar(PIXELS_PER_METRE), toScreen: (point, out) => { out.set(point.x, point.y).multiplyScalar(PIXELS_PER_METRE) return true }, + rayThrough: (point, out) => + out.set(new Vector3(point.x, point.y, CAMERA_Z), new Vector3(0, 0, -1)), groundPlane: false, alignRotation: false, ...overrides, diff --git a/src/lib/snapping/__tests__/pointSnap.spec.ts b/src/lib/snapping/__tests__/pointSnap.spec.ts index 58d91afca..177123ad0 100644 --- a/src/lib/snapping/__tests__/pointSnap.spec.ts +++ b/src/lib/snapping/__tests__/pointSnap.spec.ts @@ -31,6 +31,35 @@ describe('pointSnap', () => { expect(result?.indicator).toEqual({ kind: 'point', point: new Vector3(4, 1, 2) }) }) + it('skips a key point hidden behind the surface under the cursor', () => { + const lid = { shape: box(4, 4, 1), matrix: placed(5, 0, 3) } + const result = pointSnap( + topDownQuery({ x: 4, y: 1 }, { position: new Vector3(), targets: [target, lid] }) + ) + + expect(result).toBeNull() + }) + + it('skips a key point below the ground when the ground plane is on', () => { + const buried = { shape: box(2, 2, 2), matrix: placed(5, 0, -2) } + const result = pointSnap( + topDownQuery( + { x: 4, y: 1 }, + { position: new Vector3(), targets: [buried], groundPlane: true } + ) + ) + + expect(result).toBeNull() + }) + + it('still snaps to a corner of the box whose surface its own sight line grazes', () => { + const result = pointSnap( + topDownQuery({ x: 6, y: -1 }, { position: new Vector3(), targets: [target] }) + ) + + expect(result?.indicator).toEqual({ kind: 'point', point: new Vector3(6, -1, 2) }) + }) + it('sets cubes flush side by side when edge midpoints meet', () => { const result = pointSnap( topDownQuery( diff --git a/src/lib/snapping/nearestSurfaceHit.ts b/src/lib/snapping/nearestSurfaceHit.ts new file mode 100644 index 000000000..76254b0d5 --- /dev/null +++ b/src/lib/snapping/nearestSurfaceHit.ts @@ -0,0 +1,36 @@ +import { Plane, type Ray, Vector3 } from 'three' + +import type { SnapTarget } from './snapQuery' + +import { raycastSnapShape, type SnapHit } from './raycastSnapShape' + +const GROUND_PLANE = new Plane(new Vector3(0, 0, 1), 0) + +const groundHit = (ray: Ray): SnapHit | null => { + // Only from above. Seen from below, the ground is not a surface anything rests on. + if (ray.direction.z >= 0) return null + + const point = ray.intersectPlane(GROUND_PLANE, new Vector3()) + if (!point) return null + + return { point, normal: new Vector3(0, 0, 1), distance: point.distanceTo(ray.origin) } +} + +/** + * The first surface a ray meets among the targets, and the ground when `groundPlane` is set. + * Returns `null` when it meets none. + */ +export const nearestSurfaceHit = ( + ray: Ray, + targets: SnapTarget[], + groundPlane: boolean +): SnapHit | null => { + let nearest = groundPlane ? groundHit(ray) : null + + for (const target of targets) { + const hit = raycastSnapShape(ray, target.shape, target.matrix) + if (hit && (!nearest || hit.distance < nearest.distance)) nearest = hit + } + + return nearest +} diff --git a/src/lib/snapping/pointSnap.ts b/src/lib/snapping/pointSnap.ts index 0c27bb4d3..e6f8ff328 100644 --- a/src/lib/snapping/pointSnap.ts +++ b/src/lib/snapping/pointSnap.ts @@ -1,9 +1,10 @@ -import { Matrix4, Vector2, Vector3 } from 'three' +import { Matrix4, Ray, Vector2, Vector3 } from 'three' import type { SnapQuery, SnapResult } from './snapQuery' import { constrainDelta } from './dragConstraint' import { geometryKeyPoints } from './geometryKeyPoints' +import { nearestSurfaceHit } from './nearestSurfaceHit' /** How close the cursor has to come to a key point, in canvas pixels, for it to catch. */ export const SNAP_RADIUS_PX = 12 @@ -11,14 +12,28 @@ export const SNAP_RADIUS_PX = 12 const UNIT_SCALE = new Vector3(1, 1, 1) const screenPoint = new Vector2() const geometryMatrix = new Matrix4() +const sightLine = new Ray() +const toPoint = new Vector3() // Points this close together on screen read as one spot to the user, so depth decides between them. const SCREEN_TIE_PX = 0.5 +// A key point sits on its own geometry's surface, so the sight line to it can meet that surface a +// hair before the point. Only a surface further in front than this hides it. +const OCCLUSION_TOLERANCE_M = 0.001 + +/** Whether a surface stands between the camera and `point`, so the user cannot see it. */ +const isHidden = (query: SnapQuery, point: Vector3): boolean => { + query.rayThrough(point, sightLine) + const pointDistance = toPoint.copy(point).sub(sightLine.origin).dot(sightLine.direction) + const hit = nearestSurfaceHit(sightLine, query.targets, query.groundPlane) + return hit !== null && hit.distance < pointDistance - OCCLUSION_TOLERANCE_M +} + /** - * The target key point nearest the cursor on screen, within {@link SNAP_RADIUS_PX}. Of points that - * overlap on screen, such as a box's top and bottom corners seen from above, the one nearer the - * camera wins. + * The visible target key point nearest the cursor on screen, within {@link SNAP_RADIUS_PX}. A point + * behind a surface is skipped, since the user is pointing at the surface, not through it. Of points + * that overlap on screen, the one nearer the camera wins. */ const nearestTargetPoint = (query: SnapQuery): Vector3 | undefined => { let nearest: Vector3 | undefined @@ -35,6 +50,8 @@ const nearestTargetPoint = (query: SnapQuery): Vector3 | undefined => { const depth = point.distanceTo(query.ray.origin) const isTie = nearest !== undefined && Math.abs(pixels - nearestPixels) <= SCREEN_TIE_PX if (isTie ? depth >= nearestDepth : pixels > nearestPixels) continue + // Last, since it casts a ray against every target. Few points get this far. + if (isHidden(query, point)) continue nearest = point nearestPixels = pixels diff --git a/src/lib/snapping/snapQuery.ts b/src/lib/snapping/snapQuery.ts index 163a92ef5..31f0d0c6d 100644 --- a/src/lib/snapping/snapQuery.ts +++ b/src/lib/snapping/snapQuery.ts @@ -39,6 +39,8 @@ export interface SnapQuery { * camera. */ toScreen: (point: Vector3, out: Vector2) => boolean + /** Sets `out` to the camera's ray through the pixel `point` projects to. */ + rayThrough: (point: Vector3, out: Ray) => Ray groundPlane: boolean alignRotation: boolean } diff --git a/src/lib/snapping/surfaceSnap.ts b/src/lib/snapping/surfaceSnap.ts index a7c14c9d8..7bf545a0b 100644 --- a/src/lib/snapping/surfaceSnap.ts +++ b/src/lib/snapping/surfaceSnap.ts @@ -1,10 +1,10 @@ -import { Matrix4, Plane, Quaternion, Vector3 } from 'three' +import { Matrix4, Quaternion, Vector3 } from 'three' import type { SnapQuery, SnapResult } from './snapQuery' import { alignUpToNormal } from './alignUpToNormal' import { constrainDelta } from './dragConstraint' -import { raycastSnapShape, type SnapHit } from './raycastSnapShape' +import { nearestSurfaceHit } from './nearestSurfaceHit' import { supportDistance } from './supportDistance' // Below this, the constraint leaves almost none of the surface normal to move along, so reaching @@ -12,33 +12,10 @@ import { supportDistance } from './supportDistance' const MIN_NORMAL_ALIGNMENT = 0.1 const UNIT_SCALE = new Vector3(1, 1, 1) -const GROUND_PLANE = new Plane(new Vector3(0, 0, 1), 0) const geometryMatrix = new Matrix4() const geometryOrigin = new Vector3() const downward = new Vector3() -const groundHit = (query: SnapQuery): SnapHit | null => { - // Only from above. Seen from below, the ground is not a surface anything rests on. - if (query.ray.direction.z >= 0) return null - - const point = query.ray.intersectPlane(GROUND_PLANE, new Vector3()) - if (!point) return null - - return { point, normal: new Vector3(0, 0, 1), distance: point.distanceTo(query.ray.origin) } -} - -/** The nearest surface under the cursor, including the ground when it is enabled. */ -const nearestHit = (query: SnapQuery): SnapHit | null => { - let nearest = query.groundPlane ? groundHit(query) : null - - for (const target of query.targets) { - const hit = raycastSnapShape(query.ray, target.shape, target.matrix) - if (hit && (!nearest || hit.distance < nearest.distance)) nearest = hit - } - - return nearest -} - /** How far the dragged geometries reach from the anchor in `direction`, taken together. */ const reachFromAnchor = (query: SnapQuery, orientation: Quaternion, direction: Vector3): number => { let reach = -Infinity @@ -63,7 +40,7 @@ const reachFromAnchor = (query: SnapQuery, orientation: Quaternion, direction: V export const surfaceSnap = (query: SnapQuery): SnapResult | null => { if (query.dragged.length === 0) return null - const hit = nearestHit(query) + const hit = nearestSurfaceHit(query.ray, query.targets, query.groundPlane) if (!hit) return null const orientation = query.alignRotation diff --git a/src/lib/snapping/useObjectSnapDrag.svelte.ts b/src/lib/snapping/useObjectSnapDrag.svelte.ts index 7067500ad..7b3b4ef9b 100644 --- a/src/lib/snapping/useObjectSnapDrag.svelte.ts +++ b/src/lib/snapping/useObjectSnapDrag.svelte.ts @@ -1,7 +1,7 @@ import type { Entity } from 'koota' import { useThrelte } from '@threlte/core' -import { Matrix4, type Object3D, Quaternion, Raycaster, Vector2, Vector3 } from 'three' +import { Matrix4, type Object3D, Quaternion, type Ray, Raycaster, Vector2, Vector3 } from 'three' import { useWorld } from '$lib/ecs' import { useSettings } from '$lib/hooks/useSettings.svelte' @@ -67,7 +67,9 @@ export const useObjectSnapDrag = (): ObjectSnapDrag => { const cursorPixels = new Vector2() const cursorNdc = new Vector2() const raycaster = new Raycaster() + const sightRaycaster = new Raycaster() const projected = new Vector3() + const projectedNdc = new Vector2() const anchorMatrix = new Matrix4() const isSnappingObjects = () => @@ -97,6 +99,14 @@ export const useObjectSnapDrag = (): ObjectSnapDrag => { return true } + // Through the point's pixel rather than from the camera's position, so an orthographic camera, + // whose rays are parallel, gets the right sight line too. + const rayThrough = (point: Vector3, out: Ray): Ray => { + projected.copy(point).project(camera.current) + sightRaycaster.setFromCamera(projectedNdc.set(projected.x, projected.y), camera.current) + return out.copy(sightRaycaster.ray) + } + return { get isInverted() { return isInverted @@ -136,6 +146,7 @@ export const useObjectSnapDrag = (): ObjectSnapDrag => { ray: raycaster.ray, cursor: cursorPixels, toScreen, + rayThrough, groundPlane: settings.current.snapGroundPlane, alignRotation: settings.current.snapAlignRotation, })