Skip to content

feat(input): classify two-contact pinch and spread - #75

Open
jpirnay wants to merge 1 commit into
Free-Ink:mainfrom
jpirnay:pr/multitouch-pinch
Open

feat(input): classify two-contact pinch and spread#75
jpirnay wants to merge 1 commit into
Free-Ink:mainfrom
jpirnay:pr/multitouch-pinch

Conversation

@jpirnay

@jpirnay jpirnay commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

InputManager already 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 and hasRotationScale() the second. This adds the classifier that picks up what they drop: classifyPinch(), plus wasMultiTouchPinch() / 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:

separation (end ÷ start) |angle|
rotation stays inside 80–120% > 20°
pinch leaves that band ≤ 15°

The first column is the interesting one: hasRotationScale() is precisely what makes a pinch a non-rotation today, and classifyPinch() requires its negation. So the separation gate alone makes the two mutually exclusive — no gesture can be accepted by both — which is what lets finishMultiTouchGesture() 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. testPinchAndRotationAreMutuallyExclusive asserts 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 past TOUCH_SWIPE_MIN_PX satisfies 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, and testPinchWinsWithTranslation pins 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

bool wasMultiTouchPinch(float& scale, float& nxCenter, float& nyCenter, unsigned long& durationMs) const;
bool popMultiTouchPinch(float& scale, float& nxCenter, float& nyCenter, unsigned long& durationMs);

scale is end separation ÷ start separation — <1.0 is a pinch in (zoom out), >1.0 a 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.sh43 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.h conventions (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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant