From 065957f23c0c70aca28f1f636e3839cbaadeab31 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 31 Aug 2026 23:56:50 +0200 Subject: [PATCH] feat(input): classify two-contact pinch and spread The multi-touch machine already tracked two contacts and could tell a rotation from a translation, but a pinch fell through both and was reported as nothing -- deliberately, and both headers say so: wasMultiTouchSwipe() rejects it on hasStableTranslationGeometry() and wasMultiTouchRotation() on the scale band. classifyPinch() picks up what they drop. It is the complement of classifyRotation() over the same two measurements, and the two acceptance regions are disjoint by construction: rotation: separation stays INSIDE 80-120% and |angle| > 20 degrees pinch: separation leaves that band and |angle| <= 15 degrees The separation test alone makes them mutually exclusive. The angle bands are disjoint too, leaving a deliberate 15-20 degree dead zone where a gesture is neither -- better than a shared boundary that has to hand an ambiguous turn to one of them. That is what lets finishMultiTouchGesture() try them in sequence without an ordering subtlety between those two. Pinch is tried before translation, and there the order does decide something. The translation path tolerates up to TOUCH_MULTI_CONTACT_SEPARATION_SLOP_PX (45) of separation change per axis, so a gesture whose contacts converge by just over the 20% pinch threshold while both also travel far enough to pass TOUCH_SWIPE_MIN_PX can satisfy both classifiers. Trying pinch first reports that as a pinch. A 60 px minimum on both start and end separation keeps two contacts landing almost on top of each other from turning a few pixels of jitter into a large scale. `scale` is end separation over start separation, so <1 is a pinch in and >1 a spread; the caller is spared the square roots. The async queue, the suppressTouchContact() reset and the per-update event clear all follow the rotation path exactly, so a pinch cannot survive suppression or leak into the next frame. Host tests cover both directions, each rejection threshold, mutual exclusion with rotation in both directions, and the precedence case above. --- .../InputManager/include/InputManager.h | 23 +++++++ .../InputManager/src/InputManager.cpp | 62 +++++++++++++++++++ .../InputManager/src/MultiTouchGestureMath.h | 43 +++++++++++++ .../host/test_multitouch_gesture_math.cpp | 54 ++++++++++++++++ 4 files changed, 182 insertions(+) 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;