From 29f096fa0e3e268934645bb377b205bbe0e27105 Mon Sep 17 00:00:00 2001 From: Sam Hislop-Lynch Date: Wed, 17 Dec 2025 14:34:04 +1000 Subject: [PATCH] Add inertial panning (fling/throw) to MapManipulator Implements physics-based map flinging - when users drag and release the map, it continues moving with exponentially decaying velocity. Features: - Frame-rate independent physics using exponential decay - Only triggers for pan operations (not rotate/zoom) - Configurable via Settings: throwingEnabled, throwDecayRate, throwThreshold, throwHistoryTime, maxThrowVelocity - Velocity calculated from 150ms drag history for smooth, responsive feel - Cancels on any new user input (click, key, scroll) - Uses NDC coordinates for resolution independence --- src/rocky/vsg/MapManipulator.cpp | 158 +++++++++++++++++++++++++++++++ src/rocky/vsg/MapManipulator.h | 39 ++++++++ 2 files changed, 197 insertions(+) diff --git a/src/rocky/vsg/MapManipulator.cpp b/src/rocky/vsg/MapManipulator.cpp index 95e059c46..b61fd622f 100644 --- a/src/rocky/vsg/MapManipulator.cpp +++ b/src/rocky/vsg/MapManipulator.cpp @@ -458,6 +458,8 @@ MapManipulator::reinitialize() _thrown = false; _delta.set(0.0, 0.0); _throwDelta.set(0.0, 0.0); + _throwVelocity.set(0.0, 0.0); + _dragHistory.clear(); _continuousDelta.set(0.0, 0.0); _continuous = 0; _lastAction = ACTION_NULL; @@ -465,6 +467,105 @@ MapManipulator::reinitialize() clearEvents(); } +void +MapManipulator::recordDragSample(vsg::time_point time, const vsg::dvec2& ndcDelta) +{ + // Add new sample + _dragHistory.push_back({ time, ndcDelta }); + + // Remove samples older than throwHistoryTime + double historyWindow = settings.throwHistoryTime; + while (!_dragHistory.empty()) + { + double age = to_seconds(time - _dragHistory.front().time); + if (age > historyWindow) + _dragHistory.pop_front(); + else + break; + } +} + +vsg::dvec2 +MapManipulator::calculateThrowVelocity(vsg::time_point releaseTime) const +{ + if (_dragHistory.size() < 2) + return vsg::dvec2(0.0, 0.0); + + // Sum all deltas in the history window + vsg::dvec2 totalDelta(0.0, 0.0); + + for (const auto& sample : _dragHistory) + { + totalDelta += sample.ndcDelta; + } + + // Calculate time span of the history + double timeSpan = to_seconds(releaseTime - _dragHistory.front().time); + + if (timeSpan < 0.001) // Avoid division by near-zero + return vsg::dvec2(0.0, 0.0); + + // Velocity = total displacement / time + vsg::dvec2 velocity = totalDelta / timeSpan; + + // Clamp to maximum velocity + double speed = vsg::length(velocity); + if (speed > settings.maxThrowVelocity) + { + velocity = vsg::normalize(velocity) * settings.maxThrowVelocity; + } + + return velocity; +} + +void +MapManipulator::clearDragHistory() +{ + _dragHistory.clear(); +} + +void +MapManipulator::cancelThrow() +{ + _thrown = false; + _throwVelocity.set(0.0, 0.0); +} + +bool +MapManipulator::serviceThrow(vsg::time_point now) +{ + if (!_thrown) + return false; + + double dt = to_seconds(now - _previousTime); + if (dt <= 0.0) + return _thrown; + + // Apply velocity as pan delta + // Velocity is in NDC/second, so multiply by dt to get this frame's delta + vsg::dvec2 frameDelta = _throwVelocity * dt; + pan(frameDelta.x, frameDelta.y); + + // Frame-rate independent exponential decay: + // For decay per reference frame (1/60s): velocity *= decayRate^(dt * 60) + // This ensures consistent behavior regardless of framerate + double referenceFrameRate = 60.0; + double decayExponent = dt * referenceFrameRate; + double decayFactor = std::pow(settings.throwDecayRate, decayExponent); + + _throwVelocity *= decayFactor; + + // Check if velocity has dropped below threshold + double speed = vsg::length(_throwVelocity); + if (speed < settings.throwThreshold) + { + _thrown = false; + _throwVelocity.set(0.0, 0.0); + } + + return _thrown; +} + bool MapManipulator::createLocalCoordFrame(const vsg::dvec3& worldPos, vsg::dmat4& out_frame) const { @@ -966,6 +1067,9 @@ MapManipulator::apply(vsg::KeyPressEvent& keyPress) if (keyPress.handled || !withinRenderArea(_previousMove)) return; + // Cancel any active throw on new input + cancelThrow(); + _keyPress = keyPress; recalculateCenterAndDistanceFromLookVector(); @@ -999,6 +1103,12 @@ MapManipulator::apply(vsg::ButtonPressEvent& buttonPress) //std::cout << "ButtonPressEvent" << std::endl; + // Cancel any active throw on new input + cancelThrow(); + + // Clear drag history for fresh velocity calculation + clearDragHistory(); + // simply record the button press event. clearEvents(); @@ -1027,8 +1137,32 @@ MapManipulator::apply(vsg::ButtonReleaseEvent& buttonRelease) vsg::dvec3 world; viewportToWorld(buttonRelease.x, buttonRelease.y, world); } + else + { + // Not a click - check for throw initiation + // Initiate throw if: + // 1. Throwing is enabled + // 2. Last action was pan + // 3. Not in continuous mode + // 4. Velocity exceeds threshold + if (settings.throwingEnabled && + _lastAction._type == ACTION_PAN && + _continuous == 0) + { + vsg::dvec2 velocity = calculateThrowVelocity(buttonRelease.time); + double speed = vsg::length(velocity); + + if (speed >= settings.throwThreshold) + { + _thrown = true; + _throwVelocity = velocity; + _dirty = true; + } + } + } clearEvents(); + clearDragHistory(); buttonRelease.handled = true; } @@ -1072,6 +1206,18 @@ MapManipulator::apply(vsg::MoveEvent& moveEvent) if (handleMouseAction(_lastAction, _previousMove.value(), moveEvent)) _dirty = true; + // Record drag sample for throw velocity calculation (only for pan actions) + if (settings.throwingEnabled && + (_lastAction._type == ACTION_PAN) && + _previousMove.has_value()) + { + auto curr = ndc(moveEvent); + auto prev = ndc(_previousMove.value()); + vsg::dvec2 ndcDelta(curr.x - prev.x, -(curr.y - prev.y)); + ndcDelta *= settings.mouseSensitivity; + recordDragSample(moveEvent.time, ndcDelta); + } + if (_continuous > 0) // && !wasContinuous) { _continuousAction = _lastAction; @@ -1091,6 +1237,9 @@ MapManipulator::apply(vsg::ScrollWheelEvent& scrollEvent) if (scrollEvent.handled || !withinRenderArea(_previousMove)) return; + // Cancel any active throw on new input + cancelThrow(); + //std::cout << "ScrollWheelEvent" << std::endl; Direction dir = @@ -1155,6 +1304,15 @@ MapManipulator::apply(vsg::FrameEvent& frame) serviceTask(frame.time); + // Service throw animation + if (_thrown) + { + if (serviceThrow(frame.time)) + { + _dirty = true; // Keep rendering while throw is active + } + } + if (isSettingViewpoint()) { setViewpointFrame(frame.time); diff --git a/src/rocky/vsg/MapManipulator.h b/src/rocky/vsg/MapManipulator.h index 7b25ccc09..8f121bbb6 100644 --- a/src/rocky/vsg/MapManipulator.h +++ b/src/rocky/vsg/MapManipulator.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -289,6 +290,21 @@ namespace ROCKY_NAMESPACE //! Whtehr to zoom towards the mouse cursor when zooming bool zoomToMouse = true; + //! Enable throw/fling after drag release + bool throwingEnabled = true; + + //! Friction coefficient - velocity retained per 1/60s frame (0.95 = 5% decay) + double throwDecayRate = 0.95; + + //! Minimum velocity (NDC/s) to initiate throw + double throwThreshold = 0.05; + + //! History window for velocity calculation (seconds) + double throwHistoryTime = 0.15; + + //! Maximum throw velocity cap (NDC/s) + double maxThrowVelocity = 4.0; + //! Assigns behavior to the action of dragging the mouse while depressing one or //! more mouse buttons and modifier keys. @@ -430,6 +446,12 @@ namespace ROCKY_NAMESPACE //vsg::time_point _previousTick; }; + //! A single sample of drag movement for throw velocity calculation + struct DragSample { + vsg::time_point time; + vsg::dvec2 ndcDelta; + }; + // "ticks" the resident Task, which allows for multi-frame animation of navigation // movements. bool serviceTask(vsg::time_point); @@ -472,6 +494,21 @@ namespace ROCKY_NAMESPACE virtual bool handlePointAction(const Action& type, float mx, float my, vsg::time_point time); virtual void handleMovementAction(const ActionType& type, vsg::dvec2 delta); + //! Adds a drag sample to the history buffer (called during mouse/touch drag) + void recordDragSample(vsg::time_point time, const vsg::dvec2& ndcDelta); + + //! Calculates throw velocity from recent drag history + vsg::dvec2 calculateThrowVelocity(vsg::time_point releaseTime) const; + + //! Clears drag history (called on button press) + void clearDragHistory(); + + //! Services the throw animation each frame (returns true if throw is still active) + bool serviceThrow(vsg::time_point now); + + //! Cancels any active throw + void cancelThrow(); + void clearEvents(); vsg::ref_ptr getMapNode() const; @@ -525,6 +562,8 @@ namespace ROCKY_NAMESPACE bool _thrown; vsg::dvec2 _throwDelta; + vsg::dvec2 _throwVelocity; // Current throw velocity (NDC/second) + std::deque _dragHistory; // Recent drag samples for velocity calculation vsg::dvec2 _delta; vsg::dmat4 _viewMatrix; State _state;