diff --git a/libs/hardware/InputManager/include/InputManager.h b/libs/hardware/InputManager/include/InputManager.h index a8092c45..13bf656a 100644 --- a/libs/hardware/InputManager/include/InputManager.h +++ b/libs/hardware/InputManager/include/InputManager.h @@ -156,6 +156,12 @@ class InputManager { // contact centroids, normalized to 0..1. Pinches and sub-threshold turns are // rejected, and an accepted rotation cannot also become a translation. bool wasMultiTouchRotation(float& degrees, float& nxCenter, float& nyCenter, unsigned long& durationMs) const; + // One-shot two-contact pinch/spread on release. `scale` is end separation + // divided by start separation (<1.0 = pinch in / zoom out, >1.0 = spread / + // zoom in). The center is the average of the start/end contact centroids, + // normalized to 0..1. Rotations, tiny scale changes, and ambiguous contacts + // are rejected, and an accepted pinch cannot also become a translation. + bool wasMultiTouchPinch(float& scale, float& nxCenter, float& nyCenter, unsigned long& durationMs) const; // One-shot long-press: fires WHILE the finger is still down, once a // stationary contact (within tap slop) has been held TOUCH_LONG_PRESS_MS. // Position is the touch-down point, normalized like wasTouchTap. Fires at @@ -241,6 +247,10 @@ class InputManager { // wasMultiTouchRotation(). bool popMultiTouchRotation(float& degrees, float& nxCenter, float& nyCenter, unsigned long& durationMs); + // Pop a queued completed two-contact pinch/spread. Values use the same scale, + // normalized center, and duration contract as wasMultiTouchPinch(). + bool popMultiTouchPinch(float& scale, float& nxCenter, float& nyCenter, unsigned long& durationMs); + // --- Diagnostics ----------------------------------------------------------- // A live sample of one button-group ADC pin: the raw reading plus the BTN_* // it currently classifies as (-1 = no band matched). On the Xteink ADC ladder @@ -282,6 +292,13 @@ class InputManager { uint16_t durationMs; }; QueueHandle_t _asyncMultiTouchRotationQueue = nullptr; + struct QueuedMultiTouchPinch { + float scale; + uint16_t centerX; + uint16_t centerY; + uint16_t durationMs; + }; + QueueHandle_t _asyncMultiTouchPinchQueue = nullptr; TaskHandle_t _asyncTask = nullptr; uint32_t _asyncPollMs = 15; static void asyncTaskTrampoline(void* self); @@ -341,6 +358,7 @@ class InputManager { bool hasEligibleRotationScale() const; bool isMultiTouchTranslation(unsigned long now) const; bool classifyMultiTouchRotation(unsigned long now); + bool classifyMultiTouchPinch(unsigned long now); void normalizeTouchPoint(uint16_t x, uint16_t y, float& nx, float& ny) const; uint8_t currentState; @@ -397,6 +415,11 @@ class InputManager { uint16_t multiTouchRotationCenterX = 0; uint16_t multiTouchRotationCenterY = 0; uint16_t multiTouchRotationDurationMs = 0; + bool multiTouchPinchEvent = false; + float multiTouchPinchScale = 1.0f; + uint16_t multiTouchPinchCenterX = 0; + uint16_t multiTouchPinchCenterY = 0; + uint16_t multiTouchPinchDurationMs = 0; TouchPoint touchDownPoint = {false, 0, 0, 0}; // first sample of the current contact (tap routing) TouchPoint touchUpPoint = {false, 0, 0, 0}; // last sample before release (swipe routing) unsigned long lastTouchHeldDurationMs = 0; // contact duration, latched at release diff --git a/libs/hardware/InputManager/src/InputManager.cpp b/libs/hardware/InputManager/src/InputManager.cpp index 4219266f..b2afb7fb 100644 --- a/libs/hardware/InputManager/src/InputManager.cpp +++ b/libs/hardware/InputManager/src/InputManager.cpp @@ -229,6 +229,7 @@ void InputManager::beginAsync(const uint8_t taskPriority, const uint32_t pollMs, _asyncSwipeQueue = xQueueCreate(queueLen, sizeof(float) * 4); _asyncMultiTouchSwipeQueue = xQueueCreate(queueLen, sizeof(QueuedMultiTouchSwipe)); _asyncMultiTouchRotationQueue = xQueueCreate(queueLen, sizeof(QueuedMultiTouchRotation)); + _asyncMultiTouchPinchQueue = xQueueCreate(queueLen, sizeof(QueuedMultiTouchPinch)); xTaskCreate(asyncTaskTrampoline, "fi_input", 4096, this, taskPriority, &_asyncTask); } @@ -260,6 +261,11 @@ void InputManager::asyncPoll() { multiTouchRotationCenterY, multiTouchRotationDurationMs}; xQueueSend(_asyncMultiTouchRotationQueue, &rotation, 0); } + if (_asyncMultiTouchPinchQueue && multiTouchPinchEvent && !touchSuppressed) { + const QueuedMultiTouchPinch pinch = {multiTouchPinchScale, multiTouchPinchCenterX, multiTouchPinchCenterY, + multiTouchPinchDurationMs}; + xQueueSend(_asyncMultiTouchPinchQueue, &pinch, 0); + } vTaskDelay(pdMS_TO_TICKS(_asyncPollMs)); } } @@ -311,6 +317,16 @@ bool InputManager::popMultiTouchRotation(float& degrees, float& nxCenter, float& return true; } +bool InputManager::popMultiTouchPinch(float& scale, float& nxCenter, float& nyCenter, unsigned long& durationMs) { + if (!_asyncMultiTouchPinchQueue) return false; + QueuedMultiTouchPinch pinch{}; + if (xQueueReceive(_asyncMultiTouchPinchQueue, &pinch, 0) != pdTRUE) return false; + scale = pinch.scale; + normalizeTouchPoint(pinch.centerX, pinch.centerY, nxCenter, nyCenter); + durationMs = pinch.durationMs; + return true; +} + bool InputManager::isDigitalPressed(const int8_t pin) const { return pin >= 0 && digitalRead(pin) == LOW; } uint8_t InputManager::getDigitalState() const { @@ -507,6 +523,7 @@ void InputManager::update() { touchLongPressEvent = false; multiTouchSwipeEvent = false; multiTouchRotationEvent = false; + multiTouchPinchEvent = false; touchHomeKeyEvent = false; touchHomeKeyTapEvent = false; touchHomeKeyLongEvent = false; @@ -782,6 +799,22 @@ bool InputManager::wasMultiTouchRotation(float& degrees, float& nxCenter, float& #endif } +bool InputManager::wasMultiTouchPinch(float& scale, float& nxCenter, float& nyCenter, unsigned long& durationMs) const { +#if FREEINK_CAP_TOUCH + if (!multiTouchPinchEvent || touchSuppressed) return false; + scale = multiTouchPinchScale; + normalizeTouchPoint(multiTouchPinchCenterX, multiTouchPinchCenterY, nxCenter, nyCenter); + durationMs = multiTouchPinchDurationMs; + return true; +#else + (void)scale; + (void)nxCenter; + (void)nyCenter; + (void)durationMs; + return false; +#endif +} + bool InputManager::wasTouchLongPress(float& nx, float& ny) const { #if FREEINK_CAP_TOUCH if (!touchLongPressEvent || touchMultiContactSequence) return false; @@ -803,6 +836,7 @@ void InputManager::suppressTouchContact() { cancelMultiTouchGesture(); if (_asyncMultiTouchSwipeQueue) xQueueReset(_asyncMultiTouchSwipeQueue); if (_asyncMultiTouchRotationQueue) xQueueReset(_asyncMultiTouchRotationQueue); + if (_asyncMultiTouchPinchQueue) xQueueReset(_asyncMultiTouchPinchQueue); #endif } @@ -860,6 +894,7 @@ void InputManager::resetMultiTouchGesture() { void InputManager::cancelMultiTouchGesture() { multiTouchSwipeEvent = false; multiTouchRotationEvent = false; + multiTouchPinchEvent = false; multiTouchGestureState = (touchPressed || touchReleasedEvent) ? MultiTouchGestureState::Blocked : MultiTouchGestureState::Idle; } @@ -1064,11 +1099,38 @@ bool InputManager::classifyMultiTouchRotation(const unsigned long now) { return true; } +bool InputManager::classifyMultiTouchPinch(const unsigned long now) { + if (trackedTouchContactCount != 2 || now - multiTouchContacts[0].start.timestamp > TOUCH_MULTI_SWIPE_MAX_MS) { + return false; + } + + const auto toGesturePoint = [](const TouchPoint& point) { + return freeink::input_detail::GesturePoint{point.x, point.y}; + }; + freeink::input_detail::PinchResult result; + if (!freeink::input_detail::classifyPinch( + toGesturePoint(multiTouchContacts[0].start), toGesturePoint(multiTouchContacts[1].start), + toGesturePoint(multiTouchContacts[0].last), toGesturePoint(multiTouchContacts[1].last), result)) { + return false; + } + + multiTouchPinchScale = result.scale; + multiTouchPinchCenterX = result.centerX; + multiTouchPinchCenterY = result.centerY; + multiTouchPinchDurationMs = static_cast(now - multiTouchContacts[0].start.timestamp); + multiTouchPinchEvent = true; + return true; +} + void InputManager::finishMultiTouchGesture(const unsigned long now) { if (classifyMultiTouchRotation(now)) { multiTouchGestureState = MultiTouchGestureState::Blocked; return; } + if (classifyMultiTouchPinch(now)) { + multiTouchGestureState = MultiTouchGestureState::Blocked; + return; + } if (isMultiTouchTranslation(now)) { uint32_t startX = 0; uint32_t startY = 0; diff --git a/libs/hardware/InputManager/src/MultiTouchGestureMath.h b/libs/hardware/InputManager/src/MultiTouchGestureMath.h index 92a358ae..6d9e272f 100644 --- a/libs/hardware/InputManager/src/MultiTouchGestureMath.h +++ b/libs/hardware/InputManager/src/MultiTouchGestureMath.h @@ -16,6 +16,12 @@ struct RotationResult { uint16_t centerY = 0; }; +struct PinchResult { + float scale = 1.0f; + uint16_t centerX = 0; + uint16_t centerY = 0; +}; + inline int64_t separationSquared(const GesturePoint& first, const GesturePoint& second) { const int64_t dx = static_cast(second.x) - first.x; const int64_t dy = static_cast(second.y) - first.y; @@ -65,4 +71,41 @@ inline bool classifyRotation(const GesturePoint& startFirst, const GesturePoint& return true; } +inline bool classifyPinch(const GesturePoint& startFirst, const GesturePoint& startSecond, const GesturePoint& endFirst, + const GesturePoint& endSecond, PinchResult& result) { + constexpr int64_t minSeparationPxSq = 60LL * 60LL; + constexpr int64_t scalePercentSq = 100LL * 100LL; + constexpr int64_t minPinchPercentSq = 80LL * 80LL; + constexpr int64_t maxPinchPercentSq = 120LL * 120LL; + constexpr float maxRotationDegrees = 15.0f; + constexpr double radiansToDegrees = 57.295779513082320876; + + const int64_t startDx = static_cast(startSecond.x) - startFirst.x; + const int64_t startDy = static_cast(startSecond.y) - startFirst.y; + const int64_t endDx = static_cast(endSecond.x) - endFirst.x; + const int64_t endDy = static_cast(endSecond.y) - endFirst.y; + const int64_t startSeparationSq = separationSquared(startFirst, startSecond); + const int64_t endSeparationSq = separationSquared(endFirst, endSecond); + + if (startSeparationSq < minSeparationPxSq || endSeparationSq < minSeparationPxSq) return false; + + const bool pinchedIn = endSeparationSq * scalePercentSq <= startSeparationSq * minPinchPercentSq; + const bool pinchedOut = endSeparationSq * scalePercentSq >= startSeparationSq * maxPinchPercentSq; + if (!pinchedIn && !pinchedOut) return false; + + const int64_t cross = startDx * endDy - startDy * endDx; + const int64_t dot = startDx * endDx + startDy * endDy; + const float degrees = + static_cast(std::atan2(static_cast(cross), static_cast(dot)) * radiansToDegrees); + if (std::fabs(degrees) > maxRotationDegrees) return false; + + result.scale = + static_cast(std::sqrt(static_cast(endSeparationSq) / static_cast(startSeparationSq))); + result.centerX = + static_cast((static_cast(startFirst.x) + startSecond.x + endFirst.x + endSecond.x) / 4); + result.centerY = + static_cast((static_cast(startFirst.y) + startSecond.y + endFirst.y + endSecond.y) / 4); + return true; +} + } // namespace freeink::input_detail diff --git a/libs/hardware/InputManager/test/host/test_multitouch_gesture_math.cpp b/libs/hardware/InputManager/test/host/test_multitouch_gesture_math.cpp index 05243d8a..2667abc1 100644 --- a/libs/hardware/InputManager/test/host/test_multitouch_gesture_math.cpp +++ b/libs/hardware/InputManager/test/host/test_multitouch_gesture_math.cpp @@ -5,9 +5,11 @@ namespace { +using freeink::input_detail::classifyPinch; using freeink::input_detail::classifyRotation; using freeink::input_detail::GesturePoint; using freeink::input_detail::hasRotationScale; +using freeink::input_detail::PinchResult; using freeink::input_detail::RotationResult; int checksRun = 0; @@ -79,6 +81,54 @@ void testRotationWinsWithTranslation() { CHECK(result.centerY == 50); } +void testPinchInAndOut() { + PinchResult result; + CHECK(classifyPinch({0, 0}, {100, 0}, {10, 0}, {70, 0}, result)); + CHECK(near(result.scale, 0.6f, 0.01f)); + CHECK(result.centerX == 45); + CHECK(result.centerY == 0); + + CHECK(classifyPinch({0, 0}, {100, 0}, {-20, 0}, {140, 0}, result)); + CHECK(near(result.scale, 1.6f, 0.01f)); + CHECK(result.centerX == 55); +} + +void testPinchRejectionThresholds() { + PinchResult result; + CHECK(!classifyPinch({0, 0}, {100, 0}, {5, 0}, {95, 0}, result)); // Small scale change. + CHECK(!classifyPinch({0, 0}, {50, 0}, {0, 0}, {90, 0}, result)); // Start span below 60 px. + CHECK(!classifyPinch({0, 0}, {100, 0}, {50, -50}, {50, 90}, result)); // Rotation too large. + CHECK(!classifyPinch({0, 0}, {100, 0}, {60, 80}, {160, 80}, result)); // Translation only. +} + +// The two classifiers are gated on the same separation band from opposite +// sides, so no gesture can be accepted by both. Each of these is accepted by +// one and must be rejected by the other. +void testPinchAndRotationAreMutuallyExclusive() { + RotationResult rotation; + PinchResult pinch; + + // A pure 90-degree turn holds the separation, so the pinch gate rejects it. + CHECK(classifyRotation({0, 0}, {100, 0}, {50, -50}, {50, 50}, rotation)); + CHECK(!classifyPinch({0, 0}, {100, 0}, {50, -50}, {50, 50}, pinch)); + + // A pure 40% close leaves the band, so the rotation gate rejects it. + CHECK(classifyPinch({0, 0}, {100, 0}, {10, 0}, {70, 0}, pinch)); + CHECK(!classifyRotation({0, 0}, {100, 0}, {10, 0}, {70, 0}, rotation)); +} + +// Contacts that converge by exactly the 20% threshold while both also travel +// 80 px. The translation path tolerates 45 px of separation change, so it would +// accept this as a two-finger swipe; finishMultiTouchGesture() tries pinch +// first, which is what makes it a pinch. +void testPinchWinsWithTranslation() { + PinchResult result; + CHECK(classifyPinch({0, 0}, {100, 0}, {80, 0}, {160, 0}, result)); + CHECK(near(result.scale, 0.8f, 0.01f)); + CHECK(result.centerX == 85); + CHECK(result.centerY == 0); +} + } // namespace int main() { @@ -88,6 +138,10 @@ int main() { testRejectionThresholds(); testScaleEligibilityCanLatchIntermediatePinch(); testRotationWinsWithTranslation(); + testPinchInAndOut(); + testPinchRejectionThresholds(); + testPinchAndRotationAreMutuallyExclusive(); + testPinchWinsWithTranslation(); std::printf("%d checks, %d failures\n", checksRun, checksFailed); return checksFailed == 0 ? 0 : 1;