feat(input): classify two-contact pinch and spread - #75
Open
jpirnay wants to merge 1 commit into
Open
Conversation
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.
jpirnay
added a commit
to jpirnay/freeink-sdk
that referenced
this pull request
Sep 3, 2026
Both changes were made while preparing the PRs and only ever existed on the PR branches, which were cut from Free-Ink main rather than from here -- so our copy had the weaker version of each. The two pinch tests (Free-Ink#75) assert what the commit message claims and nothing did before: that rotation and pinch can never both accept one gesture, checked from both sides, and that a gesture converging by exactly 20% while both contacts travel 80 px is a pinch rather than the two-finger swipe the translation path would also accept. Host suite goes 35 -> 43 checks. The FREEINK_CAP_USB_MSC comment (Free-Ink#74) no longer talks about "an earlier revision of this comment", which meant nothing outside our own history, and states the requirement positively: ARDUINO_USB_MODE=0 is one way to reach the OTG PHY, not a requirement, and the actual constraint is the prebuilt Arduino core carrying CONFIG_TINYUSB_MSC_ENABLED. Deliberately NOT synced: the stray clang-format reflow in gslUploadFirmware() that Free-Ink#75 drops. The SDK ships no .clang-format, so our pre-commit hook formats these files to the firmware's 120-column limit and would simply re-split that line on the next commit that touches the file. It stays a fork-local artifact.
jpirnay
added a commit
to jpirnay/witchhunt-reader
that referenced
this pull request
Sep 3, 2026
Two improvements that were made while preparing the upstream PRs and had only ever existed on the PR branches (cut from Free-Ink main, not from our fork): the two pinch classifier tests from Free-Ink/freeink-sdk#75, and the reworded FREEINK_CAP_USB_MSC comment from #74. No firmware-visible behaviour change -- a test file and a comment. Builds for lilygo_t5s3 and x4pro; the SDK host gesture suite goes 35 -> 43 checks.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
InputManageralready tracks two contacts and classifies a translation and a rotation from them. A pinch or spread is currently reported as nothing — and that is deliberate rather than an oversight; both existing headers say so in as many words:wasMultiTouchSwipe()— "Pinches, diagonal motion, delayed gestures ... are rejected"wasMultiTouchRotation()— "Pinches and sub-threshold turns are rejected"hasStableTranslationGeometry()enforces the first andhasRotationScale()the second. This adds the classifier that picks up what they drop:classifyPinch(), pluswasMultiTouchPinch()/popMultiTouchPinch()mirroring the rotation API exactly.Pure addition — 182 lines added, none removed. No existing behaviour changes, and a board that never calls the new getter is untouched.
How a pinch differs from a rotation
They are the same two measurements — how the separation between the contacts changed, and how the line between them turned — read from opposite sides:
The first column is the interesting one:
hasRotationScale()is precisely what makes a pinch a non-rotation today, andclassifyPinch()requires its negation. So the separation gate alone makes the two mutually exclusive — no gesture can be accepted by both — which is what letsfinishMultiTouchGesture()try them in sequence with no ordering question between them.The angle bands are disjoint too, leaving a deliberate 15–20° dead zone where a gesture is neither. A two-finger motion that both scales and turns meaningfully is dropped rather than assigned to one of them by a shared boundary: a turn is a turn and a pinch is a pinch, and the ambiguous middle is better rejected than guessed.
testPinchAndRotationAreMutuallyExclusiveasserts the exclusion in both directions.The one design decision worth your review
Pinch is tried before translation, and there the order decides something real.
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 pastTOUCH_SWIPE_MIN_PXsatisfies both classifiers — contacts at 0 and 100 ending at 80 and 160, say: an exact 80% close, both contacts moved 80 px, centroid moved 70 px. Trying pinch first reports that as a pinch, andtestPinchWinsWithTranslationpins the case down.I think that is the right call — a deliberate 20% squeeze is a stronger statement of intent than incidental centroid travel — but it is a judgment call, it is one line in
finishMultiTouchGesture(), and I will gladly flip it if you would rather a travelling gesture stay a two-finger swipe.API
scaleis end separation ÷ start separation —<1.0is a pinch in (zoom out),>1.0a spread (zoom in) — so the caller is spared the square roots. The center is the average of the start/end contact centroids normalized to 0..1, the same contract rotation already uses.A 60 px floor on both start and end separation stops two contacts landing nearly on top of each other from turning a few pixels of jitter into a large scale.
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.Testing
libs/hardware/InputManager/test/host/run.sh— 43 checks, 0 failures, built-Wall -Wextra -Werror. New coverage: both directions with expected scale and centre, all four rejection thresholds, mutual exclusion with rotation both ways, and the precedence case above.On hardware, honestly: our firmware consumes this on a LilyGo T5 S3 (GT911), where pinch in/out is bound by default to font-size smaller/larger, so the classifier does run against real contacts. But the device session I can point to confirms the swipe and brightness gestures specifically — I have not recorded one that confirms the pinch binding itself. So: host-tested and in use, hardware-confirmed I would not claim. Happy to run it and report back if you want that before merging.
Context
Builds directly on @uxjulia's #42 (GT911 multi-touch) and #45 (rotation gestures) — this is the third classifier on the machine those two established, and it reuses their
MultiTouchGestureMath.hconventions (integer geometry, squared comparisons, no allocation).AI usage
PARTIALLY. An AI coding assistant was used for the implementation, the tests and this description. The thresholds were chosen against the existing rotation constants and the reasoning above was checked against the code by hand.