diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b2cd78..3106fc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release - name: Build - run: cmake --build build --target displayxr_linux_window_test displayxr_linux_window_lattice_test displayxr_linux_window_drag_test displayxr_linux_window_input_test displayxr_url_fetch_test displayxr_launch_args_test + run: cmake --build build --target displayxr_linux_window_test displayxr_linux_window_lattice_test displayxr_linux_window_drag_test displayxr_linux_window_input_test displayxr_url_fetch_test displayxr_launch_args_test displayxr_cursor_depth_test displayxr_cursor_depth_x11_compile - name: Test (launch contract + SHA-1 cache key) run: ./build/tests/displayxr_launch_args_test @@ -93,6 +93,11 @@ jobs: - name: Test (probe + arg parsing, no display) run: ./build/tests/displayxr_linux_window_test + - name: Test (dxr::CursorDepth core + the X11 cursor adapter under Xvfb) + run: | + ./build/tests/displayxr_cursor_depth_test + xvfb-run -a -s "-screen 0 1280x800x24" ./build/tests/displayxr_cursor_depth_x11_compile + - name: Test (X11 windows under Xvfb) run: xvfb-run -a -s "-screen 0 1920x1080x24" env DXR_LW_TEST_X11=1 ./build/tests/displayxr_linux_window_test diff --git a/CMakeLists.txt b/CMakeLists.txt index c848485..b79986b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -207,7 +207,11 @@ else() # XR_DXR_wayland_surface_binding.h SPEC_VERSION 2 (the surface-geometry # struct + transparentBackgroundEnabled that displayxr::linux_window # chains; the previous pin had spec 1). - GIT_TAG 4c3fa8e816f0224844e12e59374f01f9d8d50f9a + # Bumped 2026-10-11 to displayxr-extensions@main (runtime c4a9d06b5): + # XR_DXR_cursor_depth.h SPEC_VERSION 5 (the runtime-drawn cursor's + # XrCursorDepthDrawInfoDXR / XrCursorDepthDrawStateDXR), which + # common/cursor_depth.h needs. + GIT_TAG af2de88348ab062a21344bf4d27c19eba40dfd8a ) FetchContent_GetProperties(displayxr_extensions_headers) if(NOT displayxr_extensions_headers_POPULATED) @@ -279,6 +283,7 @@ set(DISPLAYXR_COMMON_SOURCES common/content_bounds.h # header-only: ProjectAabbToCanvasBounds + ChainContentBounds (XR_DXR_depth_budget v2) common/content_mask.h # header-only: ContentMaskFromCoverage + ChainContentMask (XR_DXR_depth_budget v3) common/dxr_view_config.h # header-only: DxrSelectViewConfigType() PRIMARY_MULTIVIEW_DXR opt-in (runtime #1486) + common/cursor_depth.h # header-only core: dxr::CursorDepth, XR_DXR_cursor_depth v5 (ADR-046 Amendment 2) ) if(WIN32) list(APPEND DISPLAYXR_COMMON_SOURCES @@ -299,6 +304,7 @@ if(WIN32) common/text_overlay.h common/hud_renderer.cpp common/hud_renderer.h + common/cursor_depth_win32.cpp # dxr::cursorPlatformWin32 (pointer + WM_SETCURSOR hide) ) elseif(APPLE) list(APPEND DISPLAYXR_COMMON_SOURCES @@ -306,6 +312,7 @@ elseif(APPLE) common/hud_renderer_macos.mm common/hud_renderer_macos.h common/stb_image_impl_macos.cpp + common/cursor_depth_macos.mm # dxr::cursorPlatformCocoa (pointer + NSCursor hide) ) endif() @@ -337,6 +344,13 @@ endif() # frame ring most (Adreno DVFS, modelviewer #170). They need # on the include path and the consumer's existing Vulkan link; nothing is added # here. +# +# common/cursor_depth.h (dxr::CursorDepth) likewise: its core is header-only +# and needs only (SPEC_VERSION >= 5), so an +# Android leg gets the footprint rays + struct chaining through this target +# and drives it with setPointerUV(). Its Win32 / Cocoa window adapters are +# compiled into displayxr::common; the X11 one is header-only +# (cursor_depth_x11.h) because this library does not link libX11. add_library(displayxr_rules INTERFACE) add_library(displayxr::rules ALIAS displayxr_rules) target_include_directories(displayxr_rules INTERFACE diff --git a/README.md b/README.md index 403097f..8d1361f 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ The `common/` directory is the lib's second target (epic #396 W4, re-scoped [#39 | View-configuration opt-in: `DxrSelectViewConfigType()` — begin the session with `PRIMARY_MULTIVIEW_DXR` when the runtime advertises it; `DxrAliasInactiveViews()` — submit every located view, aliasing the ones the active mode does not use (ADR-041) | `dxr_view_config.h` | both (also `displayxr::rules`, so Linux/Android legs get it) | | Vulkan **frame ring**: N frames in flight (default 3), ONE `vkQueueSubmit` per frame, per-slot command buffer + fence + dynamic-offset UBO slice allocated once, tile barriers, deferred deletion, one named blocking escape hatch (`submitImmediate`) — see *Vulkan frame discipline* below | `vk_frame_ring.h` | all (also `displayxr::rules`, so Linux/Android legs get it; needs ``) | | Vulkan overlay kit: content-hash-gated layer uploads (`CachedLayerUploader`) + fence-pipelined readbacks (`PipelinedReadback`), each with ONE reused command buffer ([runtime#837](https://github.com/DisplayXR/displayxr-runtime/issues/837)) | `vk_overlay_kit.h` | all (needs ``) | +| Depth-aware cursor (`XR_DXR_cursor_depth` v5, ADR-046 Amendment 2): `dxr::CursorDepth` — pointer -> canvas UV, footprint rays through last frame's views, nearest hit, struct chaining, OS cursor hidden exactly while the runtime draws; `dxr::cursorFootprintRays()` | `cursor_depth.h` (+ `cursor_depth_win32.cpp`, `cursor_depth_macos.mm`, header-only `cursor_depth_x11.h`) | core: all (also `displayxr::rules`); adapters: Win32, macOS, X11 | **Divergence policy:** behavior differences between consumers are parameterized at the call site (e.g. `InputState::hudToggleRequiresShift`, `EndFrame(..., projectionLayerFlags)`) — never `#ifdef APP` in the lib. Request-flag fields that only one app consumes (file picker, clip playback, transparency toggle) are fine: unconsumed flags are inert. @@ -685,6 +686,61 @@ if (const uint8_t* cov = punch.coverage()) { } ``` +## Depth-aware cursor (`cursor_depth.h`, `XR_DXR_cursor_depth` v5) + +The runtime **draws** the depth cursor (ADR-046 Amendment 2 §B): a stereo sprite lifted just in +front of whatever content pops out of the screen under the pointer, and nothing at all (the OS +cursor is the cursor) over content on or behind the glass. The app only owns the depth under the +pointer. `dxr::CursorDepth` does the rest of the bookkeeping: + +```cpp +#include "cursor_depth.h" + +dxr::CursorDepth cursor; // inert until enable() +cursor.enable(cursorDepthSpecVersion); // extensionVersion if you enabled XR_DXR_cursor_depth, else 0 +cursor.setWindowCocoa((__bridge void *)view); // or setWindowWin32(hwnd), or + // setPlatform(dxr::cursorPlatformX11(dpy, win)) +cursor.setRayCast([&](const dxr::CursorRay &r) -> std::optional { + return scene.raycast(r.origin, r.dir); // t of the first hit, or std::nullopt +}); + +// every frame: +cursor.chain(locateInfo, viewState); // pointer UV + hit test + struct chaining +xrLocateViews(session, &locateInfo, &viewState, n, &n, views.data()); +cursor.afterLocate(views.data(), activeViewCount);// hides the OS cursor exactly while willDraw +``` + +- **Zero cost when not enabled:** `enable(0)` (or never calling it) leaves both struct chains + untouched and never touches the OS cursor. Enabled, a frame allocates nothing. +- **Depth source:** `setRayCast` (ray -> nearest hit; the helper fires rays from the two outer + views through the cursor's footprint and keeps the hit nearest the viewer), `setHitTest` (full + control: you get the views, the UV and the ready-made rays), or `useDepthLayer(true)` (spec v2: + the runtime reads the depth you submit with `XR_KHR_composition_layer_depth`). +- **Look:** `setSprite(swapchain, hotSpot, sizeMeters, aspect)`; the default (`XR_NULL_HANDLE`) + is the runtime's own look, the OS cursor's shape or the classic arrow. The footprint follows + the sprite: the arrow's is the tip plus a ring around its body, the same rule as the runtime + and the web SDK (`cursorArrowFootprint`). +- **Options:** `setAnchor()`, `setMargin()` (spec v3), `setCursorHeight()` (footprint size, + default 0.03 of the canvas height), `setWantPlacement(true)` for diagnostics. +- **App-drawn cursor** (v1-v4 style): `setDrawMode(DrawMode::App)`, then draw your sprite at + `placement().pose` / `placement().height` while `appShouldDraw()`. +- **Never no cursor:** the OS cursor is hidden only while the runtime reports `willDraw`, so a + backend that cannot draw yet (spec v5: Metal draws first) leaves the flat OS cursor. + +**Platforms.** The core is header-only and platform-neutral (it ships in `displayxr::rules`, so +Android legs can use it). Window adapters: + +| Window | Adapter | Pointer | OS-cursor hide | +|---|---|---|---| +| Win32 `HWND` | `setWindowWin32(hwnd)` (`cursor_depth_win32.cpp`) | `GetCursorPos` -> client rect | answers `WM_SETCURSOR`/`HTCLIENT` with `SetCursor(NULL)` via a chained window proc; client area only | +| Cocoa `NSView*` | `setWindowCocoa(view)` (`cursor_depth_macos.mm`) | `mouseLocationOutsideOfEventStream` -> view bounds | `NSCursor hide/unhide`, balanced, on the main thread | +| Xlib `(Display*, Window)` | `setPlatform(dxr::cursorPlatformX11(dpy, win))` (header-only `cursor_depth_x11.h`; the app links libX11) | `XQueryPointer` | an invisible pixmap cursor on the window | +| Anything else (Wayland, Android, `displayxr::linux_window`, a custom toolkit) | manual: `setPointerUV(u, v)` / `clearPointerUV()` + `onDepthCursorShownChanged(cb)` | yours | yours, e.g. `win.set_cursor(shown ? DxrCursor::Hidden : DxrCursor::Default)` | + +`dxr::cursorFootprintRays(views, activeCount, uv, heightFrac)` is usable on its own for an app that +keeps its own chaining. Reference adoption: the runtime's +`test_apps/handle/cube_handle_metal_macos`. + ## Client-side window chrome (`csd_titlebar.h`) A windowed 3D app on Linux draws its own title bar. On X11 that is how the app diff --git a/common/cursor_depth.h b/common/cursor_depth.h new file mode 100644 index 0000000..ff7c0d9 --- /dev/null +++ b/common/cursor_depth.h @@ -0,0 +1,564 @@ +// Copyright 2026, DisplayXR contributors +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief `dxr::CursorDepth` — the app-side helper for the depth-aware cursor + * (`XR_DXR_cursor_depth`, ADR-046 + Amendment 2). + * + * The runtime DRAWS the depth cursor (spec v5, Amendment 2 §B); the app only + * owns the depth under it. What is left for an app is bookkeeping that every + * app would otherwise re-type: read the pointer as a canvas UV, fire the + * cursor FOOTPRINT rays through the previous frame's views, keep the nearest + * hit, chain four structs on the right `next` pointers, and hide the OS + * cursor exactly while the runtime says it draws. That is this class. + * + * dxr::CursorDepth cursor; // inert until enable() + * cursor.enable(cursorDepthSpecVersion); // 0 = ext not enabled: stays inert, zero cost + * cursor.setWindowCocoa((__bridge void*)view); // or setWindowWin32(hwnd), or + * // setPlatform(dxr::cursorPlatformX11(dpy, win)) + * cursor.setRayCast([&](const dxr::CursorRay& r) -> std::optional { + * float t; return myScene.raycast(r.origin, r.dir, t) ? std::optional(t) : std::nullopt; + * }); // or useDepthLayer(true), or setHitTest(...) + * // per frame, around xrLocateViews: + * cursor.chain(locateInfo, viewState); + * xrLocateViews(session, &locateInfo, &viewState, cap, &n, views); + * cursor.afterLocate(views, activeViewCount); // OS cursor hidden exactly while willDraw + * + * Manual path (any platform, incl. Wayland / Android / displayxr::linux_window): + * feed `setPointerUV(u, v)` (or `clearPointerUV()` when the pointer leaves + * the canvas) and react to `onDepthCursorShownChanged(cb)` with your own + * cursor-hide call, e.g. `win.set_cursor(shown ? DxrCursor::Hidden : DxrCursor::Default)`. + * + * Design rules: + * - ZERO COST WHEN NOT ENABLED (ADR-046 §0): before `enable(v>0)`, `chain()` + * returns without touching either struct chain and `afterLocate()` returns + * at once. Enabled, a frame allocates nothing (fixed-size storage only). + * - No graphics-API code: the runtime draws (or, in DrawMode::App, the app + * draws from `placement()`). + * - Never no cursor: the OS cursor is hidden only while the runtime reports + * willDraw (DrawMode::Runtime) or the placement is active (DrawMode::App). + * On a pre-v5 runtime, Runtime mode never hides it (nothing would draw). + * + * The pure parts (`cursorFootprintUV`, `cursorFootprintRays`, `cursorViewRay`, + * `cursorCanvasUV`, the class itself) are header-only and platform-neutral, + * so this header is part of `displayxr::rules` and works on Android. The + * window adapters live in `cursor_depth_win32.cpp` (displayxr::common, + * Windows), `cursor_depth_macos.mm` (displayxr::common, macOS) and the + * header-only `cursor_depth_x11.h` (include it where Xlib is available). + */ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#if !defined(XR_DXR_cursor_depth_SPEC_VERSION) || (XR_DXR_cursor_depth_SPEC_VERSION < 5) +#error "cursor_depth.h needs XR_DXR_cursor_depth.h SPEC_VERSION >= 5 (displayxr-runtime v2.36+ headers)" +#endif + +namespace dxr { + +// --------------------------------------------------------------------------- +// Pure geometry +// --------------------------------------------------------------------------- + +//! A ray in the locate space: origin = a view's eye, dir = unit direction. +struct CursorRay +{ + XrVector3f origin; + XrVector3f dir; +}; + +/*! + * The footprint's ring centre relative to the hotspot, in units of the cursor + * height (x right, y DOWN). The default arrow hangs down-right of its tip; + * this is the SAME rule as the runtime / web SDK (`cursorArrowFootprint`: + * centre = tip + (0.3 h / aspect, 0.5 h)). + */ +inline constexpr XrVector2f kCursorArrowBodyOffset = {0.3f, 0.5f}; +//! A sprite whose hotspot is its centre (crosshair, ring): the ring is around the hotspot. +inline constexpr XrVector2f kCursorCenteredBodyOffset = {0.0f, 0.0f}; + +/*! + * Body offset for a custom sprite: from its hotspot (normalised, v down) to + * its centre, in units of its height. `spriteAspect` = width / height. + */ +inline XrVector2f +cursorBodyOffsetForSprite(XrVector2f hotSpot, float spriteAspect) +{ + return {(0.5f - hotSpot.x) * spriteAspect, 0.5f - hotSpot.y}; +} + +//! Max points `cursorFootprintUV` produces: the hotspot + an 8-point ring (+ the ring centre). +inline constexpr uint32_t kCursorFootprintMaxPoints = 10; + +/*! + * The cursor FOOTPRINT, canvas-normalised (origin top-left, v down): the + * hotspot first, then 8 points on a circle of radius 0.75 x heightFrac + * (canvas heights) around the sprite's body (hotspot + bodyOffset x height), + * plus the body centre itself when it is not the hotspot. Same rule as the + * runtime's submitted-depth search and the web SDK. + * + * @param canvasAspect canvas width / height (> 0). + * @return the number of points written (<= kCursorFootprintMaxPoints). + */ +inline uint32_t +cursorFootprintUV(XrVector2f uv, + float heightFrac, + float canvasAspect, + XrVector2f out[kCursorFootprintMaxPoints], + XrVector2f bodyOffset = kCursorArrowBodyOffset) +{ + if (!(canvasAspect > 0.0f) || !std::isfinite(canvasAspect)) canvasAspect = 1.0f; + uint32_t n = 0; + out[n++] = uv; + const float cu = uv.x + bodyOffset.x * heightFrac / canvasAspect; + const float cv = uv.y + bodyOffset.y * heightFrac; + const float r = 0.75f * heightFrac; + for (int k = 0; k < 8; k++) { + const float a = (float)k * 0.785398163f; // pi/4 + out[n++] = {cu + r * std::cos(a) / canvasAspect, cv + r * std::sin(a)}; + } + if (bodyOffset.x != 0.0f || bodyOffset.y != 0.0f) out[n++] = {cu, cv}; + return n; +} + +namespace detail { +inline XrVector3f +rotate(const XrQuaternionf &q, XrVector3f v) +{ + // v' = v + 2w (q x v) + 2 q x (q x v) + const float tx = 2.0f * (q.y * v.z - q.z * v.y); + const float ty = 2.0f * (q.z * v.x - q.x * v.z); + const float tz = 2.0f * (q.x * v.y - q.y * v.x); + return {v.x + q.w * tx + (q.y * tz - q.z * ty), v.y + q.w * ty + (q.z * tx - q.x * tz), + v.z + q.w * tz + (q.x * ty - q.y * tx)}; +} +inline float dot(XrVector3f a, XrVector3f b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +//! Zeroed struct with its type set (no -Wmissing-field-initializers noise). +template +inline T +makeXr(XrStructureType type) +{ + T v{}; + v.type = type; + return v; +} +} // namespace detail + +/*! + * Ray from a located view through canvas point (u, v). Every DisplayXR view + * is a Kooima frustum framing the canvas, so (u, v) maps linearly onto the + * fov tangents — exact for display rigs, camera rigs and any m2v. + */ +inline CursorRay +cursorViewRay(const XrView &view, float u, float v) +{ + const float tl = std::tan(view.fov.angleLeft), tr = std::tan(view.fov.angleRight); + const float tu = std::tan(view.fov.angleUp), td = std::tan(view.fov.angleDown); + XrVector3f d = detail::rotate(view.pose.orientation, {tl + u * (tr - tl), tu - v * (tu - td), -1.0f}); + const float len = std::sqrt(detail::dot(d, d)); + if (len > 0.0f) d = {d.x / len, d.y / len, d.z / len}; + return {view.pose.position, d}; +} + +/*! + * The canvas aspect (width / height) a Kooima view frames: the tangent + * extents of its fov ARE the canvas extents over the eye distance. 0 when the + * fov is degenerate. + */ +inline float +cursorCanvasAspect(const XrView &view) +{ + const float w = std::tan(view.fov.angleRight) - std::tan(view.fov.angleLeft); + const float h = std::tan(view.fov.angleUp) - std::tan(view.fov.angleDown); + return (h > 0.0f && w > 0.0f) ? w / h : 0.0f; +} + +//! Max rays: footprint points x the two outer views. +inline constexpr uint32_t kCursorFootprintMaxRays = 2 * kCursorFootprintMaxPoints; + +//! Fixed-size ray set (no allocation). +struct CursorFootprintRays +{ + CursorRay rays[kCursorFootprintMaxRays]; + uint32_t count = 0; + const CursorRay *begin() const { return rays; } + const CursorRay *end() const { return rays + count; } +}; + +/*! + * Rays from the OUTER views (views[0] and views[viewCount-1]) through every + * footprint point (cursorFootprintUV). "Ray -> nearest hit" over these, kept + * nearest the viewer, is the hint's nearestPoint. + * + * @param viewCount the ACTIVE view count (1 in 2D -> no rays: nothing to hint). + * @param canvasAspect width / height; <= 0 derives it from views[0]'s fov. + */ +inline CursorFootprintRays +cursorFootprintRays(const XrView *views, + uint32_t viewCount, + XrVector2f uv, + float heightFrac, + float canvasAspect = 0.0f, + XrVector2f bodyOffset = kCursorArrowBodyOffset) +{ + CursorFootprintRays out; + if (views == nullptr || viewCount < 2) return out; + if (!(canvasAspect > 0.0f)) canvasAspect = cursorCanvasAspect(views[0]); + XrVector2f pts[kCursorFootprintMaxPoints]; + const uint32_t np = cursorFootprintUV(uv, heightFrac, canvasAspect, pts, bodyOffset); + const XrView *outer[2] = {&views[0], &views[viewCount - 1]}; + for (uint32_t i = 0; i < np; i++) + for (const XrView *v : outer) out.rays[out.count++] = cursorViewRay(*v, pts[i].x, pts[i].y); + return out; +} + +/*! + * Pointer position in a window's client area -> canvas UV (origin top-left, + * v down). `yUp` = the coordinates grow upward (an unflipped NSView). + * Returns false when the area is empty; the UV may lie outside [0,1]. + */ +inline bool +cursorCanvasUV(double x, double y, double width, double height, bool yUp, XrVector2f &uv) +{ + if (!(width > 0.0) || !(height > 0.0)) return false; + uv.x = (float)(x / width); + uv.y = (float)(yUp ? 1.0 - y / height : y / height); + return true; +} + +inline bool +cursorUVInCanvas(XrVector2f uv) +{ + return uv.x >= 0.0f && uv.x <= 1.0f && uv.y >= 0.0f && uv.y <= 1.0f; +} + +// --------------------------------------------------------------------------- +// Window adapters +// --------------------------------------------------------------------------- + +/*! + * A window the helper reads the pointer from and hides the OS cursor over. + * Ready-made: Win32 (setWindowWin32), Cocoa (setWindowCocoa), X11 + * (cursor_depth_x11.h). Implement it yourself for anything else, or skip it + * and use the manual path. + */ +class CursorPlatform +{ +public: + virtual ~CursorPlatform() = default; + //! Pointer as canvas UV (may be outside [0,1]); false = unknown. + virtual bool pointerUV(XrVector2f &uv) = 0; + //! Hide (true) / show (false) the OS cursor over the canvas. Called on transitions only. + virtual void setOsCursorHidden(bool hidden) = 0; +}; + +//! Win32: HWND. Defined in cursor_depth_win32.cpp (displayxr::common on Windows). +std::unique_ptr cursorPlatformWin32(void *hwnd); +//! Cocoa: NSView* (the canvas view). Defined in cursor_depth_macos.mm (displayxr::common on macOS). +std::unique_ptr cursorPlatformCocoa(void *nsView); + +// --------------------------------------------------------------------------- +// The helper +// --------------------------------------------------------------------------- + +class CursorDepth +{ +public: + //! Who draws the depth cursor. + enum class DrawMode + { + Runtime, //!< spec v5: chain XrCursorDepthDrawInfoDXR; hide the OS cursor while willDraw + App, //!< v1-v4: the app draws from placement(); OS cursor hidden while it is active + }; + + //! "ray -> nearest hit": the ray parameter t of the first hit (point = origin + t * dir). + using RayCastFn = std::function(const CursorRay &)>; + //! Full control: return the nearest content point under the footprint, in the locate space. + using HitTestFn = std::function(const XrView *views, uint32_t viewCount, + XrVector2f uv, const CursorFootprintRays &rays)>; + + CursorDepth() = default; + ~CursorDepth() { setDepthCursorShown(false); } + CursorDepth(const CursorDepth &) = delete; + CursorDepth &operator=(const CursorDepth &) = delete; + + // ---- setup ------------------------------------------------------------ + + /*! + * @param specVersion the XR_DXR_cursor_depth extensionVersion the runtime + * reported, IF you enabled the extension at xrCreateInstance; 0 = + * not enabled (the helper stays inert). Below 5 the helper falls + * back to DrawMode::App semantics for the OS cursor (see DrawMode). + */ + void enable(uint32_t specVersion) + { + m_spec = specVersion; + if (m_spec == 0) setDepthCursorShown(false); + } + bool enabled() const { return m_spec > 0; } + uint32_t specVersion() const { return m_spec; } + + void setPlatform(std::unique_ptr p) + { + setDepthCursorShown(false); + m_platform = std::move(p); + } + void setWindowWin32(void *hwnd) { setPlatform(cursorPlatformWin32(hwnd)); } + void setWindowCocoa(void *nsView) { setPlatform(cursorPlatformCocoa(nsView)); } + + //! Manual pointer (overrides the platform until clearPointerUV()). Outside [0,1] = off the canvas. + void setPointerUV(float u, float v) + { + m_manualUV = true; + m_uv = {u, v}; + } + void clearPointerUV() { m_manualUV = false; } + + //! Called on every change of "a depth cursor replaces the OS cursor" (after the platform hide/show). + void onDepthCursorShownChanged(std::function cb) { m_onShown = std::move(cb); } + + void setDrawMode(DrawMode m) { m_drawMode = m; } + DrawMode drawMode() const { return m_drawMode; } + + /*! + * The runtime-drawn sprite (spec v5): an XrSwapchain the app filled and + * released, its hotspot (normalised, v down), its height on the panel in + * metres (0 = the OS cursor's size), and its aspect (w/h, for the + * footprint). XR_NULL_HANDLE (the default) = the runtime's default look + * (the OS cursor / classic arrow), footprint = the arrow's. + */ + void setSprite(XrSwapchain image, XrVector2f hotSpot, float sizeMeters, float spriteAspect = 1.0f) + { + m_drawInfo.image = image; + m_drawInfo.hotSpot = hotSpot; + m_drawInfo.sizeMeters = sizeMeters; + m_bodyOffset = image == XR_NULL_HANDLE ? kCursorArrowBodyOffset + : cursorBodyOffsetForSprite(hotSpot, spriteAspect); + } + void setVisible(bool visible) { m_drawInfo.visible = visible ? XR_TRUE : XR_FALSE; } + //! Override the footprint shape (e.g. kCursorCenteredBodyOffset for an app-drawn crosshair). + void setFootprintBodyOffset(XrVector2f o) { m_bodyOffset = o; } + + //! Cursor height as a fraction of the canvas height: the footprint size (and the + //! hint's cursorHeight). Default 0.03, the runtime's. + void setCursorHeight(float frac) { m_height = frac; } + float cursorHeight() const { return m_height; } + + //! Spec v3 options; chained only once one of these was called. + void setAnchor(XrCursorDepthAnchorModeDXR mode) + { + m_options.anchorMode = mode; + m_chainOptions = true; + } + void setMargin(float marginBaselines) + { + m_options.margin = marginBaselines; + m_chainOptions = true; + } + + /*! + * Spec v2: the runtime finds the nearest content in the depth the app + * submits (XR_KHR_composition_layer_depth, projection layer in the locate + * space). No hit test runs. Ignored below v2. + */ + void useDepthLayer(bool on) { m_depthLayer = on; } + + void setRayCast(RayCastFn fn) { m_rayCast = std::move(fn); } + void setHitTest(HitTestFn fn) { m_hitTest = std::move(fn); } + + //! Also chain XrCursorDepthPlacementDXR (diagnostics in Runtime mode; always chained in App mode). + void setWantPlacement(bool on) { m_wantPlacement = on; } + + // ---- per frame -------------------------------------------------------- + + /*! + * Before xrLocateViews: reads the pointer, runs the hit test against the + * PREVIOUS locate's views (a frame late; the runtime's filter absorbs it), + * and prepends the cursor structs to both chains: + * locateInfo -> hint [-> drawInfo] [-> options] [-> source] -> (previous locateInfo.next) + * viewState -> drawState [-> placement] -> (previous viewState.next) + * The structs live in this object: keep it alive across the xrLocateViews. + * Not enabled: touches nothing. + */ + void chain(XrViewLocateInfo &locateInfo, XrViewState &viewState) + { + m_hintChained = false; + if (m_spec == 0) return; + + XrVector2f uv{}; + bool have = false; + if (m_manualUV) { + uv = m_uv; + have = true; + } else if (m_platform) { + have = m_platform->pointerUV(uv); + } + m_inCanvas = have && cursorUVInCanvas(uv); + + if (m_inCanvas) { + m_hint = detail::makeXr(XR_TYPE_CURSOR_DEPTH_HINT_DXR); + m_hint.cursorUV = uv; + m_hint.cursorHeight = m_height; + m_hint.hasContent = XR_FALSE; + m_hint.nearestPoint = {0.0f, 0.0f, 0.0f}; + const bool layer = m_depthLayer && m_spec >= 2; + if (!layer) { + std::optional hit = hitTest(uv); + if (hit) { + m_hint.hasContent = XR_TRUE; + m_hint.nearestPoint = *hit; + } + } + const void *tail = locateInfo.next; + if (layer) { + m_source = detail::makeXr(XR_TYPE_CURSOR_DEPTH_SOURCE_DXR); + m_source.next = tail; + m_source.source = XR_CURSOR_DEPTH_SOURCE_SUBMITTED_DEPTH_DXR; + tail = &m_source; + } + if (m_chainOptions && m_spec >= 3) { + m_options.type = XR_TYPE_CURSOR_DEPTH_OPTIONS_DXR; + m_options.next = tail; + tail = &m_options; + } + if (runtimeDraws()) { + m_drawInfo.type = XR_TYPE_CURSOR_DEPTH_DRAW_INFO_DXR; + m_drawInfo.next = tail; + tail = &m_drawInfo; + } + m_hint.next = tail; + locateInfo.next = &m_hint; + m_hintChained = true; + } + + m_placement = detail::makeXr(XR_TYPE_CURSOR_DEPTH_PLACEMENT_DXR); + m_drawState = detail::makeXr(XR_TYPE_CURSOR_DEPTH_DRAW_STATE_DXR); + void *vtail = viewState.next; + if (m_wantPlacement || !runtimeDraws()) { + m_placement.next = vtail; + vtail = &m_placement; + } + if (runtimeDraws()) { + m_drawState.next = vtail; + vtail = &m_drawState; + } + viewState.next = vtail; + } + + /*! + * After xrLocateViews: remembers the views for next frame's hit test and + * hides / shows the OS cursor on transitions. + * @param activeViewCount the views that are live this frame (1 in 2D). + */ + void afterLocate(const XrView *views, uint32_t activeViewCount) + { + if (m_spec == 0) return; + // Keep the outer pair only: that is all the footprint rays use. + m_prevCount = 0; + if (views != nullptr && activeViewCount >= 2) { + m_prevViews[0] = views[0]; + m_prevViews[1] = views[activeViewCount - 1]; + m_prevCount = 2; + } + bool shown; + if (runtimeDraws()) + shown = m_hintChained && m_drawState.willDraw == XR_TRUE; + else + shown = m_drawMode == DrawMode::App && m_hintChained && m_placement.isActive == XR_TRUE; + setDepthCursorShown(shown); + } + + // ---- results ---------------------------------------------------------- + + //! True while a depth cursor replaces the OS cursor (the OS cursor is hidden). + bool depthCursorShown() const { return m_shown; } + //! Runtime mode on a v5+ runtime: the runtime is asked to draw. + bool runtimeDraws() const { return m_drawMode == DrawMode::Runtime && m_spec >= 5; } + //! DrawMode::App: draw your sprite at placement().pose / height while this is true. + bool appShouldDraw() const { return m_drawMode == DrawMode::App && m_shown; } + bool pointerInCanvas() const { return m_inCanvas; } + bool hintChained() const { return m_hintChained; } + const XrCursorDepthHintDXR &hint() const { return m_hint; } + const XrCursorDepthPlacementDXR &placement() const { return m_placement; } + const XrCursorDepthDrawStateDXR &drawState() const { return m_drawState; } + +private: + std::optional hitTest(XrVector2f uv) const + { + if (m_prevCount < 2) return std::nullopt; // 2D or no previous locate: nothing to hint + const CursorFootprintRays rays = cursorFootprintRays(m_prevViews, 2, uv, m_height, 0.0f, m_bodyOffset); + if (m_hitTest) return m_hitTest(m_prevViews, 2, uv, rays); + if (!m_rayCast) return std::nullopt; + const XrVector3f fwd = detail::rotate(m_prevViews[0].pose.orientation, {0.0f, 0.0f, -1.0f}); + std::optional best; + float bestDepth = 0.0f; + for (const CursorRay &r : rays) { + const std::optional t = m_rayCast(r); + if (!t || !std::isfinite(*t) || *t < 0.0f) continue; + const XrVector3f p = {r.origin.x + r.dir.x * *t, r.origin.y + r.dir.y * *t, + r.origin.z + r.dir.z * *t}; + const float depth = detail::dot(p, fwd); // smaller = nearer the viewer + if (!best || depth < bestDepth) { + best = p; + bestDepth = depth; + } + } + return best; + } + + void setDepthCursorShown(bool shown) + { + if (shown == m_shown) return; + m_shown = shown; + if (m_platform) m_platform->setOsCursorHidden(shown); + if (m_onShown) m_onShown(shown); + } + + uint32_t m_spec = 0; + DrawMode m_drawMode = DrawMode::Runtime; + std::unique_ptr m_platform; + std::function m_onShown; + RayCastFn m_rayCast; + HitTestFn m_hitTest; + + bool m_manualUV = false; + XrVector2f m_uv{}; + bool m_inCanvas = false; + float m_height = 0.03f; + XrVector2f m_bodyOffset = kCursorArrowBodyOffset; + bool m_depthLayer = false; + bool m_chainOptions = false; + bool m_wantPlacement = false; + bool m_hintChained = false; + bool m_shown = false; + + XrView m_prevViews[2] = {}; + uint32_t m_prevCount = 0; + + XrCursorDepthHintDXR m_hint = detail::makeXr(XR_TYPE_CURSOR_DEPTH_HINT_DXR); + XrCursorDepthSourceDXR m_source = detail::makeXr(XR_TYPE_CURSOR_DEPTH_SOURCE_DXR); + //! anchorMode 0 = HYBRID, margin 0 = the runtime default. + XrCursorDepthOptionsDXR m_options = detail::makeXr(XR_TYPE_CURSOR_DEPTH_OPTIONS_DXR); + XrCursorDepthDrawInfoDXR m_drawInfo = makeDrawInfo(); + XrCursorDepthPlacementDXR m_placement = + detail::makeXr(XR_TYPE_CURSOR_DEPTH_PLACEMENT_DXR); + XrCursorDepthDrawStateDXR m_drawState = + detail::makeXr(XR_TYPE_CURSOR_DEPTH_DRAW_STATE_DXR); + + static XrCursorDepthDrawInfoDXR makeDrawInfo() + { + XrCursorDepthDrawInfoDXR d = detail::makeXr(XR_TYPE_CURSOR_DEPTH_DRAW_INFO_DXR); + d.image = XR_NULL_HANDLE; // the runtime's default look + d.visible = XR_TRUE; + return d; + } +}; + +} // namespace dxr diff --git a/common/cursor_depth_macos.mm b/common/cursor_depth_macos.mm new file mode 100644 index 0000000..3fa485d --- /dev/null +++ b/common/cursor_depth_macos.mm @@ -0,0 +1,85 @@ +// Copyright 2026, DisplayXR contributors +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Cocoa window adapter for dxr::CursorDepth (cursor_depth.h). + * + * Pointer: the window's mouseLocationOutsideOfEventStream converted into the + * canvas view, over its bounds (flipped or not). Polled, so it needs no event + * hook; call chain() from the thread that runs the AppKit loop, as every + * DisplayXR macOS app does (off it, the read falls back to +[NSEvent + * mouseLocation] converted through the window). + * + * OS cursor: +[NSCursor hide] / +[NSCursor unhide] (balanced: the helper + * calls this on transitions only), marshalled to the main thread when called + * from another. The hide is app-wide, but the helper only hides while the + * pointer is over the canvas (no hint is chained otherwise, so willDraw is + * false), exactly like the hand-written reference adoption. + */ +#import + +#include "cursor_depth.h" + +namespace dxr { +namespace { + +class CocoaCursorPlatform final : public CursorPlatform +{ +public: + explicit CocoaCursorPlatform(NSView *view) : m_view(view) {} + + ~CocoaCursorPlatform() override + { + if (m_hidden) apply(false); + } + + bool pointerUV(XrVector2f &uv) override + { + NSView *view = m_view; + NSWindow *window = view.window; + if (view == nil || window == nil) return false; + NSPoint pw; + if ([NSThread isMainThread]) { + pw = [window mouseLocationOutsideOfEventStream]; + } else { + const NSPoint ps = [NSEvent mouseLocation]; + pw = [window convertRectFromScreen:NSMakeRect(ps.x, ps.y, 0, 0)].origin; + } + const NSPoint pv = [view convertPoint:pw fromView:nil]; + const NSRect b = view.bounds; + return cursorCanvasUV(pv.x - b.origin.x, pv.y - b.origin.y, b.size.width, b.size.height, + ![view isFlipped], uv); + } + + void setOsCursorHidden(bool hidden) override + { + if (hidden == m_hidden) return; + m_hidden = hidden; + apply(hidden); + } + +private: + static void apply(bool hidden) + { + if ([NSThread isMainThread]) { + if (hidden) [NSCursor hide]; else [NSCursor unhide]; + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + if (hidden) [NSCursor hide]; else [NSCursor unhide]; + }); + } + } + + NSView *m_view; // the app keeps the view alive while the helper uses it + bool m_hidden = false; +}; + +} // namespace + +std::unique_ptr +cursorPlatformCocoa(void *nsView) +{ + return std::make_unique((__bridge NSView *)nsView); +} + +} // namespace dxr diff --git a/common/cursor_depth_win32.cpp b/common/cursor_depth_win32.cpp new file mode 100644 index 0000000..264c5d5 --- /dev/null +++ b/common/cursor_depth_win32.cpp @@ -0,0 +1,147 @@ +// Copyright 2026, DisplayXR contributors +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Win32 window adapter for dxr::CursorDepth (cursor_depth.h). + * + * Pointer: GetCursorPos -> ScreenToClient over GetClientRect. Both are in the + * process's DPI space, so the ratio is right whatever the awareness. + * + * OS cursor: Windows re-sets the cursor on every WM_SETCURSOR, so hiding it + * means answering WM_SETCURSOR (HTCLIENT) with SetCursor(NULL). The adapter + * subclasses the window procedure (SetWindowLongPtrW, chained with + * CallWindowProcW — callable from the render thread, unlike + * SetWindowSubclass) and, on each hide/show transition, posts itself a + * private message so the change takes effect on the WINDOW's thread without + * waiting for the next mouse move. Only the client area is affected: the + * frame, title bar and other windows keep their cursors. The original + * procedure is restored on destruction when ours is still installed. + */ +#ifdef _WIN32 + +#include "cursor_depth.h" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include + +namespace dxr { +namespace { + +const wchar_t *const kProp = L"DisplayXR.CursorDepth"; + +UINT +refreshMessage() +{ + static const UINT msg = RegisterWindowMessageW(L"DisplayXR.CursorDepth.Refresh"); + return msg; +} + +struct Win32State +{ + HWND hwnd = nullptr; + WNDPROC prev = nullptr; + std::atomic hidden{false}; +}; + +bool +pointerOverClient(HWND hwnd) +{ + POINT pt; + if (!GetCursorPos(&pt) || WindowFromPoint(pt) != hwnd) return false; + RECT rc; + if (!GetClientRect(hwnd, &rc) || !ScreenToClient(hwnd, &pt)) return false; + return pt.x >= rc.left && pt.x < rc.right && pt.y >= rc.top && pt.y < rc.bottom; +} + +LRESULT CALLBACK +cursorWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + Win32State *s = static_cast(GetPropW(hwnd, kProp)); + if (s == nullptr) return DefWindowProcW(hwnd, msg, wp, lp); + if (msg == WM_SETCURSOR && LOWORD(lp) == HTCLIENT && s->hidden.load()) { + SetCursor(nullptr); + return TRUE; + } + if (msg == refreshMessage()) { + if (pointerOverClient(hwnd)) { + // Re-run the normal WM_SETCURSOR path: hidden -> NULL above; shown + // -> the app's own handler (or DefWindowProc's class cursor). + SendMessageW(hwnd, WM_SETCURSOR, (WPARAM)hwnd, MAKELPARAM(HTCLIENT, WM_MOUSEMOVE)); + } + return 0; + } + return CallWindowProcW(s->prev, hwnd, msg, wp, lp); +} + +class Win32CursorPlatform final : public CursorPlatform +{ +public: + explicit Win32CursorPlatform(HWND hwnd) : m_state(new Win32State) + { + m_state->hwnd = hwnd; + if (hwnd == nullptr || !IsWindow(hwnd)) return; + refreshMessage(); + SetPropW(hwnd, kProp, m_state); + m_state->prev = reinterpret_cast( + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(&cursorWndProc))); + if (m_state->prev == nullptr) RemovePropW(hwnd, kProp); + } + + ~Win32CursorPlatform() override + { + HWND hwnd = m_state->hwnd; + m_state->hidden = false; + if (m_state->prev == nullptr || !IsWindow(hwnd)) { + delete m_state; + return; + } + if (reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_WNDPROC)) == &cursorWndProc) { + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(m_state->prev)); + RemovePropW(hwnd, kProp); + delete m_state; + PostMessageW(hwnd, WM_MOUSEMOVE, 0, 0); // let the app's cursor come back promptly + return; + } + // Someone subclassed after us: leave our proc chained, inert (hidden = + // false), and its state alive for the window's lifetime, rather than + // cut their chain off. A few bytes, once per such window. + } + + bool pointerUV(XrVector2f &uv) override + { + HWND hwnd = m_state->hwnd; + if (hwnd == nullptr) return false; + POINT pt; + RECT rc; + if (!GetCursorPos(&pt) || !ScreenToClient(hwnd, &pt) || !GetClientRect(hwnd, &rc)) return false; + return cursorCanvasUV(pt.x, pt.y, rc.right - rc.left, rc.bottom - rc.top, false, uv); + } + + void setOsCursorHidden(bool hidden) override + { + m_state->hidden = hidden; + if (m_state->prev != nullptr) PostMessageW(m_state->hwnd, refreshMessage(), 0, 0); + } + +private: + Win32State *m_state; // heap: may outlive us (see the destructor) +}; + +} // namespace + +std::unique_ptr +cursorPlatformWin32(void *hwnd) +{ + return std::make_unique(static_cast(hwnd)); +} + +} // namespace dxr + +#endif // _WIN32 diff --git a/common/cursor_depth_x11.h b/common/cursor_depth_x11.h new file mode 100644 index 0000000..dd683e9 --- /dev/null +++ b/common/cursor_depth_x11.h @@ -0,0 +1,93 @@ +// Copyright 2026, DisplayXR contributors +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief X11 (Xlib) window adapter for dxr::CursorDepth (cursor_depth.h). + * + * Header-only on purpose: displayxr::common does not link libX11, the app + * that owns an Xlib window does. Include it in that app: + * + * #include "cursor_depth_x11.h" + * cursor.setPlatform(dxr::cursorPlatformX11(display, window)); + * + * Pointer: XQueryPointer relative to the window over XGetWindowAttributes' + * size (two round trips per frame). OS cursor: an invisible 1x1 pixmap + * cursor is defined on the window while hidden; showing restores the cursor + * the window had (`restoreCursor`, default None = inherit the parent's). + * Core Xlib only, no XFixes. If you call chain() from a thread other than + * the one running your X event loop, call XInitThreads() first (Xlib rule). + * + * Apps on displayxr::linux_window (X11 or Wayland) should use its own + * set_cursor() through the manual path instead — see cursor_depth.h. + */ +#pragma once + +#include "cursor_depth.h" + +#include + +namespace dxr { + +class X11CursorPlatform final : public CursorPlatform +{ +public: + X11CursorPlatform(Display *dpy, Window win, Cursor restoreCursor = None) + : m_dpy(dpy), m_win(win), m_restore(restoreCursor) + {} + + ~X11CursorPlatform() override + { + if (m_dpy == nullptr) return; + if (m_hidden) setOsCursorHidden(false); + if (m_blank != None) XFreeCursor(m_dpy, m_blank); + XFlush(m_dpy); + } + + bool pointerUV(XrVector2f &uv) override + { + if (m_dpy == nullptr || m_win == 0) return false; + Window root, child; + int rx, ry, wx, wy; + unsigned int mask; + if (!XQueryPointer(m_dpy, m_win, &root, &child, &rx, &ry, &wx, &wy, &mask)) return false; + XWindowAttributes a; + if (!XGetWindowAttributes(m_dpy, m_win, &a)) return false; + return cursorCanvasUV(wx, wy, a.width, a.height, false, uv); + } + + void setOsCursorHidden(bool hidden) override + { + if (m_dpy == nullptr || m_win == 0 || hidden == m_hidden) return; + m_hidden = hidden; + if (hidden) { + if (m_blank == None) { + static const char zero[1] = {0}; + Pixmap pm = XCreateBitmapFromData(m_dpy, m_win, zero, 1, 1); + XColor black = {}; + m_blank = XCreatePixmapCursor(m_dpy, pm, pm, &black, &black, 0, 0); + XFreePixmap(m_dpy, pm); + } + XDefineCursor(m_dpy, m_win, m_blank); + } else if (m_restore != None) { + XDefineCursor(m_dpy, m_win, m_restore); + } else { + XUndefineCursor(m_dpy, m_win); + } + XFlush(m_dpy); + } + +private: + Display *m_dpy; + Window m_win; + Cursor m_restore; + Cursor m_blank = None; + bool m_hidden = false; +}; + +inline std::unique_ptr +cursorPlatformX11(Display *dpy, Window win, Cursor restoreCursor = None) +{ + return std::make_unique(dpy, win, restoreCursor); +} + +} // namespace dxr diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ab00fd7..bbb8b23 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -136,3 +136,33 @@ set_target_properties(displayxr_vk_frame_ring_test PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) add_test(NAME displayxr_vk_frame_ring_test COMMAND displayxr_vk_frame_ring_test) + +# dxr::CursorDepth contract test (XR_DXR_cursor_depth v5, ADR-046 Amendment 2): +# canvas UV, the footprint rule, footprint rays vs a Kooima pair, the struct +# chain order and the OS-cursor transitions. Header-only core, like the view +# config test: displayxr::rules + the header-only OpenXR / extension targets. +add_executable(displayxr_cursor_depth_test cursor_depth_test.cpp) +target_link_libraries(displayxr_cursor_depth_test PRIVATE + displayxr::rules + displayxr_ext_headers + ${DISPLAYXR_MATH_OPENXR}) +set_target_properties(displayxr_cursor_depth_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +add_test(NAME displayxr_cursor_depth_test COMMAND displayxr_cursor_depth_test) + +# The header-only X11 adapter (cursor_depth_x11.h) compiles and links against +# Xlib wherever Xlib is installed (the Linux CI legs). +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_package(X11 QUIET) + if(X11_FOUND) + add_executable(displayxr_cursor_depth_x11_compile cursor_depth_x11_compile.cpp) + target_link_libraries(displayxr_cursor_depth_x11_compile PRIVATE + displayxr::rules + displayxr_ext_headers + ${DISPLAYXR_MATH_OPENXR} + ${X11_LIBRARIES}) + target_include_directories(displayxr_cursor_depth_x11_compile PRIVATE ${X11_INCLUDE_DIR}) + set_target_properties(displayxr_cursor_depth_x11_compile PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) + endif() +endif() diff --git a/tests/cursor_depth_test.cpp b/tests/cursor_depth_test.cpp new file mode 100644 index 0000000..4ed1401 --- /dev/null +++ b/tests/cursor_depth_test.cpp @@ -0,0 +1,432 @@ +// Copyright 2026, The DisplayXR Project and its contributors +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Contract test for cursor_depth.h (dxr::CursorDepth, XR_DXR_cursor_depth v5). + * + * The pure parts: pointer -> canvas UV, the footprint (same rule as the + * runtime / web SDK), footprint rays against a known Kooima pair (built the + * way the runtime's tests_aux_cursor_depth.cpp builds them, incl. a camera-rig + * transform), the nearest-hit reduction, the struct chain order, and the + * OS-cursor hide/show transitions. No OpenXR loader, no window system. + */ + +#include "cursor_depth.h" + +#include +#include +#include + +static int g_failures = 0; + +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); \ + g_failures++; \ + } \ + } while (0) + +static bool +near(float a, float b, float eps = 1e-4f) +{ + return std::fabs(a - b) <= eps; +} + +namespace { + +constexpr float W = 0.30f; // canvas width, m +constexpr float H = 0.20f; // canvas height, m + +struct Xform +{ + XrQuaternionf q{0, 0, 0, 1}; + XrVector3f t{0, 0, 0}; + float s = 1.0f; +}; + +XrVector3f +rotateRef(XrQuaternionf q, XrVector3f v) +{ + // Rotation-matrix reference, independent of the unit under test. + const float x = q.x, y = q.y, z = q.z, w = q.w; + return { + (1 - 2 * (y * y + z * z)) * v.x + 2 * (x * y - z * w) * v.y + 2 * (x * z + y * w) * v.z, + 2 * (x * y + z * w) * v.x + (1 - 2 * (x * x + z * z)) * v.y + 2 * (y * z - x * w) * v.z, + 2 * (x * z - y * w) * v.x + 2 * (y * z + x * w) * v.y + (1 - 2 * (x * x + y * y)) * v.z, + }; +} + +XrVector3f +apply(const Xform &x, XrVector3f p) +{ + XrVector3f r = rotateRef(x.q, {p.x * x.s, p.y * x.s, p.z * x.s}); + return {r.x + x.t.x, r.y + x.t.y, r.z + x.t.z}; +} + +// Kooima view from a display-space eye onto the W x H canvas centred at the +// origin in the z = 0 plane, carried into the locate space by x. +XrView +kooima(XrVector3f eye, const Xform &x = Xform{}) +{ + XrView v{XR_TYPE_VIEW}; + const float d = eye.z; + v.fov.angleLeft = std::atan((-W / 2 - eye.x) / d); + v.fov.angleRight = std::atan((W / 2 - eye.x) / d); + v.fov.angleUp = std::atan((H / 2 - eye.y) / d); + v.fov.angleDown = std::atan((-H / 2 - eye.y) / d); + v.pose.orientation = x.q; + v.pose.position = apply(x, eye); + return v; +} + +// Canvas point (display space) under canvas UV (origin top-left, v down). +XrVector3f +canvasPoint(XrVector2f uv) +{ + return {-W / 2 + uv.x * W, H / 2 - uv.y * H, 0.0f}; +} + +// Where a ray crosses the display plane, in DISPLAY space (undo the xform first). +XrVector3f +hitDisplayPlane(const dxr::CursorRay &r, const Xform &x) +{ + XrQuaternionf qi{-x.q.x, -x.q.y, -x.q.z, x.q.w}; + XrVector3f o = {r.origin.x - x.t.x, r.origin.y - x.t.y, r.origin.z - x.t.z}; + o = rotateRef(qi, o); + o = {o.x / x.s, o.y / x.s, o.z / x.s}; + const XrVector3f d = rotateRef(qi, r.dir); + const float k = -o.z / d.z; + return {o.x + d.x * k, o.y + d.y * k, 0.0f}; +} + +struct FakePlatform : dxr::CursorPlatform +{ + XrVector2f uv{0.5f, 0.5f}; + bool known = true; + int hides = 0, shows = 0; + bool hidden = false; + bool pointerUV(XrVector2f &out) override + { + out = uv; + return known; + } + void setOsCursorHidden(bool h) override + { + hidden = h; + (h ? hides : shows)++; + } +}; + +struct Base +{ + XrStructureType type; + const void *next; +}; + +} // namespace + +static void +testCanvasUV() +{ + XrVector2f uv{}; + CHECK(dxr::cursorCanvasUV(100, 50, 400, 200, false, uv) && near(uv.x, 0.25f) && near(uv.y, 0.25f), + "y-down client coords map straight"); + CHECK(dxr::cursorCanvasUV(100, 50, 400, 200, true, uv) && near(uv.x, 0.25f) && near(uv.y, 0.75f), + "y-up (unflipped NSView) coords flip v"); + CHECK(!dxr::cursorCanvasUV(1, 1, 0, 200, false, uv), "empty client area -> unknown"); + CHECK(dxr::cursorCanvasUV(-10, 50, 400, 200, false, uv) && !dxr::cursorUVInCanvas(uv), + "left of the window is off the canvas"); + CHECK(dxr::cursorUVInCanvas({0.0f, 1.0f}), "canvas edges are on the canvas"); +} + +static void +testFootprintUV() +{ + XrVector2f pts[dxr::kCursorFootprintMaxPoints]; + const float h = 0.04f, aspect = 1.5f; + const uint32_t n = dxr::cursorFootprintUV({0.5f, 0.4f}, h, aspect, pts); + CHECK(n == 10, "arrow footprint = tip + 8 ring points + body centre"); + CHECK(near(pts[0].x, 0.5f) && near(pts[0].y, 0.4f), "hotspot (the arrow tip) first"); + // web SDK cursorArrowFootprint: ring around (u + 0.3 h / aspect, v + 0.5 h), radius 0.75 h. + const float cu = 0.5f + 0.3f * h / aspect, cv = 0.4f + 0.5f * h; + CHECK(near(pts[1].x, cu + 0.75f * h / aspect) && near(pts[1].y, cv), "ring k=0 is right of the body"); + CHECK(near(pts[3].x, cu, 1e-5f) && near(pts[3].y, cv + 0.75f * h), "ring k=2 is below the body (v down)"); + CHECK(near(pts[9].x, cu) && near(pts[9].y, cv), "body centre last"); + + const uint32_t nc = dxr::cursorFootprintUV({0.5f, 0.4f}, h, aspect, pts, dxr::kCursorCenteredBodyOffset); + CHECK(nc == 9, "centred sprite: hotspot + ring, no duplicate centre"); + CHECK(near(pts[5].x, 0.5f - 0.75f * h / aspect) && near(pts[5].y, 0.4f), "centred ring is around the hotspot"); + + const XrVector2f off = dxr::cursorBodyOffsetForSprite({0.5f, 0.5f}, 2.0f); + CHECK(near(off.x, 0.0f) && near(off.y, 0.0f), "centre hotspot -> centred footprint"); + const XrVector2f tl = dxr::cursorBodyOffsetForSprite({0.0f, 0.0f}, 12.0f / 19.0f); + CHECK(near(tl.x, 0.5f * 12.0f / 19.0f) && near(tl.y, 0.5f), "top-left hotspot -> body down-right"); +} + +static void +testRaysKooima() +{ + const XrVector2f uv{0.62f, 0.31f}; + const float h = 0.04f; + Xform rigs[2]; + // A camera rig: rotated 30 deg about Y and 10 deg about X, moved, scaled x3. + const float ay = 0.2617994f, ax = 0.0872665f; + const XrQuaternionf qy{0, std::sin(ay), 0, std::cos(ay)}, qx{std::sin(ax), 0, 0, std::cos(ax)}; + rigs[1].q = {qy.w * qx.x + qy.x * qx.w + qy.y * qx.z - qy.z * qx.y, + qy.w * qx.y - qy.x * qx.z + qy.y * qx.w + qy.z * qx.x, + qy.w * qx.z + qy.x * qx.y - qy.y * qx.x + qy.z * qx.w, + qy.w * qx.w - qy.x * qx.x - qy.y * qx.y - qy.z * qx.z}; + rigs[1].t = {1.0f, -2.0f, 0.5f}; + rigs[1].s = 3.0f; + + for (const Xform &x : rigs) { + XrView views[3] = {kooima({-0.032f, 0.01f, 0.6f}, x), kooima({0.0f, 0.01f, 0.6f}, x), + kooima({0.032f, 0.01f, 0.6f}, x)}; + CHECK(near(dxr::cursorCanvasAspect(views[0]), W / H), "aspect from the fov = canvas W/H"); + + const dxr::CursorFootprintRays rays = dxr::cursorFootprintRays(views, 3, uv, h); + CHECK(rays.count == 20, "10 footprint points x 2 outer views"); + XrVector2f pts[dxr::kCursorFootprintMaxPoints]; + dxr::cursorFootprintUV(uv, h, W / H, pts); + for (uint32_t i = 0; i < rays.count; i++) { + const dxr::CursorRay &r = rays.rays[i]; + const XrView &outer = (i % 2 == 0) ? views[0] : views[2]; + CHECK(near(r.origin.x, outer.pose.position.x) && near(r.origin.z, outer.pose.position.z), + "ray starts at an OUTER view's eye"); + CHECK(near(r.dir.x * r.dir.x + r.dir.y * r.dir.y + r.dir.z * r.dir.z, 1.0f), "unit direction"); + const XrVector3f p = hitDisplayPlane(r, x); + const XrVector3f e = canvasPoint(pts[i / 2]); + CHECK(near(p.x, e.x) && near(p.y, e.y), "each ray crosses the canvas at its footprint point"); + } + CHECK(dxr::cursorFootprintRays(views, 1, uv, h).count == 0, "one active view (2D): no rays"); + } +} + +static void +testChainDisabled() +{ + dxr::CursorDepth c; + auto *fp = new FakePlatform; + c.setPlatform(std::unique_ptr(fp)); + Base rig{XR_TYPE_VIEW_LOCATE_INFO, nullptr}; + int vsTail = 0; + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO, &rig}; + XrViewState vs{XR_TYPE_VIEW_STATE, &vsTail}; + c.chain(li, vs); + CHECK(li.next == &rig && vs.next == &vsTail, "not enabled: neither chain touched"); + XrView v[2] = {kooima({-0.03f, 0, 0.6f}), kooima({0.03f, 0, 0.6f})}; + c.afterLocate(v, 2); + CHECK(fp->hides == 0 && fp->shows == 0 && !c.depthCursorShown(), "not enabled: OS cursor untouched"); + c.enable(0); + c.chain(li, vs); + CHECK(li.next == &rig && vs.next == &vsTail, "enable(0) = still inert"); +} + +static void +testChainOrder() +{ + Base rig{XR_TYPE_VIEW_LOCATE_INFO, nullptr}; + int vsTail = 0; + + { // v5, runtime draw, default options. + dxr::CursorDepth c; + c.enable(5); + c.setPointerUV(0.5f, 0.5f); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO, &rig}; + XrViewState vs{XR_TYPE_VIEW_STATE, &vsTail}; + c.chain(li, vs); + const Base *hint = static_cast(li.next); + CHECK(hint && hint->type == XR_TYPE_CURSOR_DEPTH_HINT_DXR, "locateInfo -> hint"); + const Base *draw = hint ? static_cast(hint->next) : nullptr; + CHECK(draw && draw->type == XR_TYPE_CURSOR_DEPTH_DRAW_INFO_DXR, "hint -> drawInfo"); + CHECK(draw && draw->next == &rig, "drawInfo -> the app's previous chain"); + const Base *ds = static_cast(vs.next); + CHECK(ds && ds->type == XR_TYPE_CURSOR_DEPTH_DRAW_STATE_DXR && ds->next == &vsTail, + "viewState -> drawState -> previous (no placement unless asked)"); + CHECK(c.hint().cursorUV.x == 0.5f && c.hint().hasContent == XR_FALSE, "no hit test, no content"); + } + { // v5, every optional struct. + dxr::CursorDepth c; + c.enable(5); + c.setPointerUV(0.2f, 0.7f); + c.setAnchor(XR_CURSOR_DEPTH_ANCHOR_MODE_SCREEN_DXR); + c.useDepthLayer(true); + c.setWantPlacement(true); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO, &rig}; + XrViewState vs{XR_TYPE_VIEW_STATE, &vsTail}; + c.chain(li, vs); + const XrStructureType want[] = {XR_TYPE_CURSOR_DEPTH_HINT_DXR, XR_TYPE_CURSOR_DEPTH_DRAW_INFO_DXR, + XR_TYPE_CURSOR_DEPTH_OPTIONS_DXR, XR_TYPE_CURSOR_DEPTH_SOURCE_DXR}; + const Base *b = static_cast(li.next); + for (XrStructureType t : want) { + CHECK(b && b->type == t, "hint -> drawInfo -> options -> source"); + b = b ? static_cast(b->next) : nullptr; + } + CHECK(b == &rig, "... -> the app's previous chain"); + const Base *v = static_cast(vs.next); + CHECK(v && v->type == XR_TYPE_CURSOR_DEPTH_DRAW_STATE_DXR, "viewState -> drawState"); + v = v ? static_cast(v->next) : nullptr; + CHECK(v && v->type == XR_TYPE_CURSOR_DEPTH_PLACEMENT_DXR && v->next == &vsTail, "-> placement -> previous"); + } + { // Pointer off the canvas: no hint, but the draw state is still read (willDraw false). + dxr::CursorDepth c; + c.enable(5); + c.setPointerUV(1.2f, 0.5f); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO, &rig}; + XrViewState vs{XR_TYPE_VIEW_STATE, &vsTail}; + c.chain(li, vs); + CHECK(li.next == &rig && !c.hintChained(), "off the canvas: no hint"); + CHECK(vs.next != &vsTail, "off the canvas: draw state still chained"); + } + { // v4 runtime: no draw request, placement chained; v1: no options / source. + dxr::CursorDepth c; + c.enable(1); + c.setPointerUV(0.5f, 0.5f); + c.setAnchor(XR_CURSOR_DEPTH_ANCHOR_MODE_WORLD_DXR); + c.useDepthLayer(true); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO, &rig}; + XrViewState vs{XR_TYPE_VIEW_STATE, &vsTail}; + c.chain(li, vs); + const Base *hint = static_cast(li.next); + CHECK(hint && hint->next == &rig, "v1: hint only (no v2 source, v3 options, v5 draw)"); + const Base *v = static_cast(vs.next); + CHECK(v && v->type == XR_TYPE_CURSOR_DEPTH_PLACEMENT_DXR && v->next == &vsTail, "v1: placement only"); + } +} + +static void +testShowHide() +{ + XrView v[2] = {kooima({-0.032f, 0, 0.6f}), kooima({0.032f, 0, 0.6f})}; + { // Runtime mode: hidden exactly while willDraw. + dxr::CursorDepth c; + auto *fp = new FakePlatform; + c.setPlatform(std::unique_ptr(fp)); + int cbShown = -1, cbCalls = 0; + c.onDepthCursorShownChanged([&](bool s) { + cbShown = s; + cbCalls++; + }); + c.enable(5); + for (int frame = 0; frame < 6; frame++) { + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + c.chain(li, vs); + const bool runtimeWillDraw = frame >= 2 && frame < 4; + auto *ds = (XrCursorDepthDrawStateDXR *)vs.next; // what the runtime fills + ds->willDraw = runtimeWillDraw ? XR_TRUE : XR_FALSE; + c.afterLocate(v, 2); + CHECK(fp->hidden == runtimeWillDraw && c.depthCursorShown() == runtimeWillDraw, + "OS cursor hidden exactly while willDraw"); + } + CHECK(fp->hides == 1 && fp->shows == 1, "transitions only"); + CHECK(cbCalls == 2 && cbShown == 0, "callback on each transition"); + fp->uv = {-0.1f, 0.5f}; // leaves the canvas while drawn + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + c.chain(li, vs); + ((XrCursorDepthDrawStateDXR *)vs.next)->willDraw = XR_TRUE; // stale TRUE must not hide it + c.afterLocate(v, 2); + CHECK(!fp->hidden, "no hint chained -> OS cursor shown"); + } + { // Runtime mode on a v4 runtime: nothing would draw -> never hide. + dxr::CursorDepth c; + auto *fp = new FakePlatform; + c.setPlatform(std::unique_ptr(fp)); + c.enable(4); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + c.chain(li, vs); + ((XrCursorDepthPlacementDXR *)vs.next)->isActive = XR_TRUE; + c.afterLocate(v, 2); + CHECK(!fp->hidden && !c.appShouldDraw(), "pre-v5 runtime mode never hides the OS cursor"); + } + { // App mode: hidden while the placement is active; appShouldDraw follows. + dxr::CursorDepth c; + auto *fp = new FakePlatform; + c.setPlatform(std::unique_ptr(fp)); + c.setDrawMode(dxr::CursorDepth::DrawMode::App); + c.enable(5); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + c.chain(li, vs); + CHECK(static_cast(li.next)->next == nullptr, "app mode: no draw request"); + ((XrCursorDepthPlacementDXR *)vs.next)->isActive = XR_TRUE; + c.afterLocate(v, 2); + CHECK(fp->hidden && c.appShouldDraw(), "app mode: hidden + draw while active"); + } + { // Destruction while hidden restores the OS cursor. + FakePlatform probe; + bool restored = false; + { + dxr::CursorDepth c; + c.enable(5); + c.setPointerUV(0.5f, 0.5f); + c.onDepthCursorShownChanged([&](bool s) { restored = !s; }); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + c.chain(li, vs); + ((XrCursorDepthDrawStateDXR *)vs.next)->willDraw = XR_TRUE; + c.afterLocate(v, 2); + } + CHECK(restored, "destructor shows the OS cursor again"); + } +} + +static void +testNearestHit() +{ + // Right eye's rays hit a plane 8 cm in front of the canvas, the left eye's + // a plane 2 cm in front: the nearest (to the viewer) wins. + XrView v[2] = {kooima({-0.032f, 0, 0.6f}), kooima({0.032f, 0, 0.6f})}; + dxr::CursorDepth c; + c.enable(5); + c.setPointerUV(0.5f, 0.5f); + int casts = 0; + c.setRayCast([&](const dxr::CursorRay &r) -> std::optional { + casts++; + const float z = r.origin.x > 0.0f ? 0.08f : 0.02f; + return (z - r.origin.z) / r.dir.z; + }); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + c.chain(li, vs); + CHECK(casts == 0 && c.hint().hasContent == XR_FALSE, "first frame: no previous views, no hit test"); + c.afterLocate(v, 2); + c.chain(li, vs); + CHECK(casts == 20, "every footprint ray cast once"); + CHECK(c.hint().hasContent == XR_TRUE && near(c.hint().nearestPoint.z, 0.08f), "nearest-to-viewer hit kept"); + + c.setRayCast([](const dxr::CursorRay &) -> std::optional { return std::nullopt; }); + c.chain(li, vs); + CHECK(c.hint().hasContent == XR_FALSE, "all misses -> hasContent false"); + + c.useDepthLayer(true); + casts = 0; + c.setRayCast([&](const dxr::CursorRay &) -> std::optional { + casts++; + return 1.0f; + }); + c.chain(li, vs); + CHECK(casts == 0 && c.hint().hasContent == XR_FALSE, "depth-layer source: no app hit test"); + + c.useDepthLayer(false); + c.afterLocate(v, 1); // 2D + c.chain(li, vs); + CHECK(casts == 0 && c.hint().hasContent == XR_FALSE, "2D: no rays"); +} + +int +main() +{ + testCanvasUV(); + testFootprintUV(); + testRaysKooima(); + testChainDisabled(); + testChainOrder(); + testShowHide(); + testNearestHit(); + if (g_failures == 0) std::printf("cursor_depth_test: all checks passed\n"); + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/cursor_depth_x11_compile.cpp b/tests/cursor_depth_x11_compile.cpp new file mode 100644 index 0000000..af28092 --- /dev/null +++ b/tests/cursor_depth_x11_compile.cpp @@ -0,0 +1,48 @@ +// Copyright 2026, The DisplayXR Project and its contributors +// SPDX-License-Identifier: Apache-2.0 +/*! + * @file + * @brief Compile + link proof for the header-only X11 adapter (cursor_depth_x11.h). + * + * Runs without a display: with no X server it only checks that an adapter on a + * null Display is inert. With $DISPLAY set (e.g. under xvfb-run) it also maps a + * window and exercises pointer UV + hide/show once. + */ +#include "cursor_depth_x11.h" + +#include + +int +main() +{ + { // No display: inert, never crashes. + dxr::X11CursorPlatform none(nullptr, 0); + XrVector2f uv{}; + if (none.pointerUV(uv)) return 1; + none.setOsCursorHidden(true); + none.setOsCursorHidden(false); + } + Display *dpy = XOpenDisplay(nullptr); + if (dpy == nullptr) { + std::printf("cursor_depth_x11_compile: no X display, inert checks only\n"); + return 0; + } + Window win = XCreateSimpleWindow(dpy, DefaultRootWindow(dpy), 0, 0, 320, 200, 0, 0, 0); + XMapWindow(dpy, win); + XSync(dpy, False); + { + dxr::CursorDepth cursor; + cursor.enable(5); + cursor.setPlatform(dxr::cursorPlatformX11(dpy, win)); + XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO}; + XrViewState vs{XR_TYPE_VIEW_STATE}; + cursor.chain(li, vs); // reads the pointer through XQueryPointer + auto platform = dxr::cursorPlatformX11(dpy, win); + platform->setOsCursorHidden(true); + platform->setOsCursorHidden(false); + } + XDestroyWindow(dpy, win); + XCloseDisplay(dpy); + std::printf("cursor_depth_x11_compile: ok\n"); + return 0; +}