import BoundingRectangle from "../Core/BoundingRectangle.js"; import Cartesian2 from "../Core/Cartesian2.js"; import Cartesian3 from "../Core/Cartesian3.js"; import Check from "../Core/Check.js"; import defined from "../Core/defined.js"; import oneTimeWarning from "../Core/oneTimeWarning.js"; import Ray from "../Core/Ray.js"; import { pickBegin, pickEnd } from "./Picking.js"; import SnapFramebuffer from "./SnapFramebuffer.js"; /** * Implementation of {@link Scene#snap}: snap-to-geometry picking. * * A snapping pass is an offscreen pick render (it reuses the pick render * machinery via pickBegin/pickEnd) that targets a * dedicated RGBA32F framebuffer instead of the RGBA8 pick framebuffer. Each * pixel carries a fuller payload than a pick color: * * The payload expression is built in PickingPipelineStage (DrawCommand.snapId) * and compiled into a snap-derived shader by * DerivedCommand.createSnapDerivedCommand. Only commands with a * snapId (i.e. Model-pipeline primitives) render during a * snapping pass. * * @namespace Snapping * * @private */ const Snapping = {}; const scratchRectangle = new BoundingRectangle(0.0, 0.0, 3.0, 3.0); const scratchSnapCoord = new Cartesian2(); const scratchSnapRay = new Ray(); const scratchSnapOffset = new Cartesian3(); // Radius around the crosshair, in pixels, used to sample the nearest surface // (the occluder the cursor is on). const SNAP_OCCLUDER_RADIUS_PIXELS = 3.0; // An edge more than this fraction deeper than that surface is treated as // occluded by it (the edge is only visible because it pokes through a gap in a // nearer silhouette), so snap doesn't punch through to geometry behind the // object the cursor is on. const SNAP_OCCLUSION_TOLERANCE = 0.1; function cursorDist(hit) { return Math.sqrt(hit.x * hit.x + hit.y * hit.y); } function selectBestHit(hits) { // Depth of the nearest surface under the crosshair; edges well behind it are // occluded by the object the cursor is on and must not win. let occluderDepth = Number.POSITIVE_INFINITY; for (const hit of hits) { if (!hit.isEdge && cursorDist(hit) <= SNAP_OCCLUDER_RADIUS_PIXELS) { occluderDepth = Math.min(occluderDepth, hit.depth); } } const maxEdgeDepth = occluderDepth * (1.0 + SNAP_OCCLUSION_TOLERANCE); // Edges outrank surfaces, but only edges in front of (or at) the occluder; // otherwise fall back to the closest surface. Within the chosen group the // hit closest to the crosshair wins. const visible = hits.filter( (hit) => !hit.isEdge || hit.depth <= maxEdgeDepth, ); const wantEdge = visible.some((hit) => hit.isEdge); const group = visible.filter((hit) => hit.isEdge === wantEdge); return group.reduce((best, hit) => cursorDist(hit) < cursorDist(best) ? hit : best, ); } // Unproject a snap hit's eye-space depth (channel B of the snap framebuffer, // written by the snap shader at the edge fragment itself) into a world // position. function snapHitToWorld(scene, windowPosition, hit) { const coords = scratchSnapCoord; coords.x = windowPosition.x + hit.x; coords.y = windowPosition.y + hit.y; const ray = scene.camera.getPickRay(coords, scratchSnapRay); if (!defined(ray)) { return undefined; } // hit.depth is perpendicular distance from the camera plane along the view // direction; convert to distance along the (non-axis-aligned) pick ray. const cos = Cartesian3.dot(ray.direction, scene.camera.directionWC); if (cos <= 0.0) { return undefined; } const t = hit.depth / cos; const offset = Cartesian3.multiplyByScalar( ray.direction, t, scratchSnapOffset, ); return Cartesian3.add(ray.origin, offset, new Cartesian3()); } /** * Returns the best snap target in a screen-space region around * windowPosition. See {@link Scene#snap} for the public API. * * @param {Scene} scene * @param {Cartesian2} windowPosition Window coordinates at the center of the search region. * @param {object} [options] Object with the following properties: * @param {number} [options.width=25] Width of the search region in pixels. * @param {number} [options.height=options.width] Height of the search region in pixels. * @returns {SceneSnapResult | undefined} * * @private */ Snapping.snap = function (scene, windowPosition, options) { //>>includeStart('debug', pragmas.debug); Check.defined("windowPosition", windowPosition); //>>includeEnd('debug'); const width = options?.width ?? 25; const height = options?.height ?? width; const { context, defaultView } = scene; // The snap framebuffer is RGBA32F; rendering to it requires float color // attachment support. if (!context.colorBufferFloat) { oneTimeWarning( "snap-color-buffer-float", "Scene.snap requires the EXT_color_buffer_float extension, which is not supported on this platform.", ); return undefined; } // Created lazily so applications that never snap pay no framebuffer cost. if (!defined(defaultView.snapFramebuffer)) { defaultView.snapFramebuffer = new SnapFramebuffer(context); } const snapFramebuffer = defaultView.snapFramebuffer; const drawingBufferRectangle = scratchRectangle; pickBegin(scene, windowPosition, drawingBufferRectangle, width, height, { framebuffer: snapFramebuffer, snap: true, }); const hits = snapFramebuffer.end(drawingBufferRectangle); pickEnd(scene); if (hits.length === 0) { return undefined; } const best = selectBestHit(hits); if (!defined(best)) { return undefined; } const position = snapHitToWorld(scene, windowPosition, best); if (!defined(position)) { return undefined; } return { object: best.object, isEdge: best.isEdge, position: position, screenPosition: new Cartesian2( windowPosition.x + best.x, windowPosition.y + best.y, ), }; }; // Exposed for testing. Snapping._selectBestHit = selectBestHit; Snapping._snapHitToWorld = snapHitToWorld; export default Snapping;